diff --git a/README.md b/README.md index d65fb107..a428d531 100644 --- a/README.md +++ b/README.md @@ -571,4 +571,9 @@ environment. Verification machinery is consumed from the `receipt` package with this repository's trust pins committed in `scripts/receipt_pins.py`; see `releases/README.md` (immutable post-genesis, like everything under `releases/`) for the schema, offline -verification procedure, and security limits. +verification procedure, and security limits. The gate surface in that file +lists every base-checkout file that can decide a verdict: all of `scripts/`, +the append workflow, the anchors, and the uv inputs that build the gate's +environment (`pyproject.toml`, `uv.lock`, `uv.toml`, `.python-version`, +`.venv/`). The gate refuses a pull request that changes any of them together +with the ledger, and names them when a pull request changes only them. diff --git a/scripts/receipt_pins.py b/scripts/receipt_pins.py index 2136c758..ed1328ee 100644 --- a/scripts/receipt_pins.py +++ b/scripts/receipt_pins.py @@ -86,13 +86,27 @@ (f"releases/anchors/{LEDGER_SPEC.producer_public_key_filename}"), } ), + # Every file in the judging (base) checkout that can decide a verdict. A + # proposal that changes one of them together with the ledger is refused as + # mixed, and a proposal that changes only these is reported by name. gate_surface=frozenset( { - "scripts/check_thesis_facts_append.py", - "scripts/verify_release_chain.py", - "scripts/canonical_json.py", - "scripts/cut_release_manifest.py", + # The judge runs scripts/check_thesis_facts_append.py, so scripts/ + # is sys.path[0], and both pull request jobs also put it on + # PYTHONPATH. Any file there can decide the verdict: these pins, + # a module that shadows an import (the standard library's + # included), or a sitecustomize.py, which runs at startup. + "scripts/**", ".github/workflows/thesis-facts-append.yml", + # `uv sync --locked --no-dev --project ` builds the judge's + # environment from these, including the receipt version that + # implements the gate. uv also reads the project's uv.toml and + # .python-version, and reuses a .venv it finds there. + "pyproject.toml", + "uv.lock", + "uv.toml", + ".python-version", + ".venv/**", "releases/anchors/**", } ), diff --git a/tests/test_receipt_shim_transparency.py b/tests/test_receipt_shim_transparency.py index 99fccc19..095e5f19 100644 --- a/tests/test_receipt_shim_transparency.py +++ b/tests/test_receipt_shim_transparency.py @@ -113,6 +113,54 @@ def test_original_oracle_fixtures_are_authenticated(name: str) -> None: assert _sha256(ORIGINAL_FIXTURES / name) == ORIGINAL_HASHES[name] +def _original_surface(name: str) -> frozenset[str]: + """Read one surface literal from the authenticated original gate.""" + + import ast + + module = ast.parse( + (ORIGINAL_FIXTURES / "check_thesis_facts_append.py").read_text(encoding="utf-8") + ) + for node in module.body: + if ( + isinstance(node, ast.Assign) + and [getattr(target, "id", None) for target in node.targets] == [name] + and isinstance(node.value, ast.Call) + ): + return frozenset(ast.literal_eval(node.value.args[0])) + raise AssertionError(f"{name} not found in the original gate") + + +def test_the_shim_gate_surface_deliberately_widens_the_originals() -> None: + """The one place the pair is meant to disagree about classification. + + The originals' GATE_SURFACE predates scripts/receipt_pins.py, where the + shim's trust pins now live, and never listed the uv inputs that provision + the judge. The shim's surface covers every original entry and adds those, + so a proposal that changes one of them is refused as mixed (with rows) or + named as gate-only (alone), where the original judged it as plain data. + No case in this file changes such a file, so the byte-identity asserted + here is unaffected; tests/test_thesis_append_shim_isolation.py holds the + shim to the wider surface. + """ + + from receipt.append_gate import _matches_surface + + from scripts.receipt_pins import APPEND_GATE_SPEC + + original_gate = _original_surface("GATE_SURFACE") + shim_gate = APPEND_GATE_SPEC.gate_surface + for entry in original_gate: + if entry.endswith("/**"): + assert entry in shim_gate, entry + else: + assert _matches_surface(entry, shim_gate), entry + assert _original_surface("DATA_SURFACE") == APPEND_GATE_SPEC.data_surface + for widened in ("scripts/receipt_pins.py", "pyproject.toml", "uv.lock"): + assert not _matches_surface(widened, original_gate), widened + assert _matches_surface(widened, shim_gate), widened + + @pytest.fixture(scope="session") def original_oracle(tmp_path_factory: pytest.TempPathFactory) -> pathlib.Path: """Copy the authenticated original scripts into one executable tree.""" diff --git a/tests/test_thesis_append_shim_isolation.py b/tests/test_thesis_append_shim_isolation.py index 61e6d4c8..063d0223 100644 --- a/tests/test_thesis_append_shim_isolation.py +++ b/tests/test_thesis_append_shim_isolation.py @@ -57,6 +57,7 @@ def _subject_line(clone: pathlib.Path, candidate: str, base: str | None) -> str: 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 @@ -942,6 +943,242 @@ def test_a_true_append_is_accepted_end_to_end(tmp_path): _assert_nothing_left_behind(clone, tmp_path / "tmp") +# Files that decide a verdict when they sit in the judging checkout, one per +# way in: the pins the shim imports, the uv inputs the workflow provisions the +# judge from, and files that are not there today but would be read if added. +# Until 2026-09-29 the first three were off the gate surface, so a resolver- +# shaped append that also edited them was judged as plain data and never named +# them (PolicyEngine/chronicle#299 review). +GATE_DECIDERS = ( + "scripts/receipt_pins.py", + "pyproject.toml", + "uv.lock", + "uv.toml", + ".python-version", + "scripts/sitecustomize.py", + ".venv/lib/python3.14/site-packages/zz_injected.pth", +) + + +def _change_gate_decider(relative: str): + """Return a mutation that changes one decider, or adds it if absent.""" + + def mutate(root: pathlib.Path) -> None: + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as handle: + handle.write("# a change riding the proposal\n") + + return mutate + + +def _copy_existing_gate_deciders(root: pathlib.Path) -> None: + """Put the repository's own copy of every decider it has into the base.""" + + for relative in GATE_DECIDERS: + source = ROOT / relative + if source.is_file(): + destination = root / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, destination) + + +@pytest.mark.parametrize("relative", GATE_DECIDERS) +def test_an_append_that_also_changes_a_gate_decider_is_refused_as_mixed( + tmp_path, relative +): + """The witnessed append plus one decider change cannot pass as data. + + The refusal comes before any ledger check and names the decider, so a trust + change can no longer ride an append that the resolver merges on its own. + """ + + clone, base, candidate = _replay_latest_release( + tmp_path, + prepare=_copy_existing_gate_deciders, + mutate=_change_gate_decider(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"{FAILED}mixed data/gate proposal is forbidden: " in completed.stderr + assert ( + f"; GATE_SURFACE changes={[relative]!r}; split them into separate pull requests" + ) in completed.stderr + _assert_nothing_left_behind(clone, tmp_path / "tmp") + + +@pytest.mark.parametrize("relative", GATE_DECIDERS) +def test_a_gate_decider_change_alone_is_a_named_gate_only_proposal(tmp_path, relative): + clone, base = _replay_current_state(tmp_path) + _copy_existing_gate_deciders(clone) + base = _commit(clone, "deciders in the base") + _change_gate_decider(relative)(clone) + candidate = _commit(clone, f"change {relative}") + + completed = _run_shim( + clone, commit=candidate, base_ref=base, temporary_root=tmp_path / "tmp" + ) + + assert completed.returncode == 0, completed.stdout + completed.stderr + assert completed.stdout.splitlines() == [ + "thesis-facts append check OK: gate-only proposal; DATA_SURFACE " + f"unchanged; GATE_SURFACE changes={[relative]!r}", + _subject_line(clone, candidate, base), + ] + _assert_nothing_left_behind(clone, tmp_path / "tmp") + + +def test_an_append_beside_unchanged_gate_deciders_still_passes(tmp_path): + """The resolver's shape: ledger and release files only, deciders untouched.""" + + clone, base, candidate = _replay_latest_release( + tmp_path, prepare=_copy_existing_gate_deciders + ) + changed = set(_git(clone, "diff", "--name-only", base, candidate).splitlines()) + assert changed and all( + path.startswith(("ledger/", "releases/manifests/")) for path in changed + ), changed + + completed = _run_shim( + clone, commit=candidate, base_ref=base, temporary_root=tmp_path / "tmp" + ) + + assert completed.returncode == 0, completed.stdout + completed.stderr + assert completed.stdout == ( + f"{APPEND_GATE_OK}\n{_subject_line(clone, candidate, base)}\n" + ) + _assert_nothing_left_behind(clone, tmp_path / "tmp") + + +OPENED_FILES_DRIVER = """\ +import json +import os +import pathlib +import sys + +opened = set() + + +def _record(event, arguments): + if event == "open" and isinstance(arguments[0], (str, bytes, os.PathLike)): + opened.add(os.fsdecode(arguments[0])) + + +sys.addaudithook(_record) +sys.path.insert(0, {scripts!r}) + +import check_thesis_facts_append as shim # noqa: E402 + +try: + code = shim.main() +finally: + opened.update( + getattr(module, "__file__", None) or "" for module in list(sys.modules.values()) + ) + pathlib.Path({report!r}).write_text(json.dumps(sorted(opened)), encoding="utf-8") +raise SystemExit(code) +""" + + +def test_every_file_the_gate_reads_from_its_own_checkout_is_on_the_gate_surface( + tmp_path, +): + """The invariant behind GATE_SURFACE, measured on an accepting run. + + Every file the gate process opens or imports from inside the judging + checkout is on the surface. That is the pins, the gate's scripts, the + trust anchors, and the installed receipt wheel, which lives in the + checkout's own .venv just as it does in the workflow's base-gate clone. + """ + + from receipt.append_gate import _matches_surface + + from scripts.receipt_pins import APPEND_GATE_SPEC + + clone, base, candidate = _replay_latest_release(tmp_path) + report = tmp_path / "opened.json" + driver = tmp_path / "driver" / "driver.py" + driver.parent.mkdir() + driver.write_text( + OPENED_FILES_DRIVER.format(scripts=str(SHIM_SCRIPTS), report=str(report)), + encoding="utf-8", + ) + + completed = subprocess.run( + [ + sys.executable, + str(driver), + "--root", + str(clone), + "--commit", + candidate, + "--base-ref", + base, + ], + cwd=clone, + capture_output=True, + text=True, + check=False, + ) + assert completed.returncode == 0, completed.stdout + completed.stderr + + root = ROOT.resolve() + inside = set() + for name in json.loads(report.read_text(encoding="utf-8")): + if not name: + continue + path = pathlib.Path(name) + if not path.is_absolute(): + path = clone / path + resolved = path.resolve() + if resolved.is_relative_to(root): + inside.add(resolved.relative_to(root).as_posix()) + assert "scripts/receipt_pins.py" in inside + assert any(path.startswith("releases/anchors/") for path in inside), inside + off_surface = sorted( + path + for path in inside + if not _matches_surface(path, APPEND_GATE_SPEC.gate_surface) + ) + assert off_surface == [], off_surface + + +def test_the_gate_surface_covers_the_judges_import_path_and_environment(): + """What a run cannot show: files that would decide a verdict if added. + + The judge runs a script in scripts/, so scripts/ is sys.path[0], and both + pull request jobs also put it on PYTHONPATH, where a sitecustomize.py runs + at startup. The whole directory is therefore on the surface, not just the + files imported today. The judge's environment comes from the base's own + uv project, so its inputs are on the surface too. + """ + + from scripts.receipt_pins import APPEND_GATE_SPEC + + workflow = (ROOT / ".github" / "workflows" / "thesis-facts-append.yml").read_text( + encoding="utf-8" + ) + assert 'PYTHONPATH="$base_gate/scripts"' in workflow + assert 'uv sync --locked --no-dev --project "$base_gate"' in workflow + assert SHIM.parent.relative_to(ROOT).as_posix() == "scripts" + for required in ( + "scripts/**", + ".github/workflows/thesis-facts-append.yml", + "pyproject.toml", + "uv.lock", + "uv.toml", + ".python-version", + ".venv/**", + "releases/anchors/**", + ): + assert required in APPEND_GATE_SPEC.gate_surface, required + + 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.