From c2e040e003172850d7d70e8631ec9f2c51ba5a8d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 5 Sep 2026 12:55:49 -0400 Subject: [PATCH 1/8] Record receipt 0.6 migration preconditions Co-Authored-By: GPT-6 --- PROGRESS.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 PROGRESS.md diff --git a/PROGRESS.md b/PROGRESS.md new file mode 100644 index 00000000..ebadd869 --- /dev/null +++ b/PROGRESS.md @@ -0,0 +1,19 @@ +bc619d697533e8f37b6520d3d9464b0afdcefffb +receipt 0.6.0 wheel sha256 84dd540bc77f14547bcf5b4654ff22184a404aa280d8b13cda8e179593575734 +verify-publish: PUBLISH VERIFIED 2026-09-05 12:51 + +State: release preconditions verified; implementation not yet changed. + +Done: +- Read PLAN-0.6 sections 3.10, 3.4, 3.9 and residual rows 2/14; read merged Chronicle #241, #242 and the existing shim/tests. +- Starting branch shim/receipt-0.6 equals origin/codex/thesis-ledger-facts at the first-line OID. +- receipt v0.6.0 peels to a2228e40fc0bb2d8e525cae61b91ea495eec4112, the reviewed head in receipt #59 and approval comment 5553008482. +- PyPI publication is corroborated by release-smoke-060/pypi.json, verify-publish-060.log (completed success), and hashing the published local wheel against its recorded PyPI digest above. Shell DNS cannot resolve api.github.com or pypi.org; GitHub connector can read PR records. +- Exact local receipt tag diff v0.5.2..v0.6.0 -- src/receipt/append_gate.py confirms commit="HEAD" on both verifiers and AppendGateVerdict(summary, candidate_commit, candidate_tree, base_commit, base_tree, object_format, name_repertoire). + +Next: +- Run the byte-equivalence proof at receipt 0.5.2 before changing the pin. +- Migrate the shim to the released wheel, adapt installed-package tests and workflow documentation, then rerun proof and required suites. +- Prepare draft PR and default-branch workflow copy; push if networking permits. Write final report to OUTPUT.md unless another output path is supplied. + +Progress policy: committed under the standing order; asked for clarification because the Record paragraph also says untracked. From 8c729dd2a71ef240a527c82519e200a58b0178d3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 5 Sep 2026 12:59:28 -0400 Subject: [PATCH 2/8] Record the byte-equivalence proof at receipt 0.5.2 Co-Authored-By: GPT-6 --- PROGRESS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/PROGRESS.md b/PROGRESS.md index ebadd869..4372ba00 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -17,3 +17,5 @@ Next: - Prepare draft PR and default-branch workflow copy; push if networking permits. Write final report to OUTPUT.md unless another output path is supplied. Progress policy: committed under the standing order; asked for clarification because the Record paragraph also says untracked. + +Done: pre-bump byte-equivalence proof at installed receipt 0.5.2: 18 passed in 29.66s (`uv run --frozen --no-sync -q pytest -q tests/test_receipt_shim_transparency.py`; isolated writable UV_CACHE_DIR, offline). The environment was copied from the 0.5.2 shim worktree; ordinary frozen sync initially lacked cached build dependencies. No shim/pin edits preceded this proof. From eb3b2954841d57d0fdd6dbda99e1405ac8bd362b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 5 Sep 2026 13:07:43 -0400 Subject: [PATCH 3/8] Bind workflow test verdicts to their explicit candidate OIDs Co-Authored-By: GPT-6 --- .github/workflows/thesis-facts-append.yml | 40 ++++++++++++----------- PROGRESS.md | 10 ++++++ tests/test_thesis_append_adversarial.py | 27 ++++++++++++--- 3 files changed, 53 insertions(+), 24 deletions(-) diff --git a/.github/workflows/thesis-facts-append.yml b/.github/workflows/thesis-facts-append.yml index 9137ac9e..5eccd2c0 100644 --- a/.github/workflows/thesis-facts-append.yml +++ b/.github/workflows/thesis-facts-append.yml @@ -101,12 +101,11 @@ jobs: # keeps the PR unable to influence what code judges it. uv sync --locked --no-dev --project "$base_gate" - # The gate judges the commit named here, which it checks out for - # itself into a private directory. The object id is always an - # argument and is never inferred from whatever the checkout at - # --root happens to be sitting at, so nothing that writes into that - # working tree between the fetch above and the run below can change - # what is judged. --root only says which clone the id is resolved in. + # Receipt 0.6 reads and rehashes the named commit's objects; the shim + # prints the commit and tree from that verdict. --root only locates + # the clone, so later working-tree or index writes cannot change the + # subject. A 0.5.2 base still establishes its own exact-checkout + # precondition for the same explicit --commit argument. # The judge is the BASE commit's copy of the script, and --commit was # introduced by a pull request into this branch. There is no ordering # of that merge and the default branch's copy of this file in which @@ -137,8 +136,8 @@ jobs: ;; *) echo "note: the base gate at $BASE_SHA predates --commit, so it" \ - "judges the working tree at $candidate rather than an isolated" \ - "checkout of $MERGE_SHA. This is the base's own guarantee and" \ + "judges the working tree at $candidate, without binding its" \ + "verdict to $MERGE_SHA. This is the base's own guarantee and" \ "the only one available against that base." >&2 PYTHONPATH="$base_gate/scripts" \ PYTHONNOUSERSITE=1 \ @@ -205,8 +204,8 @@ jobs: ;; *) echo "note: the base gate at $base_sha predates --commit, so" \ - "it judges the workspace rather than an isolated checkout" \ - "of $workspace_sha." >&2 + "it judges the workspace, without binding its verdict to" \ + "$workspace_sha." >&2 PYTHONPATH="$base_gate/scripts" \ PYTHONNOUSERSITE=1 \ uv run --locked --no-dev --project "$base_gate" \ @@ -234,14 +233,17 @@ jobs: python-version: "3.14" - name: Install dependencies - run: uv sync --locked --all-extras + run: | + set -euo pipefail + uv sync --locked --all-extras - name: Ledger observation invariants - run: > - uv run pytest - tests/test_policyengine_ledger.py - tests/test_release_chain.py - tests/test_thesis_append_adversarial.py - tests/test_thesis_append_shim_isolation.py - tests/test_receipt_shim_transparency.py - -q + run: | + set -euo pipefail + uv run pytest \ + tests/test_policyengine_ledger.py \ + tests/test_release_chain.py \ + tests/test_thesis_append_adversarial.py \ + tests/test_thesis_append_shim_isolation.py \ + tests/test_receipt_shim_transparency.py \ + -q diff --git a/PROGRESS.md b/PROGRESS.md index 4372ba00..6d3af528 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -19,3 +19,13 @@ Next: Progress policy: committed under the standing order; asked for clarification because the Record paragraph also says untracked. Done: pre-bump byte-equivalence proof at installed receipt 0.5.2: 18 passed in 29.66s (`uv run --frozen --no-sync -q pytest -q tests/test_receipt_shim_transparency.py`; isolated writable UV_CACHE_DIR, offline). The environment was copied from the 0.5.2 shim worktree; ordinary frozen sync initially lacked cached build dependencies. No shim/pin edits preceded this proof. + +State: migration implemented; released-wheel verification running. + +Done: +- Kept all three explicit workflow OID arguments and the base-owned help compatibility branches; updated object-read documentation and pipefail on every shell block. +- Strengthened adversarial success/refusal assertions to bind the verdict line; 28 passed, 5 existing strict xfails against installed receipt 0.6.0. +- Prepared the default-branch byte-identical workflow in local branch shim/receipt-0.6-workflow at 2f9f5de5c534d15fc64d70a6f742deb7ccf784ba, based on origin/main 743742c. All four workflow run blocks pass bash -n. +- Provisioned the released local wheel offline and verified ordinary uv sync --frozen succeeds using writable /tmp/chronicle-uv-cache. Lock metadata copies the exact published artifact URLs, sizes and SHA-256s; UV_FROZEN=false uv lock --check --offline succeeds. + +Next: finish exact requested suite and released-pin byte proof, include the necessary release-chain shim compatibility change discovered by the proof, then commit and attempt delivery. diff --git a/tests/test_thesis_append_adversarial.py b/tests/test_thesis_append_adversarial.py index 21610589..0b2a61a3 100644 --- a/tests/test_thesis_append_adversarial.py +++ b/tests/test_thesis_append_adversarial.py @@ -131,8 +131,8 @@ def _write_checker_fixture(path: Path, ledger_text: str, manifest: dict) -> None def _commit_candidate(path: Path) -> str: """Commit whatever the fixture has just written and name that commit. - The checker judges a commit it checks out itself, so a fixture that mutated - a working tree has not yet said anything the checker can be asked about. + The installed receipt package judges the named commit's objects, so a + fixture that mutated a working tree has not yet stated the candidate. Committing is how the fixture states its candidate. An empty commit is allowed because some fixtures change nothing and the question -- does this commit pass -- is still a real one. @@ -155,7 +155,21 @@ def _run_checker( ] if base_ref is not None: command.extend(["--base-ref", base_ref]) - return subprocess.run(command, cwd=path, capture_output=True, text=True) + completed = subprocess.run(command, cwd=path, capture_output=True, text=True) + _assert_verdict_subject(completed, path, commit) + return completed + + +def _assert_verdict_subject( + completed: subprocess.CompletedProcess, path: Path, commit: str +) -> None: + if completed.returncode == 0: + tree = _git(path, "rev-parse", f"{commit}^{{tree}}") + assert completed.stdout.splitlines()[1:] == [ + f"candidate commit {commit} tree {tree}" + ] + else: + assert "candidate commit " not in completed.stdout def _git(path: Path, *args: str) -> str: @@ -351,6 +365,7 @@ def test_base_gate_uses_base_script_and_dependency_imports( assert completed.returncode == 0, completed.stdout + completed.stderr assert "gate-only proposal" in completed.stdout + _assert_verdict_subject(completed, candidate, candidate_commit) assert not candidate_gate_marker.exists() assert not candidate_dependency_marker.exists() @@ -384,11 +399,12 @@ def test_workflow_has_a_base_owned_trusted_pr_gate(): '--base-ref "$BASE_SHA"', # The commit under judgement is named on the command line in all three # invocations. Dropping any of them would leave the gate judging - # whichever tree the checkout at --root happened to be sitting at, - # which is the divergence the shim exists to exclude. + # whichever HEAD the clone at --root happened to name. The package's + # object reader binds its verdict to this explicit commit and tree. '--commit "$MERGE_SHA"', '--commit "$workspace_sha"', '--commit "$GITHUB_SHA"', + "fetch-depth: 0", ): assert required in workflow @@ -742,6 +758,7 @@ def test_full_file_check_alone_rejects_joint_manifest_and_file_rewrite(tmp_path) lines[0] = _json_line(rewritten) manifest = _rehash_manifest(lines) _write_checker_fixture(tmp_path, "\n".join(lines) + "\n", manifest) + _init_fixture_repo(tmp_path) completed = _run_checker(tmp_path) From d16804162e7e0da3620e5eb23e0e365e8bcdbaba Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 5 Sep 2026 13:10:19 -0400 Subject: [PATCH 4/8] Verify named commits with the receipt 0.6.0 wheel Co-Authored-By: GPT-6 --- PROGRESS.md | 12 + pyproject.toml | 2 +- scripts/check_thesis_facts_append.py | 638 ++---------------- scripts/verify_release_chain.py | 82 ++- tests/test_release_chain.py | 42 +- tests/test_thesis_append_shim_isolation.py | 741 ++++++--------------- uv.lock | 8 +- 7 files changed, 382 insertions(+), 1143 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 6d3af528..dcbe7451 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -29,3 +29,15 @@ Done: - Provisioned the released local wheel offline and verified ordinary uv sync --frozen succeeds using writable /tmp/chronicle-uv-cache. Lock metadata copies the exact published artifact URLs, sizes and SHA-256s; UV_FROZEN=false uv lock --check --offline succeeds. Next: finish exact requested suite and released-pin byte proof, include the necessary release-chain shim compatibility change discovered by the proof, then commit and attempt delivery. + +Done: +- Exact requested command at receipt 0.6.0: `uv run --frozen -q pytest -q tests/test_thesis_append_shim_isolation.py tests/test_thesis_append_adversarial.py tests/test_build_series_catalog.py` -> 217 passed, 5 xfailed in 52.87s. Runs use UV_CACHE_DIR=/tmp/chronicle-uv-cache and UV_OFFLINE=1 because shell network access is blocked. +- Released-pin proof plus workflow companions: tests/test_receipt_shim_transparency.py, tests/test_release_chain.py, tests/test_policyengine_ledger.py -> 91 passed in 31.31s (18 + 64 + 9). Authenticated oracle fixtures unchanged. +- Current census: isolation 34, adversarial 33 (5 strict xfails), catalog 155, transparency 18, release chain 64, ledger 9; 313 total cases, 308 passed and 5 xfailed across these suites. +- Removed ten tests whose subject was the deleted exact checkout/worktree logic; retained and adapted committed mode/configuration tests, added seven installed-wheel cases for mandatory commit and the three workflow paths' divergent/repaired local state. +- The byte proof exposed obsolete release-chain shim exports. Removed four unused git helper aliases and materialize_base_tree; migrated live history/base wrappers and CLI --base-ref to entered snapshots and candidate materialization. Added two CLI cases for committed history despite dirty/repaired working trees. Ordinary directory verification and all original oracle bytes are preserved. +- Independent review approved append shim, dependency lock, workflows and tests with no actionable findings. Ruff check passes on all changed Python files; formatted the isolation test. + +Next: finish release-chain compatibility review, commit implementation, run the gate on that committed head, and attempt both branch pushes. + +Review: release-chain compatibility approved with no blocking defects. Kept the legacy standalone --full help bytes for the required help differential; the module docstring states that adding --base-ref selects HEAD objects and verifies their private materialization. diff --git a/pyproject.toml b/pyproject.toml index a40d3312..4d420ec0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ dependencies = [ "odfpy>=1.4.1", "xlrd>=2.0.1", "pypdf>=6.0.0", - "receipt==0.5.2", + "receipt==0.6.0", ] [project.optional-dependencies] diff --git a/scripts/check_thesis_facts_append.py b/scripts/check_thesis_facts_append.py index 5c1fc845..9aa03afe 100644 --- a/scripts/check_thesis_facts_append.py +++ b/scripts/check_thesis_facts_append.py @@ -2,46 +2,34 @@ # Thin shim over the receipt pin recorded in uv.lock. Any receipt upgrade # requires a fresh byte-equivalence proof at this repo's then-current pin BEFORE # the bump. -"""Gate every change to the thesis-facts observation ledger. - -The gate this shim calls states its verdict about one thing: a clean checkout -of one named commit, read once, with no concurrent writer. Findings whose -precondition is a writer to the working tree or the index during the run, or an -index, a working tree and a commit that disagree with one another, are waived -against that stated contract. A waiver like that is only honest if the program -that calls the gate actually establishes the precondition, and establishing it -is what this shim does. - -Every run names the commit to judge as an argument. The shim checks that commit -out into a private directory of its own under a git environment the candidate -cannot redirect, asserts that the directory really is that commit and nothing -else, runs the gate over that directory, and removes the directory and its -registration afterwards whatever happened in between. - -What that excludes: - -* an index, a working tree and a commit that diverge from one another, because - the checkout is made from the commit and is then required to be clean, with - no untracked and no ignored file, and with every protected path's bytes equal - to the bytes of the blob the commit names; -* a checkout the candidate prepared, because the shim makes its own rather than - reading the one the job happens to be sitting in; -* a ref the candidate can move, because the commit is given as a full object - id and a symbolic name is refused before any git command runs; -* a git environment the candidate can redirect, because every ``GIT_*`` - variable is dropped from the environment the git calls inherit, the system - configuration is switched off, the global configuration is a file this shim - writes, and the repository's own configuration is read and refused if it - names a hook path, a filesystem monitor, a content filter or an include. - -What it does not exclude: the window between the assertions and the gate's own -reads. A process that can write into the private checkout during that window -runs as the job's own user, which is the trust level of the job's own code, and -this shim claims nothing about it. - -A package user who calls ``verify_append_gate`` against some other directory is -outside the contract the gate states, and outside everything the shim -establishes here. +"""Gate every change to the thesis-facts observation ledger at a named commit. + +Receipt 0.6 reads rehashed objects from the selected commit and base, checks +ancestry, and materializes the protected bytes privately for its leaf verifier. +The working tree and index are not the verdict's subject. The required full +``--commit`` OID selects that subject; the second output line comes directly +from ``AppendGateVerdict.candidate_commit`` and ``candidate_tree``. Trust anchors +remain the verifier's, and same-user writers remain inside the job's trust +boundary. Branch protection must require an up-to-date branch if a stale GitHub +test merge must be recomputed before merging. + +The package reader drops every inherited ``GIT_*`` variable, creates its own +safe.directory-only global configuration, and audits its deny list at selection +and close. That list already refuses includes, hooks, filesystem monitors, +program-valued connection settings, partial clones and fsck weakening settings. +The shim delegates those refusals to the package. It retains its existing +caller policy against ``filter.*``, ``core.sparseCheckout`` and +``core.sparseCheckoutCone``: receipt does not refuse these settings because its +object reads use neither checkout filters nor the sparse index. This remaining +audit is a caller configuration policy, not an exact-checkout precondition. + +The shim also retains its frozen process environment, scratch directory and +audit of its own safe.directory-only global configuration and absent system +scope. The public append entry still refuses redirecting variables before the +reader sanitizes them, and the shim's own configuration commands run before +that reader exists. Freezing both these commands and the package call keeps the +same caller guarantee. No checkout, checkout hook, index scan or worktree +registration is needed; scratch cleanup runs on success, refusal and exception. """ from __future__ import annotations @@ -63,13 +51,13 @@ from receipt.release_chain import MANIFEST_RE, ReleaseChainError try: - from receipt_pins import APPEND_GATE_SPEC, LEDGER_SPEC + from receipt_pins import APPEND_GATE_SPEC except ModuleNotFoundError as exc: if exc.name != "receipt_pins": raise # The test suite copies the legacy three-script surface into temporary # repositories. The editable consumer tree remains the sole pin owner. - from scripts.receipt_pins import APPEND_GATE_SPEC, LEDGER_SPEC + from scripts.receipt_pins import APPEND_GATE_SPEC CODE_ROOT = pathlib.Path(__file__).resolve().parents[1] @@ -81,6 +69,7 @@ AppendError = _receipt.AppendError AppendGateSpec = _receipt.AppendGateSpec +AppendGateVerdict = _receipt.AppendGateVerdict reject_non_append_bytes = _receipt.reject_non_append_bytes # A full object id in either of git's two hash algorithms, spelled the way git @@ -186,45 +175,20 @@ # The configuration keys the shim writes into its own global file, and the # whole of what the global scope is allowed to resolve to. -GATE_CONFIG_KEYS = ("safe.directory", "core.hookspath") - -# Tree modes a protected path is allowed to have. Every other mode -- a symlink -# at 120000, a gitlink at 160000, a subtree at 040000 -- means the path is not -# a file whose bytes this commit fixes, so no comparison below is about it. -PROTECTED_TREE_MODES = ("100644", "100755") - -# The five settings receipt spells on every one of its own working-tree reads, -# spelled on the shim's two scans for the same reason: a read that gains a -# cache in a later git is a read this program would otherwise silently begin -# to trust (peer review, round 1). ``core.fsmonitor`` is also refused by the -# configuration audit. -SCAN_SETTINGS = ( - "-c", - "core.untrackedCache=false", - "-c", - "core.fsmonitor=false", - "-c", - "core.trustctime=true", - "-c", - "core.checkStat=default", - "-c", - "feature.manyFiles=false", -) +GATE_CONFIG_KEYS = ("safe.directory",) # The two ways a run can end without a verdict of "OK", kept apart on purpose. # "failed" is the gate's verdict about a tree it was given; "refused" is this -# shim declining to give it one, because the precondition the verdict would be -# stated against could not be established. +# shim declining to call the package because its caller configuration policy +# or frozen environment could not be established. VERDICT_REFUSAL_PREFIX = "thesis-facts append check failed: " REFUSAL_PREFIX = "thesis-facts append check refused: " class ShimRefusal(RuntimeError): - """The shim will not hand the gate a tree it cannot vouch for. + """The shim's environment or caller configuration policy was not established. - Distinct from ``AppendError``, which is the gate's verdict about a tree it - was handed. A ``ShimRefusal`` says that no verdict was reached, because the - precondition the verdict would be stated against could not be established. + Distinct from ``AppendError``, which is a refusal from the package verifier. """ @@ -237,8 +201,8 @@ def _git( """Run one git command under the frozen environment and return its stdout. A non-zero exit is a refusal rather than an exception to interpret: every - git call the shim makes is part of establishing the precondition, so a call - that did not answer means the precondition was not established. + git call the shim makes establishes or audits the frozen configuration. A + call that did not answer means that audit could not be completed. """ try: @@ -268,8 +232,7 @@ def _scratch_root() -> pathlib.Path: ``mkdtemp`` creates the directory with mode 0700 and does so atomically, so no other user has a moment in which the directory is readable. The mode is asserted rather than assumed, because everything the shim writes -- the - global git configuration whose contents decide whether hooks run, the empty - hook directory itself, and the checkout the gate reads -- lives under it. + global git configuration and its audit -- lives under it. """ root = pathlib.Path(tempfile.mkdtemp(prefix="thesis-facts-gate-")) @@ -286,48 +249,13 @@ def _gate_environment( clone_root: pathlib.Path, scratch_root: pathlib.Path, ) -> dict[str, str]: - """Build the only environment any git in this run is allowed to see. - - The returned mapping is the caller's environment with every variable whose - name begins with ``GIT_`` removed, and exactly three put back: - - * ``GIT_NO_REPLACE_OBJECTS=1``, so a replace ref committed into the - repository cannot substitute one object for another underneath a read; - * ``GIT_CONFIG_NOSYSTEM=1``, so the machine's system configuration -- which - is not part of what this run is judging -- contributes nothing; - * ``GIT_CONFIG_GLOBAL``, naming a file this function writes under - ``scratch_root``. It holds ``safe.directory`` for the clone and - ``core.hooksPath`` pointing at an empty directory created here, so no - hook in the repository runs during the checkout. Both are written with - ``git config -f``, so git does its own quoting of the paths. - - Dropping by prefix rather than by name is deliberate: it is a superset of - any documented list, and ``DOCUMENTED_GIT_VARIABLES`` exists only so a test - can check the documentation has not outgrown the rule. - - The environment is used two ways, and both are needed. The shim's own git - calls receive it as ``env=``. The gate builds the environment for its own - git calls out of ``os.environ`` -- on the pinned receipt, - ``release_chain._git_environment`` returns ``os.environ`` with - ``GIT_NO_REPLACE_OBJECTS`` set and four pathspec variables removed, and its - docstring says in as many words that it is still not a sanitizer: the five - redirecting variables are refused at the package's public verifier entries - rather than dropped there, and everything else in the ambient environment - is carried through. So ``main`` replaces ``os.environ`` - with this mapping for exactly the duration of the gate call (see - ``_frozen_environment``); that is the control the package asks its callers - for. Running the gate in a child process with ``env=`` would work equally - well for the environment, but would put a process boundary between the - gate's exception text and this program's stderr, and that text is what the - byte-equivalence proof compares. - - ``safe.directory`` names the clone. If the clone were owned by another user - the linked worktree's own path would not be covered, and git would refuse - the checkout; that is a refusal, not a pass, so it fails closed. + """Drop all inherited GIT_* values and freeze the shim's configuration. + + Receipt constructs the same three-variable environment with its own global + file. The outer freeze also covers this audit and the append entry's + redirect-variable refusal, which precedes the package reader. """ - hooks = scratch_root / "hooks" - hooks.mkdir(mode=0o700) config = scratch_root / "gitconfig" config.write_bytes(b"") @@ -338,15 +266,11 @@ def _gate_environment( environment["GIT_CONFIG_NOSYSTEM"] = "1" environment["GIT_CONFIG_GLOBAL"] = str(config) - for key, value in ( - ("safe.directory", str(clone_root)), - ("core.hooksPath", str(hooks)), - ): - _git( - ["config", "-f", str(config), key, value], - cwd=scratch_root, - env=environment, - ) + _git( + ["config", "-f", str(config), "safe.directory", str(clone_root)], + cwd=scratch_root, + env=environment, + ) return environment @@ -402,42 +326,15 @@ def _config_records(payload: bytes, *, scoped: bool) -> list[tuple[str, str, str def _refused_config_key(key: str) -> str | None: - """Name the reason this configuration key may not stand, or return None. - - Four kinds of key decide what a checkout does rather than what it holds, - and each of them is a way for a repository to run code or rewrite bytes - during a read the gate is about to make: - - * ``core.hooksPath`` moves the hook directory, which would defeat the empty - one the shim points git at; - * ``core.fsmonitor`` names a program git runs to learn what changed; - * any ``filter.*`` driver names programs git runs on a file's contents on - the way in and on the way out; - * any ``include`` or ``includeIf`` pulls in a file whose contents this - audit has not read, and could set any of the above; - * ``core.sparseCheckout`` (and ``core.sparseCheckoutCone``) makes a - checkout materialise only the paths a patterns file in the shared git - directory selects, marking the rest skip-worktree so ``git status`` - stays clean over a working tree that is a strict subset of the commit - (peer review of this pull request, round 1). - - Git compares a key's section and variable case-insensitively and its - subsection case-sensitively, which is what the folding here reproduces. - """ + """Retain only caller configuration refusals absent from receipt's audit.""" section, _, remainder = key.partition(".") section = section.lower() variable = remainder.rpartition(".")[2].lower() if section == "filter": return "names a content filter driver" - if section.startswith("include"): - return "pulls in configuration this audit has not read" - if section == "core" and variable == "hookspath": - return "moves the hook directory" - if section == "core" and variable == "fsmonitor": - return "names a filesystem monitor program" if section == "core" and variable in {"sparsecheckout", "sparsecheckoutcone"}: - return "makes a checkout materialise a subset of the commit" + return "enables sparse checkout configuration excluded by caller policy" return None @@ -445,20 +342,12 @@ def _audit_repository_config( clone_root: pathlib.Path, env: dict[str, str], ) -> None: - """Refuse a clone whose configuration would change what a checkout does. - - Reads the configuration git will actually use, with ``--no-includes`` so an - include is visible as the key it is rather than as its effects, and with - ``--show-scope`` so each entry is attributable. Entries in the repository's - own scopes -- ``local`` and ``worktree``, both of which the candidate - commits or the candidate's clone carries -- are refused when they name a - hook path, a filesystem monitor, a content filter or an include, because - each of those takes precedence over the global file the shim wrote. - - The global scope must then resolve to exactly the file the shim wrote and - nothing else, and no ``system`` entry may appear at all -- one cannot, - under ``GIT_CONFIG_NOSYSTEM``, so an entry claiming that scope means the - environment did not reach git. + """Audit the shim's frozen configuration and remaining caller policy. + + --no-includes exposes include keys without evaluating them; receipt refuses + those keys itself. Local/worktree filter and sparse settings remain caller + refusals. The global scope must equal the file the shim wrote and no system + scope may appear under GIT_CONFIG_NOSYSTEM. """ _git(["rev-parse", "--git-dir"], cwd=clone_root, env=env) @@ -506,15 +395,6 @@ def _audit_repository_config( f"{sorted({key for key, _ in written})}, not " f"{sorted(GATE_CONFIG_KEYS)}" ) - hooks = pathlib.Path( - next(value for key, value in written if key == "core.hookspath") - ) - if not hooks.is_dir() or any(hooks.iterdir()): - raise ShimRefusal( - f"the hook directory {hooks} the shim points git at is not an " - "existing empty directory" - ) - globals_in_effect = [ (key, value) for scope, key, value in resolved if scope == "global" ] @@ -525,375 +405,6 @@ def _audit_repository_config( ) -def _isolated_checkout( - clone_root: pathlib.Path, - scratch_root: pathlib.Path, - oid: str, - env: dict[str, str], -) -> pathlib.Path: - """Check the named commit out into a directory this run owns. - - The checkout is a linked worktree of the clone, detached at the object id. - It shares the clone's object database, so a base commit named with - ``--base-ref`` resolves inside it, and it is a directory nothing else in - the job has a name for. - - The object id is re-checked here even though the argument parser already - refused anything else, because this function is the last place before a - name reaches git and a caller that bypassed the parser must not get a - symbolic name resolved on its behalf. - """ - - if not OBJECT_ID.fullmatch(oid): - raise ShimRefusal( - f"{oid!r} is not a full object id, so it will not be checked out" - ) - checkout = scratch_root / "checkout" - try: - completed = subprocess.run( - [ - "git", - "-C", - str(clone_root), - "worktree", - "add", - "--detach", - str(checkout), - oid, - ], - env=env, - check=False, - capture_output=True, - ) - except OSError as exc: - raise ShimRefusal( - f"git could not be run to check {oid} out: {exc.strerror or exc}" - ) from exc - if completed.returncode != 0: - detail = completed.stderr.decode("utf-8", "replace").strip() - raise ShimRefusal( - f"cannot check {oid} out in isolation from {clone_root}: {detail}" - ) - return checkout - - -def _tree_entries( - checkout: pathlib.Path, - oid: str, - pathspec: str, - env: dict[str, str], -) -> list[tuple[str, str, str, str]]: - """List the commit's blob entries under one pathspec as mode, type, id, path. - - ``-z`` is what makes this a parse rather than a guess: git quotes unusual - path names in its default output and emits them raw under ``-z``. - """ - - payload = _git( - ["ls-tree", "-r", "-z", oid, "--", pathspec], - cwd=checkout, - env=env, - ) - entries: list[tuple[str, str, str, str]] = [] - for record in payload.split(b"\0"): - if not record: - continue - metadata, _, raw_path = record.partition(b"\t") - mode, kind, blob = metadata.decode("utf-8", "replace").split(" ") - entries.append((mode, kind, blob, os.fsdecode(raw_path))) - return entries - - -def _protected_entries( - checkout: pathlib.Path, - oid: str, - env: dict[str, str], -) -> list[tuple[str, str, str, str]]: - """Every entry of the commit whose bytes the gate's verdict is about. - - The two state files the chain specification names, plus everything under - the release root, which is where the manifests, the producer signatures, - the RFC 3161 receipts and the trust anchors live, plus everything else - under the state files' directory, so the byte comparison covers the whole - of the gate's data surface (``ledger/**`` and ``releases/manifests/**``) - and not only the paths the gate reads (peer review, round 1). The prefixes - are the chain specification's, not the data-surface globs themselves; - ``test_the_byte_comparison_covers_the_whole_data_surface`` holds the two - together, so a widened data surface cannot outgrow this comparison - unnoticed. A commit that carries no - entry at one of the two state paths is refused here: the gate would refuse - it too, but this says which of the two is missing and says it before any - file is read. - """ - - chain = LEDGER_SPEC - entries: list[tuple[str, str, str, str]] = [] - seen: set[str] = set() - for relative in (chain.state_relative, chain.prefix_relative): - found = _tree_entries(checkout, oid, str(relative), env) - if not found: - raise ShimRefusal(f"commit {oid} carries no entry at {relative}") - for entry in found: - if entry[3] not in seen: - seen.add(entry[3]) - entries.append(entry) - for prefix in (chain.release_root_relative, chain.state_relative.parent): - for entry in _tree_entries(checkout, oid, f"{prefix}/", env): - if entry[3] not in seen: - seen.add(entry[3]) - entries.append(entry) - return entries - - -def _hash_working_files( - checkout: pathlib.Path, - paths: list[str], - env: dict[str, str], -) -> list[str]: - """Hash each path's bytes on disk, with no attribute conversion applied. - - ``--no-filters`` is the whole point of this call. Without it git applies - whatever the commit's own ``.gitattributes`` asks for -- an end-of-line - conversion, a clean filter -- and the result is by construction the blob id - the commit names, whatever the file on disk actually holds. With it the - answer is a hash of the bytes the gate is about to read. - """ - - hashes: list[str] = [] - for start in range(0, len(paths), 500): - chunk = paths[start : start + 500] - payload = _git( - ["hash-object", "--no-filters", "--", *chunk], - cwd=checkout, - env=env, - ) - lines = payload.decode("utf-8", "replace").split("\n") - if lines and lines[-1] == "": - lines.pop() - if len(lines) != len(chunk): - raise ShimRefusal( - f"git hash-object answered for {len(lines)} of " - f"{len(chunk)} protected paths" - ) - hashes.extend(lines) - return hashes - - -def _assert_exact_checkout( - checkout: pathlib.Path, - oid: str, - env: dict[str, str], -) -> tuple[str, str]: - """Require the checkout to be that commit and nothing besides. - - Four things are asserted about the directory as a whole: its ``HEAD`` is - the named commit; ``git status`` reports nothing, with untracked files - forced on from the command line so a repository setting cannot hide one and - with submodules included; and ``git ls-files`` reports no ignored file, so - a ``.gitignore`` the commit carries cannot conceal one either. - - Then four things are asserted about every protected path the commit names. - Its tree mode must be a regular file's, so a symlink, a gitlink or a - subtree standing where a file should be is refused rather than followed. - ``os.lstat`` must agree that the path on disk is a regular file, without - following anything. The bytes on disk must hash to the blob id the commit - names, computed with no attribute conversion. And the owner-execute bit on - disk must be the one the tree mode states. - - The structural three are checked for every entry in tree order first and - the byte comparison for every entry after them, so the two passes each name - the first path that failed them. - - Returns the commit and its tree, for the line ``main`` prints after the - verdict. - """ - - head = ( - _git(["rev-parse", "HEAD"], cwd=checkout, env=env) - .decode("utf-8", "replace") - .strip() - ) - if head != oid: - raise ShimRefusal( - f"the isolated checkout is at {head}, not the named commit {oid}" - ) - - status = _git( - [ - *SCAN_SETTINGS, - "-c", - "status.showUntrackedFiles=all", - "status", - "--porcelain", - "--untracked-files=all", - "--ignore-submodules=none", - ], - cwd=checkout, - env=env, - ).decode("utf-8", "replace") - if status.strip(): - first = status.strip().splitlines()[0] - raise ShimRefusal(f"the isolated checkout of {oid} is not clean: {first}") - - ignored = _git( - [*SCAN_SETTINGS, "ls-files", "--others", "--ignored", "--exclude-standard"], - cwd=checkout, - env=env, - ).decode("utf-8", "replace") - if ignored.strip(): - first = ignored.strip().splitlines()[0] - raise ShimRefusal( - f"the isolated checkout of {oid} carries an ignored file: {first}" - ) - # The checkout must be the whole commit, not a selection of it: a sparse - # checkout leaves the omitted entries in the index marked skip-worktree, - # which a clean ``status`` does not reveal (peer review, round 1). Every - # index entry must be an ordinary tracked file, and there must be exactly - # as many of them as the commit has entries. - tagged = _git( - [*SCAN_SETTINGS, "ls-files", "-v", "-z"], - cwd=checkout, - env=env, - ) - index_entries = 0 - for record in tagged.split(b"\0"): - if not record: - continue - index_entries += 1 - tag = record[:1] - if tag != b"H": - path = os.fsdecode(record[2:]) - reason = { - b"S": "skip-worktree", - b"h": "assume-unchanged", - b"M": "unmerged", - }.get(tag, f"tagged {tag.decode('ascii', 'replace')!r}") - raise ShimRefusal( - f"the isolated checkout of {oid} has an index entry that is " - f"not an ordinary tracked file ({reason}): {path}" - ) - tree_entries = sum( - 1 - for record in _git(["ls-tree", "-r", "-z", oid], cwd=checkout, env=env).split( - b"\0" - ) - if record - ) - if index_entries != tree_entries: - raise ShimRefusal( - f"the isolated checkout of {oid} indexes {index_entries} entries " - f"where the commit has {tree_entries}" - ) - - entries = _protected_entries(checkout, oid, env) - for mode, _kind, _blob, relative in entries: - if mode not in PROTECTED_TREE_MODES: - raise ShimRefusal( - f"commit {oid} carries the protected path {relative} with " - f"tree mode {mode}; a protected path must be a regular file " - f"({' or '.join(PROTECTED_TREE_MODES)})" - ) - target = checkout / relative - try: - info = os.lstat(target) - except OSError as exc: - raise ShimRefusal( - f"the protected path {relative} cannot be inspected in the " - f"isolated checkout of {oid}: {exc.strerror}" - ) from exc - if not stat.S_ISREG(info.st_mode): - raise ShimRefusal( - f"the protected path {relative} is not a regular file in the " - f"isolated checkout of {oid}" - ) - executable = bool(info.st_mode & stat.S_IXUSR) - if executable != (mode == "100755"): - raise ShimRefusal( - f"the protected path {relative} is " - f"{'executable' if executable else 'not executable'} in the " - f"isolated checkout of {oid}, against tree mode {mode}" - ) - - hashes = _hash_working_files(checkout, [entry[3] for entry in entries], env) - for (_mode, _kind, blob, relative), found in zip(entries, hashes, strict=True): - if found != blob: - raise ShimRefusal( - f"the protected path {relative} holds bytes hashing to " - f"{found} in the isolated checkout of {oid}, against the " - f"{blob} the commit names" - ) - - tree = _git(["rev-parse", "HEAD^{tree}"], cwd=checkout, env=env) - return head, tree.decode("utf-8", "replace").strip() - - -def _worktree_removed( - clone_root: pathlib.Path, - checkout: pathlib.Path, - env: dict[str, str], -) -> bool: - """Try once to deregister this checkout, and report whether git did.""" - - try: - completed = subprocess.run( - [ - "git", - "-C", - str(clone_root), - "worktree", - "remove", - "--force", - str(checkout), - ], - env=env, - check=False, - capture_output=True, - ) - except OSError: - # Cleanup runs in a finally, so it never raises over whatever brought - # the run here; a failure to remove is reported at the end instead. - return False - return completed.returncode == 0 - - -def _remove_checkout( - clone_root: pathlib.Path, - scratch_root: pathlib.Path, - checkout: pathlib.Path | None, - env: dict[str, str] | None, -) -> None: - """Remove this run's checkout and everything else it wrote. - - Named by path, so it removes this checkout and no other. ``git worktree - prune`` is never used and must never be: it deregisters every prunable - worktree of the repository, including registrations that belong to - somebody else's work in the same clone. - - An add that failed part-way leaves no registration -- git validates the - commit before it writes one -- so the ordinary case after a failure is that - there is nothing to deregister and only the directory to delete. The order - here handles both: deregister, delete the directory tree, and if the first - attempt failed try once more, because git accepts the path of a - registration whose directory has already gone. Only if that also fails does - anything get printed, and then it names the directory and the command that - clears it. - """ - - removed = True - if checkout is not None and env is not None: - removed = _worktree_removed(clone_root, checkout, env) - shutil.rmtree(scratch_root, ignore_errors=True) - if not removed and checkout is not None and env is not None: - removed = _worktree_removed(clone_root, checkout, env) - if not removed: - print( - "thesis-facts append check warning: the isolated checkout " - f"registration at {checkout} could not be removed; clear it with " - f"git -C {clone_root} worktree remove --force {checkout}", - file=sys.stderr, - ) - - def expected_assertion_version_id(row: dict[str, Any]) -> str: return _receipt.expected_assertion_version_id(row, APPEND_GATE_SPEC) @@ -911,13 +422,15 @@ def check_rows(lines: list[str], prefix_count: int) -> None: def verify_append_gate( root: pathlib.Path, *, + commit: str, base_ref: str | None = None, trusted_code_root: pathlib.Path = CODE_ROOT, release_anchor_dir: pathlib.Path | None = None, -) -> str: - return _receipt.verify_append_gate( +) -> AppendGateVerdict: + return _receipt.verify_append_gate_verdict( root, spec=APPEND_GATE_SPEC, + commit=commit, base_ref=base_ref, trusted_code_root=trusted_code_root, release_anchor_dir=release_anchor_dir, @@ -943,7 +456,7 @@ def main() -> int: type=pathlib.Path, default=CODE_ROOT, help=( - "clone in which --commit is resolved and checked out " + "repository whose objects --commit selects " "(defaults to the checker's repository)" ), ) @@ -971,42 +484,29 @@ def main() -> int: ) return 1 - scratch_root: pathlib.Path | None = None - environment: dict[str, str] | None = None - checkout: pathlib.Path | None = None try: scratch_root = _scratch_root() try: environment = _gate_environment(clone_root, scratch_root) _audit_repository_config(clone_root, environment) - checkout = _isolated_checkout( - clone_root, - scratch_root, - args.commit, - environment, - ) - commit, tree = _assert_exact_checkout( - checkout, - args.commit, - environment, - ) with _frozen_environment(environment): - summary = verify_append_gate( - checkout, + verdict = verify_append_gate( + clone_root, + commit=args.commit, base_ref=args.base_ref, trusted_code_root=CODE_ROOT.resolve(), release_anchor_dir=args.release_anchor_dir, ) finally: - _remove_checkout(clone_root, scratch_root, checkout, environment) + shutil.rmtree(scratch_root) except ShimRefusal as exc: print(f"{REFUSAL_PREFIX}{exc}", file=sys.stderr) return 1 except AppendError as exc: print(f"{VERDICT_REFUSAL_PREFIX}{exc}", file=sys.stderr) return 1 - print(summary) - print(f"candidate commit {commit} tree {tree}") + print(verdict.summary) + print(f"candidate commit {verdict.candidate_commit} tree {verdict.candidate_tree}") return 0 @@ -1015,6 +515,7 @@ def main() -> int: "ASSERTION_CONTENT_KEYS", "AppendError", "AppendGateSpec", + "AppendGateVerdict", "CODE_ROOT", "DATA_SURFACE", "DOCUMENTED_GIT_VARIABLES", @@ -1024,7 +525,6 @@ def main() -> int: "GENESIS_SUPPORT_FILES", "MANIFEST_RE", "OBJECT_ID", - "PROTECTED_TREE_MODES", "RELEASE_MANIFEST_PREFIX", "ReleaseChainError", "ShimRefusal", diff --git a/scripts/verify_release_chain.py b/scripts/verify_release_chain.py index ad6b85c3..0231113f 100644 --- a/scripts/verify_release_chain.py +++ b/scripts/verify_release_chain.py @@ -2,7 +2,13 @@ # Thin shim over the receipt pin recorded in uv.lock. Any receipt upgrade # requires a fresh byte-equivalence proof at this repo's then-current pin BEFORE # the bump. -"""Offline verification for the witnessed thesis-ledger release chain.""" +"""Offline verification for the witnessed thesis-ledger release chain. + +The ordinary directory verifier retains its directory-as-read contract. With +``--base-ref``, receipt 0.6 compares the selected HEAD and base objects, then +verifies that same candidate's private materialization. The index and working +tree cannot substitute a different candidate between those two checks. +""" from __future__ import annotations @@ -13,6 +19,7 @@ from typing import Any import receipt.release_chain as _receipt +from receipt.snapshot import SnapshotError, TreeSnapshot try: from receipt_pins import LEDGER_SPEC @@ -53,14 +60,10 @@ ReleaseChainError = _receipt.ReleaseChainError ReleaseRecord = _receipt.ReleaseRecord -git_blob_bytes = _receipt.git_blob_bytes -git_file_entry = _receipt.git_file_entry -git_tree_entries = _receipt.git_tree_entries jsonl_line_offsets = _receipt.jsonl_line_offsets manifest_filename = _receipt.manifest_filename parse_created_at = _receipt.parse_created_at producer_signature_path_for_manifest = _receipt.producer_signature_path_for_manifest -resolve_base_commit = _receipt.resolve_base_commit sha256_bytes = _receipt.sha256_bytes @@ -179,40 +182,25 @@ def verify_release_chain( def verify_release_history_immutable( - root: pathlib.Path, base_ref: str + *, candidate: TreeSnapshot, base: TreeSnapshot ) -> tuple[str, set[str], dict[str, GitEntry]]: - return _receipt.verify_release_history_immutable(root, base_ref, LEDGER_SPEC) - - -def materialize_base_tree( - root: pathlib.Path, - commit: str, - destination: pathlib.Path, - release_entries: dict[str, GitEntry], -) -> None: - return _receipt.materialize_base_tree( - root, - commit, - destination, - release_entries, + return _receipt.verify_release_history_immutable( LEDGER_SPEC, + candidate=candidate, + base=base, ) def verify_base_release_chain( - root: pathlib.Path, - commit: str, - release_entries: dict[str, GitEntry], *, + base: TreeSnapshot, anchor_dir: pathlib.Path | None = None, enforce_production_pins: bool = True, clock_skew_seconds: int = DEFAULT_CLOCK_SKEW_SECONDS, ) -> ChainVerification: return _receipt.verify_base_release_chain( - root, - commit, - release_entries, - spec=LEDGER_SPEC, + LEDGER_SPEC, + base=base, anchor_dir=anchor_dir, enforce_production_pins=enforce_production_pins, clock_skew_seconds=clock_skew_seconds, @@ -255,16 +243,31 @@ def main() -> int: enforce_pins = anchor_dir is None try: if args.base_ref: - verify_release_history_immutable(root, args.base_ref) - verification = verify_release_chain( - root, - anchor_dir=anchor_dir, - require_chain=args.full or bool(args.base_ref), - verify_state=True, - enforce_production_pins=enforce_pins, - clock_skew_seconds=args.clock_skew_seconds, - ) - except (OSError, ReleaseChainError) as exc: + with ( + TreeSnapshot.select(root, "HEAD") as candidate, + TreeSnapshot.select(root, args.base_ref) as base, + ): + candidate.assert_ancestor(base) + verify_release_history_immutable(candidate=candidate, base=base) + # This package helper verifies any selected snapshot through + # its private materialization. Here the snapshot is HEAD, + # already compared against the selected base above. + verification = verify_base_release_chain( + base=candidate, + anchor_dir=anchor_dir, + enforce_production_pins=enforce_pins, + clock_skew_seconds=args.clock_skew_seconds, + ) + else: + verification = verify_release_chain( + root, + anchor_dir=anchor_dir, + require_chain=args.full, + verify_state=True, + enforce_production_pins=enforce_pins, + clock_skew_seconds=args.clock_skew_seconds, + ) + except (OSError, ReleaseChainError, SnapshotError) as exc: print(f"release chain verification failed: {exc}", file=sys.stderr) return 1 if not verification.releases: @@ -304,18 +307,13 @@ def main() -> int: "ReleaseRecord", "SCHEMA_VERSION", "STATE_PATH", - "git_blob_bytes", - "git_file_entry", - "git_tree_entries", "jsonl_line_offsets", "load_manifest", "main", "manifest_filename", - "materialize_base_tree", "parse_created_at", "producer_signature_path_for_manifest", "receipt_paths_for_manifest", - "resolve_base_commit", "sha256_bytes", "validate_manifest_schema", "verify_base_release_chain", diff --git a/tests/test_release_chain.py b/tests/test_release_chain.py index edcf6804..b383efce 100644 --- a/tests/test_release_chain.py +++ b/tests/test_release_chain.py @@ -341,8 +341,8 @@ def _run_gate( ) -> subprocess.CompletedProcess[str]: """Commit whatever the fixture just wrote and judge that commit. - The gate judges a commit it checks out for itself, so a fixture that has - only written into a working tree has not yet stated a candidate. Each of + The gate judges the named commit's objects, so a fixture that has only + written into a working tree has not yet stated a candidate. Each of these tests takes its base ref before it writes, so committing here makes the base the parent of what is judged. """ @@ -365,7 +365,9 @@ def _run_gate( ) -def _run_verifier(environment: ReleaseEnvironment) -> subprocess.CompletedProcess[str]: +def _run_verifier( + environment: ReleaseEnvironment, *, base_ref: str | None = None +) -> subprocess.CompletedProcess[str]: return _run( [ sys.executable, @@ -375,6 +377,7 @@ def _run_verifier(environment: ReleaseEnvironment) -> subprocess.CompletedProces "--anchor-dir", str(environment.anchors), "--full", + *(["--base-ref", base_ref] if base_ref is not None else []), ], cwd=environment.repo, check=False, @@ -704,6 +707,39 @@ def test_verifier_cli_full_chain_passes( assert "release chain OK: 2 releases" in completed.stdout +def test_verifier_cli_base_ref_uses_committed_chain_despite_dirty_workspace( + full_chain_environment: ReleaseEnvironment, +): + root = full_chain_environment.repo + base = _git(root, "rev-parse", "HEAD^") + (root / "ledger" / "official_observations.jsonl").write_text("{}\n") + + completed = _run_verifier(full_chain_environment, base_ref=base) + + assert completed.returncode == 0, completed.stderr + assert "release chain OK: 2 releases" in completed.stdout + + +def test_verifier_cli_base_ref_rejects_committed_rewrite_despite_repaired_workspace( + full_chain_environment: ReleaseEnvironment, +): + root = full_chain_environment.repo + base = _git(root, "rev-parse", "HEAD") + manifest = next((root / "releases" / "manifests").glob("0000-*.json")) + original = manifest.read_bytes() + manifest.write_bytes(original + b"\n") + _git(root, "add", str(manifest.relative_to(root))) + _git(root, "commit", "-qm", "rewrite historical release") + manifest.write_bytes(original) + + completed = _run_verifier(full_chain_environment, base_ref=base) + + assert completed.returncode == 1 + assert "existing release file bytes changed relative to" in completed.stderr + assert manifest.name in completed.stderr + assert completed.stdout == "" + + @pytest.mark.parametrize("index", [0, 1]) def test_verifier_requires_producer_signature_for_every_release( full_chain_environment: ReleaseEnvironment, diff --git a/tests/test_thesis_append_shim_isolation.py b/tests/test_thesis_append_shim_isolation.py index 10d12d73..9cb9bbab 100644 --- a/tests/test_thesis_append_shim_isolation.py +++ b/tests/test_thesis_append_shim_isolation.py @@ -1,24 +1,15 @@ -"""What the append-gate shim establishes before the gate reaches a verdict. - -The gate states its verdict about a clean checkout of one named commit, read -once, with no concurrent writer. The shim is the part that makes that true: it -checks the named commit out itself, under a git environment the candidate -cannot redirect, and refuses rather than reaching a verdict when the checkout is -not exactly that commit. - -Every case here is one way a repository could have handed the old shim a -directory that was not the commit it claimed to be, run code during the -checkout, or made the bytes on disk differ from the bytes the commit fixes. Each -runs the shim in a subprocess and asserts what it printed and what it exited -with, so the refusal text and the exit code are the assertions rather than an -internal state. - -Some cases need something to happen between the checkout and the assertion -- -a writer, a permission change -- which is a window no repository setting can -open on its own, because the shim disables hooks and owns the directory. Those -cases run the shim through a small driver that imports the module and wraps one -function, and each says in its own docstring what it wrapped and why the thing -it simulates has no other construction. Everything else runs the script itself. +"""Exercise receipt's released commit-addressed gate through the Chronicle shim. + +The shim supplies a full candidate OID, freezes the caller's git environment, +and prints the OIDs returned by the package. The installed wheel reads the +named commit's objects, so HEAD, the index and workspace may disagree without +changing its verdict. These subprocess tests cover all three workflow paths, +committed refusals, environment/configuration boundaries and scratch cleanup. + +The former exact-checkout tests are removed where their subject was a private +worktree, its index, byte comparison or registration: the 0.6 shim creates none. +Cases for committed unsafe entries and caller configuration remain because the +package now enforces those refusals itself. """ from __future__ import annotations @@ -31,6 +22,7 @@ import stat import subprocess import sys +from importlib import metadata import pytest @@ -59,6 +51,26 @@ ABSENT_OBJECT_ID = "0" * 40 +@pytest.fixture(scope="module", autouse=True) +def _released_receipt_wheel(): + """Every subprocess uses the released installed distribution, not a checkout.""" + + import receipt.append_gate + + distribution = metadata.distribution("receipt") + assert distribution.version == "0.6.0" + assert distribution.read_text("WHEEL") is not None + direct_url = distribution.read_text("direct_url.json") + if direct_url is not None: + source = json.loads(direct_url) + assert "archive_info" in source + assert source["url"].endswith("receipt-0.6.0-py3-none-any.whl") + installed = pathlib.Path(distribution.locate_file("")).resolve() + assert ( + pathlib.Path(receipt.append_gate.__file__).resolve().is_relative_to(installed) + ) + + def _release_manifests() -> list[pathlib.Path]: return sorted((ROOT / "releases" / "manifests").glob("[0-9]" * 4 + "-*.json")) @@ -258,204 +270,14 @@ def _assert_nothing_left_behind( ) -> None: """No scratch directory survives, and no registration names one.""" - leftovers = sorted(temporary_root.glob("thesis-facts-gate-*")) + leftovers = sorted(temporary_root.iterdir()) assert leftovers == [], leftovers listing = _git(clone, "worktree", "list", "--porcelain") assert "thesis-facts-gate-" not in listing, listing -def test_a_checkout_of_another_commit_is_refused(tmp_path): - """HEAD must be the commit that was named, not one near it. - - The writer simulated here is a checkout that answered for a different - commit. There is no repository setting that produces it -- the shim passes - the object id to `git worktree add` itself -- so the driver wraps - `_isolated_checkout` and has it check out the candidate's parent, which is - the closest thing to a checkout that looks right and is not. - """ - - clone, base, candidate = _replay_latest_release(tmp_path) - parent = _git(clone, "rev-parse", f"{candidate}^") - injection = """\ -_real_checkout = shim._isolated_checkout - - -def _checkout_the_parent(clone_root, scratch_root, oid, env): - parent = subprocess.run( - ["git", "-C", str(clone_root), "rev-parse", oid + "^"], - capture_output=True, - text=True, - check=True, - ).stdout.strip() - return _real_checkout(clone_root, scratch_root, parent, env) - - -shim._isolated_checkout = _checkout_the_parent -""" - completed = _run_shim( - clone, - commit=candidate, - base_ref=base, - temporary_root=tmp_path / "tmp", - injection=injection, - workspace=tmp_path / "driver", - ) - - assert completed.returncode == 1, completed.stdout + completed.stderr - assert completed.stdout == "" - assert REFUSED in completed.stderr - assert ( - f"the isolated checkout is at {parent}, not the named commit " - f"{candidate}" in completed.stderr - ) - _assert_nothing_left_behind(clone, tmp_path / "tmp") - - -def test_a_modified_tracked_file_in_the_checkout_is_refused(tmp_path): - """A tracked file written after the checkout is refused. - - A `post-checkout` hook is the realistic writer, and the shim disables hooks - by construction, which is proved separately. So the writer here is the - driver: it wraps `_isolated_checkout` and appends a byte to the ledger in - the directory the real function returned, which is exactly what a hook - would have had the opportunity to do. - """ - - clone, base, candidate = _replay_latest_release(tmp_path) - injection = """\ -_real_checkout = shim._isolated_checkout - - -def _write_into_the_checkout(clone_root, scratch_root, oid, env): - checkout = _real_checkout(clone_root, scratch_root, oid, env) - ledger = checkout / "ledger" / "official_observations.jsonl" - ledger.write_bytes(ledger.read_bytes() + b"{}\\n") - return checkout - - -shim._isolated_checkout = _write_into_the_checkout -""" - completed = _run_shim( - clone, - commit=candidate, - base_ref=base, - temporary_root=tmp_path / "tmp", - injection=injection, - workspace=tmp_path / "driver", - ) - - assert completed.returncode == 1, completed.stdout + completed.stderr - assert completed.stdout == "" - assert f"{REFUSED}the isolated checkout of {candidate} is not clean" in ( - completed.stderr - ) - assert LEDGER_RELATIVE in completed.stderr - _assert_nothing_left_behind(clone, tmp_path / "tmp") - - -def test_an_untracked_file_is_refused_though_the_clone_hides_untracked_files( - tmp_path, -): - """`status.showUntrackedFiles=no` in the clone does not hide the file. - - The setting is the repository's, and it is the kind of setting the shim - deliberately does not refuse in the configuration audit, because it changes - what a report says rather than what a checkout does. It is answered - instead: the shim forces `status.showUntrackedFiles=all` on the command - line, where it outranks any configured value, and passes - `--untracked-files=all` as well. - """ - - clone, base, candidate = _replay_latest_release(tmp_path) - _git(clone, "config", "status.showUntrackedFiles", "no") - injection = """\ -_real_checkout = shim._isolated_checkout - - -def _leave_an_untracked_file(clone_root, scratch_root, oid, env): - checkout = _real_checkout(clone_root, scratch_root, oid, env) - (checkout / "unexpected.txt").write_text("left behind\\n", encoding="utf-8") - return checkout - - -shim._isolated_checkout = _leave_an_untracked_file -""" - completed = _run_shim( - clone, - commit=candidate, - base_ref=base, - temporary_root=tmp_path / "tmp", - injection=injection, - workspace=tmp_path / "driver", - ) - - assert completed.returncode == 1, completed.stdout + completed.stderr - assert f"{REFUSED}the isolated checkout of {candidate} is not clean" in ( - completed.stderr - ) - assert "?? unexpected.txt" in completed.stderr - _assert_nothing_left_behind(clone, tmp_path / "tmp") - - -def test_an_ignored_file_is_refused(tmp_path): - """A file the commit's own .gitignore hides is still a file in the tree. - - `git status` says nothing about an ignored file however untracked files are - configured, so the clean-tree assertion cannot see one. The separate - `ls-files --others --ignored` is what does. - """ - - def _ignore_a_directory(root: pathlib.Path) -> None: - (root / ".gitignore").write_text("workspace/\n", encoding="utf-8") - - clone, base, candidate = _replay_latest_release( - tmp_path, prepare=_ignore_a_directory - ) - injection = """\ -_real_checkout = shim._isolated_checkout - - -def _leave_an_ignored_file(clone_root, scratch_root, oid, env): - checkout = _real_checkout(clone_root, scratch_root, oid, env) - hidden = checkout / "workspace" - hidden.mkdir() - (hidden / "smuggled.txt").write_text("ignored\\n", encoding="utf-8") - return checkout - - -shim._isolated_checkout = _leave_an_ignored_file -""" - completed = _run_shim( - clone, - commit=candidate, - base_ref=base, - temporary_root=tmp_path / "tmp", - injection=injection, - workspace=tmp_path / "driver", - ) - - assert completed.returncode == 1, completed.stdout + completed.stderr - assert f"{REFUSED}the isolated checkout of {candidate} carries an ignored " in ( - completed.stderr - ) - assert "workspace/smuggled.txt" in completed.stderr - _assert_nothing_left_behind(clone, tmp_path / "tmp") - - -def test_an_attribute_that_rewrites_the_checkout_bytes_is_refused(tmp_path): - """Committed attributes cannot make the gate read bytes the commit fixes. - - A `text eol=crlf` attribute over an LF blob is the whole attack in one - line: the checkout writes CRLF, `git status` stays clean because git - normalises on the way back in, and a content hash taken the ordinary way - reproduces the blob id by construction whatever is on disk. Hashing with - `--no-filters` is what makes the comparison a comparison, and this is the - case that fails without it. - - A `filter.*` driver would do the same thing with a program instead of a - conversion; the configuration audit refuses that one earlier, and its own - case is below. - """ +def test_checkout_line_endings_do_not_change_the_committed_verdict(tmp_path): + """Raw committed blobs stay authoritative when eol rewrites the workspace.""" def _convert_the_ledger_to_crlf(root: pathlib.Path) -> None: (root / ".gitattributes").write_text( @@ -465,69 +287,24 @@ def _convert_the_ledger_to_crlf(root: pathlib.Path) -> None: clone, base, candidate = _replay_latest_release( tmp_path, prepare=_convert_the_ledger_to_crlf ) - blob = _git(clone, "rev-parse", f"{candidate}:{LEDGER_RELATIVE}") - - completed = _run_shim( - clone, - commit=candidate, - base_ref=base, - temporary_root=tmp_path / "tmp", - ) - - assert completed.returncode == 1, completed.stdout + completed.stderr - assert completed.stdout == "" - assert f"{REFUSED}the protected path {LEDGER_RELATIVE} holds bytes hashing" in ( - completed.stderr - ) - assert f"against the {blob} the commit names" in completed.stderr - _assert_nothing_left_behind(clone, tmp_path / "tmp") - - -def test_an_execute_bit_that_disagrees_with_the_tree_mode_is_refused(tmp_path): - """The owner-execute bit on disk must be the one the commit states. - - `core.fileMode=false` tells git to stop comparing the bit, so `git status` - reports a clean tree while the file's permissions are not the ones the - commit records. That is why this is a separate assertion and not something - the clean-tree check already covers: under that setting the clean-tree check - cannot see it. The clearing of the bit itself is done by the driver, because - with the setting in place nothing in the repository has to do it for the - tree to still look clean. - """ - - clone, base, candidate = _replay_latest_release( - tmp_path, - prepare=lambda root: (root / LEDGER_RELATIVE).chmod(0o755), + tree = _git(clone, "rev-parse", f"{candidate}^{{tree}}") + ledger = clone / LEDGER_RELATIVE + ledger.write_bytes(ledger.read_bytes().replace(b"\n", b"\r\n")) + assert _git(clone, "hash-object", "--no-filters", LEDGER_RELATIVE) != _git( + clone, "rev-parse", f"{candidate}:{LEDGER_RELATIVE}" ) - assert _git(clone, "ls-tree", candidate, "--", LEDGER_RELATIVE).startswith("100755") - _git(clone, "config", "core.fileMode", "false") - injection = """\ -_real_checkout = shim._isolated_checkout - -def _clear_the_execute_bit(clone_root, scratch_root, oid, env): - checkout = _real_checkout(clone_root, scratch_root, oid, env) - (checkout / "ledger" / "official_observations.jsonl").chmod(0o644) - return checkout - - -shim._isolated_checkout = _clear_the_execute_bit -""" completed = _run_shim( clone, commit=candidate, base_ref=base, temporary_root=tmp_path / "tmp", - injection=injection, - workspace=tmp_path / "driver", ) - assert completed.returncode == 1, completed.stdout + completed.stderr - assert ( - f"{REFUSED}the protected path {LEDGER_RELATIVE} is not executable" - in completed.stderr + assert completed.returncode == 0, completed.stdout + completed.stderr + assert completed.stdout == ( + f"{APPEND_GATE_OK}\ncandidate commit {candidate} tree {tree}\n" ) - assert "against tree mode 100755" in completed.stderr _assert_nothing_left_behind(clone, tmp_path / "tmp") @@ -576,21 +353,14 @@ def _stage_a_gitlink_under_releases(root: pathlib.Path) -> None: def test_a_protected_path_that_is_not_a_file_is_refused( tmp_path, case, mutate, mutate_index, path, mode ): - """A link and a submodule boundary are both refused on their tree mode. - - Neither is a path whose bytes this commit fixes: a link is a name for - somewhere else, and a gitlink is a name for another repository whose - contents this commit does not carry. They are refused rather than followed. - - The refusal is the shim's and not the gate's -- the message says "refused" - and not "failed" -- so the mode was read before any verdict was reached - about what the tree holds. - """ + """The package refuses committed links and gitlinks without following them.""" clone, base, candidate = _replay_latest_release( tmp_path / case, mutate=mutate, mutate_index=mutate_index ) + assert _git(clone, "ls-tree", candidate, "--", path).startswith(mode) + completed = _run_shim( clone, commit=candidate, @@ -600,27 +370,24 @@ def test_a_protected_path_that_is_not_a_file_is_refused( assert completed.returncode == 1, completed.stdout + completed.stderr assert completed.stdout == "" - assert FAILED not in completed.stderr - assert REFUSED in completed.stderr + assert FAILED in completed.stderr assert path in completed.stderr - assert f"tree mode {mode}" in completed.stderr + assert ("symlink" if mode == "120000" else "not regular") in completed.stderr _assert_nothing_left_behind(clone, tmp_path / "tmp") def test_a_symlink_materialised_as_a_file_is_still_refused(tmp_path): - """`core.symlinks=false` turns the link into a regular file, and it is - still refused. - - Under that setting git writes the link's target text into an ordinary file, - which passes every test that asks what the thing on disk is. The tree mode - is what says the commit does not fix that file's bytes, and the tree mode is - what the refusal names. - """ + """A repaired regular workspace file cannot hide a committed symlink.""" clone, base, candidate = _replay_latest_release( tmp_path, mutate=_commit_a_symlink_under_releases ) _git(clone, "config", "core.symlinks", "false") + link = clone / "releases" / "manifests" / "0000-shortcut.json" + target = os.readlink(link) + link.unlink() + link.write_text(target, encoding="utf-8") + assert link.is_file() and not link.is_symlink() completed = _run_shim( clone, @@ -630,19 +397,13 @@ def test_a_symlink_materialised_as_a_file_is_still_refused(tmp_path): ) assert completed.returncode == 1, completed.stdout + completed.stderr - assert REFUSED in completed.stderr - assert "tree mode 120000" in completed.stderr + assert FAILED in completed.stderr + assert "symlink" in completed.stderr _assert_nothing_left_behind(clone, tmp_path / "tmp") -def test_a_checkout_that_never_happened_leaves_no_registration(tmp_path): - """An object id nothing holds fails the add, and leaves nothing behind. - - git validates the commit before it writes a registration, so the ordinary - outcome is that there is nothing to deregister and only a directory to - delete. The assertion is that both are true afterwards, which is what the - `finally` is for. - """ +def test_an_absent_candidate_object_is_refused_without_a_verdict(tmp_path): + """An unavailable named object fails closed and leaves no scratch state.""" clone, _base, _candidate = _replay_latest_release(tmp_path) before = _git(clone, "worktree", "list", "--porcelain") @@ -655,9 +416,8 @@ def test_a_checkout_that_never_happened_leaves_no_registration(tmp_path): assert completed.returncode == 1, completed.stdout + completed.stderr assert completed.stdout == "" - assert f"{REFUSED}cannot check {ABSENT_OBJECT_ID} out in isolation" in ( - completed.stderr - ) + assert FAILED in completed.stderr + assert ABSENT_OBJECT_ID in completed.stderr assert _git(clone, "worktree", "list", "--porcelain") == before _assert_nothing_left_behind(clone, tmp_path / "tmp") @@ -672,22 +432,19 @@ def test_a_checkout_that_never_happened_leaves_no_registration(tmp_path): RECORDING = __RECORDING__ -import receipt.append_gate as gate # noqa: E402 - _real_run = subprocess.run +_real_popen = subprocess.Popen _records = [] -def _spy(*args, **kwargs): +def _record(args, kwargs, caller): argv = args[0] if args else kwargs.get("args") given = kwargs.get("env") - # A call that passes no environment gets the process's, so what the child - # actually receives is os.environ at the moment of the call either way. effective = dict(given) if given is not None else dict(os.environ) _records.append( { "argv": [str(item) for item in argv], - "caller": _sys._getframe(1).f_globals.get("__name__", "?"), + "caller": caller, "explicit": given is not None, "git": { name: value @@ -696,11 +453,20 @@ def _spy(*args, **kwargs): }, } ) + + +def _spy_run(*args, **kwargs): + _record(args, kwargs, _sys._getframe(1).f_globals.get("__name__", "?")) return _real_run(*args, **kwargs) -subprocess.run = _spy -gate.subprocess.run = _spy +def _spy_popen(*args, **kwargs): + _record(args, kwargs, _sys._getframe(1).f_globals.get("__name__", "?")) + return _real_popen(*args, **kwargs) + + +subprocess.run = _spy_run +subprocess.Popen = _spy_popen def _record_and_exit(code): @@ -714,22 +480,11 @@ def _record_and_exit(code): def test_no_inherited_git_variable_reaches_any_child(tmp_path): - """Every git the run starts sees exactly three GIT_ variables. - - The caller's environment is loaded with every GIT_ name git(1) documents, - plus GIT_CONFIG_COUNT, which git-config(1) documents and git(1) does not -- - the shim drops by prefix rather than by list, so a name outside the list is - dropped too. - - Both halves of the run are checked, told apart by which module made the - call. The shim passes its environment explicitly. The gate builds its own - from ``os.environ`` and says in as many words that it is not a sanitizer and - that a caller which does not control the environment it invokes the package - in has a problem outside that function's scope. Controlling ``os.environ`` - for the duration of the gate call is how the shim answers that, and this is - the test that it works: whichever module made the call, and whether or not - an environment was passed, what the child receives carries exactly the three - variables and none of the hostile values. + """No inherited GIT_ value reaches shim calls or the package's batch child. + + The shim drops all names by prefix; receipt then freezes its own object + reader environment. Recording both run and Popen includes the long-lived + cat-file child, not just discovery/configuration calls. """ clone, base, candidate = _replay_latest_release(tmp_path) @@ -771,6 +526,12 @@ def test_no_inherited_git_variable_reaches_any_child(tmp_path): ] assert by_shim, [record["caller"] for record in git_calls] assert by_gate, [record["caller"] for record in git_calls] + assert any("cat-file" in record["argv"] for record in by_gate) + assert all("worktree" not in record["argv"] for record in git_calls) + shim_globals = {record["git"]["GIT_CONFIG_GLOBAL"] for record in by_shim} + assert all( + record["git"]["GIT_CONFIG_GLOBAL"] not in shim_globals for record in by_gate + ) for record in git_calls: assert set(record["git"]) == { @@ -813,13 +574,7 @@ def test_the_documented_variable_list_is_not_narrower_than_the_drop(): def test_a_post_checkout_hook_in_the_clone_does_not_run(tmp_path): - """`git worktree add` runs post-checkout, and here it does not. - - The hook is a real one, installed where git looks by default. It is not - reached because the shim points `core.hooksPath` at an empty directory it - made, from a global configuration file the repository's own configuration - is separately checked for not overriding. - """ + """Object reads never invoke the clone's executable post-checkout hook.""" clone, base, candidate = _replay_latest_release(tmp_path) marker = tmp_path / "the-hook-ran" @@ -844,36 +599,6 @@ def test_a_post_checkout_hook_in_the_clone_does_not_run(tmp_path): _assert_nothing_left_behind(clone, tmp_path / "tmp") -def test_an_unrelated_prunable_worktree_keeps_its_registration(tmp_path): - """Cleanup removes this run's checkout by name and nothing else. - - `git worktree prune` would have removed the other registration too -- the - second half of this test shows that it does -- which is why the shim never - calls it. A worktree whose directory has gone is prunable, and a repository - in which other work is going on can hold several at any moment. - """ - - clone, base, candidate = _replay_latest_release(tmp_path) - other = tmp_path / "somebody-elses-worktree" - _git(clone, "worktree", "add", "--detach", str(other), base) - shutil.rmtree(other) - assert str(other) in _git(clone, "worktree", "list", "--porcelain") - - completed = _run_shim( - clone, - commit=candidate, - base_ref=base, - temporary_root=tmp_path / "tmp", - ) - - assert completed.returncode == 0, completed.stdout + completed.stderr - assert str(other) in _git(clone, "worktree", "list", "--porcelain") - _assert_nothing_left_behind(clone, tmp_path / "tmp") - - _git(clone, "worktree", "prune") - assert str(other) not in _git(clone, "worktree", "list", "--porcelain") - - @pytest.mark.parametrize( ("key", "value", "reported"), [ @@ -883,17 +608,8 @@ def test_an_unrelated_prunable_worktree_keeps_its_registration(tmp_path): ("include.path", "extra-config", "include.path"), ], ) -def test_a_clone_that_redirects_the_checkout_is_refused_before_it_happens( - tmp_path, key, value, reported -): - """Four repository settings decide what a checkout does, and all are refused. - - Each of them outranks the global file the shim wrote: a hook path moves the - directory the shim emptied, a filesystem monitor and a content filter each - name a program git runs, and an include pulls in a file this audit has not - read and which could set any of the other three. The audit runs before the - checkout, so nothing has been created when the refusal is made. - """ +def test_repository_configuration_redirects_are_refused(tmp_path, key, value, reported): + """The package audits hooks, fsmonitor and includes; the shim keeps filters.""" clone, _base, candidate = _replay_latest_release(tmp_path) _git(clone, "config", key, value) @@ -907,8 +623,8 @@ def test_a_clone_that_redirects_the_checkout_is_refused_before_it_happens( assert completed.returncode == 1, completed.stdout + completed.stderr assert completed.stdout == "" - assert REFUSED in completed.stderr - assert f"sets {reported} in its local configuration" in completed.stderr + assert (REFUSED if key.startswith("filter.") else FAILED) in completed.stderr + assert reported in completed.stderr.lower() assert _git(clone, "worktree", "list", "--porcelain") == before _assert_nothing_left_behind(clone, tmp_path / "tmp") @@ -948,7 +664,20 @@ def test_a_commit_that_is_not_a_full_object_id_is_refused_by_the_parser( assert "a full object id is required" in completed.stderr -def test_an_exception_after_the_checkout_still_removes_everything(tmp_path): +def test_a_commit_argument_is_required(tmp_path): + completed = subprocess.run( + [sys.executable, str(SHIM), "--root", str(tmp_path)], + cwd=tmp_path, + capture_output=True, + text=True, + check=False, + ) + assert completed.returncode == 2 + assert "--commit" in completed.stderr + assert "required" in completed.stderr + + +def test_an_exception_during_verification_removes_the_frozen_configuration(tmp_path): """The cleanup is in a `finally`, so it does not need the run to succeed. The gate is replaced by something that raises, which stands for every way @@ -977,12 +706,119 @@ def _explode(*args, **kwargs): _assert_nothing_left_behind(clone, tmp_path / "tmp") +WORKFLOW_PATHS = ("pull_request_target", "candidate", "push") + + +def _workflow_arguments(clone, workflow, base, candidate): + """Select the full OID exactly where each workflow obtains its argument.""" + + if workflow == "pull_request_target": + # GitHub's test merge has the accepted candidate tree and two parents. + merge = _git( + clone, + "commit-tree", + f"{candidate}^{{tree}}", + "-p", + base, + "-p", + candidate, + "-m", + "synthetic pull request merge", + ) + environment = {"MERGE_SHA": merge, "BASE_SHA": base} + return environment["MERGE_SHA"], environment["BASE_SHA"] + if workflow == "candidate": + return _git(clone, "rev-parse", "HEAD"), base + assert workflow == "push" + environment = {"GITHUB_SHA": candidate} + return environment["GITHUB_SHA"], None + + +@pytest.mark.parametrize("workflow", WORKFLOW_PATHS) +def test_every_workflow_verifies_the_named_commit_when_local_state_disagrees( + tmp_path, workflow +): + """Each installed-wheel path prints its selected OIDs despite other state.""" + + clone, base, candidate = _replay_latest_release(tmp_path) + selected, base_ref = _workflow_arguments(clone, workflow, base, candidate) + tree = _git(clone, "rev-parse", f"{selected}^{{tree}}") + ledger = clone / LEDGER_RELATIVE + committed_blob = _git(clone, "rev-parse", f"{selected}:{LEDGER_RELATIVE}") + + # HEAD, the index and the workspace each disagree with the selected tree. + _git(clone, "update-ref", "HEAD", base) + ledger.write_bytes(b"{}\n") + _git(clone, "add", LEDGER_RELATIVE) + ledger.write_bytes(b"[]\n") + ledger.chmod(0o755) + (clone / "untracked.txt").write_text("untracked\n", encoding="utf-8") + (clone / ".git" / "info" / "exclude").write_text("ignored.txt\n", encoding="utf-8") + (clone / "ignored.txt").write_text("ignored\n", encoding="utf-8") + _git(clone, "config", "status.showUntrackedFiles", "no") + assert _git(clone, "rev-parse", "HEAD") != selected + assert _git(clone, "rev-parse", f":{LEDGER_RELATIVE}") != committed_blob + assert _git(clone, "hash-object", "--no-filters", LEDGER_RELATIVE) != committed_blob + + completed = _run_shim( + clone, commit=selected, base_ref=base_ref, temporary_root=tmp_path / "tmp" + ) + + assert completed.returncode == 0, completed.stdout + completed.stderr + assert completed.stderr == "" + lines = completed.stdout.splitlines() + assert len(lines) == 2 + assert lines[0].startswith("thesis-facts append check OK:") + assert lines[1] == f"candidate commit {selected} tree {tree}" + if base_ref is not None: + assert lines[0] == APPEND_GATE_OK + _assert_nothing_left_behind(clone, tmp_path / "tmp") + + +@pytest.mark.parametrize("workflow", WORKFLOW_PATHS) +def test_every_workflow_refuses_bad_committed_data_after_the_workspace_is_repaired( + tmp_path, workflow +): + """Repairing HEAD, index and disk cannot turn a refused candidate into PASS.""" + + clone, base = _replay_current_state(tmp_path) + ledger = clone / LEDGER_RELATIVE + trusted = ledger.read_bytes() + _drop_the_last_row(clone) + candidate = _commit(clone, "truncate the committed ledger") + selected, base_ref = _workflow_arguments(clone, workflow, base, candidate) + before = _run_shim( + clone, commit=selected, base_ref=base_ref, temporary_root=tmp_path / "tmp" + ) + assert before.returncode == 1, before.stdout + before.stderr + assert before.stdout == "" + assert FAILED in before.stderr + + _git(clone, "update-ref", "HEAD", base) + ledger.write_bytes(trusted) + _git(clone, "add", LEDGER_RELATIVE) + assert _git(clone, "rev-parse", "HEAD") != selected + assert _git(clone, "rev-parse", f":{LEDGER_RELATIVE}") == _git( + clone, "rev-parse", f"{base}:{LEDGER_RELATIVE}" + ) + completed = _run_shim( + clone, commit=selected, base_ref=base_ref, temporary_root=tmp_path / "tmp" + ) + assert completed.returncode == 1, completed.stdout + completed.stderr + assert completed.stdout == "" + assert FAILED in completed.stderr + if base_ref is not None: + assert "change truncates the ledger" in completed.stderr + assert completed.stderr == before.stderr + _assert_nothing_left_behind(clone, tmp_path / "tmp") + + def test_the_witnessed_release_passes_and_names_its_commit(tmp_path): """The accepting case, end to end, over a committed replay of the last release. stdout is the gate's own summary line and then one line the shim adds, - naming the commit it checked out and that commit's tree. + naming the candidate commit and tree returned by the installed package. """ clone, base, candidate = _replay_latest_release(tmp_path) @@ -1095,11 +931,8 @@ def test_a_true_append_is_accepted_end_to_end(tmp_path): def test_the_scratch_directory_is_private_to_the_run(tmp_path): """The private directory is created 0700, and the shim asserts that it was. - Everything the run writes lives under it: the global configuration file - whose contents decide whether hooks run, the empty hook directory itself, - and the checkout the gate reads. The assertion here is on the other side of - the same fact -- that the shim records the mode it requires and would refuse - a directory that did not have it. + The shim keeps its frozen global safe.directory configuration under this + private directory and removes it when verification ends. """ clone, base, candidate = _replay_latest_release(tmp_path) @@ -1144,22 +977,8 @@ def _sparse_patterns(root: pathlib.Path, *patterns: str) -> None: @pytest.mark.parametrize("scope", ["local", "worktree"]) -def test_a_sparse_checkout_is_refused_before_it_can_pass(tmp_path, scope): - """A clone configured for sparse checkout cannot produce the commit. - - With ``core.sparseCheckout`` on and a patterns file in the shared git - directory keeping the protected paths but dropping an unprotected tracked - file, ``git worktree add`` materialises a strict subset of the commit; the - omitted entry is marked skip-worktree, so ``git status`` and the ignored - listing both stay empty, and on the push invocation (no ``--base-ref``) - the gate reads only the state files and the release tree and said OK - (peer review, round 1). What this test shows is the first line: the key - is refused by the configuration audit before any checkout happens, in both - scopes it can be set in, so the sparse checkout never occurs here. The - second line — the checkout assertions refusing a skip-worktree entry, and - an index shorter than the commit — is shown by the two tests that follow, - which bypass the audit. - """ +def test_the_shim_retains_its_sparse_checkout_policy(tmp_path, scope): + """The caller retains sparse-configuration refusals receipt does not audit.""" def prepare(root: pathlib.Path) -> None: (root / "notes.txt").write_text("unprotected, tracked\n", encoding="utf-8") @@ -1177,65 +996,9 @@ def prepare(root: pathlib.Path) -> None: assert completed.stdout == "" assert completed.stderr.startswith(REFUSED) assert "core.sparsecheckout" in completed.stderr.lower() - assert "subset of the commit" in completed.stderr _assert_nothing_left_behind(clone, tmp_path / "tmp") -def test_a_checkout_that_is_not_the_whole_commit_is_refused(tmp_path): - """The second line: a skip-worktree entry, however it arose, refuses. - - The audit is bypassed here by injecting the sparse configuration through - the shim's own frozen global file after the audit ran, so the checkout - itself is sparse; the index assertion names the omitted entry. - """ - - def prepare(root: pathlib.Path) -> None: - (root / "notes.txt").write_text("unprotected, tracked\n", encoding="utf-8") - - clone, base, candidate = _replay_latest_release(tmp_path, prepare=prepare) - _sparse_patterns(clone, "/ledger/", "/releases/") - injection = """ -_real_audit = shim._audit_repository_config -def _audit_then_sparse(clone_root, env): - _real_audit(clone_root, env) - subprocess.run(["git", "config", "-f", env["GIT_CONFIG_GLOBAL"], "core.sparseCheckout", "true"], check=True, env=env) -shim._audit_repository_config = _audit_then_sparse -""" - completed = _run_shim( - clone, - commit=candidate, - temporary_root=tmp_path / "tmp", - injection=injection, - workspace=tmp_path / "workspace", - ) - assert completed.returncode == 1, completed.stdout + completed.stderr - assert completed.stdout == "" - assert completed.stderr.startswith(REFUSED) - assert "skip-worktree" in completed.stderr - _assert_nothing_left_behind(clone, tmp_path / "tmp") - - -def test_git_runs_post_checkout_on_worktree_add_when_hooks_are_not_redirected(tmp_path): - """Positive control for the hook test above (peer review, round 1). - - Without the shim's ``core.hooksPath`` redirection, ``git worktree add`` - does run ``post-checkout``; so the absence of the marker in the shim's run - is the shim's doing and not git's. - """ - - clone, base, candidate = _replay_latest_release(tmp_path) - marker = tmp_path / "the-hook-ran" - hook = clone / ".git" / "hooks" / "post-checkout" - hook.parent.mkdir(parents=True, exist_ok=True) - hook.write_text(f"#!/bin/sh\nprintf 'ran' > {marker}\n", encoding="utf-8") - hook.chmod(0o755) - _git(clone, "worktree", "add", "--detach", str(tmp_path / "plain"), candidate) - assert marker.exists(), ( - "git did not run post-checkout on worktree add; the hook test would be vacuous" - ) - _git(clone, "worktree", "remove", "--force", str(tmp_path / "plain")) - - def test_the_documented_variable_list_covers_the_installed_manual(tmp_path): """The tuple is held to git's own documentation where it is installed. @@ -1272,73 +1035,3 @@ def test_the_documented_variable_list_covers_the_installed_manual(tmp_path): name for name in mentioned if name not in documented and not name.endswith("_") ) assert missing == [], f"documented in git(1) but not in the tuple: {missing}" - - -def test_an_index_shorter_than_the_commit_is_refused(tmp_path): - """The entry-count half of the checkout assertion, exercised on its own. - - Every tag is ``H`` here; the shim's ``ls-files -v -z`` read is made to - return one record fewer than the commit has entries, which is the shape - the count check exists for (peer review, round 2). - """ - - clone, base, candidate = _replay_latest_release(tmp_path) - injection = """ -_real_git = shim._git -def _short_index(arguments, **kwargs): - payload = _real_git(arguments, **kwargs) - if "ls-files" in arguments and "-v" in arguments: - records = [r for r in payload.split(b"\\0") if r] - payload = b"\\0".join(records[:-1]) + b"\\0" - return payload -shim._git = _short_index -""" - completed = _run_shim( - clone, - commit=candidate, - temporary_root=tmp_path / "tmp", - injection=injection, - workspace=tmp_path / "workspace", - ) - assert completed.returncode == 1, completed.stdout + completed.stderr - assert completed.stdout == "" - assert completed.stderr.startswith(REFUSED) - assert "indexes" in completed.stderr and "where the commit has" in completed.stderr - _assert_nothing_left_behind(clone, tmp_path / "tmp") - - -def test_the_byte_comparison_covers_the_whole_data_surface(tmp_path): - """Every tracked path on the gate's data surface is in the protected set. - - The protected prefixes come from the chain specification; the data surface - is a separate set of globs on the gate specification. This holds the two - together at the repository's own head, so a widened data surface cannot - outgrow the byte comparison unnoticed (peer review, round 2). - """ - - import fnmatch - import importlib - - sys.path.insert(0, str(SHIM_SCRIPTS)) - shim = importlib.import_module("check_thesis_facts_append") - clone, commit = _replay_current_state(tmp_path) - scratch = tmp_path / "scratch" - scratch.mkdir() - environment = shim._gate_environment(clone, scratch) - protected = { - entry[3] for entry in shim._protected_entries(clone, commit, environment) - } - tracked = _git(clone, "ls-tree", "-r", "--name-only", commit).splitlines() - patterns = list(shim.APPEND_GATE_SPEC.data_surface) - on_surface = { - path - for path in tracked - if any( - (pattern.endswith("/**") and path.startswith(pattern[:-2])) - or path == pattern - or fnmatch.fnmatchcase(path, pattern) - for pattern in patterns - ) - } - assert on_surface, "the data surface matched nothing; the test is vacuous" - assert on_surface <= protected, sorted(on_surface - protected) diff --git a/uv.lock b/uv.lock index 42d5994e..f7ea1c0b 100644 --- a/uv.lock +++ b/uv.lock @@ -1687,7 +1687,7 @@ requires-dist = [ { name = "pypdf", specifier = ">=6.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0,<9" }, { name = "pyyaml", specifier = ">=6.0" }, - { name = "receipt", specifier = "==0.5.2" }, + { name = "receipt", specifier = "==0.6.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5.0" }, { name = "scipy", specifier = ">=1.11.0" }, { name = "sqlmodel", specifier = ">=0.0.22" }, @@ -2327,14 +2327,14 @@ wheels = [ [[package]] name = "receipt" -version = "0.5.2" +version = "0.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4e/62/ea02501263f9d484298d79248799e7a48712593d1c9eb9b910ed313421c3/receipt-0.5.2.tar.gz", hash = "sha256:8e27ff67e4ff20eb9fea44db71b4adb8d4bbbcbe5c3f490eb92aa1b4c74f1367", size = 1690418, upload-time = "2026-09-04T08:59:41.566Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/2d/952e672b5de571712ab896b9f93fd3ea373429eac0b24c88fb90dcc6cfc0/receipt-0.6.0.tar.gz", hash = "sha256:c84f221d83099dcd86d271de8ecadf10e7eb987b7a13761af520ac9311bf773d", size = 1615892, upload-time = "2026-09-05T16:51:22.810508Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/f5/0865ff65adbeba37c3df25519611ac6daa5f979121866358d3a5140f1701/receipt-0.5.2-py3-none-any.whl", hash = "sha256:b6c1dec5fec8ad23c2ed74302d87ee9c162ed795b503eee494258bb77335b1b7", size = 295432, upload-time = "2026-09-04T08:59:39.626Z" }, + { url = "https://files.pythonhosted.org/packages/ff/40/1bdf7498ff671dcb69dba776695f9d257d86a39dff689da63263282dc5b7/receipt-0.6.0-py3-none-any.whl", hash = "sha256:84dd540bc77f14547bcf5b4654ff22184a404aa280d8b13cda8e179593575734", size = 233986, upload-time = "2026-09-05T16:51:21.113950Z" }, ] [[package]] From b46b153c7aeb8ba97e1b1c2533f796de46ce5c7c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 5 Sep 2026 13:13:19 -0400 Subject: [PATCH 5/8] Record released-wheel verification and blocked remote delivery Co-Authored-By: GPT-6 --- PROGRESS.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/PROGRESS.md b/PROGRESS.md index dcbe7451..a34959ec 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -41,3 +41,13 @@ Done: Next: finish release-chain compatibility review, commit implementation, run the gate on that committed head, and attempt both branch pushes. Review: release-chain compatibility approved with no blocking defects. Kept the legacy standalone --full help bytes for the required help differential; the module docstring states that adding --base-ref selects HEAD objects and verifies their private materialization. + +State: local implementation and verification complete; remote delivery blocked by sandbox DNS. + +Done: +- Implementation commit d16804162e7e0da3620e5eb23e0e365e8bcdbaba has tree a1d9b9f4c23290358b1448f37de4283c4d05c8a6. Direct released-wheel base-relative and push invocations both pass and print those exact OIDs. +- Verified every installed receipt package file (14) matches the published wheel byte for byte. +- Both `git push -u origin shim/receipt-0.6` and the workflow branch push fail: Could not resolve host: github.com. No PR was opened, no hosted workflow result is claimed, and branch protection is unchanged. +- Draft PR bodies are prepared at /tmp/chronicle-receipt-0.6-pr-body.md and /tmp/chronicle-receipt-0.6-workflow-pr-body.md. The main draft title is: Run the append gate on receipt 0.6.0: commit-addressed verification. The bodies include test census, proof, removal rationale, responsibilities, workflow arguments, and the up-to-date-branch recommendation. + +Next (requires network): push both local branches; open draft PR shim/receipt-0.6 -> codex/thesis-ledger-facts and the byte-identical workflow-copy PR -> main; require Append gate and Trusted base append gate to pass. Final report is OUTPUT.md in this worktree; its last lines record PR status, final head and totals. From 727cd474baeced5c856a11cb1fd32320f4544adc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 5 Sep 2026 13:16:22 -0400 Subject: [PATCH 6/8] Keep the lane record out of the tree PROGRESS.md is the lane's working log, not part of the shim. Co-Authored-By: Claude Fable 5.1 --- PROGRESS.md | 53 ----------------------------------------------------- 1 file changed, 53 deletions(-) delete mode 100644 PROGRESS.md diff --git a/PROGRESS.md b/PROGRESS.md deleted file mode 100644 index a34959ec..00000000 --- a/PROGRESS.md +++ /dev/null @@ -1,53 +0,0 @@ -bc619d697533e8f37b6520d3d9464b0afdcefffb -receipt 0.6.0 wheel sha256 84dd540bc77f14547bcf5b4654ff22184a404aa280d8b13cda8e179593575734 -verify-publish: PUBLISH VERIFIED 2026-09-05 12:51 - -State: release preconditions verified; implementation not yet changed. - -Done: -- Read PLAN-0.6 sections 3.10, 3.4, 3.9 and residual rows 2/14; read merged Chronicle #241, #242 and the existing shim/tests. -- Starting branch shim/receipt-0.6 equals origin/codex/thesis-ledger-facts at the first-line OID. -- receipt v0.6.0 peels to a2228e40fc0bb2d8e525cae61b91ea495eec4112, the reviewed head in receipt #59 and approval comment 5553008482. -- PyPI publication is corroborated by release-smoke-060/pypi.json, verify-publish-060.log (completed success), and hashing the published local wheel against its recorded PyPI digest above. Shell DNS cannot resolve api.github.com or pypi.org; GitHub connector can read PR records. -- Exact local receipt tag diff v0.5.2..v0.6.0 -- src/receipt/append_gate.py confirms commit="HEAD" on both verifiers and AppendGateVerdict(summary, candidate_commit, candidate_tree, base_commit, base_tree, object_format, name_repertoire). - -Next: -- Run the byte-equivalence proof at receipt 0.5.2 before changing the pin. -- Migrate the shim to the released wheel, adapt installed-package tests and workflow documentation, then rerun proof and required suites. -- Prepare draft PR and default-branch workflow copy; push if networking permits. Write final report to OUTPUT.md unless another output path is supplied. - -Progress policy: committed under the standing order; asked for clarification because the Record paragraph also says untracked. - -Done: pre-bump byte-equivalence proof at installed receipt 0.5.2: 18 passed in 29.66s (`uv run --frozen --no-sync -q pytest -q tests/test_receipt_shim_transparency.py`; isolated writable UV_CACHE_DIR, offline). The environment was copied from the 0.5.2 shim worktree; ordinary frozen sync initially lacked cached build dependencies. No shim/pin edits preceded this proof. - -State: migration implemented; released-wheel verification running. - -Done: -- Kept all three explicit workflow OID arguments and the base-owned help compatibility branches; updated object-read documentation and pipefail on every shell block. -- Strengthened adversarial success/refusal assertions to bind the verdict line; 28 passed, 5 existing strict xfails against installed receipt 0.6.0. -- Prepared the default-branch byte-identical workflow in local branch shim/receipt-0.6-workflow at 2f9f5de5c534d15fc64d70a6f742deb7ccf784ba, based on origin/main 743742c. All four workflow run blocks pass bash -n. -- Provisioned the released local wheel offline and verified ordinary uv sync --frozen succeeds using writable /tmp/chronicle-uv-cache. Lock metadata copies the exact published artifact URLs, sizes and SHA-256s; UV_FROZEN=false uv lock --check --offline succeeds. - -Next: finish exact requested suite and released-pin byte proof, include the necessary release-chain shim compatibility change discovered by the proof, then commit and attempt delivery. - -Done: -- Exact requested command at receipt 0.6.0: `uv run --frozen -q pytest -q tests/test_thesis_append_shim_isolation.py tests/test_thesis_append_adversarial.py tests/test_build_series_catalog.py` -> 217 passed, 5 xfailed in 52.87s. Runs use UV_CACHE_DIR=/tmp/chronicle-uv-cache and UV_OFFLINE=1 because shell network access is blocked. -- Released-pin proof plus workflow companions: tests/test_receipt_shim_transparency.py, tests/test_release_chain.py, tests/test_policyengine_ledger.py -> 91 passed in 31.31s (18 + 64 + 9). Authenticated oracle fixtures unchanged. -- Current census: isolation 34, adversarial 33 (5 strict xfails), catalog 155, transparency 18, release chain 64, ledger 9; 313 total cases, 308 passed and 5 xfailed across these suites. -- Removed ten tests whose subject was the deleted exact checkout/worktree logic; retained and adapted committed mode/configuration tests, added seven installed-wheel cases for mandatory commit and the three workflow paths' divergent/repaired local state. -- The byte proof exposed obsolete release-chain shim exports. Removed four unused git helper aliases and materialize_base_tree; migrated live history/base wrappers and CLI --base-ref to entered snapshots and candidate materialization. Added two CLI cases for committed history despite dirty/repaired working trees. Ordinary directory verification and all original oracle bytes are preserved. -- Independent review approved append shim, dependency lock, workflows and tests with no actionable findings. Ruff check passes on all changed Python files; formatted the isolation test. - -Next: finish release-chain compatibility review, commit implementation, run the gate on that committed head, and attempt both branch pushes. - -Review: release-chain compatibility approved with no blocking defects. Kept the legacy standalone --full help bytes for the required help differential; the module docstring states that adding --base-ref selects HEAD objects and verifies their private materialization. - -State: local implementation and verification complete; remote delivery blocked by sandbox DNS. - -Done: -- Implementation commit d16804162e7e0da3620e5eb23e0e365e8bcdbaba has tree a1d9b9f4c23290358b1448f37de4283c4d05c8a6. Direct released-wheel base-relative and push invocations both pass and print those exact OIDs. -- Verified every installed receipt package file (14) matches the published wheel byte for byte. -- Both `git push -u origin shim/receipt-0.6` and the workflow branch push fail: Could not resolve host: github.com. No PR was opened, no hosted workflow result is claimed, and branch protection is unchanged. -- Draft PR bodies are prepared at /tmp/chronicle-receipt-0.6-pr-body.md and /tmp/chronicle-receipt-0.6-workflow-pr-body.md. The main draft title is: Run the append gate on receipt 0.6.0: commit-addressed verification. The bodies include test census, proof, removal rationale, responsibilities, workflow arguments, and the up-to-date-branch recommendation. - -Next (requires network): push both local branches; open draft PR shim/receipt-0.6 -> codex/thesis-ledger-facts and the byte-identical workflow-copy PR -> main; require Append gate and Trusted base append gate to pass. Final report is OUTPUT.md in this worktree; its last lines record PR status, final head and totals. From 5a9d02f5134909ed26413ddd7b38fdbc9283106d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 5 Sep 2026 13:31:57 -0400 Subject: [PATCH 7/8] Name the base commit and tree on base-relative verdicts The verdict line named the candidate pair only; with --base-ref the verdict is a function of the base tree as well, and the package returns its identity. The second line now carries "base commit tree " after the candidate pair whenever a base was supplied, and every test that pins the line (the three workflow paths, the accepting release, the line-ending case, the adversarial subject check and the byte-equivalence proof) asserts the base pair from the fixture's own object ids. The proof helpers' docstrings no longer describe a checkout the shim makes; the package judges the commit's objects. Measured: ruff clean; isolation, adversarial and transparency suites 80 passed, 5 xfailed at this head. Co-Authored-By: Claude Fable 5.1 --- scripts/check_thesis_facts_append.py | 7 +++++- tests/test_receipt_shim_transparency.py | 25 ++++++++++++++++------ tests/test_thesis_append_adversarial.py | 22 +++++++++++++------ tests/test_thesis_append_shim_isolation.py | 22 +++++++++++++++---- 4 files changed, 58 insertions(+), 18 deletions(-) diff --git a/scripts/check_thesis_facts_append.py b/scripts/check_thesis_facts_append.py index 9aa03afe..62972832 100644 --- a/scripts/check_thesis_facts_append.py +++ b/scripts/check_thesis_facts_append.py @@ -506,7 +506,12 @@ def main() -> int: print(f"{VERDICT_REFUSAL_PREFIX}{exc}", file=sys.stderr) return 1 print(verdict.summary) - print(f"candidate commit {verdict.candidate_commit} tree {verdict.candidate_tree}") + # The second line names every object the verdict is a function of: the + # candidate pair always, and the base pair whenever a base was supplied. + subject = f"candidate commit {verdict.candidate_commit} tree {verdict.candidate_tree}" + if verdict.base_commit is not None: + subject += f" base commit {verdict.base_commit} tree {verdict.base_tree}" + print(subject) return 0 diff --git a/tests/test_receipt_shim_transparency.py b/tests/test_receipt_shim_transparency.py index 3421a860..f9c4ab22 100644 --- a/tests/test_receipt_shim_transparency.py +++ b/tests/test_receipt_shim_transparency.py @@ -163,16 +163,19 @@ def _assert_byte_identical( CANDIDATE_LINE = re.compile( rb"(?m)^candidate commit [0-9a-f]{40}(?:[0-9a-f]{24})? " - rb"tree [0-9a-f]{40}(?:[0-9a-f]{24})?\n\Z" + rb"tree [0-9a-f]{40}(?:[0-9a-f]{24})?" + rb"(?: base commit [0-9a-f]{40}(?:[0-9a-f]{24})? " + rb"tree [0-9a-f]{40}(?:[0-9a-f]{24})?)?\n\Z" ) def _split_candidate_line(stdout: bytes) -> tuple[bytes, bytes | None]: """Separate the shim's own last line from the gate's output. - The shim prints one line the original never printed: the commit it checked - out and that commit's tree. Everything before it is the gate's own bytes, - and those are what the differential compares. + The shim prints one line the original never printed: the commit and tree + the package judged, and the base pair when a base was supplied. Everything + before it is the gate's own bytes, and those are what the differential + compares. """ match = CANDIDATE_LINE.search(stdout) @@ -188,17 +191,23 @@ def _assert_gate_bytes_identical( expected_code: int, candidate: str | None, tree: str | None, + base: str | None = None, + base_tree: str | None = None, ) -> None: """Compare the pair on the gate's bytes, and check the shim's extra line. - The shim reaches its verdict about a checkout it makes itself, so its - stdout carries one line the original's does not. That line is asserted + The package reaches its verdict about the named commit's objects, and the + shim prints one line the original never did: the candidate commit and + tree, and the base pair when a base was supplied. That line is asserted against the object ids the fixture committed; the rest must be identical. """ body, tail = _split_candidate_line(shim.stdout) if expected_code == 0: - assert tail == f"candidate commit {candidate} tree {tree}\n".encode("utf-8") + expected = f"candidate commit {candidate} tree {tree}" + if base is not None: + expected += f" base commit {base} tree {base_tree}" + assert tail == f"{expected}\n".encode("utf-8") else: assert tail is None, shim.stdout assert original.returncode == expected_code @@ -515,6 +524,8 @@ def test_valid_base_ref_append_is_byte_identical( expected_code=0, candidate=oid, tree=_tree_of(candidate, oid), + base=base, + base_tree=_tree_of(candidate, base), ) assert shim.stderr == b"" assert _split_candidate_line(shim.stdout)[0] == APPEND_GATE_OK diff --git a/tests/test_thesis_append_adversarial.py b/tests/test_thesis_append_adversarial.py index 0b2a61a3..3fd17a53 100644 --- a/tests/test_thesis_append_adversarial.py +++ b/tests/test_thesis_append_adversarial.py @@ -156,18 +156,28 @@ def _run_checker( if base_ref is not None: command.extend(["--base-ref", base_ref]) completed = subprocess.run(command, cwd=path, capture_output=True, text=True) - _assert_verdict_subject(completed, path, commit) + _assert_verdict_subject(completed, path, commit, base_ref=base_ref) return completed def _assert_verdict_subject( - completed: subprocess.CompletedProcess, path: Path, commit: str + completed: subprocess.CompletedProcess, + path: Path, + commit: str, + *, + base_ref: str | None = None, ) -> None: + """The second line names the candidate pair, and the base pair when a base was given.""" + if completed.returncode == 0: tree = _git(path, "rev-parse", f"{commit}^{{tree}}") - assert completed.stdout.splitlines()[1:] == [ - f"candidate commit {commit} tree {tree}" - ] + subject = f"candidate commit {commit} tree {tree}" + if base_ref is not None: + base = _git(path, "rev-parse", base_ref) + subject += ( + f" base commit {base} tree {_git(path, 'rev-parse', base + '^{tree}')}" + ) + assert completed.stdout.splitlines()[1:] == [subject] else: assert "candidate commit " not in completed.stdout @@ -365,7 +375,7 @@ def test_base_gate_uses_base_script_and_dependency_imports( assert completed.returncode == 0, completed.stdout + completed.stderr assert "gate-only proposal" in completed.stdout - _assert_verdict_subject(completed, candidate, candidate_commit) + _assert_verdict_subject(completed, candidate, candidate_commit, base_ref=base) assert not candidate_gate_marker.exists() assert not candidate_dependency_marker.exists() diff --git a/tests/test_thesis_append_shim_isolation.py b/tests/test_thesis_append_shim_isolation.py index 9cb9bbab..6a141d2c 100644 --- a/tests/test_thesis_append_shim_isolation.py +++ b/tests/test_thesis_append_shim_isolation.py @@ -44,9 +44,19 @@ CANDIDATE_LINE = re.compile( r"(?m)^candidate commit (?P[0-9a-f]{40,64}) " - r"tree (?P[0-9a-f]{40,64})$" + r"tree (?P[0-9a-f]{40,64})" + r"(?: base commit (?P[0-9a-f]{40,64}) tree (?P[0-9a-f]{40,64}))?$" ) + +def _subject_line(clone: pathlib.Path, candidate: str, base: str | None) -> str: + """The shim's second line: the candidate pair, and the base pair when given.""" + + line = f"candidate commit {candidate} tree {_git(clone, 'rev-parse', candidate + '^{tree}')}" + if base is not None: + line += f" base commit {base} tree {_git(clone, 'rev-parse', base + '^{tree}')}" + return line + # A commit id of the right shape that no repository holds. ABSENT_OBJECT_ID = "0" * 40 @@ -303,8 +313,9 @@ def _convert_the_ledger_to_crlf(root: pathlib.Path) -> None: assert completed.returncode == 0, completed.stdout + completed.stderr assert completed.stdout == ( - f"{APPEND_GATE_OK}\ncandidate commit {candidate} tree {tree}\n" + f"{APPEND_GATE_OK}\n{_subject_line(clone, candidate, base)}\n" ) + assert tree in completed.stdout _assert_nothing_left_behind(clone, tmp_path / "tmp") @@ -769,7 +780,8 @@ def test_every_workflow_verifies_the_named_commit_when_local_state_disagrees( lines = completed.stdout.splitlines() assert len(lines) == 2 assert lines[0].startswith("thesis-facts append check OK:") - assert lines[1] == f"candidate commit {selected} tree {tree}" + assert lines[1] == _subject_line(clone, selected, base_ref) + assert tree in lines[1] if base_ref is not None: assert lines[0] == APPEND_GATE_OK _assert_nothing_left_behind(clone, tmp_path / "tmp") @@ -834,12 +846,14 @@ def test_the_witnessed_release_passes_and_names_its_commit(tmp_path): assert completed.returncode == 0, completed.stdout + completed.stderr assert completed.stderr == "" assert completed.stdout == ( - f"{APPEND_GATE_OK}\ncandidate commit {candidate} tree {tree}\n" + f"{APPEND_GATE_OK}\n{_subject_line(clone, candidate, base)}\n" ) match = CANDIDATE_LINE.search(completed.stdout) assert match is not None assert match.group("commit") == candidate assert match.group("tree") == tree + assert match.group("base") == base + assert match.group("base_tree") == _git(clone, "rev-parse", f"{base}^{{tree}}") _assert_nothing_left_behind(clone, tmp_path / "tmp") From 93187f4e55128b01aeb73b967d4aef422aff77a4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 5 Sep 2026 13:40:22 -0400 Subject: [PATCH 8/8] Describe object reads in the remaining proof comments Three comments in the proof helpers still said the shim checks the named commit out; the package reads its objects and materializes them privately. Wording only; the transparency module passes unchanged. Co-Authored-By: Claude Fable 5.1 --- tests/test_receipt_shim_transparency.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/test_receipt_shim_transparency.py b/tests/test_receipt_shim_transparency.py index f9c4ab22..8b132c2d 100644 --- a/tests/test_receipt_shim_transparency.py +++ b/tests/test_receipt_shim_transparency.py @@ -395,7 +395,7 @@ def _replay_latest_release( Returns the clone, the base commit, and the candidate commit. The candidate is a commit rather than a working-tree state because that is the only thing - the shim will judge: it checks the named commit out for itself. + the shim will judge: the package reads the named commit's objects. """ root = _copy_custody_tree(destination) @@ -429,7 +429,8 @@ def _plain_checkout( The original script judges whatever directory it is pointed at, so the fair comparison hands it a directory holding exactly the candidate commit -- - which is what the shim now builds for itself instead of being handed one. + the same bytes the package reads from that commit's objects and + materializes privately for its leaf verifier. """ _git(clone, "worktree", "add", "--detach", str(destination), oid) @@ -586,9 +587,9 @@ def test_corrupt_base_ref_append_refusals_are_byte_identical( ) -> None: candidate, base, _accepted = _replay_latest_release(tmp_path / case) mutation(candidate) - # The corruption has to be committed: an uncommitted one is a divergence - # between the commit and the working tree, which is the precondition the - # shim now establishes rather than a refusal it is being asked to make. + # The corruption has to be committed: the package judges the named + # commit's objects, so an uncommitted change is not part of the candidate + # at all and would not be seen. oid = _commit_candidate(candidate, f"corrupt: {case}") original, shim = _run_append_pair(original_oracle, candidate, base, oid) _assert_gate_bytes_identical(