Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 21 additions & 19 deletions .github/workflows/thesis-facts-append.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 \
Expand Down Expand Up @@ -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" \
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
643 changes: 74 additions & 569 deletions scripts/check_thesis_facts_append.py

Large diffs are not rendered by default.

82 changes: 40 additions & 42 deletions scripts/verify_release_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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",
Expand Down
36 changes: 24 additions & 12 deletions tests/test_receipt_shim_transparency.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -386,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)
Expand Down Expand Up @@ -420,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)
Expand Down Expand Up @@ -515,6 +525,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
Expand Down Expand Up @@ -575,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(
Expand Down
42 changes: 39 additions & 3 deletions tests/test_release_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading