From 4d41d4c651904d583aa736ed7a02ccdc6457b47d Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Tue, 13 Oct 2026 00:38:31 +0200 Subject: [PATCH 001/147] fix(Codex): export only frozen bridge sources and attest executed overlays --- deploy/lichess/fetch_bridge.py | 55 ++++++++++++++++++++++++++- deploy/lichess/offline_session.py | 16 ++++++-- deploy/lichess/run_offline.py | 2 +- qualification/online-recovery-v1.json | 3 ++ qualification/online-recovery-v2.json | 9 ++++- scripts/build-release-candidate.py | 25 ++---------- 6 files changed, 80 insertions(+), 30 deletions(-) diff --git a/deploy/lichess/fetch_bridge.py b/deploy/lichess/fetch_bridge.py index dc7f9261..978f1579 100644 --- a/deploy/lichess/fetch_bridge.py +++ b/deploy/lichess/fetch_bridge.py @@ -2,6 +2,8 @@ """Retrieve only the locked lichess-bot Git revision; no submodules or live API.""" from __future__ import annotations import argparse +import shutil +from pathlib import PurePosixPath import json import os import subprocess @@ -22,8 +24,9 @@ def verify(location: Path, lock: dict) -> None: raise ValueError("pinned bridge Git checkout missing") if cmd("git", "rev-parse", "HEAD", cwd=location) != lock["commit"]: raise ValueError("bridge HEAD differs from locked commit") - if cmd("git", "status", "--porcelain", "--untracked-files=no", cwd=location): - raise ValueError("pinned bridge checkout has modified tracked files") + if cmd("git", "status", "--porcelain", "--untracked-files=all", + "--ignored=matching", cwd=location): + raise ValueError("pinned bridge checkout has modified, untracked, or ignored files") if cmd("git", "remote", "get-url", "origin", cwd=location) != lock["origin"]: raise ValueError("bridge remote identity differs from lock") for path, oid in lock["tracked_blobs"].items(): @@ -38,6 +41,54 @@ def verify(location: Path, lock: dict) -> None: raise ValueError("bridge scope is not offline-only") +def copy_tracked(source: Path, destination: Path) -> None: + """Export only HEAD-tracked regular bytes, never ignored or transient code. + + Exact checkout verification is mandatory before export; caller-supplied + untracked files (including ignored sitecustomize.py) cannot enter staging. + """ + lock = json.loads(LOCK.read_text(encoding="utf-8")) + verify(source, lock) + raw = subprocess.check_output( + ["git", "-C", str(source), "ls-files", "--stage", "-z"], stderr=subprocess.PIPE + ) + rows = [item for item in raw.split(b"\0") if item] + if not rows: + raise ValueError("pinned bridge HEAD has no tracked files") + if destination.exists() or destination.is_symlink(): + raise ValueError("bridge export destination must be absent") + destination.mkdir(parents=True, exist_ok=False) + try: + seen = set() + for row in rows: + mode_oid, sep, name_raw = row.partition(b"\t") + if not sep: + raise ValueError("malformed tracked Git entry") + metadata = mode_oid.split() + if len(metadata) != 3 or metadata[0] not in (b"100644", b"100755") or metadata[2] != b"0": + raise ValueError("nonregular tracked Git entry") + name = name_raw.decode("utf-8") + posix = PurePosixPath(name) + if (not name or posix.is_absolute() or ".." in posix.parts or + "\\" in name or str(posix) != name or name in seen): + raise ValueError("noncanonical tracked path: " + name) + seen.add(name) + source_file = source / name + if (not source_file.is_file() or source_file.is_symlink() or + not source_file.resolve().is_relative_to(source.resolve())): + raise ValueError("missing, linked or escaped tracked source: " + name) + target = destination / name + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source_file, target) + if metadata[0] == b"100755": + target.chmod(0o755) + if cmd("git", "hash-object", name, cwd=source) != metadata[1].decode("ascii"): + raise ValueError("tracked bridge bytes changed during export: " + name) + except BaseException: + shutil.rmtree(destination) + raise + + def fetch(location: Path, lock: dict) -> None: if location.exists(): verify(location, lock) diff --git a/deploy/lichess/offline_session.py b/deploy/lichess/offline_session.py index 766b901a..8427cba1 100644 --- a/deploy/lichess/offline_session.py +++ b/deploy/lichess/offline_session.py @@ -2,6 +2,7 @@ from __future__ import annotations import json +import hashlib import shutil import subprocess import sys @@ -25,10 +26,17 @@ def stage(*, release: Path, bridge: Path, workspace: Path, server_url: str) -> d runtime = workspace / "release" shutil.copytree(release, runtime) upstream = workspace / "bridge" - shutil.copytree(bridge, upstream, - ignore=shutil.ignore_patterns(".git", "__pycache__", "*.pyc")) - shutil.copy2(ROOT / "deploy/lichess/extra_game_handlers.py", - upstream / "extra_game_handlers.py") + fetch_bridge.copy_tracked(bridge, upstream) + extra = ROOT / "deploy/lichess/extra_game_handlers.py" + frozen = subprocess.check_output([ + "git", "-C", str(ROOT), "show", + "HEAD:deploy/lichess/extra_game_handlers.py" + ]) + if hashlib.sha256(extra.read_bytes()).digest() != hashlib.sha256(frozen).digest(): + raise ValueError("local challenge overlay differs from HEAD") + shutil.copyfile(extra, upstream / "extra_game_handlers.py") + if hashlib.sha256((upstream / "extra_game_handlers.py").read_bytes()).digest() != hashlib.sha256(frozen).digest(): + raise ValueError("executed challenge overlay copy differs from HEAD") config_path = workspace / "config.yml" effective = bridge_policy.generate(upstream, runtime, server_url, config_path) if effective["url"] != server_url: diff --git a/deploy/lichess/run_offline.py b/deploy/lichess/run_offline.py index dab85e1b..42890411 100644 --- a/deploy/lichess/run_offline.py +++ b/deploy/lichess/run_offline.py @@ -48,7 +48,7 @@ def game(*, color: str, release: Path, bridge: Path, output: Path, timeout: int shutil.copytree(release, copied_release) release = copied_release staged = work / "bridge" - shutil.copytree(bridge, staged, ignore=shutil.ignore_patterns(".git", "__pycache__", "*.pyc")) + fetch_bridge.copy_tracked(bridge, staged) shutil.copy2(ROOT / "deploy/lichess/extra_game_handlers.py", staged / "extra_game_handlers.py") cfg = bridge_policy.generate(staged, release, server.url, work / "config.yml") diff --git a/qualification/online-recovery-v1.json b/qualification/online-recovery-v1.json index 14951f1f..cd61ef81 100644 --- a/qualification/online-recovery-v1.json +++ b/qualification/online-recovery-v1.json @@ -49,6 +49,8 @@ "deploy/lichess/Dockerfile.offline", "deploy/lichess/bridge.lock.json", "deploy/lichess/bridge_policy.py", + "deploy/lichess/extra_game_handlers.py", + "deploy/lichess/fetch_bridge.py", "deploy/lichess/log_capture.py", "deploy/lichess/offline_session.py", "deploy/lichess/run_recovery.py", @@ -58,6 +60,7 @@ "tests/fixtures/lichess/recovery_scenarios.json", "tests/lichess/fake_lichess_api.py", "tests/lichess/fault_plan.py", + "tests/lichess/scripted_opponent.py", "tests/lichess/stream_sessions.py", "tests/lichess/test_fault_plan.py", "tests/lichess/test_recovery_evidence.py", diff --git a/qualification/online-recovery-v2.json b/qualification/online-recovery-v2.json index 1fa21f51..80e293b9 100644 --- a/qualification/online-recovery-v2.json +++ b/qualification/online-recovery-v2.json @@ -61,6 +61,9 @@ "attested_paths": [ ".github/workflows/online-recovery-v2.yml", "deploy/lichess/bridge.lock.json", + "deploy/lichess/bridge_policy.py", + "deploy/lichess/extra_game_handlers.py", + "deploy/lichess/fetch_bridge.py", "deploy/lichess/guarded_transport.py", "deploy/lichess/move_journal.py", "deploy/lichess/offline_session.py", @@ -71,12 +74,16 @@ "docs/ONLINE_4B_RECOVERY.md", "qualification/online-recovery-v2.json", "scripts/qualify-online-recovery-v2.py", + "scripts/qualify-online-recovery.py", "tests/fixtures/lichess/recovery_scenarios.json", "tests/lichess/fake_lichess_api.py", + "tests/lichess/scripted_opponent.py", + "tests/lichess/stream_sessions.py", "tests/lichess/test_guarded_transport.py", "tests/lichess/test_recovery_journal.py", "tests/lichess/test_recovery_overlay.py", - "tests/lichess/test_recovery_v2_evidence.py" + "tests/lichess/test_recovery_v2_evidence.py", + "tests/lichess/transport_trace.py" ], "require_terminal_state_confirmation": true, "require_new_epoch_confirmed_move": true, diff --git a/scripts/build-release-candidate.py b/scripts/build-release-candidate.py index f3150793..59369659 100644 --- a/scripts/build-release-candidate.py +++ b/scripts/build-release-candidate.py @@ -80,28 +80,9 @@ def checksum_tree(root: Path) -> list[dict]: def copy_tracked_bridge(source: Path, destination: Path) -> None: - """Export only pinned Git-tracked upstream bytes, never local untracked files. - - fetch_bridge.verify rejects dirty tracked files but intentionally does - not reject untracked files. Those must never enter the container image. - """ - names = subprocess.check_output( - ["git", "-C", str(source), "ls-files", "-z", "--cached"]) - entries = [x.decode("utf-8") for x in names.split(b"\x00") if x] - fail(bool(entries), "locked bridge has no tracked files") - destination.mkdir(parents=True, exist_ok=False) - for name in entries: - p = Path(name) - fail(not p.is_absolute() and ".." not in p.parts and - "\\\\" not in name and p.as_posix() == name, - "noncanonical tracked bridge path: " + name) - source_file = source / name - fail(source_file.is_file() and not source_file.is_symlink() - and source_file.resolve().is_relative_to(source.resolve()), - "linked/invalid tracked bridge file: " + name) - target = destination / name - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(source_file, target) + """One canonical exact-HEAD tracked bridge export for all offline consumers.""" + fetch_bridge.copy_tracked(source, destination) + def assemble(root: Path, destination: Path, release: Path, bridge: Path, From 27468537b80039ab64209e627ef35281e1ea6a99 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Tue, 13 Oct 2026 00:40:07 +0200 Subject: [PATCH 002/147] fix(ONLINE-4A): prove state-fault effects, complete observation windows and fence streams --- deploy/lichess/fetch_bridge.py | 7 ++-- deploy/lichess/run_recovery.py | 21 ++++++++--- scripts/build-release-candidate.py | 2 +- scripts/qualify-online-recovery.py | 60 ++++++++++++++++++++++++++++++ tests/lichess/fake_lichess_api.py | 27 ++++++++++++-- 5 files changed, 103 insertions(+), 14 deletions(-) diff --git a/deploy/lichess/fetch_bridge.py b/deploy/lichess/fetch_bridge.py index 978f1579..4c650306 100644 --- a/deploy/lichess/fetch_bridge.py +++ b/deploy/lichess/fetch_bridge.py @@ -41,14 +41,15 @@ def verify(location: Path, lock: dict) -> None: raise ValueError("bridge scope is not offline-only") -def copy_tracked(source: Path, destination: Path) -> None: +def copy_tracked(source: Path, destination: Path, *, verify_lock=True) -> None: """Export only HEAD-tracked regular bytes, never ignored or transient code. Exact checkout verification is mandatory before export; caller-supplied untracked files (including ignored sitecustomize.py) cannot enter staging. """ - lock = json.loads(LOCK.read_text(encoding="utf-8")) - verify(source, lock) + if verify_lock: + lock = json.loads(LOCK.read_text(encoding="utf-8")) + verify(source, lock) raw = subprocess.check_output( ["git", "-C", str(source), "ls-files", "--stage", "-z"], stderr=subprocess.PIPE ) diff --git a/deploy/lichess/run_recovery.py b/deploy/lichess/run_recovery.py index e4fa4c48..c30672e8 100644 --- a/deploy/lichess/run_recovery.py +++ b/deploy/lichess/run_recovery.py @@ -140,6 +140,7 @@ def experiment(*, scenario: dict, color: str, bridge: Path, release: Path, state["observation"] = "game_stream_started" end = time.monotonic() + timeout activated_at = None + state["postfault_observed_seconds"] = None while time.monotonic() < end: if server.finished.is_set(): state["observation"] = "server_game_complete" @@ -151,6 +152,9 @@ def experiment(*, scenario: dict, color: str, bridge: Path, release: Path, activation = server.fault.evidence()["activations"] if activation and activated_at is None: activated_at = time.monotonic() + hold = 61 if scenario["action"] == "rate_limit_429" else 12 + # Complete a late fault's declared observation window. + end = max(end, activated_at + hold + 1) if (scenario["action"] == "terminate_bridge" and activation and proc.poll() is None): os.killpg(proc.pid, signal.SIGKILL) @@ -160,7 +164,11 @@ def experiment(*, scenario: dict, color: str, bridge: Path, release: Path, hold = 61 if scenario["action"] == "rate_limit_429" else 12 if time.monotonic() - activated_at >= hold: state["observation"] = "bounded_postfault_observation" - state["postfault_observed_seconds"] = round(hold, 3) + state["postfault_observed_seconds"] = round( + time.monotonic() - activated_at, 3) + server.trace.record( + "postfault_window_complete", + action=scenario["action"]) break time.sleep(0.05) else: @@ -236,6 +244,7 @@ def experiment(*, scenario: dict, color: str, bridge: Path, release: Path, "observation": state["observation"], "activations": len((evidence.get("fault") or {}).get("activations", [])), "errors": errors}, sort_keys=True)) + return not errors def main() -> int: @@ -251,11 +260,11 @@ def main() -> int: rows = load_scenarios(CASES) if args.scenario not in rows: p.error("unknown frozen scenario") - experiment(scenario=rows[args.scenario], color=args.color, - bridge=args.bridge.resolve(), release=args.release.resolve(), - output=args.output.resolve(), source_sha=args.source_sha, - timeout=args.timeout) - return 0 + success = experiment(scenario=rows[args.scenario], color=args.color, + bridge=args.bridge.resolve(), release=args.release.resolve(), + output=args.output.resolve(), source_sha=args.source_sha, + timeout=args.timeout) + return 0 if success else 2 if __name__ == "__main__": diff --git a/scripts/build-release-candidate.py b/scripts/build-release-candidate.py index 59369659..180319f1 100644 --- a/scripts/build-release-candidate.py +++ b/scripts/build-release-candidate.py @@ -81,7 +81,7 @@ def checksum_tree(root: Path) -> list[dict]: def copy_tracked_bridge(source: Path, destination: Path) -> None: """One canonical exact-HEAD tracked bridge export for all offline consumers.""" - fetch_bridge.copy_tracked(source, destination) + fetch_bridge.copy_tracked(source, destination, verify_lock=False) diff --git a/scripts/qualify-online-recovery.py b/scripts/qualify-online-recovery.py index a9dc4c6a..233fc433 100644 --- a/scripts/qualify-online-recovery.py +++ b/scripts/qualify-online-recovery.py @@ -107,6 +107,46 @@ def verify_trace(trace: list[dict], game: dict) -> dict: "http_attempts": len(attempts), "game_complete": game.get("complete") is True} +def verify_state_fault(action: str, witnessed: dict, sends: list[dict], + trace: list[dict]) -> None: + """Verify the delivered state or elapsed delay, not only producer labels.""" + matching = [r for r in trace if r.get("event") == "stream_fault_source" + and r.get("sid") == witnessed.get("sid") + and r.get("action") == action + and witnessed["seq"] < r.get("seq", -1)] + ensure(len(matching) == 1 and len(sends) == 1, + "faulted state missing unique before/after witness") + source, delivery = matching[0], sends[0] + ensure(source["seq"] < delivery["seq"] and + source.get("original_sha256") and + type(source.get("monotonic_ns")) is int and + type(delivery.get("monotonic_ns")) is int, + "state fault timing or source identity absent") + if action == "delay_state": + ensure(delivery["monotonic_ns"] - source["monotonic_ns"] >= 390_000_000, + "state delay did not actually occur") + ensure(delivery.get("payload_sha256") == source["original_sha256"] + and delivery.get("moves") == source.get("original_moves"), + "delayed delivery does not preserve original state") + if action == "stale_state": + old_moves = source.get("predecessor_moves") + new_moves = source.get("original_moves") + ensure(isinstance(new_moves, str) and isinstance(old_moves, str), + "stale-state original/predecessor history absent") + old = old_moves.split() if old_moves else [] + new = new_moves.split() if new_moves else [] + commits = [r["uci"] for r in trace if + r.get("event") == "board_commit" and + r.get("seq", 0) < source["seq"]] + ensure(len(new) == len(old) + 1 and new[:-1] == old + and new == commits[:len(new)] + and source["predecessor_sha256"] != source["original_sha256"], + "stale-state predecessor is not the actual prior game position") + ensure(delivery.get("moves") == old_moves and + delivery.get("payload_sha256") == source["predecessor_sha256"], + "stale-state delivered unchanged/current payload") + + def verify_scenario_witness(scenario: dict, activation: dict, trace: list[dict], result: dict): fault = activation.get("activations") @@ -173,6 +213,8 @@ def verify_scenario_witness(scenario: dict, activation: dict, sends = [r for r in trace if r.get("event") == "stream_emit" and r.get("sid") == sid and r.get("fault_action") == action] ensure(bool(sends), "game-state fault was never written") + if action in ("delay_state", "stale_state"): + verify_state_fault(action, witnessed[0], sends, trace) if action in ("duplicate_state", "duplicate_terminal"): ensure(any(r.get("event") == "stream_emit" and r.get("fault_action") == "duplicate" and @@ -289,12 +331,30 @@ def validate_experiment(case_dir: Path, scenario: dict, color: str, "bridge process cleanup was not recorded") early_rate_requests = [] if scenario["action"] == "rate_limit_429": + ensure(result.get("observation") == "bounded_postfault_observation" + and type(result.get("postfault_observed_seconds")) in (int, float) + and result["postfault_observed_seconds"] >= 61, + "incomplete R10 post-fault observation") + activation_row = next((r for r in trace if + r.get("event") == "fault_activated" and + r.get("action") == "rate_limit_429"), None) + complete_rows = [r for r in trace if + r.get("event") == "postfault_window_complete" + and r.get("action") == "rate_limit_429"] + ensure(activation_row is not None and len(complete_rows) == 1 + and type(activation_row.get("monotonic_ns")) is int + and type(complete_rows[0].get("monotonic_ns")) is int + and complete_rows[0]["monotonic_ns"] - + activation_row["monotonic_ns"] >= 61_000_000_000, + "R10 server trace lacks 61 seconds after rate-limit fault") responses = [r for r in http_requests if r.get("status") == 429] ensure(len(responses) == 1, "missing or repeated authoritative 429") when = responses[0]["monotonic_ms"] rate_index = http_requests.index(responses[0]) early_rate_requests = [r for r in http_requests[rate_index + 1:] if 0 <= r["monotonic_ms"] - when < 60000] + ensure(isinstance(result.get("runner_errors"), list) and not result["runner_errors"], + "fault harness recorded execution or teardown errors") unusable_moves = invalid_move_submissions(http_requests) if scenario["action"] == "none": ensure(history["bot_commits"] > 0 and logging is not None, diff --git a/tests/lichess/fake_lichess_api.py b/tests/lichess/fake_lichess_api.py index 07169578..658e5538 100644 --- a/tests/lichess/fake_lichess_api.py +++ b/tests/lichess/fake_lichess_api.py @@ -108,12 +108,22 @@ def emit_faultable(self, payload: dict, subscriber) -> bool: server.trace.record("stream_partial_chunk", sid=subscriber.sid) self.drop_connection() return False + if action in ("delay_state", "stale_state"): + predecessor = (server.state_predecessors.get(state_digest(payload)) + if action == "stale_state" else None) + if action == "stale_state" and predecessor is None: + raise RuntimeError("fault state has no authoritative predecessor") + server.trace.record( + "stream_fault_source", sid=subscriber.sid, + action=action, original_sha256=state_digest(payload), + original_moves=payload.get("moves"), + predecessor_sha256=(state_digest(predecessor) + if predecessor is not None else None), + predecessor_moves=(predecessor.get("moves") + if predecessor is not None else None)) if action == "delay_state": - time.sleep(0.4) # bounded transport delay; not a virtual game clock + time.sleep(0.4) # real monotonic delay, not a game-clock mutation if action == "stale_state": - predecessor = server.state_predecessors.get(state_digest(payload)) - if predecessor is None: - raise RuntimeError("fault state has no authoritative predecessor") payload = copy.deepcopy(predecessor) if action == "wrong_game": payload = dict(payload, gameId="untrusted-other-game") @@ -141,6 +151,7 @@ def stream(self, channel: queue.Queue | None, ready: threading.Event, self.send_header("Cache-Control", "no-cache") self.end_headers() ready.set() + partial_chunk_broken = False try: if subscriber is None: if first is not None: @@ -158,11 +169,19 @@ def stream(self, channel: queue.Queue | None, ready: threading.Event, self.chunk(None) continue if not self.emit_faultable(payload, subscriber): + partial_chunk_broken = True break except (BrokenPipeError, ConnectionResetError, OSError): return finally: if subscriber is not None: + if not partial_chunk_broken: + try: + self.wfile.write(b"0\r\n\r\n") + self.wfile.flush() + except (BrokenPipeError, ConnectionResetError, OSError): + pass + self.close_connection = True server.sessions.unsubscribe(subscriber) def chunk(self, payload): From b72bb91549cb3a21141d934ecc129711c7091a21 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Tue, 13 Oct 2026 00:41:35 +0200 Subject: [PATCH 003/147] fix(ONLINE-4B): enforce authoritative HTTP chess, durable intents and frozen overlay hashes --- .github/workflows/online-recovery-v2.yml | 4 +- scripts/qualify-online-recovery-v2.py | 138 +++++++++++++++++++++-- 2 files changed, 129 insertions(+), 13 deletions(-) diff --git a/.github/workflows/online-recovery-v2.yml b/.github/workflows/online-recovery-v2.yml index 1c90cade..830c2915 100644 --- a/.github/workflows/online-recovery-v2.yml +++ b/.github/workflows/online-recovery-v2.yml @@ -147,7 +147,9 @@ jobs: - name: Install independent chess oracle run: | bash scripts/ci-install-apt-deps.sh python3-pip python3-venv - python3 -m pip install --break-system-packages 'chess==1.11.2' + python3 -m pip install --break-system-packages 'chess==1.11.2' 'PyYAML==6.0.3' + - name: Rebuild expected overlay from independent pinned upstream + run: python3 deploy/lichess/fetch_bridge.py - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: online4b-guarded-raw-evidence diff --git a/scripts/qualify-online-recovery-v2.py b/scripts/qualify-online-recovery-v2.py index 18a29f62..bc26a3a5 100644 --- a/scripts/qualify-online-recovery-v2.py +++ b/scripts/qualify-online-recovery-v2.py @@ -10,6 +10,9 @@ import hashlib import importlib.util import json +import tempfile +from collections import Counter +from urllib.parse import unquote import subprocess import sqlite3 import sys @@ -67,6 +70,107 @@ def attest(document: dict, exact_sha: str, paths: list[str]): require(files[name] == hash_file(target), "source hash mismatch: "+name) +def independently_expected_overlay() -> dict: + """Rebuild the frozen overlay from verified upstream source, never evidence.""" + from deploy.lichess import fetch_bridge, recovery_overlay + source = fetch_bridge.DEFAULT + lock = read(fetch_bridge.LOCK) + fetch_bridge.verify(source, lock) + with tempfile.TemporaryDirectory(prefix="offline-overlay-verifier-") as d: + staged = Path(d) / "bridge" + fetch_bridge.copy_tracked(source, staged) + return recovery_overlay.apply(staged) + + +def verify_guarded_requests(requests: list[dict], trace: list[dict], + game: dict, intents: list[dict]) -> dict: + """Bind every HTTP move attempt to a unique durable intent and legal board.""" + gid = game.get("game_id") + moves = game.get("moves") + color = game.get("color") + require(gid == "off3b001" and color in ("white", "black") + and isinstance(moves, list), "invalid authoritative game identity") + prefix = f"/api/bot/game/{gid}/move/" + allowed = {"/api/token/test", f"/api/challenge/{gid}/accept", + f"/api/challenge/{gid}/decline"} + require(isinstance(requests, list) and isinstance(trace, list) + and isinstance(intents, list), "missing source request/journal evidence") + forbidden = game.get("forbidden_attempts") + require(isinstance(forbidden, list) + and forbidden == ["POST " + r["path"] for r in requests + if r.get("method") == "POST" and r.get("status") == 403], + "server forbidden-attempt audit differs from HTTP records") + require(not forbidden, "guard submitted forbidden HTTP operations") + http_moves = [] + for request in requests: + if request.get("method") != "POST": + continue + path = request.get("path") + require(isinstance(path, str) + and (path in allowed or path.startswith(prefix)), + "unexpected guarded POST operation") + if path.startswith(prefix): + uci = unquote(path[len(prefix):]) + require(uci and "/" not in uci and "?" not in uci, + "noncanonical guarded move URL") + http_moves.append((path, uci)) + raw_attempts = [r for r in trace if r.get("event") == "move_attempt"] + require(Counter(path for path, _ in http_moves) == + Counter(r.get("path") for r in raw_attempts), + "move HTTP records and trace attempts differ") + + board = chess.Board() + seen = set() + ledger = {(i.get("game_id"),i.get("ply_before"),i.get("move")): i + for i in intents} + require(len(ledger) == len(intents), "duplicate durable intent identity") + validated = 0 + for event in trace: + typ = event.get("event") + if typ == "board_commit": + uci = event.get("uci") + require(event.get("game_id") == gid and + event.get("ply_before") == len(board.move_stack), + "commit does not match authoritative ply") + try: + move = chess.Move.from_uci(uci) + except (ValueError,TypeError) as exc: + raise ValueError("non-UCI authoritative chess move") from exc + require(move in board.legal_moves, "illegal authoritative commit") + board.push(move) + if typ != "move_attempt": + continue + ply = len(board.move_stack) + uci = event.get("uci") + require(event.get("game_id") == gid and + type(event.get("ply_at_receipt")) is int and + event["ply_at_receipt"] == ply, + "move attempt does not match authoritative game/ply") + require(board.turn == (color == "white"), + "HTTP move attempt made on opponent turn") + try: + move = chess.Move.from_uci(uci) + except (ValueError,TypeError) as exc: + raise ValueError("malformed HTTP UCI move submission") from exc + require(move != chess.Move.null() and move in board.legal_moves, + "position-illegal HTTP move submission") + key = (gid, ply, uci) + require(key not in seen, "blind repeat HTTP move submission") + seen.add(key) + intent = ledger.get(key) + require(intent is not None and + json.loads(intent["history_json"]) == + [m.uci() for m in board.move_stack], + "move attempt without exact authoritative durable intent") + validated += 1 + require([m.uci() for m in board.move_stack] == moves, + "HTTP trace board does not reconstruct game history") + require(validated == len(raw_attempts), + "not every recorded HTTP move was independently checked") + return {"unique_legal_move_attempts": validated, + "forbidden_requests": 0} + + def verify_journal(journal: dict, trace: list[dict], game: dict, result: dict, guard_events: list[dict]): require(isinstance(journal, dict) and isinstance(journal.get("owner_epoch"), str), @@ -92,15 +196,22 @@ def verify_journal(journal: dict, trace: list[dict], game: dict, "conflicting intents for one authoritative ply") for row in intents: prior = tuple(json.loads(row["history_json"])) + ply = row.get("ply_before") + require(row.get("game_id") == game.get("game_id") and + type(ply) is int and 0 <= ply <= len(game["moves"]) and + prior == tuple(game["moves"][:ply]), + "journal intent not bound to authoritative game history") require(row["position_id"] == position_id(prior) and - row["ply_before"] == len(prior), "journal position identity drift") + ply == len(prior), "journal position identity drift") require(row["status"] in ("PREPARED", "SUBMITTING", "ACK_OBSERVED", "UNKNOWN_ACK", "CONFIRMED"), "unknown move state") if row["status"] == "CONFIRMED": - require(any(r["ply_before"] == row["ply_before"] - and r["uci"] == row["move"] for r in committed), - "journal claims uncommitted move was confirmed") + require(len([r for r in committed + if r.get("game_id") == row["game_id"] and + r["ply_before"] == row["ply_before"] + and r["uci"] == row["move"]]) == 1, + "journal claims uncommitted or duplicated move confirmation") g = journal.get("games") require(isinstance(g, list) and len(g) <= 1, "unexpected concurrent games") if g: @@ -267,7 +378,8 @@ def event_ids(event_name): def verify_one(folder: Path, *, scenario: dict, color: str, - sha: str, bridge: str, attested_paths: list[str]) -> dict: + sha: str, bridge: str, attested_paths: list[str], + expected_overlay: dict) -> dict: result = read(folder / "result.json") game = read(folder / "authoritative-game.json") plan = read(folder / "fault-plan.json") @@ -291,9 +403,8 @@ def verify_one(folder: Path, *, scenario: dict, color: str, manifest.get("mode") == "OFFLINE-4B-ONLY" and manifest.get("public_bot_release") is False, "upstream recovery overlay identity invalid") - require(len(manifest.get("patched_upstream", {})) == 3 and - len(manifest.get("guard_modules", {})) == 4, - "source-locked overlay surface incomplete") + require(manifest == expected_overlay, + "overlay files, original blob IDs or actual patched hashes differ from frozen source") require(commits == [r for r in trace if r.get("event") == "board_commit"], "server commit extract differs from raw trace") history = v1.verify_trace(trace, game) @@ -311,9 +422,9 @@ def verify_one(folder: Path, *, scenario: dict, color: str, r.get("method") in ("GET", "POST") for r in requests), "malformed HTTP request record") invalid = v1.invalid_move_submissions(requests) - forbidden = [r for r in requests if r["path"].endswith( - ("/abort", "/resign", "/chat", "/upgrade"))] - require(not invalid and not forbidden, "guarded transport sent invalid or prohibited operation") + require(not invalid, "guarded transport sent malformed or null UCI move") + http_witness = verify_guarded_requests(requests, trace, game, + result["journal"]["intents"]) runner_errors = result.get("runner_errors") require(isinstance(runner_errors, list) and not runner_errors, "harness startup/cleanup failed: " + str(runner_errors)) @@ -337,6 +448,7 @@ def verify_one(folder: Path, *, scenario: dict, color: str, journal = result.get("journal") verify_persisted_sqlite(folder, journal) details = verify_journal(journal, trace, game, result, guarded) + details.update(http_witness) terminal_confirmed = verify_terminal_confirmation(game, journal, phases) epochs = {p["epoch"] for p in phases} require(all(e.get("owner_epoch") in epochs for e in guarded), @@ -401,6 +513,7 @@ def qualify(evidence: Path, source_sha: str): "not executing verifier on exact CI source head") scenarios = load_scenarios(ROOT / policy["scenario_source"]) bridge = read(ROOT / policy["bridge_lock"])["commit"] + expected_overlay = independently_expected_overlay() results, failures = [], [] for case, colors in policy["required"].items(): require(case in scenarios and colors == scenarios[case]["colors"], @@ -410,7 +523,8 @@ def qualify(evidence: Path, source_sha: str): results.append(verify_one( evidence / (case+"-"+color), scenario=scenarios[case], color=color, sha=source_sha, bridge=bridge, - attested_paths=policy["attested_paths"])) + attested_paths=policy["attested_paths"], + expected_overlay=expected_overlay)) except (OSError, ValueError, TypeError, KeyError, IndexError, json.JSONDecodeError) as exc: failures.append({"scenario": case, "color": color, "error": str(exc)}) From 646b64d1f964941bed4d5082341dfeaad56bb53a Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Tue, 13 Oct 2026 00:42:40 +0200 Subject: [PATCH 004/147] fix(RELEASE-1): preserve smoke exceptions, authenticate bridge lock and enforce promotion gate --- .github/workflows/release-candidate.yml | 21 ++------------------- deploy/release/offline_smoke.py | 23 ++++++++++++++++++++--- docs/RELEASE_1_OPERATIONS.md | 4 +++- scripts/build-release-candidate.py | 1 + scripts/qualify-release-aggregate.py | 25 +++++++++++++++++++++++++ scripts/release-promotion-policy.py | 4 ++-- scripts/verify-release-candidate.py | 13 ++++++++++--- 7 files changed, 63 insertions(+), 28 deletions(-) diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 108a6217..05aab3b7 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -1,25 +1,8 @@ name: RELEASE-1 immutable offline OCI candidate on: pull_request: - paths: - - 'deploy/release/**' - - 'deploy/lichess/**' - - 'scripts/build-release-candidate.py' - - 'scripts/verify-release-candidate.py' - - 'scripts/verify-release-oci.py' - - 'scripts/qualify-release-host.py' - - 'scripts/qualify-release-aggregate.py' - - 'scripts/prepare-online3b-wheels.py' - - 'qualification/release-*.json' - - 'qualification/license-compliance-v1.json' - - 'tests/release/**' - - '.github/workflows/release-candidate.yml' - - 'controller/**' - - 'common/**' - - 'adapters/**' - - 'engines/**' - - 'config/**' - - 'vendor.lock.json' + push: + branches: [main] workflow_dispatch: permissions: contents: read diff --git a/deploy/release/offline_smoke.py b/deploy/release/offline_smoke.py index 853aa15b..a9d24739 100644 --- a/deploy/release/offline_smoke.py +++ b/deploy/release/offline_smoke.py @@ -213,6 +213,11 @@ def run(*, color: str, scenario_id: str, run_id: str): outcome["guard_events"] = [] except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as exc: outcome["runner_errors"].append(type(exc).__name__ + ": " + str(exc)) + except BaseException as exc: + # Retain negative evidence, but never silently turn an unexpected + # test-supervisor failure into a successful qualification result. + outcome["runner_errors"].append(type(exc).__name__ + ": " + str(exc)) + raise finally: if started: game.close() @@ -263,8 +268,19 @@ def run(*, color: str, scenario_id: str, run_id: str): else: confirmed = (evidence.get("complete") and outcome["observations"][-1]["observation"] == "complete_confirmed") - bad_http = [r for r in records if r["path"].endswith( - ("/abort", "/resign", "/upgrade", "/takeback")) or "/move/0000" in r["path"]] + allowed_posts = {"/api/token/test", + "/api/challenge/off3b001/accept", + "/api/challenge/off3b001/decline"} + prefix = "/api/bot/game/off3b001/move/" + bad_http = [r for r in records if + r.get("method") == "POST" and + (r.get("status") == 403 or + not (r.get("path") in allowed_posts or + (isinstance(r.get("path"), str) and + r["path"].startswith(prefix) and + r["path"][len(prefix):] not in ("", "0000"))))] + if not isinstance(outcome["game"].get("forbidden_attempts"), list) or outcome["game"]["forbidden_attempts"]: + outcome["runner_errors"].append("server recorded forbidden HTTP attempts") outcome["rate_cooldown_verified"] = rate_clean if scenario_id == "R10" else None outcome["fresh_second_epoch_commits"] = len(second_epoch_commits) if scenario_id == "R02" else None outcome["ready"] = bool(confirmed and not outcome["runner_errors"] and @@ -277,7 +293,8 @@ def run(*, color: str, scenario_id: str, run_id: str): "errors": outcome["runner_errors"], "observations": outcome["observations"], "public_bot_release": False}, sort_keys=True)) - return 0 if outcome["ready"] else 2 + # NEVER return from finally: doing so suppresses an active exception. + return 0 if outcome["ready"] else 2 def main(): diff --git a/docs/RELEASE_1_OPERATIONS.md b/docs/RELEASE_1_OPERATIONS.md index cd8d7f0b..03a142ff 100644 --- a/docs/RELEASE_1_OPERATIONS.md +++ b/docs/RELEASE_1_OPERATIONS.md @@ -78,12 +78,14 @@ docker run --rm --network none --read-only \ --tmpfs /run/allfather:rw,nosuid,size=64m,uid=10001,gid=10001 \ --tmpfs /tmp:rw,nosuid,size=128m,uid=10001,gid=10001 \ --volume "$PWD/test-persistent:/var/lib/allfather:rw" \ + --volume "$PWD/test-persistent/replays:/opt/allfather/build/replays-online-hybrid-v2:rw" \ -e ONLINE_RELEASEx_ISOLATED=1 -e ONLINE4B_ISOLATED_CONTAINER=1 \ allfather/release1:offline offline-smoke \ --scenario R00 --color white --run-id smoke-white ``` -Prepare the host test-persistent directory for UID/GID 10001 before use. +Prepare `test-persistent/replays` and the parent directory for UID/GID 10001 +before use (for example `mkdir -p test-persistent/replays && sudo chown -R 10001:10001 test-persistent`). The distinct bound persistent mount contains the SQLite WAL and epoch fence. A separate persistent replay bind mount is mandatory because the sealed UCI config writes to build/replays-online-hybrid-v2 relative to /opt/allfather. diff --git a/scripts/build-release-candidate.py b/scripts/build-release-candidate.py index 180319f1..d3d527bd 100644 --- a/scripts/build-release-candidate.py +++ b/scripts/build-release-candidate.py @@ -24,6 +24,7 @@ ENGINE_MANIFEST = "build/online-release/release-manifest.json" TOOL_FILES = ( + "deploy/lichess/bridge.lock.json", "deploy/release/entrypoint.py", "deploy/release/offline_smoke.py", "deploy/release/runtime-contract.json", diff --git a/scripts/qualify-release-aggregate.py b/scripts/qualify-release-aggregate.py index 408a959f..0791017b 100644 --- a/scripts/qualify-release-aggregate.py +++ b/scripts/qualify-release-aggregate.py @@ -94,6 +94,31 @@ def qualify(*, stage:Path, oci:Path, host:Path, smoke_dir:Path, result.get("runner_errors")==[] and result.get("public_bot_release") is False, "release-image smoke failed: "+key) + game=result.get("game") or {} + require(game.get("forbidden_attempts")==[], + "release image simulator recorded forbidden API attempts: "+key) + requests=result.get("http") + require(isinstance(requests,list), + "release-image complete HTTP audit missing: "+key) + allow={"/api/token/test","/api/challenge/off3b001/accept", + "/api/challenge/off3b001/decline"} + prefix="/api/bot/game/off3b001/move/" + require(all(isinstance(r,dict) and + (r.get("method")!="POST" or + (r.get("path") in allow or + (isinstance(r.get("path"),str) + and r["path"].startswith(prefix) + and r["path"][len(prefix):] not in ("","0000"))) + and r.get("status")!=403) + for r in requests), + "release image HTTP contains prohibited POST: "+key) + if key=="r10-white": + require(result.get("rate_cooldown_verified") is True, + "release-image 429 cooldown witness absent") + if key=="r02-white": + require(type(result.get("fresh_second_epoch_commits")) is int and + result["fresh_second_epoch_commits"]>0, + "release image restart has no fresh confirmed move") if key!="r10-white": require(result["game"]["complete"] is True, "offline completed-game requirement not met: "+key) diff --git a/scripts/release-promotion-policy.py b/scripts/release-promotion-policy.py index c478b486..6f786689 100644 --- a/scripts/release-promotion-policy.py +++ b/scripts/release-promotion-policy.py @@ -11,9 +11,9 @@ def audit(root=ROOT): license=json.loads((root/"qualification/license-compliance-v1.json").read_text()) qual=json.loads((root/"qualification/release-qualification-v1.json").read_text()) for doc,fields in ( - (image,("allow_real_credentials","public_bot_release","network_recovery_qualified","deployment_host_qualified")), + (image,("allow_real_credentials","public_bot_release","network_recovery_qualified","deployment_host_qualified","playing_strength_qualified")), (license,("artifact_distribution_authorized","controller_distribution_license_approved","legal_review_completed")), - (qual,("public_bot_release","artifact_distribution_authorized","release_promotion_authorized","live_transport_qualified","license_compliance_approved")), + (qual,("public_bot_release","artifact_distribution_authorized","release_promotion_authorized","live_transport_qualified","license_compliance_approved","authenticated_publication_authorized","destination_host_qualified")), ): for field in fields: if doc.get(field) is not False: diff --git a/scripts/verify-release-candidate.py b/scripts/verify-release-candidate.py index f94893c6..27c83a3a 100644 --- a/scripts/verify-release-candidate.py +++ b/scripts/verify-release-candidate.py @@ -88,10 +88,17 @@ def verify(manifest: dict, roots: dict[str, Path], for flag in ("artifact_distribution_authorized","public_bot_release", "network_recovery_qualified","deployment_host_qualified"): insist(manifest.get(flag) is False, "release claimed unapproved authority: " + flag) + frozen_bridge = roots["tools"] / "deploy/lichess/bridge.lock.json" + insist(frozen_bridge.is_file() and not frozen_bridge.is_symlink(), + "frozen upstream bridge lock absent from packaged tools") + bridge_lock = read(frozen_bridge) insist(manifest.get("source_commit") and manifest.get("source_tree") and - manifest.get("bridge_commit") == - read(roots["tools"] / "qualification/release-image-v1.json").get("bridge_commit", - manifest["bridge_commit"]), "incomplete source/bridge metadata") + manifest.get("bridge_commit") == bridge_lock["commit"] and + bridge_lock.get("live_api_permitted") is False and + bridge_lock.get("scope") == "ONLINE-3B-OFFLINE-ONLY" and + manifest.get("source_attestation", {}).get( + "deploy/lichess/bridge.lock.json") == sha(frozen_bridge), + "upstream bridge commit or source lock does not match pinned input") expected_policy = roots["tools"] / "qualification/release-image-v1.json" insist(expected_policy.is_file() and sha(expected_policy) == manifest["release_image_policy_sha256"], "frozen policy digest mismatch") From 72d881a13abbb91d57802056c82a57cbbfc642ff Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Tue, 13 Oct 2026 00:43:31 +0200 Subject: [PATCH 005/147] fix(RELEASE-1): bind OCI attestations and verify expanded rootfs diff IDs --- scripts/verify-release-oci.py | 120 ++++++++++++++++++++++++++++++---- 1 file changed, 106 insertions(+), 14 deletions(-) diff --git a/scripts/verify-release-oci.py b/scripts/verify-release-oci.py index 69b02ba2..ecda5772 100644 --- a/scripts/verify-release-oci.py +++ b/scripts/verify-release-oci.py @@ -7,6 +7,8 @@ from __future__ import annotations import argparse import hashlib +import gzip +import zlib import json import re import tarfile @@ -57,6 +59,99 @@ def verify_image_config_link(*, image_config_digest: str, return True +def verify_layer_diff_ids(archive: tarfile.TarFile, members: dict, + layers: list[dict], rootfs: dict) -> list[str]: + """Independently hash the expanded application layers in rootfs order.""" + require(rootfs.get("type") == "layers" and + isinstance(rootfs.get("diff_ids"), list) and + len(rootfs["diff_ids"]) == len(layers), + "OCI rootfs does not enumerate every application layer") + computed = [] + for layer, expected in zip(layers, rootfs["diff_ids"]): + oid = layer.get("digest") + kind = layer.get("mediaType") + require(isinstance(oid, str) and oid.startswith("sha256:") and + HEX.fullmatch(oid[7:]) is not None and + isinstance(expected, str) and expected.startswith("sha256:") and + HEX.fullmatch(expected[7:]) is not None, + "invalid application layer/rootfs digest") + require(kind in ( + "application/vnd.oci.image.layer.v1.tar", + "application/vnd.oci.image.layer.v1.tar+gzip", + "application/vnd.docker.image.rootfs.diff.tar", + "application/vnd.docker.image.rootfs.diff.tar.gzip", + ), "unsupported OCI layer compression type") + member = members.get("blobs/sha256/" + oid[7:]) + require(member is not None and member.isfile(), + "application layer blob missing from archive") + stream = archive.extractfile(member) + require(stream is not None, "unreadable OCI application layer") + compressed = kind.endswith(("+gzip", ".gzip")) + with stream: + decoded = gzip.GzipFile(fileobj=stream, mode="rb") if compressed else stream + h = hashlib.sha256() + expanded = 0 + try: + while True: + piece = decoded.read(1 << 20) + if not piece: + break + expanded += len(piece) + require(expanded <= (4 << 30), + "OCI application layer exceeds bounded expanded size") + h.update(piece) + finally: + if decoded is not stream: + decoded.close() + observed = "sha256:" + h.hexdigest() + require(observed == expected, + "application OCI layer differs from config.rootfs.diff_ids") + computed.append(observed) + return computed + + +def verify_attestation_subjects(attestations: list[dict], image: dict) -> set[str]: + """Require each BuildKit statement to refer to the actual target image.""" + accepted = {image["manifest_digest"], image["config_digest"]} + accepted.update(row["digest"] for row in image["layers"]) + predicates = set() + require(attestations, "OCI archive has no attestation manifests") + for item in attestations: + descriptor, attestation = item["descriptor"], item["statement"] + annotations = descriptor.get("annotations") or {} + require(annotations.get("vnd.docker.reference.type") == "attestation-manifest" + and annotations.get("vnd.docker.reference.digest") == + image["manifest_digest"], + "OCI attestation descriptor refers to a different application image") + platform = descriptor.get("platform") or {} + require(platform.get("architecture") == "unknown" + and platform.get("os") == "unknown", + "OCI attestation artifact uses invalid image platform") + statements = attestation.get("statements") if isinstance(attestation, dict) else None + if statements is None: + statements = [attestation] + require(isinstance(statements, list) and bool(statements), + "OCI attestation contains no in-toto statements") + for statement in statements: + require(isinstance(statement, dict) and + isinstance(statement.get("predicateType"), str), + "OCI attestation statement or predicate missing") + subjects = statement.get("subject") + require(isinstance(subjects, list) and bool(subjects), + "OCI attestation has no image subjects") + for subject in subjects: + sha = subject.get("digest", {}).get("sha256") if isinstance(subject, dict) else None + require(isinstance(sha, str) and + "sha256:" + sha in accepted, + "OCI attestation subject is unrelated to actual image") + predicates.add(statement["predicateType"]) + require(any("spdx" in x.lower() for x in predicates), + "required image SBOM attestation not exported") + require(any("slsa" in x.lower() for x in predicates), + "required image provenance attestation not exported") + return predicates + + def verify_archive(archive: Path, metadata: Path, *, docker_inspect: Path | None = None): meta = json.loads(metadata.read_text(encoding="utf-8")) blobs = {} @@ -134,16 +229,20 @@ def inspect_descriptor(descriptor, depth=0): for layer in layers: content = get(layer["digest"]) require(content is not None, "oversized attestation refused") - attestations.append(json.loads(content)) + attestations.append({"descriptor":descriptor, + "statement":json.loads(content)}) elif details.get("architecture") == "amd64" and details.get("os") == "linux": images.append({"manifest_digest":descriptor["digest"], "config_digest":cfg["digest"], "config":details, + "layers":layers, "layer_count":len(layers)}) for x in index.get("manifests",[]): inspect_descriptor(x) require(len(images)==1, "require exactly one linux/amd64 application image") image=images[0] + diff_ids=verify_layer_diff_ids(inp,by_name,image["layers"], + image["config"].get("rootfs") or {}) reported_manifest=meta.get("containerimage.digest") reported_config=meta.get("containerimage.config.digest") # BuildKit OCI index metadata can omit this config digest when @@ -172,18 +271,7 @@ def inspect_descriptor(descriptor, depth=0): "/opt/release-tools/deploy/release/entrypoint.py"], "OCI image launched with unapproved identity/entrypoint") require(not config.get("ExposedPorts"), "offline candidate must not expose ports") - predicates=set() - for att in attestations: - if isinstance(att,dict): - if att.get("predicateType"): - predicates.add(att["predicateType"]) - for row in att.get("statements",[]): - if isinstance(row,dict) and row.get("predicateType"): - predicates.add(row["predicateType"]) - require(any("spdx" in x.lower() for x in predicates), - "required image SBOM attestation not exported") - require(any("slsa" in x.lower() for x in predicates), - "required image provenance attestation not exported") + predicates=verify_attestation_subjects(attestations,image) # The OCI layout may contain unreferenced internal artifacts that are # independently hashed but cannot grant authority. return { @@ -198,6 +286,9 @@ def inspect_descriptor(descriptor, depth=0): else "image-or-index-child"), "attestation_predicate_types":sorted(predicates), "oci_layers":image["layer_count"], + "oci_diff_ids_verified":True, + "oci_diff_id_count":len(diff_ids), + "oci_attestation_subjects_verified":True, "integrity_verified":True,"publisher_signature_verified":False, "public_bot_release":False,"artifact_distribution_authorized":False, } @@ -215,7 +306,8 @@ def main(): a.output.parent.mkdir(parents=True,exist_ok=True) a.output.write_text(json.dumps(report,sort_keys=True,indent=2)+"\n") print(json.dumps(report,sort_keys=True)) - except (OSError,ValueError,KeyError,TypeError,tarfile.TarError) as exc: + except (OSError,ValueError,KeyError,TypeError,EOFError, + zlib.error,tarfile.TarError) as exc: p.exit(2,"RELEASE-1 OCI attestation refused: "+str(exc)+"\n") From c8392e3edad13c4bcc87863ea998d0525e267ffc Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Tue, 13 Oct 2026 00:45:18 +0200 Subject: [PATCH 006/147] test(Codex): add destructive faults for recovery and release admission --- scripts/qualify-online-recovery-v2.py | 8 ++- tests/lichess/test_recovery_evidence.py | 44 +++++++++++++++ tests/lichess/test_recovery_http.py | 18 +++++++ tests/lichess/test_recovery_v2_evidence.py | 63 ++++++++++++++++++++++ tests/release/test_release_aggregate.py | 31 ++++++++++- tests/release/test_release_contracts.py | 32 +++++++++++ 6 files changed, 193 insertions(+), 3 deletions(-) diff --git a/scripts/qualify-online-recovery-v2.py b/scripts/qualify-online-recovery-v2.py index bc26a3a5..e4512f5e 100644 --- a/scripts/qualify-online-recovery-v2.py +++ b/scripts/qualify-online-recovery-v2.py @@ -82,6 +82,11 @@ def independently_expected_overlay() -> dict: return recovery_overlay.apply(staged) +def verify_overlay_identity(manifest: dict, expected: dict) -> None: + require(manifest == expected, + "overlay files, original blob IDs or actual patched hashes differ from frozen source") + + def verify_guarded_requests(requests: list[dict], trace: list[dict], game: dict, intents: list[dict]) -> dict: """Bind every HTTP move attempt to a unique durable intent and legal board.""" @@ -403,8 +408,7 @@ def verify_one(folder: Path, *, scenario: dict, color: str, manifest.get("mode") == "OFFLINE-4B-ONLY" and manifest.get("public_bot_release") is False, "upstream recovery overlay identity invalid") - require(manifest == expected_overlay, - "overlay files, original blob IDs or actual patched hashes differ from frozen source") + verify_overlay_identity(manifest, expected_overlay) require(commits == [r for r in trace if r.get("event") == "board_commit"], "server commit extract differs from raw trace") history = v1.verify_trace(trace, game) diff --git a/tests/lichess/test_recovery_evidence.py b/tests/lichess/test_recovery_evidence.py index 140a7840..aaf6eda5 100644 --- a/tests/lichess/test_recovery_evidence.py +++ b/tests/lichess/test_recovery_evidence.py @@ -104,5 +104,49 @@ def test_sha256_of_stream_payload_is_stable(self): self.assertEqual(state_digest(x), state_digest(dict(reversed(list(x.items()))))) + def test_delay_state_requires_actual_monotonic_gap(self): + witnessed = {"seq": 1, "sid": 4} + base = [ + {**witnessed, "event": "fault_activated", "action": "delay_state"}, + {"seq": 2, "sid": 4, "event": "stream_fault_source", + "action": "delay_state", "monotonic_ns": 1000000000, + "original_sha256": "a"*64, "original_moves": "e2e4"}, + {"seq": 3, "sid": 4, "event": "stream_emit", "action": "delay_state", + "monotonic_ns": 1400000000, "payload_sha256": "a"*64, + "moves": "e2e4"}, + ] + QUALIFIER.verify_state_fault("delay_state", witnessed, [base[-1]], base) + stale = copy.deepcopy(base) + stale[-1]["monotonic_ns"] = 1100000000 + with self.assertRaisesRegex(ValueError, "delay"): + QUALIFIER.verify_state_fault("delay_state", witnessed, [stale[-1]], stale) + + def test_stale_state_must_deliver_authoritative_predecessor(self): + before = chess.Board() + prefen = before.fen() + before.push_uci("e2e4") + trace = [ + {"seq": 1, "event": "board_commit", "uci": "e2e4"}, + {"seq": 2, "sid": 4, "event": "fault_activated", "action": "stale_state"}, + {"seq": 3, "sid": 4, "event": "stream_fault_source", + "action": "stale_state", "monotonic_ns": 2000000000, + "original_sha256": "a"*64, "original_moves": "e2e4", + "predecessor_sha256": "b"*64, "predecessor_moves": ""}, + {"seq": 4, "sid": 4, "event": "stream_emit", "action": "stale_state", + "monotonic_ns": 2000000001, + "payload_sha256": "b"*64, "moves": ""}, + ] + witnessed=trace[1] + QUALIFIER.verify_state_fault("stale_state", witnessed, [trace[-1]], trace) + for change in ( + {"payload_sha256":"a"*64, "moves":"e2e4"}, + {"payload_sha256":"b"*64, "moves":"e2e4"}, + ): + bad=copy.deepcopy(trace) + bad[-1].update(change) + with self.assertRaises(ValueError): + QUALIFIER.verify_state_fault("stale_state", witnessed, [bad[-1]], bad) + + if __name__ == "__main__": unittest.main() diff --git a/tests/lichess/test_recovery_http.py b/tests/lichess/test_recovery_http.py index 5f8e9144..4e4fce42 100644 --- a/tests/lichess/test_recovery_http.py +++ b/tests/lichess/test_recovery_http.py @@ -143,5 +143,23 @@ def test_terminal_event_repetition_is_observable_not_a_second_commit(self): fake.close() + def test_fenced_http_stream_reaches_eof(self): + fake=FakeLichess("white",load_scenarios(SCENARIOS)["R09"]) + fake.start() + try: + url=fake.url+"api/stream/event" + with requests.get(url,headers=HEADERS,stream=True,timeout=4) as first: + with requests.get(url,headers=HEADERS,stream=True,timeout=4) as second: + self.assertEqual(first.status_code,200) + self.assertEqual(second.status_code,200) + # Must finish rather than hang on an unterminated HTTP/1.1 chunk. + self.assertEqual(list(first.iter_lines(chunk_size=1)),[]) + fake.issue_challenge() + event=json.loads(next(x for x in second.iter_lines(chunk_size=1) if x)) + self.assertEqual(event["type"],"challenge") + finally: + fake.close() + + if __name__ == "__main__": unittest.main() diff --git a/tests/lichess/test_recovery_v2_evidence.py b/tests/lichess/test_recovery_v2_evidence.py index 90dfd12a..25affe53 100644 --- a/tests/lichess/test_recovery_v2_evidence.py +++ b/tests/lichess/test_recovery_v2_evidence.py @@ -212,5 +212,68 @@ def test_valid_diagnostic_claims_do_not_authorize_online_release(self): self.assertFalse(value["deployment_host_qualified"]) + def test_guarded_request_ledger_rejects_duplicate_illegal_and_forbidden(self): + gid = "off3b001" + path = f"/api/bot/game/{gid}/move/e2e4" + game = {"game_id": gid, "color": "white", "moves": ["e2e4"], + "forbidden_attempts": []} + requests = [{"method": "POST", "path": path, "status": 200}] + trace = [ + {"seq":1,"event":"move_attempt","game_id":gid,"uci":"e2e4", + "path":path,"ply_at_receipt":0}, + {"seq":2,"event":"board_commit","game_id":gid,"origin":"allfather", + "uci":"e2e4","ply_before":0}, + ] + intents=[{"game_id":gid,"ply_before":0,"move":"e2e4","history_json":"[]"}] + self.assertEqual( + qual.verify_guarded_requests(requests,trace,game,intents)["unique_legal_move_attempts"], + 1) + invalid_cases = ( + (requests + [{"method":"POST","path":path,"status":409}], + trace + [{"seq":3, "event":"move_attempt","game_id":gid, + "uci":"e2e4","path":path,"ply_at_receipt":1}],game,intents), + ([{"method":"POST","path":f"/api/bot/game/{gid}/move/e7e5","status":400}], + [{"seq":1,"event":"move_attempt","game_id":gid,"uci":"e7e5", + "path":f"/api/bot/game/{gid}/move/e7e5","ply_at_receipt":0}], + {**game,"moves":[]},intents), + (requests+[{"method":"POST","path":f"/api/bot/game/{gid}/abort","status":403}], + trace, {**game,"forbidden_attempts":[f"POST /api/bot/game/{gid}/abort"]},intents), + (requests,trace,game,[{**intents[0],"history_json":'["d2d4"]'}]), + ) + for mutated in invalid_cases: + with self.subTest(mutated=mutated),self.assertRaises(ValueError): + qual.verify_guarded_requests(*mutated) + + def test_journal_history_must_equal_authoritative_prefix(self): + from deploy.lichess.reconcile import position_id + gid, epoch = "off3b001", "a"*32 + game = {"game_id":gid,"moves":["e2e4","e7e5"]} + journal = {"owner_epoch":epoch,"events":[{"event_id":1,"event":"observe"}], + "games":[], + "intents":[{"game_id":gid,"ply_before":2,"move":"g1f3", + "history_json":'["e2e4", "e7e5"]', + "position_id":position_id(("e2e4","e7e5")), + "status":"PREPARED"}]} + result={"phases":[{"epoch":epoch}]} + guarded=[{"event":"authoritative_snapshot_observed"}] + qual.verify_journal(journal,[],game,result,guarded) + wrong=copy.deepcopy(journal) + wrong["intents"][0]["history_json"]='["d2d4", "d7d5"]' + wrong["intents"][0]["position_id"]=position_id(("d2d4","d7d5")) + with self.assertRaisesRegex(ValueError,"authoritative game history"): + qual.verify_journal(wrong,[],game,result,guarded) + + def test_overlay_verifier_compares_keys_and_digests_not_counts(self): + expected={"upstream_commit":"a"*40,"patched_upstream":{ + "lib/lichess.py":{"git_blob_sha1":"b"*40,"patched_sha256":"c"*64}}, + "guard_modules":{"guarded_transport.py":"d"*64}, + "mode":"OFFLINE-4B-ONLY","public_bot_release":False} + qual.verify_overlay_identity(expected,copy.deepcopy(expected)) + mutated=copy.deepcopy(expected) + mutated["patched_upstream"]["lib/lichess.py"]["patched_sha256"]="e"*64 + with self.assertRaisesRegex(ValueError,"overlay"): + qual.verify_overlay_identity(mutated,expected) + + if __name__ == "__main__": unittest.main() diff --git a/tests/release/test_release_aggregate.py b/tests/release/test_release_aggregate.py index 05d3a4e5..647e7982 100644 --- a/tests/release/test_release_aggregate.py +++ b/tests/release/test_release_aggregate.py @@ -74,7 +74,11 @@ def setUp(self): write_json(self.smoke_dir / key / "result.json", { "offline": True, "ready": True, "runner_errors": [], "public_bot_release": False, - "game": {"complete": key != "r10-white", "accepted": ["e2e4"]}, + "game": {"complete": key != "r10-white", "accepted": ["e2e4"], + "forbidden_attempts": []}, + "http": [{"method": "POST", "path": "/api/token/test", "status": 200}], + "rate_cooldown_verified": key == "r10-white", + "fresh_second_epoch_commits": 1 if key == "r02-white" else None, "observations": [{"phase": "synthetic"}], }) @@ -191,5 +195,30 @@ def test_rejects_host_and_licensing_authority_escalation(self): self.qualify() + def test_aggregate_rejects_forbidden_requests_and_fake_cooldown(self): + key = "r10-white" + path = self.smoke_dir / key / "result.json" + original = json.loads(path.read_text()) + for bad in ( + {**original, "game": {**original["game"], + "forbidden_attempts": ["POST /api/bot/game/off3b001/abort"]}}, + {**original, "http": original["http"] + + [{"method":"POST","path":"/api/bot/game/off3b001/abort","status":403}]}, + {**original, "rate_cooldown_verified": False}, + ): + with self.subTest(bad=bad): + write_json(path, bad) + with self.assertRaises(ValueError): + self.qualify() + write_json(path, original) + + def test_aggregate_requires_new_epoch_confirmed_move(self): + path = self.smoke_dir / "r02-white/result.json" + doc = json.loads(path.read_text()) + write_json(path, {**doc, "fresh_second_epoch_commits": 0}) + with self.assertRaisesRegex(ValueError, "no fresh confirmed move"): + self.qualify() + + if __name__ == "__main__": unittest.main() diff --git a/tests/release/test_release_contracts.py b/tests/release/test_release_contracts.py index 2ddd2644..c4c5b74f 100644 --- a/tests/release/test_release_contracts.py +++ b/tests/release/test_release_contracts.py @@ -193,5 +193,37 @@ def test_source_tree_staging_requires_clean_exact_checkout(self): Path(temp),Path(temp)) + def test_every_authentication_and_host_release_flag_fails_policy(self): + source = ROOT / "qualification/release-qualification-v1.json" + for flag in ("authenticated_publication_authorized", + "destination_host_qualified", "public_bot_release", + "artifact_distribution_authorized", + "release_promotion_authorized"): + with self.subTest(flag=flag), tempfile.TemporaryDirectory() as temp: + root = Path(temp) + for name in ("release-image-v1.json", + "release-qualification-v1.json", + "license-compliance-v1.json"): + target = root / "qualification" / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes((ROOT / "qualification" / name).read_bytes()) + target = root / "qualification/release-qualification-v1.json" + policy_doc = json.loads(target.read_text()) + policy_doc[flag] = True + target.write_text(json.dumps(policy_doc)) + with self.assertRaisesRegex(ValueError, flag): + policy.audit(root) + + def test_replay_mount_documentation_matches_ci(self): + ops = (ROOT / "docs/RELEASE_1_OPERATIONS.md").read_text() + workflow = (ROOT / ".github/workflows/release-candidate.yml").read_text() + replay = ":/opt/allfather/build/replays-online-hybrid-v2:rw" + self.assertIn(replay, ops) + self.assertIn(replay, workflow) + for flag in ("--network none", "--read-only", "--cap-drop ALL", + "--security-opt no-new-privileges", "10001:10001"): + self.assertIn(flag, ops) + + if __name__=="__main__": unittest.main() From c6f384f6ee255e5569081c1bddd77cd6ecc1320b Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Tue, 13 Oct 2026 00:46:41 +0200 Subject: [PATCH 007/147] test(RELEASE-1): independently verify OCI layers and attestation subjects --- tests/release/test_release_oci_integrity.py | 127 ++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 tests/release/test_release_oci_integrity.py diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py new file mode 100644 index 00000000..d1cd71e8 --- /dev/null +++ b/tests/release/test_release_oci_integrity.py @@ -0,0 +1,127 @@ +"""Destructive OCI proof tests with actual tar/index/blob digests.""" +from __future__ import annotations +import gzip +import hashlib +import importlib.util +import io +import json +import tarfile +import tempfile +import unittest +from pathlib import Path + +ROOT=Path(__file__).resolve().parents[2] +spec=importlib.util.spec_from_file_location("release1_oci_integrity", + ROOT/"scripts/verify-release-oci.py") +oci=importlib.util.module_from_spec(spec) +spec.loader.exec_module(oci) + + +def encode(obj): + return json.dumps(obj,sort_keys=True,separators=(",",":")).encode() + + +def sha(body): + return "sha256:"+hashlib.sha256(body).hexdigest() + + +class OCIProofTests(unittest.TestCase): + def fixture(self, target:Path, *, replace_layer=False, bad_subject=False, + bad_reference=False, corrupt_rootfs=False): + base=b"original uncompressed OCI application layer\n" + expanded=(b"substituted uncompressed OCI application layer\n" + if replace_layer else base) + blobs={} + def store(data): + oid=sha(data) + blobs["blobs/sha256/"+oid[7:]]=data + return oid + tar_layer=store(gzip.compress(expanded,mtime=0)) + diff_id=sha(b"mismatched expected diff" if corrupt_rootfs else base) + config=store(encode({"architecture":"amd64","os":"linux", + "rootfs":{"type":"layers","diff_ids":[diff_id]}, + "config":{"User":"10001:10001", + "Entrypoint":["/opt/online3b/bin/python", + "/opt/release-tools/deploy/release/entrypoint.py"]}})) + application=store(encode({ + "schemaVersion":2, + "config":{"mediaType":"application/vnd.oci.image.config.v1+json", + "digest":config}, + "layers":[{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip", + "digest":tar_layer}], + })) + att_config=store(encode({"architecture":"unknown","os":"unknown"})) + att_layers=[] + for predicate in ("https://spdx.dev/Document","https://slsa.dev/provenance/v1"): + subject="f"*64 if bad_subject else application[7:] + data=encode({"_type":"https://in-toto.io/Statement/v1", + "predicateType":predicate, + "subject":[{"name":"offline-fixture", + "digest":{"sha256":subject}}], + "predicate":{"schema_version":1}}) + oid=store(data) + att_layers.append({"mediaType":"application/vnd.in-toto+json","digest":oid}) + att_manifest=store(encode({"schemaVersion":2, + "config":{"mediaType":"application/vnd.oci.image.config.v1+json", + "digest":att_config},"layers":att_layers})) + index=encode({"schemaVersion":2, + "mediaType":"application/vnd.oci.image.index.v1+json", + "manifests":[ + {"mediaType":"application/vnd.oci.image.manifest.v1+json", + "digest":application,"platform":{"os":"linux","architecture":"amd64"}}, + {"mediaType":"application/vnd.oci.image.manifest.v1+json", + "digest":att_manifest, + "annotations":{"vnd.docker.reference.type":"attestation-manifest", + "vnd.docker.reference.digest":( + "sha256:"+"f"*64 if bad_reference else application)}, + "platform":{"os":"unknown","architecture":"unknown"}}]}) + archive=target/"test.oci.tar" + with tarfile.open(archive,"w") as out: + payloads={"oci-layout":encode({"imageLayoutVersion":"1.0.0"}), + "index.json":index,**blobs} + for name,body in payloads.items(): + info=tarfile.TarInfo(name) + info.size=len(body) + out.addfile(info,io.BytesIO(body)) + meta=target/"metadata.json" + meta.write_text(json.dumps({"containerimage.digest":application})) + inspected=target/"docker-inspect.json" + inspected.write_text(json.dumps({"Id":config,"Architecture":"amd64"})) + return archive,meta,inspected + + def test_real_small_oci_graph_binds_every_layer_and_attestation(self): + with tempfile.TemporaryDirectory() as d: + archive,meta,inspection=self.fixture(Path(d)) + report=oci.verify_archive(archive,meta,docker_inspect=inspection) + self.assertTrue(report["oci_diff_ids_verified"]) + self.assertTrue(report["oci_attestation_subjects_verified"]) + self.assertEqual(report["oci_diff_id_count"],1) + self.assertTrue(report["integrity_verified"]) + + def test_rehashed_replacement_layer_still_rejected_by_rootfs(self): + with tempfile.TemporaryDirectory() as d: + archive,meta,inspection=self.fixture(Path(d),replace_layer=True) + with self.assertRaisesRegex(ValueError,"rootfs.diff_ids"): + oci.verify_archive(archive,meta,docker_inspect=inspection) + + def test_attestation_subject_cannot_name_an_unrelated_image(self): + with tempfile.TemporaryDirectory() as d: + archive,meta,inspection=self.fixture(Path(d),bad_subject=True) + with self.assertRaisesRegex(ValueError,"unrelated"): + oci.verify_archive(archive,meta,docker_inspect=inspection) + + def test_attestation_descriptor_cannot_point_to_other_image(self): + with tempfile.TemporaryDirectory() as d: + archive,meta,inspection=self.fixture(Path(d),bad_reference=True) + with self.assertRaisesRegex(ValueError,"different application image"): + oci.verify_archive(archive,meta,docker_inspect=inspection) + + def test_rootfs_diff_identity_is_mandatory(self): + with tempfile.TemporaryDirectory() as d: + archive,meta,inspection=self.fixture(Path(d),corrupt_rootfs=True) + with self.assertRaisesRegex(ValueError,"rootfs.diff_ids"): + oci.verify_archive(archive,meta,docker_inspect=inspection) + + +if __name__=="__main__": + unittest.main() From 3204997ed6ec177d9969f304e941e3a16d2bce87 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Tue, 13 Oct 2026 00:47:06 +0200 Subject: [PATCH 008/147] test(Codex): prove smoke exception, workflow and bridge-source boundaries --- tests/lichess/test_bridge_source_export.py | 48 ++++++++++++++++++++ tests/release/test_release_smoke_failures.py | 44 ++++++++++++++++++ tests/release/test_workflow_coverage.py | 39 ++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 tests/lichess/test_bridge_source_export.py create mode 100644 tests/release/test_release_smoke_failures.py create mode 100644 tests/release/test_workflow_coverage.py diff --git a/tests/lichess/test_bridge_source_export.py b/tests/lichess/test_bridge_source_export.py new file mode 100644 index 00000000..151aaff1 --- /dev/null +++ b/tests/lichess/test_bridge_source_export.py @@ -0,0 +1,48 @@ +"""Untracked, ignored, or modified upstream Python may never run.""" +from __future__ import annotations +import subprocess +import tempfile +import unittest +from pathlib import Path + +from deploy.lichess.fetch_bridge import copy_tracked,verify + + +def git(root,*args): + return subprocess.check_output(["git","-C",str(root),*args], + stderr=subprocess.PIPE,text=True).strip() + + +class BridgeExportTests(unittest.TestCase): + def test_unknown_files_never_enter_tracked_source_export(self): + with tempfile.TemporaryDirectory() as d: + root=Path(d)/"upstream" + root.mkdir() + git(root,"init","--quiet") + (root/"safe.py").write_text("VALUE=1\n") + (root/".gitignore").write_text("ignored.py\n") + git(root,"add","safe.py",".gitignore") + git(root,"-c","user.email=test@example.org","-c","user.name=Test", + "commit","--quiet","-m","frozen") + git(root,"remote","add","origin","https://example.invalid/fixture.git") + lock={"commit":git(root,"rev-parse","HEAD"), + "origin":"https://example.invalid/fixture.git", + "tracked_blobs":{"safe.py":git(root,"hash-object","safe.py")}, + "scope":"ONLINE-3B-OFFLINE-ONLY","live_api_permitted":False} + verify(root,lock) + (root/"ignored.py").write_text("SHADOW=1\n") + (root/"injected.py").write_text("SHADOW=2\n") + with self.assertRaisesRegex(ValueError,"untracked, or ignored"): + verify(root,lock) + dest=Path(d)/"export" + copy_tracked(root,dest,verify_lock=False) + self.assertTrue((dest/"safe.py").is_file()) + self.assertFalse((dest/"ignored.py").exists()) + self.assertFalse((dest/"injected.py").exists()) + (root/"safe.py").write_text("VALUE=999\n") + with self.assertRaisesRegex(ValueError,"modified"): + verify(root,lock) + + +if __name__=="__main__": + unittest.main() diff --git a/tests/release/test_release_smoke_failures.py b/tests/release/test_release_smoke_failures.py new file mode 100644 index 00000000..4b03c3d4 --- /dev/null +++ b/tests/release/test_release_smoke_failures.py @@ -0,0 +1,44 @@ +"""The actual image supervisor cannot turn exceptions into green evidence.""" +from __future__ import annotations +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +ROOT=Path(__file__).resolve().parents[2] +spec=importlib.util.spec_from_file_location( + "offline_smoke_failure_tests",ROOT/"deploy/release/offline_smoke.py") +smoke=importlib.util.module_from_spec(spec) +spec.loader.exec_module(smoke) + + +class SmokeFailureTests(unittest.TestCase): + def test_unexpected_exception_cannot_be_swallowed_by_finally(self): + class BrokenServer: + def __init__(self,*args): + self.fault=type("F",(),{"evidence":lambda self:{"activations":[]}})() + def start(self): + raise AssertionError("synthetic supervisor exception") + def evidence(self): + return {"complete":False,"moves":[],"accepted":[], + "forbidden_attempts":[],"requests":[], + "fault":{"activations":[]},"transport_trace":[]} + + with tempfile.TemporaryDirectory() as d: + with mock.patch.object(smoke.os,"getpid",return_value=1),\ + mock.patch.dict(smoke.os.environ,{"ONLINE_RELEASEx_ISOLATED":"1"}),\ + mock.patch.object(smoke,"PERSIST",Path(d)),\ + mock.patch.object(smoke,"TOOLS",ROOT),\ + mock.patch.object(smoke,"FakeLichess",BrokenServer): + with self.assertRaisesRegex(AssertionError,"synthetic"): + smoke.run(color="white",scenario_id="R00",run_id="broken") + result=json.loads((Path(d)/"broken/result.json").read_text()) + self.assertFalse(result["ready"]) + self.assertTrue(any("AssertionError" in x + for x in result["runner_errors"])) + + +if __name__=="__main__": + unittest.main() diff --git a/tests/release/test_workflow_coverage.py b/tests/release/test_workflow_coverage.py new file mode 100644 index 00000000..1257f33a --- /dev/null +++ b/tests/release/test_workflow_coverage.py @@ -0,0 +1,39 @@ +"""Source-bound release workflows and runnable operator examples.""" +from __future__ import annotations +import subprocess +import unittest +from pathlib import Path + +import yaml + +ROOT=Path(__file__).resolve().parents[2] + + +class WorkflowCoverageTests(unittest.TestCase): + def test_release_candidate_runs_on_all_prs_and_merged_main(self): + cfg=yaml.load((ROOT/".github/workflows/release-candidate.yml").read_text(), + Loader=yaml.BaseLoader) + events=cfg["on"] + self.assertIn("pull_request",events) + self.assertNotIn("paths",events["pull_request"] or {}) + self.assertEqual(events["push"]["branches"],["main"]) + self.assertIn("SOURCE_SHA",cfg["env"]) + self.assertIn("github.event.pull_request.head.sha",cfg["env"]["SOURCE_SHA"]) + + def test_operations_docker_command_is_valid_shell(self): + content=(ROOT/"docs/RELEASE_1_OPERATIONS.md").read_text() + fence=chr(96)*3 + start=content.index(fence+"sh\n",content.index("## Runtime contract"))+len(fence+"sh\n") + end=content.index("\n"+fence,start) + command=content[start:end] + self.assertEqual(subprocess.run(["bash","-n"],input=command,text=True, + capture_output=True).returncode,0) + for fragment in ("--network none","--read-only","--cap-drop ALL", + "--security-opt no-new-privileges", + ":/opt/allfather/build/replays-online-hybrid-v2:rw", + "--user 10001:10001"): + self.assertIn(fragment,command) + + +if __name__=="__main__": + unittest.main() From 6b7a043512afbc1311c54b8e98cef0d72835092c Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Tue, 13 Oct 2026 00:47:39 +0200 Subject: [PATCH 009/147] ci(Codex): retain independent verdicts after failed fault experiments and cover inputs --- .github/workflows/online-recovery-v2.yml | 12 ++++++++++++ .github/workflows/online-recovery.yml | 9 +++++++++ 2 files changed, 21 insertions(+) diff --git a/.github/workflows/online-recovery-v2.yml b/.github/workflows/online-recovery-v2.yml index 830c2915..9d59884d 100644 --- a/.github/workflows/online-recovery-v2.yml +++ b/.github/workflows/online-recovery-v2.yml @@ -6,6 +6,16 @@ on: - 'tests/lichess/**' - 'tests/fixtures/lichess/**' - 'scripts/qualify-online-recovery-v2.py' + - 'scripts/qualify-online-recovery.py' + - 'scripts/release-manifest.py' + - 'scripts/package-online.py' + - 'scripts/build-online-engine-opt-v2.sh' + - 'scripts/qualify-engine-opt-v2.py' + - 'scripts/prepare-online3b-wheels.py' + - 'scripts/ci-install-apt-deps.sh' + - 'Makefile' + - 'deploy/bin/**' + - 'qualification/online-recovery-v1.json' - 'qualification/online-recovery-v2.json' - '.github/workflows/online-recovery-v2.yml' - 'docs/ONLINE_4B_RECOVERY.md' @@ -136,6 +146,7 @@ jobs: independent-guarded-qualification: needs: guarded-integration + if: ${{ always() }} runs-on: ubuntu-24.04 timeout-minutes: 16 steps: @@ -151,6 +162,7 @@ jobs: - name: Rebuild expected overlay from independent pinned upstream run: python3 deploy/lichess/fetch_bridge.py - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + continue-on-error: true with: name: online4b-guarded-raw-evidence path: build/test-results/online-recovery-v2 diff --git a/.github/workflows/online-recovery.yml b/.github/workflows/online-recovery.yml index cd0ed05e..b4f38a0e 100644 --- a/.github/workflows/online-recovery.yml +++ b/.github/workflows/online-recovery.yml @@ -6,6 +6,13 @@ on: - 'tests/lichess/**' - 'tests/fixtures/lichess/**' - 'scripts/qualify-online-recovery.py' + - 'scripts/release-manifest.py' + - 'scripts/package-online.py' + - 'scripts/build-online-engine-opt-v2.sh' + - 'scripts/qualify-engine-opt-v2.py' + - 'scripts/ci-install-apt-deps.sh' + - 'Makefile' + - 'deploy/bin/**' - 'qualification/online-recovery-v1.json' - '.github/workflows/online-recovery.yml' - 'docs/ONLINE_4_RECOVERY.md' @@ -133,6 +140,7 @@ jobs: independent-recovery-evidence: needs: offline-fault-integration + if: ${{ always() }} runs-on: ubuntu-24.04 timeout-minutes: 15 steps: @@ -146,6 +154,7 @@ jobs: bash scripts/ci-install-apt-deps.sh python3-pip python3-venv python3 -m pip install --break-system-packages 'chess==1.11.2' - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + continue-on-error: true with: name: online4a-raw-evidence path: build/test-results/online-recovery-v1 From 27bcbe1a6b3a44e654f2d404f9a2b471716c059d Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Tue, 13 Oct 2026 00:48:22 +0200 Subject: [PATCH 010/147] test(ONLINE-4A): accept empty chunk line while proving fenced HTTP reaches EOF --- tests/lichess/test_recovery_http.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/lichess/test_recovery_http.py b/tests/lichess/test_recovery_http.py index 4e4fce42..8832ce0d 100644 --- a/tests/lichess/test_recovery_http.py +++ b/tests/lichess/test_recovery_http.py @@ -153,7 +153,7 @@ def test_fenced_http_stream_reaches_eof(self): self.assertEqual(first.status_code,200) self.assertEqual(second.status_code,200) # Must finish rather than hang on an unterminated HTTP/1.1 chunk. - self.assertEqual(list(first.iter_lines(chunk_size=1)),[]) + self.assertFalse(any(first.iter_lines(chunk_size=1))) fake.issue_challenge() event=json.loads(next(x for x in second.iter_lines(chunk_size=1) if x)) self.assertEqual(event["type"],"challenge") From da7b5163a7d726ebc8a707f7e1d5c8a501f9f193 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Tue, 13 Oct 2026 00:49:36 +0200 Subject: [PATCH 011/147] docs(Codex): ledger all 18 late findings and closure acceptance rules --- docs/CODEX_POSTMERGE_CLOSURE.md | 38 +++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 docs/CODEX_POSTMERGE_CLOSURE.md diff --git a/docs/CODEX_POSTMERGE_CLOSURE.md b/docs/CODEX_POSTMERGE_CLOSURE.md new file mode 100644 index 00000000..8b458930 --- /dev/null +++ b/docs/CODEX_POSTMERGE_CLOSURE.md @@ -0,0 +1,38 @@ +# AllfatherChess — Post-Merge Codex Debt Ledger (draft PR #71) + +Scope: 18 code-review findings from #66–#69, plus two independently discovered admission holes. +Base main commit: 449ff7eb1d4728b6d2a6ec8d6e84a85d3bf4b685. +Status: code implementation in progress; not evidence of green CI or completed reviewer approval. + +## Review comments and remedy + +| PR | Original Codex review | Root problem | Repair | +|---|---|---|---| +| #66 | [Codex comment](https://github.com/HMarcusWH/AllfatherChess/pull/66#discussion_r4242842043) | Untracked and ignored bridge code | Canonical tracked-only export for all bridge consumers; poisoning tests | +| #67 | [Codex comment](https://github.com/HMarcusWH/AllfatherChess/pull/67#discussion_r4243620547) | State fault labels without semantic effect | R05 delay and R07 predecessor witness; negative trace mutations | +| #67 | [Codex comment](https://github.com/HMarcusWH/AllfatherChess/pull/67#discussion_r4243620550) | Missing executed bridge overlay in attestation | Executed source inventory and HEAD-bound extra_game_handlers export | +| #67 | [Codex comment](https://github.com/HMarcusWH/AllfatherChess/pull/67#discussion_r4243620555) | No independent diagnostic after fault job failure | Always-run downstream verdict; missing evidence fails | +| #67 | [Codex comment](https://github.com/HMarcusWH/AllfatherChess/pull/67#discussion_r4243620556) | Incomplete postfault observation window | Bounded deadline extension and server-side monotonic window witness | +| #67 | [Codex comment](https://github.com/HMarcusWH/AllfatherChess/pull/67#discussion_r4243620558) | Fenced chunked stream remains open | Normal termination chunk/EOF; R08 malformed chunk remains deliberate | +| #68 | [Codex comment](https://github.com/HMarcusWH/AllfatherChess/pull/68#discussion_r4243997490) | Forbidden API requests overlooked | Full POST allowlist and server-forbidden audit, including OCI smoke | +| #68 | [Codex comment](https://github.com/HMarcusWH/AllfatherChess/pull/68#discussion_r4243997492) | Overlay identities replaced with counts | Independently rebuild expected patched manifest from frozen upstream | +| #68 | [Codex comment](https://github.com/HMarcusWH/AllfatherChess/pull/68#discussion_r4243997494) | Duplicate HTTP submissions not rejected | Bijection between trace, request audit, durable intents and board ply | +| #68 | [Codex comment](https://github.com/HMarcusWH/AllfatherChess/pull/68#discussion_r4243997497) | Illegal chess moves accepted as syntactically valid | Reconstruct board in trace order and reject turn/legality violations | +| #68 | [Codex comment](https://github.com/HMarcusWH/AllfatherChess/pull/68#discussion_r4243997499) | Intent history not bound to server prefix | Exact game ID/ply/prior-history verification | +| #69 | [Codex comment](https://github.com/HMarcusWH/AllfatherChess/pull/69#discussion_r4252576126) | Bridge commit is self-attested | Bundle and verify independent pinned bridge lock and source manifest | +| #69 | [Codex comment](https://github.com/HMarcusWH/AllfatherChess/pull/69#discussion_r4252576132) | Release OCI workflow misses input changes | All-PR and main-push RELEASE-1 with input-aware ONLINE-4A/4B triggers | +| #69 | [Codex comment](https://github.com/HMarcusWH/AllfatherChess/pull/69#discussion_r4252576137) | Example misses required replay bind | Executable shell syntax and required mount contract tests | +| #69 | [Codex comment](https://github.com/HMarcusWH/AllfatherChess/pull/69#discussion_r4252576147) | Smoke finally return can swallow exceptions | Return after finally and propagate unexpected teardown failures | +| #69 | [Codex comment](https://github.com/HMarcusWH/AllfatherChess/pull/69#discussion_r4252576153) | SBOM/provenance not bound to actual image | Validate descriptor reference and each in-toto subject against exact image | +| #69 | [Codex comment](https://github.com/HMarcusWH/AllfatherChess/pull/69#discussion_r4252576159) | Release policy leaves two flags unchecked | Explicit fail-closed host/publication prohibition mutations | +| #69 | [Codex comment](https://github.com/HMarcusWH/AllfatherChess/pull/69#discussion_r4252576166) | OCI compressed layers not bound to diff_ids | Streaming decompression and config.rootfs.diff_ids match with attack fixture | + +## Two additional admission failures + +- ONLINE-4A runner could exit with code zero after recorded harness errors, while the verifier accepted a complete-looking run. The runner and qualifier now deny that condition without relabeling a legitimately unsafe experiment as a broken harness. +- RELEASE-1 packaged smoke and aggregate originally had incomplete prohibited-POST checking. Both now check the server forbidden-attempt record, legal endpoint scope, 429 cooldown evidence and restart confirmation instead of merely trusting a positive producer status. + +## Merge and release conditions + +Keep draft until all exact-head fast/real-process checks pass and final Codex code/security reviews finish. Preserve failing and incomplete evidence. Do not reduce any chess authority or runtime-budget requirement. Remain offline-only: no license approval, OS/wheel dependency promotion, public image upload, destination-host authorization, real credentials, BOT account conversion, live transport, or playing-strength claim. +On squash merge, verify new main SHA through the explicit RELEASE-1 main-push workflow. From e23461d0a74b7948f0a7fa9cf58ea81671a12d44 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Tue, 13 Oct 2026 00:56:24 +0200 Subject: [PATCH 012/147] fix(source-export): copy exact Git blob bytes, not filtered checkout bytes --- deploy/lichess/fetch_bridge.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/deploy/lichess/fetch_bridge.py b/deploy/lichess/fetch_bridge.py index 4c650306..0eda9cf5 100644 --- a/deploy/lichess/fetch_bridge.py +++ b/deploy/lichess/fetch_bridge.py @@ -80,11 +80,16 @@ def copy_tracked(source: Path, destination: Path, *, verify_lock=True) -> None: raise ValueError("missing, linked or escaped tracked source: " + name) target = destination / name target.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(source_file, target) + # Export Git's exact indexed blob, not working-tree bytes. Safe + # checkout filters (e.g. CRLF) may transform a clean path, and + # git hash-object without --path would reject that legitimate file. + # Reading by immutable object ID also prevents an export-time TOCTOU. + blob = subprocess.check_output( + ["git", "-C", str(source), "cat-file", "blob", + metadata[1].decode("ascii")], stderr=subprocess.PIPE) + target.write_bytes(blob) if metadata[0] == b"100755": target.chmod(0o755) - if cmd("git", "hash-object", name, cwd=source) != metadata[1].decode("ascii"): - raise ValueError("tracked bridge bytes changed during export: " + name) except BaseException: shutil.rmtree(destination) raise From ba4d2823b8fb7a40bb21aefc61a31ecb3269ae7a Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Tue, 13 Oct 2026 00:56:54 +0200 Subject: [PATCH 013/147] test(source-export): prove CRLF-smudged checkout exports canonical index bytes --- tests/lichess/test_bridge_source_export.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/lichess/test_bridge_source_export.py b/tests/lichess/test_bridge_source_export.py index 151aaff1..1dfd2b11 100644 --- a/tests/lichess/test_bridge_source_export.py +++ b/tests/lichess/test_bridge_source_export.py @@ -44,5 +44,23 @@ def test_unknown_files_never_enter_tracked_source_export(self): verify(root,lock) + def test_export_prefers_indexed_blob_over_filtered_worktree(self): + with tempfile.TemporaryDirectory() as d: + root=Path(d)/"upstream" + root.mkdir() + git(root,"init","--quiet") + (root/".gitattributes").write_text("*.md text eol=crlf\n") + (root/"README.md").write_bytes(b"original\r\ntracked\r\n") + git(root,"add",".gitattributes","README.md") + git(root,"-c","user.email=test@example.org","-c","user.name=Test", + "commit","--quiet","-m","crlf") + exported=Path(d)/"export" + copy_tracked(root,exported,verify_lock=False) + canonical=subprocess.check_output( + ["git","-C",str(root),"show","HEAD:README.md"]) + self.assertEqual(exported.joinpath("README.md").read_bytes(), canonical) + self.assertEqual(canonical,b"original\ntracked\n") + + if __name__=="__main__": unittest.main() From 9f160ccfb6f721cc81a993633717a75cbacb7d92 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Tue, 13 Oct 2026 02:35:15 +0200 Subject: [PATCH 014/147] fix(RELEASE-1): name offline OCI export and retain bounded attestation diagnostics --- .github/workflows/release-candidate.yml | 5 ++ scripts/inspect-release-oci.py | 99 +++++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 scripts/inspect-release-oci.py diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 05aab3b7..7daf01ad 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -157,9 +157,14 @@ jobs: IMAGE_BASE="$(python3 -c 'import json; print(json.load(open("qualification/release-image-v1.json"))["base_image"])')" docker buildx build --builder release1-builder --platform linux/amd64 \ --file deploy/release/Dockerfile --build-arg "BASE_IMAGE=$IMAGE_BASE" \ + --tag allfather/release1:offline \ --metadata-file build/test-results/release1/oci-build-metadata.json \ --provenance=mode=min --sbom=true \ --output type=oci,dest=build/release1-offline.oci.tar . + python3 scripts/inspect-release-oci.py \ + --archive build/release1-offline.oci.tar \ + --metadata build/test-results/release1/oci-build-metadata.json \ + --output build/test-results/release1/oci-attestation-structure.json python3 scripts/verify-release-oci.py \ --archive build/release1-offline.oci.tar \ --metadata build/test-results/release1/oci-build-metadata.json \ diff --git a/scripts/inspect-release-oci.py b/scripts/inspect-release-oci.py new file mode 100644 index 00000000..d2ac909a --- /dev/null +++ b/scripts/inspect-release-oci.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Diagnostic-only OCI structure inspection; never grants image qualification. + +Read only bounded JSON descriptors and statement metadata from the archive. +No binary layer, complete SBOM/provenance predicate, or credentials are exported. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import tarfile +from pathlib import Path + +MAX_JSON = 8 * 1024 * 1024 + + +def inspect(archive: Path, metadata: Path) -> dict: + result = {"schema_version": 1, "diagnostic_only": True, + "integrity_verified": False, "public_bot_release": False} + build = json.loads(metadata.read_text(encoding="utf-8")) + result["buildkit_image_name"] = build.get("image.name") + result["buildkit_digest"] = build.get("containerimage.digest") + with tarfile.open(archive, "r:*") as tar: + members = {m.name: m for m in tar.getmembers() + if m.isfile() and ".." not in Path(m.name).parts} + def document(path: str): + member = members.get(path) + if member is None or member.size > MAX_JSON: + raise ValueError("missing or oversized descriptor: " + path) + stream = tar.extractfile(member) + if stream is None: + raise ValueError("unreadable descriptor: " + path) + content = stream.read(MAX_JSON + 1) + if len(content) > MAX_JSON: + raise ValueError("oversized descriptor: " + path) + return json.loads(content) + index = document("index.json") + result["index_manifests"] = [] + for entry in index.get("manifests", []): + oid = entry.get("digest", "") + rec = {"digest": oid, "platform": entry.get("platform"), + "annotations": entry.get("annotations", {})} + if not isinstance(oid, str) or not oid.startswith("sha256:"): + raise ValueError("invalid index descriptor digest") + manifest = document("blobs/sha256/" + oid[7:]) + rec["artifact_type"] = manifest.get("artifactType") + rec["manifest_subject"] = manifest.get("subject") + rec["config_type"] = (manifest.get("config") or {}).get("mediaType") + rec["layers"] = [] + if (entry.get("annotations") or {}).get( + "vnd.docker.reference.type") == "attestation-manifest": + for layer in manifest.get("layers", []): + lid = layer.get("digest", "") + row = {"digest": lid, + "media_type": layer.get("mediaType"), + "annotations": layer.get("annotations", {})} + if isinstance(lid, str) and lid.startswith("sha256:"): + statement = document("blobs/sha256/" + lid[7:]) + statements = statement.get("statements") + if not isinstance(statements, list): + statements = [statement] + row["statements"] = [{ + "_type": st.get("_type"), + "predicate_type": st.get("predicateType"), + "subject_count": len(st.get("subject", [])) + if isinstance(st.get("subject"), list) else None, + "subjects": st.get("subject"), + "predicate_keys": sorted((st.get("predicate") or {}).keys()) + if isinstance(st.get("predicate"), dict) else [], + } for st in statements if isinstance(st, dict)] + rec["layers"].append(row) + result["index_manifests"].append(rec) + return result + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--archive", type=Path, required=True) + ap.add_argument("--metadata", type=Path, required=True) + ap.add_argument("--output", type=Path, required=True) + args = ap.parse_args() + try: + report = inspect(args.archive, args.metadata) + except (OSError, ValueError, TypeError, KeyError, tarfile.TarError) as exc: + report = {"schema_version": 1, "diagnostic_only": True, + "integrity_verified": False, "public_bot_release": False, + "inspection_error": type(exc).__name__ + ": " + str(exc)} + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", + encoding="utf-8") + print(json.dumps({"diagnostic_only": True, + "descriptor_count": len(report.get("index_manifests", [])), + "inspection_error": report.get("inspection_error")})) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 69001b2175aed892a8b9da8724581660c80e41d6 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Tue, 13 Oct 2026 02:35:53 +0200 Subject: [PATCH 015/147] fix(RELEASE-1): require application-manifest attestation subjects and exact metadata digest --- scripts/verify-release-oci.py | 95 ++++++++++++++++++++++++++--------- 1 file changed, 71 insertions(+), 24 deletions(-) diff --git a/scripts/verify-release-oci.py b/scripts/verify-release-oci.py index ecda5772..2289de0c 100644 --- a/scripts/verify-release-oci.py +++ b/scripts/verify-release-oci.py @@ -110,48 +110,89 @@ def verify_layer_diff_ids(archive: tarfile.TarFile, members: dict, return computed +SPDX = "https://spdx.dev/Document" +SLSA = {"https://slsa.dev/provenance/v0.2", "https://slsa.dev/provenance/v1"} +STATEMENT = "https://in-toto.io/Statement/v1" + + def verify_attestation_subjects(attestations: list[dict], image: dict) -> set[str]: - """Require each BuildKit statement to refer to the actual target image.""" - accepted = {image["manifest_digest"], image["config_digest"]} - accepted.update(row["digest"] for row in image["layers"]) + """Independently bind BuildKit's in-toto subjects to the application manifest. + + For an image-level qualification the image config or one component layer is + not an acceptable alternative subject. Attestations are NOT signatures. + """ + image_digest = image["manifest_digest"] predicates = set() require(attestations, "OCI archive has no attestation manifests") for item in attestations: - descriptor, attestation = item["descriptor"], item["statement"] + descriptor, statement = item["descriptor"], item["statement"] annotations = descriptor.get("annotations") or {} require(annotations.get("vnd.docker.reference.type") == "attestation-manifest" - and annotations.get("vnd.docker.reference.digest") == - image["manifest_digest"], + and annotations.get("vnd.docker.reference.digest") == image_digest, "OCI attestation descriptor refers to a different application image") platform = descriptor.get("platform") or {} require(platform.get("architecture") == "unknown" and platform.get("os") == "unknown", "OCI attestation artifact uses invalid image platform") - statements = attestation.get("statements") if isinstance(attestation, dict) else None + manifest_subject = item.get("manifest_subject") + if manifest_subject is not None: + require(isinstance(manifest_subject, dict) and + manifest_subject.get("digest") == image_digest and + manifest_subject.get("mediaType") in ( + "application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json"), + "OCI attestation manifest subject differs from application image") + require(item.get("layer_media_type") == "application/vnd.in-toto+json", + "unexpected attestation statement layer type") + require(isinstance(statement, dict), "OCI attestation statement missing") + statements = statement.get("statements") if statements is None: - statements = [attestation] + statements = [statement] require(isinstance(statements, list) and bool(statements), "OCI attestation contains no in-toto statements") - for statement in statements: - require(isinstance(statement, dict) and - isinstance(statement.get("predicateType"), str), - "OCI attestation statement or predicate missing") - subjects = statement.get("subject") + for row in statements: + require(isinstance(row, dict) and row.get("_type") == STATEMENT, + "OCI attestation is not a supported in-toto statement") + kind = row.get("predicateType") + require(kind == SPDX or kind in SLSA, + "unknown attestation predicate type") + subjects = row.get("subject") require(isinstance(subjects, list) and bool(subjects), "OCI attestation has no image subjects") for subject in subjects: - sha = subject.get("digest", {}).get("sha256") if isinstance(subject, dict) else None - require(isinstance(sha, str) and - "sha256:" + sha in accepted, - "OCI attestation subject is unrelated to actual image") - predicates.add(statement["predicateType"]) - require(any("spdx" in x.lower() for x in predicates), - "required image SBOM attestation not exported") - require(any("slsa" in x.lower() for x in predicates), + if not isinstance(subject, dict): + raise ValueError("malformed in-toto image subject") + digest = subject.get("digest") + require(isinstance(subject.get("name"), str) and + bool(subject["name"].strip()) and + isinstance(digest, dict) and + set(digest) == {"sha256"} and + digest["sha256"] == image_digest.removeprefix("sha256:"), + "OCI attestation subject does not name application manifest") + predicate = row.get("predicate") + require(isinstance(predicate, dict) and bool(predicate), + "OCI attestation predicate document empty") + if kind == SPDX: + require(isinstance(predicate.get("spdxVersion"), str) + and predicate["spdxVersion"].startswith("SPDX-2.") + and isinstance(predicate.get("SPDXID"), str) + and bool(predicate["SPDXID"]), + "SPDX SBOM predicate lacks document identity") + elif kind.endswith("/v1"): + require(isinstance(predicate.get("buildDefinition"), dict) + and isinstance(predicate.get("runDetails"), dict), + "SLSA provenance v1 predicate incomplete") + else: + require(isinstance(predicate.get("builder"), dict) + and isinstance(predicate.get("buildType"), str) + and bool(predicate["buildType"]), + "SLSA provenance v0.2 predicate incomplete") + predicates.add(kind) + require(SPDX in predicates, "required image SBOM attestation not exported") + require(bool(predicates.intersection(SLSA)), "required image provenance attestation not exported") return predicates - def verify_archive(archive: Path, metadata: Path, *, docker_inspect: Path | None = None): meta = json.loads(metadata.read_text(encoding="utf-8")) blobs = {} @@ -230,6 +271,8 @@ def inspect_descriptor(descriptor, depth=0): content = get(layer["digest"]) require(content is not None, "oversized attestation refused") attestations.append({"descriptor":descriptor, + "manifest_subject":document.get("subject"), + "layer_media_type":layer.get("mediaType"), "statement":json.loads(content)}) elif details.get("architecture") == "amd64" and details.get("os") == "linux": images.append({"manifest_digest":descriptor["digest"], @@ -261,7 +304,6 @@ def inspect_descriptor(descriptor, depth=0): ) # BuildKit reports a descriptor digest, not the sha256 of OCI tar bytes. accepted_digests = {image["manifest_digest"], index_digest} - accepted_digests.update(x.get("digest") for x in index.get("manifests",[])) require(reported_manifest in accepted_digests, "BuildKit descriptor digest does not match OCI index or image") config=image["config"].get("config") or {} @@ -283,7 +325,12 @@ def inspect_descriptor(descriptor, depth=0): "buildkit_config_digest_metadata_present": reported_config is not None, "independent_loaded_image_digest_verified": inspection is not None, "buildkit_digest_kind":("oci-index" if reported_manifest == index_digest - else "image-or-index-child"), + else "application-manifest"), + "oci_application_identity_verified":True, + "oci_attestation_references_verified":True, + "oci_sbom_verified":SPDX in predicates, + "oci_provenance_verified":bool(predicates.intersection(SLSA)), + "oci_layers_verified":True, "attestation_predicate_types":sorted(predicates), "oci_layers":image["layer_count"], "oci_diff_ids_verified":True, From a4d6102766dcf0f380cf02fca3e316c7dd6c7e51 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Tue, 13 Oct 2026 02:36:31 +0200 Subject: [PATCH 016/147] test(RELEASE-1): reject layer-only subjects, attestation metadata and malformed predicates --- tests/release/test_release_oci_integrity.py | 80 ++++++++++++++++++--- 1 file changed, 71 insertions(+), 9 deletions(-) diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index d1cd71e8..1bebfbfe 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -27,7 +27,9 @@ def sha(body): class OCIProofTests(unittest.TestCase): def fixture(self, target:Path, *, replace_layer=False, bad_subject=False, - bad_reference=False, corrupt_rootfs=False): + bad_reference=False, corrupt_rootfs=False, subject_kind="image", + metadata_kind="application", manifest_subject_kind="none", + invalid_predicate=False): base=b"original uncompressed OCI application layer\n" expanded=(b"substituted uncompressed OCI application layer\n" if replace_layer else base) @@ -53,17 +55,35 @@ def store(data): att_config=store(encode({"architecture":"unknown","os":"unknown"})) att_layers=[] for predicate in ("https://spdx.dev/Document","https://slsa.dev/provenance/v1"): - subject="f"*64 if bad_subject else application[7:] + subject=({"image":application[7:],"config":config[7:], + "layer":tar_layer[7:],"empty":None}[subject_kind]) + if bad_subject: + subject="f"*64 + body=({"SPDXID":"SPDXRef-DOCUMENT","spdxVersion":"SPDX-2.3"} + if "spdx" in predicate else + {"buildDefinition":{"buildType":"https://mobyproject.org/buildkit@v1"}, + "runDetails":{"builder":{"id":"synthetic"}}}) + if invalid_predicate: + body={"schema_version":1} data=encode({"_type":"https://in-toto.io/Statement/v1", "predicateType":predicate, - "subject":[{"name":"offline-fixture", - "digest":{"sha256":subject}}], - "predicate":{"schema_version":1}}) + "subject":([] if subject is None else + [{"name":"offline-fixture", + "digest":{"sha256":subject}}]), + "predicate":body}) oid=store(data) att_layers.append({"mediaType":"application/vnd.in-toto+json","digest":oid}) - att_manifest=store(encode({"schemaVersion":2, - "config":{"mediaType":"application/vnd.oci.image.config.v1+json", - "digest":att_config},"layers":att_layers})) + manifest_subject=(None if manifest_subject_kind=="none" else { + "mediaType":"application/vnd.oci.image.manifest.v1+json", + "digest":application if manifest_subject_kind=="image" + else "sha256:"+"f"*64}) + att_data={"schemaVersion":2, + "config":{"mediaType":"application/vnd.oci.image.config.v1+json", + "digest":att_config}, + "layers":att_layers} + if manifest_subject is not None: + att_data["subject"]=manifest_subject + att_manifest=store(encode(att_data)) index=encode({"schemaVersion":2, "mediaType":"application/vnd.oci.image.index.v1+json", "manifests":[ @@ -84,7 +104,11 @@ def store(data): info.size=len(body) out.addfile(info,io.BytesIO(body)) meta=target/"metadata.json" - meta.write_text(json.dumps({"containerimage.digest":application})) + meta.write_text(json.dumps({ + "image.name":"docker.io/allfather/release1:offline", + "containerimage.digest":( + att_manifest if metadata_kind=="attestation" else + sha(index) if metadata_kind=="index" else application)})) inspected=target/"docker-inspect.json" inspected.write_text(json.dumps({"Id":config,"Architecture":"amd64"})) return archive,meta,inspected @@ -123,5 +147,43 @@ def test_rootfs_diff_identity_is_mandatory(self): oci.verify_archive(archive,meta,docker_inspect=inspection) + def test_config_and_layer_subjects_cannot_authorize_image(self): + for kind in ("config","layer","empty"): + with self.subTest(kind=kind),tempfile.TemporaryDirectory() as d: + archive,meta,inspection=self.fixture(Path(d),subject_kind=kind) + with self.assertRaisesRegex(ValueError, + "subjects|application manifest"): + oci.verify_archive(archive,meta,docker_inspect=inspection) + + def test_index_digest_is_a_valid_metadata_reference(self): + with tempfile.TemporaryDirectory() as d: + archive,meta,inspection=self.fixture(Path(d),metadata_kind="index", + manifest_subject_kind="image") + report=oci.verify_archive(archive,meta,docker_inspect=inspection) + self.assertEqual(report["buildkit_digest_kind"],"oci-index") + self.assertTrue(report["oci_attestation_references_verified"]) + + def test_attestation_descriptor_digest_is_not_image_metadata(self): + with tempfile.TemporaryDirectory() as d: + archive,meta,inspection=self.fixture(Path(d), + metadata_kind="attestation") + with self.assertRaisesRegex(ValueError,"BuildKit descriptor digest"): + oci.verify_archive(archive,meta,docker_inspect=inspection) + + def test_attestation_manifest_subject_must_match_image(self): + with tempfile.TemporaryDirectory() as d: + archive,meta,inspection=self.fixture(Path(d), + manifest_subject_kind="wrong") + with self.assertRaisesRegex(ValueError,"manifest subject"): + oci.verify_archive(archive,meta,docker_inspect=inspection) + + def test_garbled_predicate_cannot_claim_sbom_or_provenance(self): + with tempfile.TemporaryDirectory() as d: + archive,meta,inspection=self.fixture(Path(d),invalid_predicate=True) + with self.assertRaisesRegex(ValueError,"predicate"): + oci.verify_archive(archive,meta,docker_inspect=inspection) + + + if __name__=="__main__": unittest.main() From 2b8da7af05f23ee566c9e1601aa4f6af255b9e56 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Tue, 13 Oct 2026 02:38:17 +0200 Subject: [PATCH 017/147] fix(RELEASE-1): preserve request-ID-correlated smoke trace and reject failed move POSTs --- deploy/release/offline_smoke.py | 17 +++++++++++++++-- tests/lichess/fake_lichess_api.py | 23 ++++++++++++++--------- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/deploy/release/offline_smoke.py b/deploy/release/offline_smoke.py index a9d24739..de36ce1a 100644 --- a/deploy/release/offline_smoke.py +++ b/deploy/release/offline_smoke.py @@ -7,6 +7,7 @@ """ from __future__ import annotations import argparse +import hashlib import json import os import re @@ -120,7 +121,7 @@ def run(*, color: str, scenario_id: str, run_id: str): if output.exists() or output.is_symlink(): raise RuntimeError("image smoke refuses to reuse a prior game journal") output.mkdir(mode=0o700) - outcome = {"schema_version": 1, "run_id": run_id, "scenario": scenario_id, + outcome = {"schema_version": 2, "run_id": run_id, "scenario": scenario_id, "color": color, "observations": [], "runner_errors": [], "offline": True, "network_recovery_qualified": False, "public_bot_release": False, "ready": False} @@ -183,6 +184,8 @@ def run(*, color: str, scenario_id: str, run_id: str): if scenario_id == "R10" and activated is not None: if time.monotonic()-activated > 61: observation = "rate_limited_suspension" + game.trace.record("rate_limit_hold_completed", + action="rate_limit_429") break if proc.poll() is not None: observation = "bridge_exited" @@ -228,7 +231,13 @@ def run(*, color: str, scenario_id: str, run_id: str): } outcome["fault"] = evidence.get("fault") outcome["http"] = evidence.get("requests") - outcome["commits"] = [r for r in evidence.get("transport_trace", []) + trace = evidence.get("transport_trace") + outcome["transport_trace"] = trace + if isinstance(trace, list): + raw_trace = json.dumps(trace, sort_keys=True, + separators=(",", ":"), allow_nan=False).encode() + outcome["transport_trace_sha256"] = hashlib.sha256(raw_trace).hexdigest() + outcome["commits"] = [r for r in trace or [] if r.get("event") == "board_commit"] board = chess.Board() for move in outcome["game"].get("moves") or []: @@ -275,6 +284,10 @@ def run(*, color: str, scenario_id: str, run_id: str): bad_http = [r for r in records if r.get("method") == "POST" and (r.get("status") == 403 or + (r.get("path") in allowed_posts and r.get("status") != 200) or + (isinstance(r.get("path"), str) and r["path"].startswith(prefix) + and r.get("status") not in ( + (200, 429) if scenario_id == "R10" else (200,))) or not (r.get("path") in allowed_posts or (isinstance(r.get("path"), str) and r["path"].startswith(prefix) and diff --git a/tests/lichess/fake_lichess_api.py b/tests/lichess/fake_lichess_api.py index 658e5538..97242ff9 100644 --- a/tests/lichess/fake_lichess_api.py +++ b/tests/lichess/fake_lichess_api.py @@ -195,12 +195,16 @@ def auth(self) -> bool: return False return True - def record(self, kind: str, path: str, status: int): + def record(self, kind: str, path: str, status: int, + request_id: str | None = None): with server.lock: - server.requests.append({ + row = { "monotonic_ms": round((time.monotonic() - server.start_time) * 1000), "method": kind, "path": path, "status": status - }) + } + if request_id is not None: + row["request_id"] = request_id + server.requests.append(row) def do_GET(self): if not self.auth(): @@ -314,23 +318,23 @@ def do_POST(self): endpoint="move_post", request_id=request_id, ply=ply) if action == "drop_before_commit": - self.record("POST", path, 0) + self.record("POST", path, 0, request_id=request_id) return self.drop_connection() if action == "rate_limit_429": - self.record("POST", path, 429) + self.record("POST", path, 429, request_id=request_id) return self.respond(429, {"error": "fault-injected rate limit"}) if not server.started or server.finished.is_set(): - self.record("POST", path, 409) + self.record("POST", path, 409, request_id=request_id) return self.respond(409, {"error": "game inactive"}) if server.board.turn != (server.color == "white"): - self.record("POST", path, 409) + self.record("POST", path, 409, request_id=request_id) return self.respond(409, {"error": "not bot turn"}) try: move = chess.Move.from_uci(move_text) except ValueError: move = None if move is None or move not in server.board.legal_moves: - self.record("POST", path, 400) + self.record("POST", path, 400, request_id=request_id) return self.respond(400, {"error": "illegal move"}) if action == "low_clock_close": side = "white" if server.board.turn else "black" @@ -340,7 +344,8 @@ def do_POST(self): forced_clock_after_ms=(1500 if action == "low_clock_close" else None)) server.accepted.append({"ply": len(server.board.move_stack), "uci": move.uci()}) - self.record("POST", path, 200 if action != "commit_then_504" else 504) + self.record("POST", path, 200 if action != "commit_then_504" else 504, + request_id=request_id) if not server.finished.is_set(): threading.Thread(target=server.opponent_move, daemon=True).start() if action in ("commit_then_close", "low_clock_close", "terminate_bridge"): From f9ff1f09fddc032b940636a327cc8f4efba1886d Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Tue, 13 Oct 2026 02:40:17 +0200 Subject: [PATCH 018/147] feat(RELEASE-1): independently reconstruct every packaged HTTP chess move from trace and journal --- scripts/qualify-release-smoke.py | 336 +++++++++++++++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 scripts/qualify-release-smoke.py diff --git a/scripts/qualify-release-smoke.py b/scripts/qualify-release-smoke.py new file mode 100644 index 00000000..de78c4ef --- /dev/null +++ b/scripts/qualify-release-smoke.py @@ -0,0 +1,336 @@ +#!/usr/bin/env python3 +"""RELEASE-1 independent host-side validation of packaged offline game evidence. + +Never trust the packaged producer's ready/confirmed/cooldown flags as proofs. +Reconstruct each attempted HTTP move against the authoritative trace and +durable journal. No network, credentials, bot authorization, or public release. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +from urllib.parse import unquote + +import chess + +CASES = {"r00-white": ("R00", "white"), + "r00-black": ("R00", "black"), + "r02-white": ("R02", "white"), + "r10-white": ("R10", "white")} +GAME_ID = "off3b001" +MOVE_PREFIX = f"/api/bot/game/{GAME_ID}/move/" +ALLOWED_POSTS = {"/api/token/test", f"/api/challenge/{GAME_ID}/accept", + f"/api/challenge/{GAME_ID}/decline"} +MAX_EVIDENCE_BYTES = 16 * 1024 * 1024 + + +def require(value, reason): + if not value: + raise ValueError(reason) + + +def trace_bytes(value) -> bytes: + return json.dumps(value, sort_keys=True, separators=(",", ":"), + allow_nan=False).encode("utf-8") + + +def verify_smoke(doc: dict, key: str) -> dict: + require(key in CASES, "unapproved offline smoke identity") + scenario, color = CASES[key] + require(type(doc) is dict and doc.get("schema_version") == 2 and + doc.get("run_id") == key and doc.get("scenario") == scenario and + doc.get("color") == color and doc.get("offline") is True and + doc.get("public_bot_release") is False and + doc.get("ready") is True and + doc.get("runner_errors") == [], + "packaged offline smoke has invalid source/disposition identity") + game = doc.get("game") + requests = doc.get("http") + trace = doc.get("transport_trace") + journal = doc.get("journal") + obs = doc.get("observations") + require(all(type(x) is dict for x in (game, journal)) and + type(requests) is list and type(trace) is list and bool(trace) and + type(obs) is list and bool(obs), + "packaged smoke is missing authoritative evidence") + require(game.get("game_id") == GAME_ID and game.get("color") == color and + isinstance(game.get("moves"), list) and + isinstance(game.get("accepted"), list) and + game.get("forbidden_attempts") == [], + "invalid authoritative game or forbidden endpoint evidence") + require(doc.get("transport_trace_sha256") == + hashlib.sha256(trace_bytes(trace)).hexdigest(), + "ordered transport trace digest mismatch") + require(all(type(t) is dict and type(t.get("seq")) is int and + type(t.get("monotonic_ns")) is int + for t in trace) and + [t["seq"] for t in trace] == list(range(1, len(trace) + 1)) and + all(trace[i]["monotonic_ns"] <= trace[i+1]["monotonic_ns"] + for i in range(len(trace)-1)), + "transport ordering or monotonic observation invalid") + require(type(journal.get("intents")) is list and + type(journal.get("games")) is list and + isinstance(journal.get("owner_epoch"), str), + "durable journal incomplete") + + attempt_index = {} + commits = [] + authoritative = [] + board = chess.Board() + seen_ply = set() + history_at_attempt = {} + for row in trace: + event = row.get("event") + if event == "move_attempt": + ply = len(board.move_stack) + rid = f"request-{row['seq']}" + require(row.get("game_id") == GAME_ID and + type(row.get("ply_at_receipt")) is int and + row["ply_at_receipt"] == ply and + board.turn == (color == "white") and ply not in seen_ply, + "HTTP move attempted for wrong game/turn/ply or repeated intent") + uci = row.get("uci") + try: + move = chess.Move.from_uci(uci) + except (TypeError, ValueError) as exc: + raise ValueError("malformed attempted UCI move") from exc + require(move != chess.Move.null() and move in board.legal_moves and + move.uci() == uci and row.get("path") == MOVE_PREFIX + uci, + "position-illegal, null or noncanonical HTTP move submission") + seen_ply.add(ply) + attempt_index[rid] = row + history_at_attempt[rid] = [m.uci() for m in board.move_stack] + elif event == "board_commit": + require(row.get("game_id") == GAME_ID and + type(row.get("ply_before")) is int and + row["ply_before"] == len(board.move_stack) and + row.get("fen_before") == board.fen(), + "authoritative commit before-position mismatch") + try: + move = chess.Move.from_uci(row.get("uci")) + except (TypeError, ValueError) as exc: + raise ValueError("malformed authoritative chess commit") from exc + require(move in board.legal_moves, + "illegal authoritative board commit") + origin = row.get("origin") + require(origin in ("allfather", "scripted_opponent"), + "unknown authoritative move origin") + if origin == "allfather": + rid = row.get("request_id") + prior = attempt_index.get(rid) + require(prior is not None and prior.get("uci") == row["uci"] and + prior.get("ply_at_receipt") == row["ply_before"] and + prior["seq"] < row["seq"], + "bot server commit lacks exact earlier HTTP move attempt") + else: + require(row.get("request_id") is None, + "scripted opponent has bot request identity") + board.push(move) + require(row.get("ply_after") == len(board.move_stack) and + row.get("fen_after") == board.fen(), + "authoritative commit after-position mismatch") + commits.append(row) + authoritative.append(move.uci()) + + require(authoritative == game["moves"] and + [dict(ply=x["ply_after"], uci=x["uci"]) for x in commits + if x["origin"] == "allfather"] == game["accepted"] and + doc.get("commits") == commits, + "authoritative history/accepted moves/commit extract disagree") + terminal = board.outcome(claim_draw=False) + if scenario == "R10": + require(game.get("complete") is False and terminal is None, + "429 suspension fabricated a completed game") + else: + require(game.get("complete") is True and terminal is not None and + isinstance(game.get("outcome"), dict) and + game["outcome"].get("termination") == terminal.termination.name, + "completed game lacks independent rules-based termination") + + http_attempts = {} + for request in requests: + require(type(request) is dict and + request.get("method") in ("GET", "POST") and + type(request.get("status")) is int and + type(request.get("monotonic_ms")) is int and + request["monotonic_ms"] >= 0 and + isinstance(request.get("path"), str) and + request["status"] != 403, + "unexpected or malformed HTTP request evidence") + if request["method"] != "POST": + require(request.get("request_id") is None, + "GET request has move request identity") + continue + path = request["path"] + if path.startswith(MOVE_PREFIX): + rid = request.get("request_id") + require(isinstance(rid, str) and rid in attempt_index and + rid not in http_attempts and + attempt_index[rid].get("path") == path and + request["status"] in ((200, 429) if scenario == "R10" else (200,)), + "unexpected, duplicate or rejected HTTP move request") + http_attempts[rid] = request + else: + require(path in ALLOWED_POSTS and request["status"] == 200 and + request.get("request_id") is None, + "unsupported non-move POST or rejection") + require(set(http_attempts) == set(attempt_index), + "HTTP request audit omits or duplicates trace move attempts") + require(all(type(a.get("status")) is int for a in http_attempts.values()), + "unrecorded HTTP move status") + + intents = journal["intents"] + require(all(isinstance(i, dict) for i in intents), "malformed durable intent") + by_ply = {} + for intent in intents: + require(intent.get("game_id") == GAME_ID and + type(intent.get("ply_before")) is int and + isinstance(intent.get("move"), str) and + isinstance(intent.get("epoch"), str), + "durable intent has invalid identity") + key_intent = intent["ply_before"] + require(key_intent not in by_ply, "duplicate durable intent at same game ply") + by_ply[key_intent] = intent + require(0 <= key_intent <= len(authoritative) and + json.loads(intent["history_json"]) == authoritative[:key_intent], + "durable intent history differs from authoritative game prefix") + guarded = doc.get("guard_events") + require(type(guarded) is list, "missing guarded submission audit") + guarded_submissions = [g for g in guarded + if isinstance(g, dict) and + g.get("event") == "http_move_submission_attempt"] + require(len(guarded_submissions) == len(attempt_index), + "guarded HTTP submission log does not cover exact requests") + for rid, attempt in attempt_index.items(): + ply = attempt["ply_at_receipt"] + intent = by_ply.get(ply) + require(intent is not None and intent.get("move") == attempt["uci"] and + json.loads(intent["history_json"]) == history_at_attempt[rid] and + len([g for g in guarded_submissions + if g.get("game_id") == GAME_ID and + g.get("ply") == ply and g.get("uci") == attempt["uci"] and + g.get("owner_epoch") == intent["epoch"]]) == 1, + "move attempt lacks unique matching journal/guard authorization") + for commit in commits: + if commit["origin"] != "allfather": + continue + intent = by_ply.get(commit["ply_before"]) + require(intent is not None and + intent.get("move") == commit["uci"] and + intent.get("status") == "CONFIRMED", + "server-accepted move was not durably confirmed") + records = [g for g in journal["games"] + if isinstance(g, dict) and g.get("game_id") == GAME_ID] + require(len(records) == 1, "authoritative game missing from journal") + stored = json.loads(records[0]["history_json"]) + require(stored == authoritative if scenario != "R10" + else stored == authoritative[:len(stored)], + "durable game history diverges from server") + phases = obs + require(all(isinstance(p, dict) and isinstance(p.get("epoch"), str) + and type(p.get("phase")) is int for p in phases) and + journal["owner_epoch"] == phases[-1]["epoch"], + "phase/journal ownership epoch mismatch") + + fault = doc.get("fault") or {} + activations = fault.get("activations") + require(isinstance(activations, list), "scenario fault activations missing") + fault_events = [r for r in trace if r.get("event") == "fault_activated"] + if scenario == "R00": + require(len(phases) == 1 and len(activations) == 0 + and not fault_events and + phases[0].get("observation") == "complete_confirmed", + "normal image game had unexpected fault or incomplete confirmation") + else: + action = "commit_then_close" if scenario == "R02" else "rate_limit_429" + require(len(activations) == len(fault_events) == 1 and + activations[0].get("action") == action and + fault_events[0].get("action") == action and + activations[0].get("request_id") == fault_events[0].get("request_id"), + "fault activation lacks exact independent server trace") + rid = fault_events[0].get("request_id") + require(rid in attempt_index, "fault targeted unobserved HTTP move attempt") + if scenario == "R02": + closed = [r for r in trace if + r.get("event") == "connection_close_requested" + and r.get("path") == attempt_index[rid]["path"] and + r["seq"] > fault_events[0]["seq"]] + commits_for_request = [r for r in commits if + r.get("request_id") == rid] + require(len(closed) == len(commits_for_request) == 1 and + commits_for_request[0]["seq"] < closed[0]["seq"] and + len(phases) == 2 and + phases[0].get("observation") == "restart_after_lost_ack" and + phases[1].get("observation") == "complete_confirmed" and + phases[0]["epoch"] != phases[1]["epoch"] and + any(i.get("epoch") == phases[1]["epoch"] and + i.get("status") == "CONFIRMED" and + i.get("ply_before") in {c["ply_before"] for c in commits + if c["origin"] == "allfather"} for i in intents), + "lost-ack restart lacks new-epoch authoritative confirmation") + else: + limited = [(ident, r) for ident, r in http_attempts.items() + if r["status"] == 429] + require(len(limited) == 1 and limited[0][0] == rid and + not any(c.get("request_id") == rid for c in commits), + "429 fault does not match one uncommitted HTTP attempt") + first = limited[0][1]["monotonic_ms"] + require(not any(0 <= r["monotonic_ms"] - first < 60000 + for r in requests if r is not limited[0][1]) and + type(journal.get("cooldown")) is list and bool(journal["cooldown"]) and + len(phases) == 1 and + phases[0].get("observation") == "rate_limited_suspension", + "rate-limit suspension missed account-wide 60-second cooldown") + hold = [r for r in trace if + r.get("event") == "rate_limit_hold_completed" and + r.get("action") == "rate_limit_429"] + require(len(hold) == 1 and + hold[0]["monotonic_ns"] - fault_events[0]["monotonic_ns"] + >= 61_000_000_000, + "R10 post-429 observation did not last 61 seconds") + + return {"schema_version": 1, "scenario": scenario, "color": color, + "run_id": key, "independent_qualified": True, + "legal_unique_attempts": len(attempt_index), + "authoritative_commits": len(commits), + "public_bot_release": False, "release_blocked": True} + + +def verify_file(path: Path, key: str) -> dict: + require(path.is_file() and not path.is_symlink() and + path.stat().st_size <= MAX_EVIDENCE_BYTES, + "packaged smoke evidence absent, linked or oversized") + return verify_smoke(json.loads(path.read_text(encoding="utf-8")), key) + + +def qualify_directory(root: Path) -> dict: + results, failures = {}, [] + for key in CASES: + try: + results[key] = verify_file(root / key / "result.json", key) + except (OSError, ValueError, KeyError, TypeError, IndexError) as exc: + failures.append({"scenario": key, + "error": type(exc).__name__ + ": " + str(exc)}) + return {"schema_version": 1, "offline_smoke_qualified": not failures and + len(results) == len(CASES), + "verified_cases": results, "failures": failures, + "public_bot_release": False, "release_blocked": True} + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--smoke-dir", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + report = qualify_directory(args.smoke_dir) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", + encoding="utf-8") + print(json.dumps(report, sort_keys=True)) + return 0 if report["offline_smoke_qualified"] else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) From d9b7b6c8e1d5cc59ecfdeae57c09fce45c1d0d19 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Tue, 13 Oct 2026 02:41:45 +0200 Subject: [PATCH 019/147] fix(RELEASE-1): require independent packaged-chess verification before aggregate admission --- .github/workflows/release-candidate.yml | 7 ++++++- scripts/build-release-candidate.py | 4 +++- scripts/qualify-release-aggregate.py | 28 +++++++++++++++++++++++-- tests/release/test_release_aggregate.py | 28 ++++++++++++++++++++++++- 4 files changed, 62 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 7daf01ad..07b8bb9e 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -68,7 +68,7 @@ jobs: - name: Install exact engine and wheel preparation toolchain run: | bash scripts/ci-install-apt-deps.sh build-essential meson ninja-build pkg-config libprotobuf-dev protobuf-compiler zlib1g-dev libopenblas-dev python3-pip python3-venv - python3 -m pip install --break-system-packages 'pip-tools==7.5.2' + python3 -m pip install --break-system-packages 'pip-tools==7.5.2' 'chess==1.11.2' - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 with: path: | @@ -151,6 +151,11 @@ jobs: | tee "build/test-results/release1/$CASE.log" done sudo chmod -R a+rX build/test-results/release1-volume + - name: Independently reconstruct all packaged chess and HTTP requests + run: | + python3 scripts/qualify-release-smoke.py \ + --smoke-dir build/test-results/release1-volume \ + --output build/test-results/release1/smoke-verdict.json - name: Export standalone OCI archive with SBOM/provenance attestations timeout-minutes: 20 run: | diff --git a/scripts/build-release-candidate.py b/scripts/build-release-candidate.py index d3d527bd..66374dad 100644 --- a/scripts/build-release-candidate.py +++ b/scripts/build-release-candidate.py @@ -46,7 +46,9 @@ "deploy/release/ubuntu.sources", "deploy/release/bootstrap.sources", "deploy/release/Dockerfile", "deploy/release/Dockerfile.dockerignore", - "scripts/build-release-candidate.py", *TOOL_FILES) + "scripts/build-release-candidate.py", + "scripts/qualify-release-smoke.py", + "scripts/inspect-release-oci.py", *TOOL_FILES) def fail(condition: bool, reason: str): diff --git a/scripts/qualify-release-aggregate.py b/scripts/qualify-release-aggregate.py index 0791017b..c639f948 100644 --- a/scripts/qualify-release-aggregate.py +++ b/scripts/qualify-release-aggregate.py @@ -8,6 +8,7 @@ from __future__ import annotations import argparse import hashlib +import importlib.util import json import re import subprocess @@ -45,6 +46,17 @@ def require(ok,reason): raise ValueError(reason) +def check_smoke_evidence(path:Path, key:str) -> dict: + """Execute the independent host verifier, never trust the producer's ready.""" + module_path=ROOT/"scripts/qualify-release-smoke.py" + spec=importlib.util.spec_from_file_location("release1_smoke_independent",module_path) + require(spec is not None and spec.loader is not None, + "independent packaged-smoke verifier missing") + mod=importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod.verify_file(path,key) + + def qualify(*, stage:Path, oci:Path, host:Path, smoke_dir:Path, os_inventory:Path, wheel_inventory:Path, source_sha:str, root:Path=ROOT): source= subprocess.check_output(["git","-C",str(root),"rev-parse","HEAD"], @@ -66,9 +78,15 @@ def qualify(*, stage:Path, oci:Path, host:Path, smoke_dir:Path, digest(root/"qualification/release-image-v1.json"), "candidate policy mismatch") require(oci_report.get("integrity_verified") is True and + all(oci_report.get(field) is True for field in ( + "oci_layers_verified","oci_diff_ids_verified", + "oci_application_identity_verified", + "oci_attestation_references_verified", + "oci_attestation_subjects_verified", + "oci_sbom_verified","oci_provenance_verified")) and oci_report.get("public_bot_release") is False and oci_report.get("publisher_signature_verified") is False, - "OCI attestation invalid or makes an unsupported publisher claim") + "OCI attestation or verified application binding invalid") for key in ("oci_image_manifest_digest","oci_config_digest"): require(is_sha256_hex(oci_report.get(key),prefixed=True), "invalid OCI "+key) @@ -89,6 +107,11 @@ def qualify(*, stage:Path, oci:Path, host:Path, smoke_dir:Path, target=smoke_dir/key/"result.json" require(target.is_file(), "release-image smoke scenario missing: "+key) result=load(target) + independent=check_smoke_evidence(target,key) + require(independent.get("independent_qualified") is True and + independent.get("run_id") == key and + independent.get("public_bot_release") is False, + "independent chess/HTTP evidence failed: "+key) require(result.get("offline") is True and result.get("ready") is True and result.get("runner_errors")==[] and @@ -125,7 +148,8 @@ def qualify(*, stage:Path, oci:Path, host:Path, smoke_dir:Path, else: require(result["game"]["complete"] is False, "rate-limit test unexpectedly marked game as complete") - tests[key]={"passed":True,"accepted_moves":len(result["game"]["accepted"] or []), + tests[key]={"passed":True,"independent_evidence":independent, + "accepted_moves":len(result["game"]["accepted"] or []), "phase_count":len(result["observations"])} wheel_hash=digest(stage/"wheels/runtime-wheels.lock") wheel_inv=digest(wheel_inventory) diff --git a/tests/release/test_release_aggregate.py b/tests/release/test_release_aggregate.py index 647e7982..ddab09c2 100644 --- a/tests/release/test_release_aggregate.py +++ b/tests/release/test_release_aggregate.py @@ -51,6 +51,13 @@ def setUp(self): }) write_json(self.oci, { "integrity_verified": True, + "oci_layers_verified": True, + "oci_diff_ids_verified": True, + "oci_application_identity_verified": True, + "oci_attestation_references_verified": True, + "oci_attestation_subjects_verified": True, + "oci_sbom_verified": True, + "oci_provenance_verified": True, "public_bot_release": False, "publisher_signature_verified": False, "oci_image_manifest_digest": "sha256:" + "c" * 64, @@ -97,7 +104,10 @@ def qualify(self, **overrides): wheel_inventory=self.wheel_inventory, source_sha=self.source, root=self.root) arguments.update(overrides) - with mock.patch.object(aggregate.subprocess, "check_output", side_effect=self.git): + with mock.patch.object(aggregate.subprocess, "check_output", side_effect=self.git), \ + mock.patch.object(aggregate, "check_smoke_evidence", + side_effect=lambda path,key: {"independent_qualified":True, + "run_id":key,"public_bot_release":False}): return aggregate.qualify(**arguments) def test_sha1_git_vs_sha256_artifacts(self): @@ -220,5 +230,21 @@ def test_aggregate_requires_new_epoch_confirmed_move(self): self.qualify() + def test_no_producer_ready_bit_bypasses_independent_checker(self): + path=self.smoke_dir/"r00-white"/"result.json" + original=json.loads(path.read_text()) + # The test deliberately leaves the producer saying ready=true. + self.assertTrue(original["ready"]) + with mock.patch.object(aggregate.subprocess,"check_output",side_effect=self.git), \ + mock.patch.object(aggregate,"check_smoke_evidence", + side_effect=ValueError("illegal request in independently replayed trace")): + with self.assertRaisesRegex(ValueError,"illegal request"): + aggregate.qualify( + stage=self.stage,oci=self.oci,host=self.host, + smoke_dir=self.smoke_dir,os_inventory=self.os_inventory, + wheel_inventory=self.wheel_inventory,source_sha=self.source, + root=self.root) + + if __name__ == "__main__": unittest.main() From 22352700c0d45e84168c49441f07194dc383528c Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Tue, 13 Oct 2026 02:42:51 +0200 Subject: [PATCH 020/147] test(RELEASE-1): destructive full-game, lost-ACK, 429 and illegal-HTTP evidence witnesses --- tests/release/test_release_smoke_evidence.py | 213 +++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 tests/release/test_release_smoke_evidence.py diff --git a/tests/release/test_release_smoke_evidence.py b/tests/release/test_release_smoke_evidence.py new file mode 100644 index 00000000..7f36f8db --- /dev/null +++ b/tests/release/test_release_smoke_evidence.py @@ -0,0 +1,213 @@ +"""Adversarial RELEASE-1 evidence fixtures with legal game and HTTP traces. + +A valid evidence graph is constructed first; each negative case mutates a +single contract property and recomputes the non-authenticating trace digest. +""" +from __future__ import annotations + +import copy +import hashlib +import importlib.util +import json +import unittest +from pathlib import Path + +import chess + +ROOT = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location( + "release_smoke_evidence_qualifier", ROOT / "scripts/qualify-release-smoke.py") +qual = importlib.util.module_from_spec(spec) +spec.loader.exec_module(qual) + + +def fixture(key): + scenario, color = qual.CASES[key] + first, second = "a"*32, "b"*32 + board = chess.Board() + rows, requests, guarded, intents, accepted, commits = [], [], [], [], [], [] + activations = [] + def event(kind, **kw): + row = {"seq":len(rows)+1, "event":kind, + "monotonic_ns":(len(rows)+1)*1_000_000, **kw} + rows.append(row) + return row + requests.extend([ + {"method":"POST","path":"/api/token/test","status":200,"monotonic_ms":10}, + {"method":"POST","path":f"/api/challenge/{qual.GAME_ID}/accept", + "status":200,"monotonic_ms":20}, + ]) + if scenario == "R10": + uci, ply = "e2e4", 0 + path = qual.MOVE_PREFIX + uci + request = event("move_attempt",game_id=qual.GAME_ID, + ply_at_receipt=ply,uci=uci,path=path) + rid = f"request-{request['seq']}" + fault = event("fault_activated",action="rate_limit_429", + endpoint="move_post",request_id=rid) + row = event("rate_limit_hold_completed",action="rate_limit_429") + row["monotonic_ns"] = fault["monotonic_ns"] + 61_500_000_000 + activations.append({"action":"rate_limit_429","request_id":rid}) + requests.append({"method":"POST","path":path,"status":429, + "monotonic_ms":1000,"request_id":rid}) + guarded.append({"event":"http_move_submission_attempt", + "owner_epoch":first,"game_id":qual.GAME_ID, + "ply":ply,"uci":uci}) + intents.append({"game_id":qual.GAME_ID,"epoch":first, + "ply_before":ply,"move":uci,"history_json":"[]", + "status":"UNKNOWN_ACK"}) + else: + bot_index = 0 + for uci in ("f2f3","e7e5","g2g4","d8h4"): + ply = len(board.move_stack) + before = board.fen() + is_bot = board.turn == (color=="white") + rid = None + epoch = second if scenario=="R02" and bot_index else first + if is_bot: + path = qual.MOVE_PREFIX + uci + a = event("move_attempt",game_id=qual.GAME_ID,uci=uci, + path=path,ply_at_receipt=ply) + rid = f"request-{a['seq']}" + if scenario=="R02" and bot_index==0: + event("fault_activated",action="commit_then_close", + endpoint="move_post",request_id=rid) + activations.append({"action":"commit_then_close", + "request_id":rid}) + requests.append({"method":"POST","path":path,"status":200, + "monotonic_ms":1000+ply*1000, + "request_id":rid}) + intents.append({"game_id":qual.GAME_ID,"epoch":epoch, + "ply_before":ply,"move":uci, + "history_json":json.dumps( + [m.uci() for m in board.move_stack]), + "status":"CONFIRMED"}) + guarded.append({"event":"http_move_submission_attempt", + "owner_epoch":epoch,"game_id":qual.GAME_ID, + "ply":ply,"uci":uci}) + bot_index += 1 + board.push_uci(uci) + c = event("board_commit",game_id=qual.GAME_ID, + origin="allfather" if is_bot else "scripted_opponent", + request_id=rid,uci=uci,ply_before=ply, + ply_after=len(board.move_stack),fen_before=before, + fen_after=board.fen()) + commits.append(c) + if is_bot: + accepted.append({"ply":len(board.move_stack),"uci":uci}) + if scenario=="R02" and is_bot and bot_index==1: + event("connection_close_requested",path=qual.MOVE_PREFIX+uci) + moves = [m.uci() for m in board.move_stack] + complete = scenario!="R10" + observations = ( + [{"phase":1,"epoch":first,"observation":"restart_after_lost_ack"}, + {"phase":2,"epoch":second,"observation":"complete_confirmed"}] + if scenario=="R02" else + [{"phase":1,"epoch":first, + "observation":"rate_limited_suspension" if scenario=="R10" + else "complete_confirmed"}]) + result = { + "schema_version":2,"run_id":key,"scenario":scenario,"color":color, + "offline":True,"public_bot_release":False,"ready":True, + "runner_errors":[],"http":requests,"transport_trace":rows, + "game":{"game_id":qual.GAME_ID,"color":color,"moves":moves, + "accepted":accepted,"complete":complete, + "outcome":({"termination":"CHECKMATE"} if complete else None), + "forbidden_attempts":[]}, + "commits":commits,"fault":{"activations":activations}, + "guard_events":guarded,"observations":observations, + "journal":{"owner_epoch":observations[-1]["epoch"], + "intents":intents, + "games":[{"game_id":qual.GAME_ID,"history_json":json.dumps(moves), + "terminal":int(complete)}], + "cooldown":[{"expires":"future"}] if scenario=="R10" else []}, + } + seal(result) + return result + + +def seal(value): + value["transport_trace_sha256"] = hashlib.sha256( + qual.trace_bytes(value["transport_trace"])).hexdigest() + + +class PackagedMoveEvidenceTests(unittest.TestCase): + def test_all_four_realistic_cases_have_valid_independent_witnesses(self): + for key in qual.CASES: + with self.subTest(key=key): + result = qual.verify_smoke(fixture(key), key) + self.assertTrue(result["independent_qualified"]) + self.assertFalse(result["public_bot_release"]) + + def test_position_illegal_uci_fails_even_when_the_producer_says_ready(self): + doc=fixture("r10-white") + uci="e7e5" # syntactically valid, illegal on White's first turn + doc["transport_trace"][0]["uci"]=uci + doc["transport_trace"][0]["path"]=qual.MOVE_PREFIX+uci + doc["http"][-1]["path"]=qual.MOVE_PREFIX+uci + doc["journal"]["intents"][0]["move"]=uci + doc["guard_events"][0]["uci"]=uci + seal(doc) + with self.assertRaisesRegex(ValueError,"position-illegal"): + qual.verify_smoke(doc,"r10-white") + + def test_duplicate_submission_fails_independently_of_journal_status(self): + doc=fixture("r10-white") + final=doc["transport_trace"][-1] + doc["transport_trace"].append({ + "seq":final["seq"]+1,"monotonic_ns":final["monotonic_ns"]+1_000_000, + "event":"move_attempt","game_id":qual.GAME_ID, + "ply_at_receipt":0,"uci":"e2e4","path":qual.MOVE_PREFIX+"e2e4"}) + seal(doc) + with self.assertRaisesRegex(ValueError,"repeated intent"): + qual.verify_smoke(doc,"r10-white") + + def test_unattributed_http_move_and_illegal_rejection_fail(self): + for mutation in ("unknown-request","unexpected-status"): + doc=fixture("r00-white") + if mutation=="unknown-request": + doc["http"].append({"method":"POST", + "path":qual.MOVE_PREFIX+"e7e5", + "status":400,"monotonic_ms":6000, + "request_id":"request-999"}) + else: + doc["http"][2]["status"]=400 + with self.subTest(mutation=mutation),self.assertRaises(ValueError): + qual.verify_smoke(doc,"r00-white") + + def test_forged_trace_digest_or_commit_result_fails(self): + doc=fixture("r00-black") + doc["transport_trace"][0]["uci"]="a7a5" + with self.assertRaisesRegex(ValueError,"trace digest"): + qual.verify_smoke(doc,"r00-black") + doc=fixture("r00-black") + doc["commits"].pop() + with self.assertRaisesRegex(ValueError,"commit extract"): + qual.verify_smoke(doc,"r00-black") + + def test_r02_recovery_requires_a_distinct_confirmed_epoch(self): + doc=fixture("r02-white") + doc["observations"][1]["epoch"]=doc["observations"][0]["epoch"] + doc["journal"]["owner_epoch"]=doc["observations"][0]["epoch"] + with self.assertRaisesRegex(ValueError,"new-epoch"): + qual.verify_smoke(doc,"r02-white") + + def test_r10_requires_complete_monotonic_observation(self): + doc=fixture("r10-white") + final=doc["transport_trace"][-1] + fault=doc["transport_trace"][1] + final["monotonic_ns"]=fault["monotonic_ns"]+59_000_000_000 + seal(doc) + with self.assertRaisesRegex(ValueError,"61 seconds"): + qual.verify_smoke(doc,"r10-white") + + def test_r10_rejects_all_api_requests_during_cooldown(self): + doc=fixture("r10-white") + doc["http"].append({"method":"GET","path":"/api/account/playing", + "status":200,"monotonic_ms":1200}) + with self.assertRaisesRegex(ValueError,"cooldown"): + qual.verify_smoke(doc,"r10-white") + + +if __name__=="__main__": + unittest.main() From 03a19e85ae253c11d7fb095725e0f7fb702eb982 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Tue, 13 Oct 2026 02:44:51 +0200 Subject: [PATCH 021/147] test(RELEASE-1): verify source-correlated HTTP IDs and OCI export diagnostics --- tests/lichess/test_recovery_http.py | 11 +++++++++++ tests/release/test_release_oci_integrity.py | 16 +++++++++++++++- tests/release/test_workflow_coverage.py | 11 +++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/tests/lichess/test_recovery_http.py b/tests/lichess/test_recovery_http.py index 8832ce0d..23f1a0d6 100644 --- a/tests/lichess/test_recovery_http.py +++ b/tests/lichess/test_recovery_http.py @@ -161,5 +161,16 @@ def test_fenced_http_stream_reaches_eof(self): fake.close() + def test_every_simulated_move_post_has_correlated_http_request_id(self): + _,data=self.run_fault("R02",1) + attempts=[r for r in data["transport_trace"] if r["event"]=="move_attempt"] + http=[r for r in data["requests"] if + r["method"]=="POST" and "/move/" in r["path"]] + self.assertTrue(attempts) + self.assertEqual( + {f"request-{r['seq']}" for r in attempts}, + {r.get("request_id") for r in http}) + self.assertTrue(all(r["status"]==200 for r in http)) + if __name__ == "__main__": unittest.main() diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index 1bebfbfe..b713e0a2 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -131,7 +131,7 @@ def test_rehashed_replacement_layer_still_rejected_by_rootfs(self): def test_attestation_subject_cannot_name_an_unrelated_image(self): with tempfile.TemporaryDirectory() as d: archive,meta,inspection=self.fixture(Path(d),bad_subject=True) - with self.assertRaisesRegex(ValueError,"unrelated"): + with self.assertRaisesRegex(ValueError,"application manifest"): oci.verify_archive(archive,meta,docker_inspect=inspection) def test_attestation_descriptor_cannot_point_to_other_image(self): @@ -185,5 +185,19 @@ def test_garbled_predicate_cannot_claim_sbom_or_provenance(self): + def test_diagnostic_structure_is_not_an_integrity_verdict(self): + inspect_spec=importlib.util.spec_from_file_location( + "release1_oci_diagnostic",ROOT/"scripts/inspect-release-oci.py") + mod=importlib.util.module_from_spec(inspect_spec) + inspect_spec.loader.exec_module(mod) + with tempfile.TemporaryDirectory() as d: + archive,meta,_=self.fixture(Path(d)) + report=mod.inspect(archive,meta) + self.assertFalse(report["integrity_verified"]) + self.assertEqual(len(report["index_manifests"]),2) + attest=report["index_manifests"][1] + self.assertEqual(len(attest["layers"]),2) + self.assertEqual(attest["layers"][0]["statements"][0]["subject_count"],1) + if __name__=="__main__": unittest.main() diff --git a/tests/release/test_workflow_coverage.py b/tests/release/test_workflow_coverage.py index 1257f33a..2e78db08 100644 --- a/tests/release/test_workflow_coverage.py +++ b/tests/release/test_workflow_coverage.py @@ -35,5 +35,16 @@ def test_operations_docker_command_is_valid_shell(self): self.assertIn(fragment,command) + def test_offline_oci_export_is_named_and_diagnostics_precede_admission(self): + content=(ROOT/".github/workflows/release-candidate.yml").read_text() + export=content.split("- name: Export standalone OCI archive",1)[1] + self.assertIn("--tag allfather/release1:offline",export) + self.assertIn("scripts/inspect-release-oci.py",export) + self.assertLess(export.index("scripts/inspect-release-oci.py"), + export.index("scripts/verify-release-oci.py")) + self.assertIn("scripts/qualify-release-smoke.py",content) + self.assertLess(content.index("scripts/qualify-release-smoke.py"), + content.index("- name: Export standalone OCI archive")) + if __name__=="__main__": unittest.main() From 85d55984e9c0ca702e4b32d1166e924718b7795e Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Tue, 13 Oct 2026 02:45:53 +0200 Subject: [PATCH 022/147] fix(RELEASE-1): bind aggregate source claims to raw OCI archive SHA-256 --- .github/workflows/release-candidate.yml | 1 + scripts/qualify-release-aggregate.py | 8 ++++++-- tests/release/test_release_aggregate.py | 16 +++++++++++++--- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 07b8bb9e..536c3124 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -183,6 +183,7 @@ jobs: python3 scripts/qualify-release-aggregate.py \ --stage build/release1-stage \ --oci build/test-results/release1/oci-report.json \ + --oci-archive build/release1-offline.oci.tar \ --host build/test-results/release1/host-report.json \ --smoke-dir build/test-results/release1-volume \ --os-inventory build/test-results/release1/os-packages.txt \ diff --git a/scripts/qualify-release-aggregate.py b/scripts/qualify-release-aggregate.py index c639f948..bdb227e5 100644 --- a/scripts/qualify-release-aggregate.py +++ b/scripts/qualify-release-aggregate.py @@ -57,7 +57,7 @@ def check_smoke_evidence(path:Path, key:str) -> dict: return mod.verify_file(path,key) -def qualify(*, stage:Path, oci:Path, host:Path, smoke_dir:Path, +def qualify(*, stage:Path, oci:Path, oci_archive:Path, host:Path, smoke_dir:Path, os_inventory:Path, wheel_inventory:Path, source_sha:str, root:Path=ROOT): source= subprocess.check_output(["git","-C",str(root),"rev-parse","HEAD"], text=True).strip() @@ -92,6 +92,9 @@ def qualify(*, stage:Path, oci:Path, host:Path, smoke_dir:Path, "invalid OCI "+key) require(is_sha256_hex(oci_report.get("oci_archive_sha256")), "invalid OCI archive SHA-256 digest") + require(oci_archive.is_file() and not oci_archive.is_symlink() and + digest(oci_archive) == oci_report["oci_archive_sha256"], + "OCI archive identity does not match independently verified report") require(host_report["destination_host_qualified"] is False and host_report["test_host_observed"] is True, "host admission attempted to promote CI runner") @@ -193,6 +196,7 @@ def main(): p=argparse.ArgumentParser() p.add_argument("--stage",type=Path,required=True) p.add_argument("--oci",type=Path,required=True) + p.add_argument("--oci-archive",type=Path,required=True) p.add_argument("--host",type=Path,required=True) p.add_argument("--smoke-dir",type=Path,required=True) p.add_argument("--os-inventory",type=Path,required=True) @@ -201,7 +205,7 @@ def main(): p.add_argument("--output",type=Path,required=True) a=p.parse_args() try: - report=qualify(stage=a.stage,oci=a.oci,host=a.host, + report=qualify(stage=a.stage,oci=a.oci,oci_archive=a.oci_archive,host=a.host, smoke_dir=a.smoke_dir,os_inventory=a.os_inventory, wheel_inventory=a.wheel_inventory,source_sha=a.source_sha) a.output.parent.mkdir(parents=True,exist_ok=True) diff --git a/tests/release/test_release_aggregate.py b/tests/release/test_release_aggregate.py index ddab09c2..8fb55b54 100644 --- a/tests/release/test_release_aggregate.py +++ b/tests/release/test_release_aggregate.py @@ -28,6 +28,8 @@ def setUp(self): self.root = Path(temp.name) self.stage = self.root / "stage" self.oci = self.root / "oci.json" + self.oci_archive = self.root / "image.oci.tar" + self.oci_archive.write_bytes(b"synthetic verified offline archive") self.host = self.root / "host.json" self.smoke_dir = self.root / "smoke" self.os_inventory = self.root / "os.txt" @@ -62,7 +64,7 @@ def setUp(self): "publisher_signature_verified": False, "oci_image_manifest_digest": "sha256:" + "c" * 64, "oci_config_digest": "sha256:" + "d" * 64, - "oci_archive_sha256": "e" * 64, + "oci_archive_sha256": aggregate.digest(self.oci_archive), }) write_json(self.host, { "test_host_observed": True, @@ -99,7 +101,8 @@ def git(self, command, **kwargs): raise AssertionError("unexpected git command") def qualify(self, **overrides): - arguments = dict(stage=self.stage, oci=self.oci, host=self.host, + arguments = dict(stage=self.stage, oci=self.oci, + oci_archive=self.oci_archive, host=self.host, smoke_dir=self.smoke_dir, os_inventory=self.os_inventory, wheel_inventory=self.wheel_inventory, source_sha=self.source, root=self.root) @@ -240,11 +243,18 @@ def test_no_producer_ready_bit_bypasses_independent_checker(self): side_effect=ValueError("illegal request in independently replayed trace")): with self.assertRaisesRegex(ValueError,"illegal request"): aggregate.qualify( - stage=self.stage,oci=self.oci,host=self.host, + stage=self.stage,oci=self.oci, + oci_archive=self.oci_archive,host=self.host, smoke_dir=self.smoke_dir,os_inventory=self.os_inventory, wheel_inventory=self.wheel_inventory,source_sha=self.source, root=self.root) + def test_verifier_report_must_match_actual_oci_archive_bytes(self): + self.oci_archive.write_bytes(b"tampered OCI archive") + with self.assertRaisesRegex(ValueError,"OCI archive identity"): + self.qualify() + + if __name__ == "__main__": unittest.main() From cf57ee258d7135baf4be86ec91e20b6a59f52d8f Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Tue, 13 Oct 2026 02:47:13 +0200 Subject: [PATCH 023/147] docs(RELEASE-1): map Codex follow-up repairs to strict offline evidence gates --- docs/CODEX_POSTMERGE_CLOSURE.md | 12 ++++++++++ docs/RELEASE_1_OPERATIONS.md | 31 +++++++++++++++++++++++++ tests/release/test_workflow_coverage.py | 2 ++ 3 files changed, 45 insertions(+) diff --git a/docs/CODEX_POSTMERGE_CLOSURE.md b/docs/CODEX_POSTMERGE_CLOSURE.md index 8b458930..e66376e9 100644 --- a/docs/CODEX_POSTMERGE_CLOSURE.md +++ b/docs/CODEX_POSTMERGE_CLOSURE.md @@ -36,3 +36,15 @@ Status: code implementation in progress; not evidence of green CI or completed r Keep draft until all exact-head fast/real-process checks pass and final Codex code/security reviews finish. Preserve failing and incomplete evidence. Do not reduce any chess authority or runtime-budget requirement. Remain offline-only: no license approval, OS/wheel dependency promotion, public image upload, destination-host authorization, real credentials, BOT account conversion, live transport, or playing-strength claim. On squash merge, verify new main SHA through the explicit RELEASE-1 main-push workflow. + +## PR #71 Codex review and final qualification follow-up + +- [P1: image-level in-toto subject](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4253078059): now accepted only when every subject digest equals the selected application-image manifest, not its config or individual layer. Added config-only/layer-only/empty-subject destructive tests. +- [P2: BuildKit metadata digest](https://github.com/HMarcusWH/AllfatherChess/pull/71#pullrequestreview-5496871107): only the verified OCI index or selected application manifest can match `containerimage.digest`; an otherwise-valid attestation manifest cannot substitute for the image. +- [P2: packaged illegal HTTP move attempt](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4253078070): the fake server now correlates every move request ID with its HTTP status. The image retains its ordered trace and a separate host verifier reconstructs board legality, no-blind-retry, durable intent, R02 and R10 witnesses before aggregate admission. + +CI discovered that the untagged OCI archive export lacked BuildKit default image subjects even though the standalone Docker image had an image name. The OCI export now specifies an explicit **local** image tag and records sanitized attestation structure before strict verification. Missing subject evidence is NEVER accepted as a workaround. + +The RELEASE-1 host aggregate now also recomputes the actual OCI archive SHA-256 and requires all independent OCI-verifier booleans. Smoke evidence schema 2 replaces incomplete schema 1 for qualification; no producer readiness bit alone can authorize a verdict. + +**Status:** this entry records implemented source changes, not successful exact-head OCI integration or an approved code/security review. Fresh final-head tests/reviews and disposition of any new feedback are mandatory before merging. Offline-only/publication-prohibited policy remains unchanged. diff --git a/docs/RELEASE_1_OPERATIONS.md b/docs/RELEASE_1_OPERATIONS.md index 03a142ff..be810407 100644 --- a/docs/RELEASE_1_OPERATIONS.md +++ b/docs/RELEASE_1_OPERATIONS.md @@ -176,3 +176,34 @@ RELEASE-1 also exports only Git-tracked files from the verified lichess-bot checkout. Untracked files such as a local `.env` may not enter the image regardless of their contents. The CA trust-root bootstrap source is bound into the source-attestation inventory. + +## Post-merge Codex hardening: source-bound OCI and independent move evidence + +PR #71 introduces an explicitly named **local OCI export** (`--tag allfather/release1:offline`). +The tag is an attestation identity input; the workflow uses `type=oci,dest=...`, +not a registry push, and never publishes the image or OCI tarball. +`scripts/inspect-release-oci.py` writes bounded diagnostic-only JSON before +`scripts/verify-release-oci.py` evaluates the archive. The diagnostic report is +not an integrity verdict. Image-level in-toto subjects and Docker descriptor +references must identify the exact selected application manifest, while the +OCI layers must still match the ordered expanded `config.rootfs.diff_ids`. +The final aggregate independently binds the generated OCI archive SHA-256. + +Each installed-image smoke run now retains evidence schema 2: the ordered +server transport trace with a canonical SHA-256 digest, request-ID-correlated +HTTP responses, authoritative board commits, durable SQLite journal and +guard submission audit. On the CI host, `scripts/qualify-release-smoke.py` +independently reconstructs move legality, HTTP-attempt uniqueness, accepted +board history and journal/epoch identities. It also checks R02's real +lost-ACK restart and R10's complete post-429 observation window and +account-wide 60-second API cooldown. A producer `ready: true` without those +independent witnesses is insufficient for qualification. + +The host-side report `build/test-results/release1/smoke-verdict.json` and +bounded OCI descriptor report `oci-attestation-structure.json` are retained +as non-binary CI diagnostics. No raw OCI archive, executable, private log or +persistent SQLite replay volume is uploaded by the RELEASE-1 workflow. + +**No new release permissions are implied.** OS/wheel reproducibility locks, +combined license review, destination-host qualification, real Lichess +transport, public BOT account play and operator approval remain blocked. diff --git a/tests/release/test_workflow_coverage.py b/tests/release/test_workflow_coverage.py index 2e78db08..4d439dfa 100644 --- a/tests/release/test_workflow_coverage.py +++ b/tests/release/test_workflow_coverage.py @@ -45,6 +45,8 @@ def test_offline_oci_export_is_named_and_diagnostics_precede_admission(self): self.assertIn("scripts/qualify-release-smoke.py",content) self.assertLess(content.index("scripts/qualify-release-smoke.py"), content.index("- name: Export standalone OCI archive")) + self.assertIn("--oci-archive build/release1-offline.oci.tar",content) + self.assertIn("build/test-results/release1/smoke-verdict.json",content) if __name__=="__main__": unittest.main() From 050798839aa7c31a64d9b125c03eb7851d33de6e Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Tue, 13 Oct 2026 02:50:22 +0200 Subject: [PATCH 024/147] test(RELEASE-1): prove late successful-game exception is not suppressed by finally --- tests/release/test_release_smoke_failures.py | 77 ++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/tests/release/test_release_smoke_failures.py b/tests/release/test_release_smoke_failures.py index 4b03c3d4..f86790cb 100644 --- a/tests/release/test_release_smoke_failures.py +++ b/tests/release/test_release_smoke_failures.py @@ -40,5 +40,82 @@ def evidence(self): for x in result["runner_errors"])) + def test_late_exception_after_completed_game_is_never_suppressed(self): + """Reverts to a false-green exit if the original finally-return returns.""" + moves=["f2f3","e7e5","g2g4","d8h4"] + report={"game_id":"off3b001","color":"white","moves":moves, + "accepted":[],"complete":True, + "outcome":{"termination":"CHECKMATE"}, + "forbidden_attempts":[],"requests":[], + "transport_trace":[],"fault":{"activations":[]}} + class Done: + def is_set(self): + return True + class Ready: + def wait(self,*args): + return True + class Fault: + def evidence(self): + return {"activations":[]} + class Sessions: + def snapshot(self): + return [] + class FinishedServer: + url="http://127.0.0.1:21212/" + finished=Done() + event_stream_open=Ready() + game_stream_open=Ready() + fault=Fault() + sessions=Sessions() + def __init__(self,*args): + pass + def start(self): + pass + def issue_challenge(self): + pass + def close(self): + pass + def evidence(self): + return report + class Journal: + def snapshot(self): + return {"games":[{"game_id":"off3b001","terminal":1, + "history_json":json.dumps(moves)}], + "intents":[],"cooldown":[],"events":[]} + class Owner: + epoch="a"*32 + journal=Journal() + def __init__(self,*args): + self.directory=Path(temp)/"state" + def __enter__(self): + return self + def __exit__(self,*exc): + return False + class Proc: + def poll(self): + return None + with tempfile.TemporaryDirectory() as temp: + with mock.patch.object(smoke.os,"getpid",return_value=1), \ + mock.patch.dict(smoke.os.environ,{"ONLINE_RELEASEx_ISOLATED":"1"}), \ + mock.patch.object(smoke,"PERSIST",Path(temp)), \ + mock.patch.object(smoke,"TOOLS",ROOT), \ + mock.patch.object(smoke,"FakeLichess",FinishedServer), \ + mock.patch.object(smoke,"SessionOwner",Owner), \ + mock.patch.object(smoke.bridge_policy,"generate",return_value={}), \ + mock.patch.object(smoke,"_start",return_value=(Proc(),None)), \ + mock.patch.object(smoke,"shutdown_bridge"), \ + mock.patch.object(smoke,"stop_all_isolated_processes", + side_effect=AssertionError("late after completed game")): + with self.assertRaisesRegex(AssertionError,"late after completed game"): + smoke.run(color="white",scenario_id="R00",run_id="late") + result=json.loads((Path(temp)/"late/result.json").read_text()) + self.assertEqual(result["observations"][0]["observation"], + "complete_confirmed") + self.assertFalse(result["ready"]) + self.assertTrue(any("AssertionError" in item + for item in result["runner_errors"])) + + + if __name__=="__main__": unittest.main() From 60b9f4ec2bdef2ffdda866e999a41feb31dd2a43 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Tue, 13 Oct 2026 21:11:55 +0200 Subject: [PATCH 025/147] fix(RELEASE-1): verify exact OCI export descriptor graph and SLSA identities --- .github/workflows/release-candidate.yml | 3 +- scripts/inspect-release-oci.py | 131 ++++++++++---- scripts/verify-release-oci.py | 230 ++++++++++++++++++------ 3 files changed, 272 insertions(+), 92 deletions(-) diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 536c3124..577258c1 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -164,7 +164,8 @@ jobs: --file deploy/release/Dockerfile --build-arg "BASE_IMAGE=$IMAGE_BASE" \ --tag allfather/release1:offline \ --metadata-file build/test-results/release1/oci-build-metadata.json \ - --provenance=mode=min --sbom=true \ + --attest type=provenance,mode=min,builder-id=https://github.com/HMarcusWH/AllfatherChess/actions/workflows/release-candidate.yml \ + --attest type=sbom \ --output type=oci,dest=build/release1-offline.oci.tar . python3 scripts/inspect-release-oci.py \ --archive build/release1-offline.oci.tar \ diff --git a/scripts/inspect-release-oci.py b/scripts/inspect-release-oci.py index d2ac909a..f6ce9141 100644 --- a/scripts/inspect-release-oci.py +++ b/scripts/inspect-release-oci.py @@ -16,61 +16,126 @@ def inspect(archive: Path, metadata: Path) -> dict: + """Bounded diagnostic traversal of indexes, manifests, and statement summaries.""" result = {"schema_version": 1, "diagnostic_only": True, "integrity_verified": False, "public_bot_release": False} build = json.loads(metadata.read_text(encoding="utf-8")) result["buildkit_image_name"] = build.get("image.name") result["buildkit_digest"] = build.get("containerimage.digest") with tarfile.open(archive, "r:*") as tar: - members = {m.name: m for m in tar.getmembers() - if m.isfile() and ".." not in Path(m.name).parts} + members = {} + for member in tar.getmembers(): + path = Path(member.name) + if path.is_absolute() or ".." in path.parts or member.name in members: + raise ValueError("invalid or duplicate OCI diagnostic archive path") + if member.issym() or member.islnk(): + raise ValueError("linked OCI diagnostic archive member") + members[member.name] = member + def document(path: str): member = members.get(path) - if member is None or member.size > MAX_JSON: + if member is None or not member.isfile() or member.size > MAX_JSON: raise ValueError("missing or oversized descriptor: " + path) stream = tar.extractfile(member) if stream is None: raise ValueError("unreadable descriptor: " + path) - content = stream.read(MAX_JSON + 1) + with stream: + content = stream.read(MAX_JSON + 1) if len(content) > MAX_JSON: raise ValueError("oversized descriptor: " + path) return json.loads(content) + + def bounded_descriptor(desc, ancestry): + if not isinstance(desc, dict): + raise ValueError("malformed OCI descriptor") + oid = desc.get("digest") + size = desc.get("size") + typ = desc.get("mediaType") + if (not isinstance(oid, str) or len(oid) != 71 or + not oid.startswith("sha256:") or + any(c not in "0123456789abcdef" for c in oid[7:]) or + type(size) is not int or size < 0): + raise ValueError("malformed OCI diagnostic descriptor identity") + if oid in ancestry: + raise ValueError("cyclic OCI descriptor graph") + path = "blobs/sha256/" + oid[7:] + actual = members.get(path) + if actual is None or not actual.isfile(): + raise ValueError("missing OCI referenced blob") + return {"digest": oid, "media_type": typ, + "declared_size": size, "actual_size": actual.size, + "descriptor_path": list(ancestry) + [oid], + "platform": desc.get("platform"), + "annotations": desc.get("annotations", {})} + index = document("index.json") + if not isinstance(index, dict) or not isinstance(index.get("manifests"), list): + raise ValueError("invalid layout index") + roots = index["manifests"] result["index_manifests"] = [] - for entry in index.get("manifests", []): - oid = entry.get("digest", "") - rec = {"digest": oid, "platform": entry.get("platform"), - "annotations": entry.get("annotations", {})} - if not isinstance(oid, str) or not oid.startswith("sha256:"): - raise ValueError("invalid index descriptor digest") + result["application_manifest_digest"] = None + result["attestation_manifest_digests"] = [] + result["exported_root_digest"] = build.get("containerimage.digest") + inspected = [0] + + def traverse(desc, ancestry=()): + inspected[0] += 1 + if inspected[0] > 256 or len(ancestry) > 8: + raise ValueError("OCI diagnostic graph too deep or large") + rec = bounded_descriptor(desc, ancestry) + oid = rec["digest"] + if rec["declared_size"] != rec["actual_size"]: + raise ValueError("OCI diagnostic descriptor size mismatch") + typ = rec["media_type"] + if typ == "application/vnd.oci.image.index.v1+json": + node = document("blobs/sha256/" + oid[7:]) + if not isinstance(node, dict) or not isinstance(node.get("manifests"), list): + raise ValueError("malformed nested OCI index") + rec["children"] = [traverse(child, ancestry + (oid,)) + for child in node["manifests"]] + rec["artifact_type"] = "nested-index" + return rec + if typ not in ("application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json"): + raise ValueError("unexpected descriptor media type") manifest = document("blobs/sha256/" + oid[7:]) rec["artifact_type"] = manifest.get("artifactType") rec["manifest_subject"] = manifest.get("subject") rec["config_type"] = (manifest.get("config") or {}).get("mediaType") + attested = (rec["annotations"] or {}).get( + "vnd.docker.reference.type") == "attestation-manifest" + if attested: + result["attestation_manifest_digests"].append(oid) + else: + result["application_manifest_digest"] = oid rec["layers"] = [] - if (entry.get("annotations") or {}).get( - "vnd.docker.reference.type") == "attestation-manifest": - for layer in manifest.get("layers", []): - lid = layer.get("digest", "") - row = {"digest": lid, - "media_type": layer.get("mediaType"), - "annotations": layer.get("annotations", {})} - if isinstance(lid, str) and lid.startswith("sha256:"): - statement = document("blobs/sha256/" + lid[7:]) - statements = statement.get("statements") - if not isinstance(statements, list): - statements = [statement] - row["statements"] = [{ - "_type": st.get("_type"), - "predicate_type": st.get("predicateType"), - "subject_count": len(st.get("subject", [])) - if isinstance(st.get("subject"), list) else None, - "subjects": st.get("subject"), - "predicate_keys": sorted((st.get("predicate") or {}).keys()) - if isinstance(st.get("predicate"), dict) else [], - } for st in statements if isinstance(st, dict)] - rec["layers"].append(row) - result["index_manifests"].append(rec) + for layer in manifest.get("layers", []): + lid = layer.get("digest", "") + member = members.get("blobs/sha256/" + str(lid)[7:]) + row = {"digest": lid, + "media_type": layer.get("mediaType"), + "annotations": layer.get("annotations", {}), + "declared_size": layer.get("size"), + "actual_size": member.size if member else None} + if attested and layer.get("mediaType") == "application/vnd.in-toto+json": + statement = document("blobs/sha256/" + str(lid)[7:]) + statements = statement.get("statements") + if not isinstance(statements, list): + statements = [statement] + row["statements"] = [{ + "_type": st.get("_type"), + "predicate_type": st.get("predicateType"), + "subject_count": len(st.get("subject", [])) + if isinstance(st.get("subject"), list) else None, + "subjects": st.get("subject"), + "predicate_keys": sorted((st.get("predicate") or {}).keys()) + if isinstance(st.get("predicate"), dict) else [], + } for st in statements if isinstance(st, dict)] + rec["layers"].append(row) + return rec + + result["index_manifests"] = [traverse(root) for root in roots] + result["descriptor_count"] = inspected[0] return result diff --git a/scripts/verify-release-oci.py b/scripts/verify-release-oci.py index 2289de0c..4c5734a8 100644 --- a/scripts/verify-release-oci.py +++ b/scripts/verify-release-oci.py @@ -13,6 +13,7 @@ import re import tarfile from pathlib import Path, PurePosixPath +from urllib.parse import urlsplit HEX = re.compile(r"^[0-9a-f]{64}$") @@ -115,6 +116,42 @@ def verify_layer_diff_ids(archive: tarfile.TarFile, members: dict, STATEMENT = "https://in-toto.io/Statement/v1" +def valid_identity_uri(value: object) -> bool: + """Require an actual URI for a SLSA build type or builder identity.""" + if not isinstance(value, str) or not value.strip() or value != value.strip(): + return False + parsed = urlsplit(value) + return ((parsed.scheme in ("https", "http") and bool(parsed.netloc) + and not any(ch.isspace() for ch in value)) + or (parsed.scheme == "urn" and bool(parsed.path) + and ":" in parsed.path and not any(ch.isspace() for ch in value))) + + +def validate_slsa(kind: str, predicate: dict) -> None: + if kind == "https://slsa.dev/provenance/v1": + build = predicate.get("buildDefinition") + run = predicate.get("runDetails") + require(isinstance(build, dict) and isinstance(run, dict), + "SLSA provenance v1 predicate incomplete") + require(valid_identity_uri(build.get("buildType")), + "SLSA v1 build type missing or invalid") + require(isinstance(build.get("externalParameters"), dict), + "SLSA v1 external parameters missing") + builder = run.get("builder") + require(isinstance(builder, dict) and + valid_identity_uri(builder.get("id")), + "SLSA v1 builder identity missing or invalid") + elif kind == "https://slsa.dev/provenance/v0.2": + builder = predicate.get("builder") + require(isinstance(builder, dict) and + valid_identity_uri(builder.get("id")) and + valid_identity_uri(predicate.get("buildType")) and + isinstance(predicate.get("invocation"), dict), + "SLSA v0.2 build type, builder or invocation missing") + else: + raise ValueError("unsupported SLSA version") + + def verify_attestation_subjects(attestations: list[dict], image: dict) -> set[str]: """Independently bind BuildKit's in-toto subjects to the application manifest. @@ -178,15 +215,8 @@ def verify_attestation_subjects(attestations: list[dict], image: dict) -> set[st and isinstance(predicate.get("SPDXID"), str) and bool(predicate["SPDXID"]), "SPDX SBOM predicate lacks document identity") - elif kind.endswith("/v1"): - require(isinstance(predicate.get("buildDefinition"), dict) - and isinstance(predicate.get("runDetails"), dict), - "SLSA provenance v1 predicate incomplete") else: - require(isinstance(predicate.get("builder"), dict) - and isinstance(predicate.get("buildType"), str) - and bool(predicate["buildType"]), - "SLSA provenance v0.2 predicate incomplete") + validate_slsa(kind, predicate) predicates.add(kind) require(SPDX in predicates, "required image SBOM attestation not exported") require(bool(predicates.intersection(SLSA)), @@ -220,70 +250,152 @@ def verify_archive(archive: Path, metadata: Path, *, docker_inspect: Path | None index.get("mediaType") in (None,"application/vnd.oci.image.index.v1+json"), "invalid OCI index") referenced = set() - def get(oid): - if not isinstance(oid, str) or not oid.startswith("sha256:") or not HEX.fullmatch(oid[7:]): - raise ValueError("invalid OCI digest") + + def get(oid, expected_size): + """Hash the actual tar member and validate its descriptor byte length.""" + require(isinstance(oid, str) and oid.startswith("sha256:") and + HEX.fullmatch(oid[7:]) is not None, "invalid OCI digest") + require(type(expected_size) is int and expected_size >= 0, + "missing or invalid OCI descriptor size") name = "blobs/sha256/" + oid[7:] + member = by_name.get(name) + require(member is not None and member.isfile(), "missing OCI blob " + oid) + require(member.size == expected_size, + "OCI descriptor size differs from referenced blob") if name in blobs: return blobs[name] - require(name in by_name and by_name[name].isfile(), "missing OCI blob "+oid) - f = inp.extractfile(by_name[name]) - require(f is not None, "unreadable OCI blob") + stream = inp.extractfile(member) + require(stream is not None, "unreadable OCI blob") sha = hashlib.sha256() - body = bytearray() if by_name[name].size <= 8 * 1024 * 1024 else None - while True: - data = f.read(1 << 20) - if not data: break - sha.update(data) - if body is not None: body.extend(data) - require(sha.hexdigest() == oid[7:], "OCI blob hash mismatch "+oid) + body = bytearray() if member.size <= 8 * 1024 * 1024 else None + with stream: + while True: + data = stream.read(1 << 20) + if not data: + break + sha.update(data) + if body is not None: + body.extend(data) + require(sha.hexdigest() == oid[7:], "OCI blob hash mismatch " + oid) referenced.add(name) if body is not None: blobs[name] = bytes(body) return blobs[name] return None + INDEX_TYPE = "application/vnd.oci.image.index.v1+json" + MANIFEST_TYPES = ("application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json") + CONFIG_TYPES = ("application/vnd.oci.image.config.v1+json", + "application/vnd.docker.container.image.v1+json", + "application/vnd.oci.empty.v1+json") + LAYER_TYPES = ("application/vnd.oci.image.layer.v1.tar", + "application/vnd.oci.image.layer.v1.tar+gzip", + "application/vnd.docker.image.rootfs.diff.tar", + "application/vnd.docker.image.rootfs.diff.tar.gzip") + top = index.get("manifests") + require(isinstance(top, list) and bool(top), + "OCI layout has no root descriptors") + reported_manifest = meta.get("containerimage.digest") + matches = [d for d in top if isinstance(d, dict) + and d.get("digest") == reported_manifest] + require(len(matches) == 1, + "BuildKit descriptor digest does not match exported OCI root") + root_descriptor = matches[0] + root_type = root_descriptor.get("mediaType") + require(root_type == INDEX_TYPE or root_type in MANIFEST_TYPES, + "BuildKit root descriptor has unsupported media type") + if root_type == INDEX_TYPE: + require(len(top) == 1, + "nested BuildKit root must be the sole layout root") + else: + require(all(d is root_descriptor or + (isinstance(d, dict) and + (d.get("annotations") or {}).get( + "vnd.docker.reference.type") == "attestation-manifest") + for d in top), + "flat OCI layout has unrelated root descriptors") + reported_descriptor = meta.get("containerimage.descriptor") + require(isinstance(reported_descriptor, dict) and + all(reported_descriptor.get(k) == root_descriptor.get(k) + for k in ("digest", "mediaType", "size")) and + type(root_descriptor.get("size")) is int, + "BuildKit image descriptor identity/size differs from OCI root") images, attestations = [], [] - def inspect_descriptor(descriptor, depth=0): - require(depth < 4 and isinstance(descriptor, dict), + visited_nodes = [0] + + def inspect_descriptor(descriptor, depth=0, ancestors=frozenset()): + require(depth <= 8 and isinstance(descriptor, dict), "OCI descriptor nesting invalid") - raw = get(descriptor.get("digest")) + visited_nodes[0] += 1 + require(visited_nodes[0] <= 256, + "OCI descriptor graph exceeds node budget") + typ = descriptor.get("mediaType") + require(typ == INDEX_TYPE or typ in MANIFEST_TYPES, + "unsupported OCI descriptor kind") + oid = descriptor.get("digest") + require(oid not in ancestors, "OCI descriptor graph contains a cycle") + raw = get(oid, descriptor.get("size")) require(raw is not None, "OCI descriptor JSON too large or missing") document = json.loads(raw) - typ = descriptor.get("mediaType") - if typ == "application/vnd.oci.image.index.v1+json": - for next in document.get("manifests", []): - inspect_descriptor(next, depth+1) + require(isinstance(document, dict) and + document.get("schemaVersion") == 2, + "invalid nested OCI index or image manifest") + if typ == INDEX_TYPE: + children = document.get("manifests") + require(isinstance(children, list) and bool(children), + "nested OCI index has no child descriptors") + for child in children: + inspect_descriptor(child, depth + 1, ancestors | {oid}) return - require(typ in ("application/vnd.oci.image.manifest.v1+json", - "application/vnd.docker.distribution.manifest.v2+json"), - "unsupported OCI descriptor kind") - cfg = document.get("config") or {} - config_bytes = get(cfg.get("digest")) - require(config_bytes is not None, "OCI config cannot be independently parsed") + cfg = document.get("config") + require(isinstance(cfg, dict) and + cfg.get("mediaType") in CONFIG_TYPES, + "OCI image has invalid configuration media type") + config_bytes = get(cfg.get("digest"), cfg.get("size")) + require(config_bytes is not None, + "OCI config cannot be independently parsed") details = json.loads(config_bytes) + require(isinstance(details, dict), "malformed OCI configuration") layers = document.get("layers") - require(isinstance(layers, list) and bool(layers), "OCI image missing layers") + require(isinstance(layers, list) and bool(layers), + "OCI image missing layers") + attested = (descriptor.get("annotations") or {}).get( + "vnd.docker.reference.type") == "attestation-manifest" for layer in layers: - get(layer.get("digest")) - if descriptor.get("annotations", {}).get("vnd.docker.reference.type") == "attestation-manifest": + require(isinstance(layer, dict) and + layer.get("mediaType") in ( + ("application/vnd.in-toto+json",) if attested + else LAYER_TYPES), + "unsupported OCI statement or application layer mediaType") + get(layer.get("digest"), layer.get("size")) + if attested: for layer in layers: - content = get(layer["digest"]) - require(content is not None, "oversized attestation refused") - attestations.append({"descriptor":descriptor, - "manifest_subject":document.get("subject"), - "layer_media_type":layer.get("mediaType"), - "statement":json.loads(content)}) - elif details.get("architecture") == "amd64" and details.get("os") == "linux": - images.append({"manifest_digest":descriptor["digest"], - "config_digest":cfg["digest"], - "config":details, - "layers":layers, - "layer_count":len(layers)}) - for x in index.get("manifests",[]): - inspect_descriptor(x) - require(len(images)==1, "require exactly one linux/amd64 application image") - image=images[0] + content = get(layer["digest"], layer["size"]) + require(content is not None, + "oversized attestation statement refused") + attestations.append({ + "descriptor": descriptor, + "manifest_subject": document.get("subject"), + "layer_media_type": layer.get("mediaType"), + "statement": json.loads(content)}) + else: + require(details.get("architecture") == "amd64" and + details.get("os") == "linux", + "unexpected non-attestation OCI application manifest") + images.append({"manifest_digest": oid, + "config_digest": cfg["digest"], + "config": details, "layers": layers, + "layer_count": len(layers)}) + + for entry in top: + inspect_descriptor(entry) + require(len(images) == 1, + "require exactly one linux/amd64 application image") + image = images[0] + if root_type in MANIFEST_TYPES: + require(image["manifest_digest"] == root_descriptor["digest"], + "flat OCI root is not the runnable application") diff_ids=verify_layer_diff_ids(inp,by_name,image["layers"], image["config"].get("rootfs") or {}) reported_manifest=meta.get("containerimage.digest") @@ -302,10 +414,10 @@ def inspect_descriptor(descriptor, depth=0): metadata_config_digest=reported_config, docker_inspection=inspection, ) - # BuildKit reports a descriptor digest, not the sha256 of OCI tar bytes. - accepted_digests = {image["manifest_digest"], index_digest} - require(reported_manifest in accepted_digests, - "BuildKit descriptor digest does not match OCI index or image") + # The identity was selected from the exported layout root descriptor, + # not from an unrestricted set of digests reachable in the archive. + require(reported_manifest == root_descriptor["digest"], + "BuildKit root descriptor changed during traversal") config=image["config"].get("config") or {} require(config.get("User") in ("10001:10001", "10001") and config.get("Entrypoint")==[ @@ -320,11 +432,13 @@ def inspect_descriptor(descriptor, depth=0): "schema_version":1,"oci_archive_sha256":file_digest(archive), "oci_image_manifest_digest":image["manifest_digest"], "oci_index_digest":index_digest, + "oci_exported_root_digest":root_descriptor["digest"], + "oci_exported_root_size":root_descriptor["size"], "oci_config_digest":image["config_digest"], "buildkit_digest":reported_manifest, "buildkit_config_digest_metadata_present": reported_config is not None, "independent_loaded_image_digest_verified": inspection is not None, - "buildkit_digest_kind":("oci-index" if reported_manifest == index_digest + "buildkit_digest_kind":("oci-index" if root_type == INDEX_TYPE else "application-manifest"), "oci_application_identity_verified":True, "oci_attestation_references_verified":True, From c79519a118a2d84af5cf83b7f07bf6fb005e58c3 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Tue, 13 Oct 2026 21:13:04 +0200 Subject: [PATCH 026/147] test(RELEASE-1): exercise nested BuildKit indexes, descriptor sizes and SLSA falsifiers --- tests/release/test_release_oci_integrity.py | 262 ++++++++++++++------ 1 file changed, 181 insertions(+), 81 deletions(-) diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index b713e0a2..b0bc7525 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -26,92 +26,143 @@ def sha(body): class OCIProofTests(unittest.TestCase): - def fixture(self, target:Path, *, replace_layer=False, bad_subject=False, + def fixture(self, target: Path, *, replace_layer=False, bad_subject=False, bad_reference=False, corrupt_rootfs=False, subject_kind="image", - metadata_kind="application", manifest_subject_kind="none", - invalid_predicate=False): - base=b"original uncompressed OCI application layer\n" - expanded=(b"substituted uncompressed OCI application layer\n" - if replace_layer else base) - blobs={} + metadata_kind="root", manifest_subject_kind="none", + invalid_predicate=False, nested=True, wrong_size=None, + slsa_case="valid", bad_metadata_descriptor=False): + base = b"original uncompressed OCI application layer\n" + expanded = (b"substituted uncompressed OCI application layer\n" + if replace_layer else base) + blobs = {} + def store(data): - oid=sha(data) - blobs["blobs/sha256/"+oid[7:]]=data + oid = sha(data) + blobs["blobs/sha256/" + oid[7:]] = data return oid - tar_layer=store(gzip.compress(expanded,mtime=0)) - diff_id=sha(b"mismatched expected diff" if corrupt_rootfs else base) - config=store(encode({"architecture":"amd64","os":"linux", - "rootfs":{"type":"layers","diff_ids":[diff_id]}, - "config":{"User":"10001:10001", - "Entrypoint":["/opt/online3b/bin/python", - "/opt/release-tools/deploy/release/entrypoint.py"]}})) - application=store(encode({ - "schemaVersion":2, - "config":{"mediaType":"application/vnd.oci.image.config.v1+json", - "digest":config}, - "layers":[{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip", - "digest":tar_layer}], + + def descriptor(kind, digest, **extra): + return {"mediaType": kind, "digest": digest, + "size": len(blobs["blobs/sha256/" + digest[7:]]), **extra} + + oci_manifest = "application/vnd.oci.image.manifest.v1+json" + oci_index = "application/vnd.oci.image.index.v1+json" + tar_layer = store(gzip.compress(expanded, mtime=0)) + diff_id = sha(b"mismatched expected diff" if corrupt_rootfs else base) + config = store(encode({ + "architecture": "amd64", "os": "linux", + "rootfs": {"type": "layers", "diff_ids": [diff_id]}, + "config": {"User": "10001:10001", + "Entrypoint": ["/opt/online3b/bin/python", + "/opt/release-tools/deploy/release/entrypoint.py"]} })) - att_config=store(encode({"architecture":"unknown","os":"unknown"})) - att_layers=[] - for predicate in ("https://spdx.dev/Document","https://slsa.dev/provenance/v1"): - subject=({"image":application[7:],"config":config[7:], - "layer":tar_layer[7:],"empty":None}[subject_kind]) + cfg_desc = descriptor("application/vnd.oci.image.config.v1+json", config) + layer_desc = descriptor("application/vnd.oci.image.layer.v1.tar+gzip", + tar_layer) + if wrong_size == "config": + cfg_desc["size"] += 1 + if wrong_size == "layer": + layer_desc["size"] += 1 + application = store(encode({ + "schemaVersion": 2, "config": cfg_desc, "layers": [layer_desc]})) + app_desc = descriptor(oci_manifest, application, + platform={"os": "linux", "architecture": "amd64"}) + att_config = store(encode({"architecture": "unknown", "os": "unknown"})) + attest_layers = [] + for predicate in ("https://spdx.dev/Document", + "https://slsa.dev/provenance/v1"): + subject = {"image": application[7:], "config": config[7:], + "layer": tar_layer[7:], "empty": None}[subject_kind] if bad_subject: - subject="f"*64 - body=({"SPDXID":"SPDXRef-DOCUMENT","spdxVersion":"SPDX-2.3"} - if "spdx" in predicate else - {"buildDefinition":{"buildType":"https://mobyproject.org/buildkit@v1"}, - "runDetails":{"builder":{"id":"synthetic"}}}) + subject = "f" * 64 + body = ({"SPDXID": "SPDXRef-DOCUMENT", "spdxVersion": "SPDX-2.3"} + if predicate == "https://spdx.dev/Document" else { + "buildDefinition": { + "buildType": "https://mobyproject.org/buildkit@v1", + "externalParameters": {}}, + "runDetails": {"builder": { + "id": "https://github.com/moby/buildkit"}}}) + if predicate.endswith("/v1"): + if slsa_case == "empty": + body = {"buildDefinition": {}, "runDetails": {}} + if slsa_case == "builder-empty": + body["runDetails"]["builder"]["id"] = "" + if slsa_case == "bad-build-type": + body["buildDefinition"]["buildType"] = "not-a-uri" + if slsa_case == "no-external": + del body["buildDefinition"]["externalParameters"] if invalid_predicate: - body={"schema_version":1} - data=encode({"_type":"https://in-toto.io/Statement/v1", - "predicateType":predicate, - "subject":([] if subject is None else - [{"name":"offline-fixture", - "digest":{"sha256":subject}}]), - "predicate":body}) - oid=store(data) - att_layers.append({"mediaType":"application/vnd.in-toto+json","digest":oid}) - manifest_subject=(None if manifest_subject_kind=="none" else { - "mediaType":"application/vnd.oci.image.manifest.v1+json", - "digest":application if manifest_subject_kind=="image" - else "sha256:"+"f"*64}) - att_data={"schemaVersion":2, - "config":{"mediaType":"application/vnd.oci.image.config.v1+json", - "digest":att_config}, - "layers":att_layers} - if manifest_subject is not None: - att_data["subject"]=manifest_subject - att_manifest=store(encode(att_data)) - index=encode({"schemaVersion":2, - "mediaType":"application/vnd.oci.image.index.v1+json", - "manifests":[ - {"mediaType":"application/vnd.oci.image.manifest.v1+json", - "digest":application,"platform":{"os":"linux","architecture":"amd64"}}, - {"mediaType":"application/vnd.oci.image.manifest.v1+json", - "digest":att_manifest, - "annotations":{"vnd.docker.reference.type":"attestation-manifest", - "vnd.docker.reference.digest":( - "sha256:"+"f"*64 if bad_reference else application)}, - "platform":{"os":"unknown","architecture":"unknown"}}]}) - archive=target/"test.oci.tar" - with tarfile.open(archive,"w") as out: - payloads={"oci-layout":encode({"imageLayoutVersion":"1.0.0"}), - "index.json":index,**blobs} - for name,body in payloads.items(): - info=tarfile.TarInfo(name) - info.size=len(body) - out.addfile(info,io.BytesIO(body)) - meta=target/"metadata.json" + body = {"schema_version": 1} + statement = encode({ + "_type": "https://in-toto.io/Statement/v1", + "predicateType": predicate, + "subject": ([] if subject is None else + [{"name": "offline-fixture", + "digest": {"sha256": subject}}]), + "predicate": body}) + lid = store(statement) + layer = descriptor("application/vnd.in-toto+json", lid) + if wrong_size == "statement": + layer["size"] += 1 + attest_layers.append(layer) + attest_data = { + "schemaVersion": 2, + "config": descriptor("application/vnd.oci.image.config.v1+json", + att_config), + "layers": attest_layers} + if manifest_subject_kind != "none": + attest_data["subject"] = { + "mediaType": oci_manifest, + "digest": (application if manifest_subject_kind == "image" + else "sha256:" + "f"*64)} + attest_manifest = store(encode(attest_data)) + att_desc = descriptor(oci_manifest, attest_manifest, + annotations={"vnd.docker.reference.type": "attestation-manifest", + "vnd.docker.reference.digest": ( + "sha256:" + "f"*64 if bad_reference else application)}, + platform={"os": "unknown", "architecture": "unknown"}) + if wrong_size == "application": + app_desc["size"] += 1 + children = [app_desc, att_desc] + if nested: + nested_id = store(encode({"schemaVersion": 2, "mediaType": oci_index, + "manifests": children})) + root_desc = descriptor(oci_index, nested_id) + outer_children = [root_desc] + else: + root_desc = app_desc + outer_children = children + if wrong_size == "root": + root_desc["size"] += 1 + if wrong_size == "boolean": + root_desc["size"] = True + if wrong_size == "negative": + root_desc["size"] = -1 + index = encode({"schemaVersion": 2, "mediaType": oci_index, + "manifests": outer_children}) + archive = target / "test.oci.tar" + with tarfile.open(archive, "w") as out: + payloads = {"oci-layout": encode({"imageLayoutVersion": "1.0.0"}), + "index.json": index, **blobs} + for name, body in payloads.items(): + info = tarfile.TarInfo(name) + info.size = len(body) + out.addfile(info, io.BytesIO(body)) + meta = target / "metadata.json" + reported = (attest_manifest if metadata_kind == "attestation" + else sha(index) if metadata_kind == "outer" + else application if metadata_kind == "wrong-flat" + else root_desc["digest"]) + reported_desc = dict(root_desc) + if bad_metadata_descriptor: + reported_desc["size"] += 1 meta.write_text(json.dumps({ - "image.name":"docker.io/allfather/release1:offline", - "containerimage.digest":( - att_manifest if metadata_kind=="attestation" else - sha(index) if metadata_kind=="index" else application)})) - inspected=target/"docker-inspect.json" - inspected.write_text(json.dumps({"Id":config,"Architecture":"amd64"})) - return archive,meta,inspected + "image.name": "docker.io/allfather/release1:offline", + "containerimage.digest": reported, + "containerimage.descriptor": reported_desc})) + inspected = target / "docker-inspect.json" + inspected.write_text(json.dumps({"Id": config, "Architecture": "amd64"})) + return archive, meta, inspected def test_real_small_oci_graph_binds_every_layer_and_attestation(self): with tempfile.TemporaryDirectory() as d: @@ -185,6 +236,53 @@ def test_garbled_predicate_cannot_claim_sbom_or_provenance(self): + def test_nested_exported_root_is_independently_bound(self): + with tempfile.TemporaryDirectory() as d: + archive, meta, inspection = self.fixture(Path(d)) + report = oci.verify_archive(archive, meta, docker_inspect=inspection) + self.assertEqual(report["buildkit_digest_kind"], "oci-index") + self.assertEqual(report["oci_exported_root_digest"], + report["buildkit_digest"]) + + def test_explicit_flat_image_export_still_supported(self): + with tempfile.TemporaryDirectory() as d: + archive, meta, inspection = self.fixture(Path(d), nested=False) + report = oci.verify_archive(archive, meta, docker_inspect=inspection) + self.assertEqual(report["buildkit_digest_kind"], + "application-manifest") + + def test_outer_entrypoint_and_unrelated_image_are_not_buildkit_roots(self): + for kind in ("outer", "wrong-flat", "attestation"): + with self.subTest(kind=kind), tempfile.TemporaryDirectory() as d: + archive, meta, inspection = self.fixture(Path(d), + metadata_kind=kind) + with self.assertRaisesRegex(ValueError, "BuildKit descriptor"): + oci.verify_archive(archive, meta, docker_inspect=inspection) + + def test_wrong_buildkit_descriptor_size_is_rejected(self): + with tempfile.TemporaryDirectory() as d: + archive, meta, inspection = self.fixture( + Path(d), bad_metadata_descriptor=True) + with self.assertRaisesRegex(ValueError, "descriptor identity"): + oci.verify_archive(archive, meta, docker_inspect=inspection) + + def test_all_descriptors_require_exact_nonboolean_sizes(self): + for mode in ("root", "boolean", "negative", "application", + "config", "layer", "statement"): + with self.subTest(mode=mode), tempfile.TemporaryDirectory() as d: + archive, meta, inspection = self.fixture(Path(d), + wrong_size=mode) + with self.assertRaisesRegex(ValueError, "descriptor size"): + oci.verify_archive(archive, meta, docker_inspect=inspection) + + def test_slsa_predicates_cannot_be_schema_only(self): + for mode in ("empty", "builder-empty", "bad-build-type", "no-external"): + with self.subTest(mode=mode), tempfile.TemporaryDirectory() as d: + archive, meta, inspection = self.fixture(Path(d), + slsa_case=mode) + with self.assertRaisesRegex(ValueError, "SLSA"): + oci.verify_archive(archive, meta, docker_inspect=inspection) + def test_diagnostic_structure_is_not_an_integrity_verdict(self): inspect_spec=importlib.util.spec_from_file_location( "release1_oci_diagnostic",ROOT/"scripts/inspect-release-oci.py") @@ -194,8 +292,10 @@ def test_diagnostic_structure_is_not_an_integrity_verdict(self): archive,meta,_=self.fixture(Path(d)) report=mod.inspect(archive,meta) self.assertFalse(report["integrity_verified"]) - self.assertEqual(len(report["index_manifests"]),2) - attest=report["index_manifests"][1] + self.assertEqual(len(report["index_manifests"]),1) + self.assertEqual(report["exported_root_digest"],report["buildkit_digest"]) + self.assertEqual(report["application_manifest_digest"],report["index_manifests"][0]["children"][0]["digest"]) + attest=report["index_manifests"][0]["children"][1] self.assertEqual(len(attest["layers"]),2) self.assertEqual(attest["layers"][0]["statements"][0]["subject_count"],1) From 10aeea13b2d78d7bbbafabb924fdc95bf6a6f556 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Tue, 13 Oct 2026 21:14:26 +0200 Subject: [PATCH 027/147] fix(RELEASE-1): verify persisted SQLite and request-bound lost-ACK evidence --- scripts/qualify-release-smoke.py | 99 +++++++++++++++++++++++++++++-- tests/lichess/fake_lichess_api.py | 14 ++++- 2 files changed, 106 insertions(+), 7 deletions(-) diff --git a/scripts/qualify-release-smoke.py b/scripts/qualify-release-smoke.py index de78c4ef..60f779e0 100644 --- a/scripts/qualify-release-smoke.py +++ b/scripts/qualify-release-smoke.py @@ -10,6 +10,7 @@ import argparse import hashlib import json +import sqlite3 from pathlib import Path from urllib.parse import unquote @@ -255,12 +256,37 @@ def verify_smoke(doc: dict, key: str) -> dict: if scenario == "R02": closed = [r for r in trace if r.get("event") == "connection_close_requested" - and r.get("path") == attempt_index[rid]["path"] and + and r.get("request_id") == rid and + r.get("path") == attempt_index[rid]["path"] and r["seq"] > fault_events[0]["seq"]] + all_closes = [r for r in trace if + r.get("event") == "connection_close_requested" and + r.get("path") == attempt_index[rid]["path"]] + completed_responses = [r for r in trace if + r.get("event") == "response_write_completed" and + r.get("request_id") == rid] commits_for_request = [r for r in commits if r.get("request_id") == rid] - require(len(closed) == len(commits_for_request) == 1 and + first_epoch = phases[0]["epoch"] if phases else None + unknown_ack = [g for g in guarded if isinstance(g, dict) and + g.get("event") == "http_unknown_ack" and + g.get("game_id") == GAME_ID and + g.get("owner_epoch") == first_epoch] + acknowledged = [g for g in guarded if isinstance(g, dict) and + g.get("event") == "http_move_ack_observed" and + g.get("game_id") == GAME_ID and + g.get("owner_epoch") == first_epoch and + g.get("move") == attempt_index[rid]["uci"]] + first_submissions = [g for g in guarded_submissions if + g.get("game_id") == GAME_ID and + g.get("owner_epoch") == first_epoch] + require(len(closed) == len(all_closes) == + len(commits_for_request) == len(unknown_ack) == + len(first_submissions) == 1 and + not completed_responses and not acknowledged and commits_for_request[0]["seq"] < closed[0]["seq"] and + attempt_index[rid]["seq"] < fault_events[0]["seq"] < + commits_for_request[0]["seq"] and len(phases) == 2 and phases[0].get("observation") == "restart_after_lost_ack" and phases[1].get("observation") == "complete_confirmed" and @@ -298,11 +324,75 @@ def verify_smoke(doc: dict, key: str) -> dict: "public_bot_release": False, "release_blocked": True} +def verify_persisted_sqlite(folder: Path, journal: dict) -> dict: + """Independently verify committed on-disk state, including live WAL entries. + + A producer's JSON snapshot is never authoritative. This is a consistency + check against persisted SQLite, not protection against a writer able to + rewrite both the database and evidence. + """ + state = folder / "state" + db = state / "move-journal.sqlite3" + require(folder.is_dir() and not folder.is_symlink() and + not folder.parent.is_symlink() and + state.is_dir() and not state.is_symlink() and + db.is_file() and not db.is_symlink(), + "persisted SQLite journal absent or linked") + for suffix in ("-wal", "-shm"): + sidecar = state / ("move-journal.sqlite3" + suffix) + require(not sidecar.is_symlink(), + "persisted SQLite sidecar cannot be a symlink") + require(db.stat().st_size <= 256 * 1024 * 1024, + "persisted SQLite exceeds bounded verification size") + require(type(journal) is dict, "persisted SQLite snapshot absent") + connection = sqlite3.connect( + db.resolve(strict=True).as_uri() + "?mode=ro", + uri=True, isolation_level=None, timeout=5) + connection.row_factory = sqlite3.Row + try: + connection.execute("PRAGMA query_only=ON") + connection.execute("BEGIN") # consistent view of the main file and WAL + try: + require([tuple(r) for r in connection.execute( + "PRAGMA integrity_check")] == [("ok",)], + "persisted SQLite integrity check failed") + owner = connection.execute( + "SELECT epoch FROM owner WHERE singleton=1").fetchone() + require(owner is not None and + owner["epoch"] == journal.get("owner_epoch"), + "SQLite owner epoch differs from JSON snapshot") + queries = { + "games": "SELECT * FROM games ORDER BY game_id", + "intents": "SELECT * FROM intents ORDER BY game_id,ply_before", + "cooldown": "SELECT * FROM cooldown ORDER BY singleton", + "events": "SELECT * FROM events ORDER BY event_id", + } + observed = {name: [dict(row) for row in connection.execute(query)] + for name, query in queries.items()} + for name, rows in observed.items(): + require(type(journal.get(name)) is list and + rows == journal[name], + "SQLite persisted " + name + " differs from JSON snapshot") + finally: + connection.execute("ROLLBACK") + finally: + connection.close() + return {"persisted_sqlite_verified": True, + "durable_events": len(observed["events"]), + "durable_intents": len(observed["intents"])} + + def verify_file(path: Path, key: str) -> dict: require(path.is_file() and not path.is_symlink() and path.stat().st_size <= MAX_EVIDENCE_BYTES, "packaged smoke evidence absent, linked or oversized") - return verify_smoke(json.loads(path.read_text(encoding="utf-8")), key) + doc = json.loads(path.read_text(encoding="utf-8")) + require(type(doc) is dict and type(doc.get("journal")) is dict, + "packaged smoke durable journal snapshot missing") + persisted = verify_persisted_sqlite(path.parent, doc["journal"]) + verdict = verify_smoke(doc, key) + verdict.update(persisted) + return verdict def qualify_directory(root: Path) -> dict: @@ -310,7 +400,8 @@ def qualify_directory(root: Path) -> dict: for key in CASES: try: results[key] = verify_file(root / key / "result.json", key) - except (OSError, ValueError, KeyError, TypeError, IndexError) as exc: + except (OSError, ValueError, KeyError, TypeError, IndexError, + sqlite3.DatabaseError) as exc: failures.append({"scenario": key, "error": type(exc).__name__ + ": " + str(exc)}) return {"schema_version": 1, "offline_smoke_qualified": not failures and diff --git a/tests/lichess/fake_lichess_api.py b/tests/lichess/fake_lichess_api.py index 97242ff9..41f78c91 100644 --- a/tests/lichess/fake_lichess_api.py +++ b/tests/lichess/fake_lichess_api.py @@ -69,13 +69,18 @@ def respond(self, status: int, obj: dict): self.wfile.write(data) self.wfile.flush() if server.trace is not None: + identity = getattr(self, "_move_request_id", None) server.trace.record("response_write_completed", status=status, - path=urllib.parse.urlparse(self.path).path) + path=urllib.parse.urlparse(self.path).path, + **({"request_id": identity} if identity else {})) def drop_connection(self): """An intentional server-side close, not proof of what the client read.""" - server.trace and server.trace.record("connection_close_requested", - path=urllib.parse.urlparse(self.path).path) + if server.trace is not None: + identity = getattr(self, "_move_request_id", None) + server.trace.record("connection_close_requested", + path=urllib.parse.urlparse(self.path).path, + **({"request_id": identity} if identity else {})) self.close_connection = True try: self.connection.shutdown(socket.SHUT_RDWR) @@ -207,6 +212,7 @@ def record(self, kind: str, path: str, status: int, server.requests.append(row) def do_GET(self): + self._move_request_id = None # never reuse an HTTP/1.1 request identity if not self.auth(): return path = urllib.parse.urlparse(self.path).path @@ -261,6 +267,7 @@ def do_GET(self): return self.respond(403, {"error": "offline route prohibited"}) def do_POST(self): + self._move_request_id = None # reset on every request, even on reused sockets if not self.auth(): return # requests.Session reuses HTTP/1.1 connections. POST bodies @@ -311,6 +318,7 @@ def do_POST(self): seq = server.trace.record("move_attempt", path=path, uci=move_text, game_id=GAME_ID, ply_at_receipt=ply) request_id = f"request-{seq}" + self._move_request_id = request_id action = (server.fault.hit("move_post", ply=ply, request_id=request_id) if server.fault is not None else None) if action: From 210674e4d7fdd031044339b4dd72d3216ca09db6 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Tue, 13 Oct 2026 21:16:41 +0200 Subject: [PATCH 028/147] test(RELEASE-1): correlate actual lost-ACK and unrelated responses --- tests/release/test_release_smoke_evidence.py | 61 +++++++++++++++++++- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/tests/release/test_release_smoke_evidence.py b/tests/release/test_release_smoke_evidence.py index 7f36f8db..e156e706 100644 --- a/tests/release/test_release_smoke_evidence.py +++ b/tests/release/test_release_smoke_evidence.py @@ -45,6 +45,8 @@ def event(kind, **kw): rid = f"request-{request['seq']}" fault = event("fault_activated",action="rate_limit_429", endpoint="move_post",request_id=rid) + event("response_write_completed",status=429,path=path, + request_id=rid) row = event("rate_limit_hold_completed",action="rate_limit_429") row["monotonic_ns"] = fault["monotonic_ns"] + 61_500_000_000 activations.append({"action":"rate_limit_429","request_id":rid}) @@ -96,7 +98,16 @@ def event(kind, **kw): if is_bot: accepted.append({"ply":len(board.move_stack),"uci":uci}) if scenario=="R02" and is_bot and bot_index==1: - event("connection_close_requested",path=qual.MOVE_PREFIX+uci) + event("connection_close_requested",path=qual.MOVE_PREFIX+uci, + request_id=rid) + guarded.append({"event":"http_unknown_ack","owner_epoch":first, + "game_id":qual.GAME_ID,"endpoint":"move"}) + elif is_bot: + event("response_write_completed",path=qual.MOVE_PREFIX+uci, + request_id=rid,status=200) + guarded.append({"event":"http_move_ack_observed", + "owner_epoch":epoch,"game_id":qual.GAME_ID, + "move":uci}) moves = [m.uci() for m in board.move_stack] complete = scenario!="R10" observations = ( @@ -120,8 +131,13 @@ def event(kind, **kw): "intents":intents, "games":[{"game_id":qual.GAME_ID,"history_json":json.dumps(moves), "terminal":int(complete)}], - "cooldown":[{"expires":"future"}] if scenario=="R10" else []}, + "cooldown":[{"singleton":1,"until_wall":4000000000.0, + "cause":"http429:move"}] if scenario=="R10" else [], + "events":[]}, } + from deploy.lichess.reconcile import position_id + for intent in result["journal"]["intents"]: + intent["position_id"] = position_id(json.loads(intent["history_json"])) seal(result) return result @@ -208,6 +224,47 @@ def test_r10_rejects_all_api_requests_during_cooldown(self): with self.assertRaisesRegex(ValueError,"cooldown"): qual.verify_smoke(doc,"r10-white") + def test_r02_completed_success_response_fails(self): + doc = fixture("r02-white") + close = next(x for x in doc["transport_trace"] + if x["event"] == "connection_close_requested") + rid = close["request_id"] + close["event"] = "response_write_completed" + close["status"] = 200 + final = doc["transport_trace"][-1] + doc["transport_trace"].append({ + "seq":final["seq"]+1, + "monotonic_ns":final["monotonic_ns"]+1_000_000, + "event":"connection_close_requested","path":close["path"], + "request_id":rid}) + seal(doc) + with self.assertRaisesRegex(ValueError,"lost-ack"): + qual.verify_smoke(doc,"r02-white") + + def test_r02_unrelated_completed_responses_are_allowed(self): + doc = fixture("r02-white") + target = next(g["request_id"] for g in doc["transport_trace"] + if g["event"] == "fault_activated") + completed = [r for r in doc["transport_trace"] + if r["event"] == "response_write_completed" + and r.get("request_id") != target] + self.assertTrue(completed) + self.assertTrue(qual.verify_smoke(doc,"r02-white")["independent_qualified"]) + + def test_r02_rejects_missing_unknown_ack_or_successful_ack(self): + for mode in ("missing-unknown", "acknowledged"): + doc = fixture("r02-white") + if mode == "missing-unknown": + doc["guard_events"] = [g for g in doc["guard_events"] + if g["event"] != "http_unknown_ack"] + else: + doc["guard_events"].append({ + "event":"http_move_ack_observed","game_id":qual.GAME_ID, + "owner_epoch":doc["observations"][0]["epoch"],"move":"f2f3"}) + with self.subTest(mode=mode),self.assertRaisesRegex(ValueError,"lost-ack"): + qual.verify_smoke(doc,"r02-white") + + if __name__=="__main__": unittest.main() From 07f3276e92dde2a76ed0e735515ae6a1cc5b77cd Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Tue, 13 Oct 2026 21:17:37 +0200 Subject: [PATCH 029/147] test(RELEASE-1): qualify actual SQLite snapshots, live WAL and destructive state corruption --- .../test_release_sqlite_persistence.py | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 tests/release/test_release_sqlite_persistence.py diff --git a/tests/release/test_release_sqlite_persistence.py b/tests/release/test_release_sqlite_persistence.py new file mode 100644 index 00000000..5794a84a --- /dev/null +++ b/tests/release/test_release_sqlite_persistence.py @@ -0,0 +1,156 @@ +"""Real SQLite/WAL durability evidence tests for RELEASE-1 host qualification. + +Synthetic documents are paired with real MoveJournal-schema SQLite databases. +A JSON-only fixture can exercise verify_smoke but must NEVER satisfy verify_file. +""" +from __future__ import annotations + +import importlib.util +import json +import sqlite3 +import tempfile +import unittest +from pathlib import Path + +from deploy.lichess.move_journal import SCHEMA + +ROOT = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location( + "release_smoke_test_fixture", ROOT / "tests/release/test_release_smoke_evidence.py") +original = importlib.util.module_from_spec(spec) +spec.loader.exec_module(original) +fixture = original.fixture +qual = original.qual + + +def real_fixture(folder: Path, key: str, *, keep_wal=False): + """Return an optionally live WAL writer and its complete JSON document.""" + doc = fixture(key) + folder.mkdir(parents=True) + state = folder / "state" + state.mkdir() + db = state / "move-journal.sqlite3" + conn = sqlite3.connect(db) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=FULL") + for statement in SCHEMA: + conn.execute(statement) + conn.commit() + # Materialize only the schema before inserting application data into WAL. + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + journal = doc["journal"] + conn.execute("INSERT INTO owner(singleton,epoch) VALUES(1,?)", + (journal["owner_epoch"],)) + for table in ("games", "intents", "cooldown", "events"): + for record in journal[table]: + columns = list(record) + query = ("INSERT INTO " + table + " (" + ",".join(columns) + + ") VALUES (" + ",".join("?" for _ in columns) + ")") + conn.execute(query, tuple(record[key] for key in columns)) + conn.commit() + (folder / "result.json").write_text(json.dumps(doc, sort_keys=True)) + if not keep_wal: + conn.close() + conn = None + return doc, conn + + +class RealSQLiteQualificationTests(unittest.TestCase): + def test_every_valid_case_qualifies_with_real_checkpointed_database(self): + for key in qual.CASES: + with self.subTest(key=key), tempfile.TemporaryDirectory() as directory: + folder = Path(directory) / key + real_fixture(folder, key) + report = qual.verify_file(folder / "result.json", key) + self.assertTrue(report["persisted_sqlite_verified"]) + self.assertTrue(report["independent_qualified"]) + + def test_uncheckpointed_wal_rows_are_verified_read_only(self): + with tempfile.TemporaryDirectory() as directory: + folder = Path(directory) / "r02-white" + _, conn = real_fixture(folder, "r02-white", keep_wal=True) + try: + self.assertTrue((folder / "state/move-journal.sqlite3-wal").is_file()) + report = qual.verify_file(folder / "result.json", "r02-white") + self.assertTrue(report["persisted_sqlite_verified"]) + finally: + conn.close() + + def test_missing_required_wal_refuses_unpersisted_snapshot(self): + with tempfile.TemporaryDirectory() as directory: + folder = Path(directory) / "r02-white" + _, conn = real_fixture(folder, "r02-white", keep_wal=True) + try: + (folder / "state/move-journal.sqlite3-wal").unlink() + with self.assertRaises((ValueError, sqlite3.DatabaseError)): + qual.verify_file(folder / "result.json", "r02-white") + finally: + conn.close() + + def test_missing_corrupt_and_linked_database_are_rejected(self): + for mutation in ("missing", "corrupt", "db-link", "directory-link", + "wal-link"): + with self.subTest(mutation=mutation), tempfile.TemporaryDirectory() as d: + folder = Path(d) / "r00-white" + real_fixture(folder, "r00-white") + state = folder / "state" + db = state / "move-journal.sqlite3" + if mutation == "missing": + db.unlink() + elif mutation == "corrupt": + db.write_bytes(b"not a sqlite database") + elif mutation == "db-link": + original_db = folder / "original.sqlite3" + db.rename(original_db) + db.symlink_to(original_db) + elif mutation == "directory-link": + moved = folder / "actual-state" + state.rename(moved) + state.symlink_to(moved, target_is_directory=True) + else: + (state / "move-journal.sqlite3-wal").symlink_to(db) + with self.assertRaises((ValueError, sqlite3.DatabaseError)): + qual.verify_file(folder / "result.json", "r00-white") + + def test_forged_snapshot_rows_and_owner_are_rejected(self): + for mutation in ("owner", "game", "intent", "cooldown"): + with self.subTest(mutation=mutation), tempfile.TemporaryDirectory() as d: + key = "r10-white" if mutation == "cooldown" else "r02-white" + folder = Path(d) / key + doc, _ = real_fixture(folder, key) + if mutation == "owner": + doc["journal"]["owner_epoch"] = "c"*32 + elif mutation == "game": + doc["journal"]["games"][0]["history_json"] = "[]" + elif mutation == "intent": + doc["journal"]["intents"][0]["status"] = "PREPARED" + else: + doc["journal"]["cooldown"].clear() + (folder / "result.json").write_text(json.dumps(doc)) + with self.assertRaisesRegex(ValueError, "SQLite"): + qual.verify_file(folder / "result.json", key) + + def test_disk_mutation_is_detected_without_json_mutation(self): + with tempfile.TemporaryDirectory() as directory: + folder = Path(directory) / "r00-white" + real_fixture(folder, "r00-white") + with sqlite3.connect(folder / "state/move-journal.sqlite3") as conn: + conn.execute("UPDATE owner SET epoch=?", ("d"*32,)) + with self.assertRaisesRegex(ValueError, "SQLite owner"): + qual.verify_file(folder / "result.json", "r00-white") + + def test_qualification_fails_closed_for_missing_or_corrupt_case(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for key in qual.CASES: + real_fixture(root / key, key) + self.assertTrue(qual.qualify_directory(root)["offline_smoke_qualified"]) + (root / "r10-white/state/move-journal.sqlite3").unlink() + report = qual.qualify_directory(root) + self.assertFalse(report["offline_smoke_qualified"]) + self.assertEqual(len(report["failures"]), 1) + self.assertEqual(report["failures"][0]["scenario"], "r10-white") + + +if __name__ == "__main__": + unittest.main() From 799723abc6a1fd16f52e5768291834a09016647e Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Tue, 13 Oct 2026 21:20:03 +0200 Subject: [PATCH 030/147] docs(RELEASE-1): record six Codex repairs and correct size-falsifier assertion --- docs/CODEX_POSTMERGE_CLOSURE.md | 17 +++++++++++++++++ docs/RELEASE_1_OPERATIONS.md | 12 ++++++++++++ tests/release/test_release_oci_integrity.py | 2 +- 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/docs/CODEX_POSTMERGE_CLOSURE.md b/docs/CODEX_POSTMERGE_CLOSURE.md index e66376e9..8c4e8912 100644 --- a/docs/CODEX_POSTMERGE_CLOSURE.md +++ b/docs/CODEX_POSTMERGE_CLOSURE.md @@ -48,3 +48,20 @@ CI discovered that the untagged OCI archive export lacked BuildKit default image The RELEASE-1 host aggregate now also recomputes the actual OCI archive SHA-256 and requires all independent OCI-verifier booleans. Smoke evidence schema 2 replaces incomplete schema 1 for qualification; no producer readiness bit alone can authorize a verdict. **Status:** this entry records implemented source changes, not successful exact-head OCI integration or an approved code/security review. Fresh final-head tests/reviews and disposition of any new feedback are mandatory before merging. Offline-only/publication-prohibited policy remains unchanged. + +## Final PR #71 follow-up: six fresh P1/P2 findings + +**Implemented source and destructive fixtures; actual exact-head CI and Codex review still required.** + +| Finding | Source repair and test evidence | +|---|---| +| [P1 nested OCI root](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4253714605) | Select only the BuildKit-reported root descriptor from the OCI layout; compare its digest, media type and byte size to BuildKit metadata. Nested and flat layouts, wrong-root and unrelated-attestation fixtures. | +| [P2 OCI descriptor size](https://github.com/HMarcusWH/AllfatherChess/pull/71#pullrequestreview-5497647677) | Validate every embedded index, manifest, config, image/attestation layer descriptor with exact actual bytes; reject boolean, absent, negative or wrong sizes. Outer index.json is the layout entrypoint, not an embedded descriptor. | +| [P2 nested diagnostics](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4253714615) | Bounded recursive diagnostic inspector exposes nested indexes, manifest identities, sizes and summarized in-toto subjects; never grants integrity_verified. | +| [P2 SLSA predicate](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4253714599) | Version-specific v1/v0.2 nested structure, buildType URI, parameters, and builder.id URI; workflow requests a meaningful builder ID; synthetic empty-schema and malformed-ID attacks fail. | +| [P2 persisted journal](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4253714591) | Independent host-side read-only SQLite transaction, integrity_check, owner and all four tables exact snapshot match. Real MoveJournal-schema fixtures verify WAL, corruption, missing file, symlinks and forged snapshots. | +| [P2 R02 response](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4253714587) | Trace response and close events carry per-request IDs with HTTP/1.1 reset. Target lost-ACK case rejects completed response, requires correct close, first-epoch unknown-ACK and no same-intent successful ACK. | + +The prior illegal POST and image-manifest subject protections remain enforced. These changes are local qualification and provenance controls, not move-search or HYBRID authorization changes. + +**Merge hold:** Do not merge PR #71 until final-head fast contracts, RELEASE-1 real OCI and packaged games, all ONLINE/LOCAL/LC0 checks, and fresh code/security review are green; no public deployment or licensing grant. diff --git a/docs/RELEASE_1_OPERATIONS.md b/docs/RELEASE_1_OPERATIONS.md index be810407..ff4b63e9 100644 --- a/docs/RELEASE_1_OPERATIONS.md +++ b/docs/RELEASE_1_OPERATIONS.md @@ -207,3 +207,15 @@ persistent SQLite replay volume is uploaded by the RELEASE-1 workflow. **No new release permissions are implied.** OS/wheel reproducibility locks, combined license review, destination-host qualification, real Lichess transport, public BOT account play and operator approval remain blocked. + +### Final PR #71 evidence-integrity controls + +BuildKit's OCI metadata identifies the exported root descriptor, usually a nested image index. The outer layout index.json, selected nested image index, runnable application manifest, and attestation manifest must be kept distinct. The exact exported descriptor digest, media type and size must match BuildKit metadata. Every embedded OCI descriptor is checked against its actual archive member length and hash. + +The workflow requests minimal SLSA provenance with an explicit builder-id URI. A schema-shaped empty predicate or blank builder identity fails the version-correct validator; actual BuildKit output must still pass the real OCI qualification job. The bounded recursive inspection report is diagnostic-only. + +The packaged smoke verifier now opens the real state/move-journal.sqlite3 read-only and checks integrity, owner, games, intents, cooldown, events and WAL-visible state before accepting result.json. It rejects linked or missing state paths and mismatching snapshots. SQLite/JSON consistency is not a signature and does not protect against a writer that rewrites both records coherently. + +For R02 the fake server records move request IDs in response_write_completed and connection_close_requested, resetting the identifier at every new HTTP/1.1 request. The independent host verifier rejects a completed response for the targeted move and requires the correlated close, first-epoch unknown ACK and subsequent recovery evidence. A server-side 200 status alone does not establish that the client received the response. + +The pure verify_smoke() function remains useful for synthetic trace-unit tests. Only verify_file() and qualify_directory() verify persisted SQLite durability. The release permissions, source/engine configurations, and outward move authority remain unchanged. diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index b0bc7525..8b78d005 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -272,7 +272,7 @@ def test_all_descriptors_require_exact_nonboolean_sizes(self): with self.subTest(mode=mode), tempfile.TemporaryDirectory() as d: archive, meta, inspection = self.fixture(Path(d), wrong_size=mode) - with self.assertRaisesRegex(ValueError, "descriptor size"): + with self.assertRaisesRegex(ValueError, r"descriptor.*size"): oci.verify_archive(archive, meta, docker_inspect=inspection) def test_slsa_predicates_cannot_be_schema_only(self): From d75f10e976decd80c4c0f0c8e74296fbb65751cc Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Tue, 13 Oct 2026 21:23:25 +0200 Subject: [PATCH 031/147] fix(RELEASE-1): reject uncorrelated HTTP terminal traces and invalid BuildKit size types --- scripts/qualify-release-smoke.py | 15 +++++++++++++++ scripts/verify-release-oci.py | 1 + tests/release/test_release_oci_integrity.py | 10 ++++++++++ tests/release/test_release_smoke_evidence.py | 15 +++++++++++++++ 4 files changed, 41 insertions(+) diff --git a/scripts/qualify-release-smoke.py b/scripts/qualify-release-smoke.py index 60f779e0..4b7aef31 100644 --- a/scripts/qualify-release-smoke.py +++ b/scripts/qualify-release-smoke.py @@ -181,6 +181,21 @@ def verify_smoke(doc: dict, key: str) -> dict: "HTTP request audit omits or duplicates trace move attempts") require(all(type(a.get("status")) is int for a in http_attempts.values()), "unrecorded HTTP move status") + for row in trace: + if (row.get("event") not in + ("response_write_completed", "connection_close_requested") or + not isinstance(row.get("path"), str) or + not row["path"].startswith(MOVE_PREFIX)): + continue + rid = row.get("request_id") + require(rid in attempt_index and + row["path"] == attempt_index[rid]["path"] and + row["seq"] > attempt_index[rid]["seq"], + "move response/close event lacks exact request correlation") + if row["event"] == "response_write_completed": + require(type(row.get("status")) is int and + row["status"] == http_attempts[rid]["status"], + "completed move response status differs from HTTP audit") intents = journal["intents"] require(all(isinstance(i, dict) for i in intents), "malformed durable intent") diff --git a/scripts/verify-release-oci.py b/scripts/verify-release-oci.py index 4c5734a8..888a4de2 100644 --- a/scripts/verify-release-oci.py +++ b/scripts/verify-release-oci.py @@ -317,6 +317,7 @@ def get(oid, expected_size): "flat OCI layout has unrelated root descriptors") reported_descriptor = meta.get("containerimage.descriptor") require(isinstance(reported_descriptor, dict) and + type(reported_descriptor.get("size")) is int and all(reported_descriptor.get(k) == root_descriptor.get(k) for k in ("digest", "mediaType", "size")) and type(root_descriptor.get("size")) is int, diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index 8b78d005..72761ed8 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -259,6 +259,16 @@ def test_outer_entrypoint_and_unrelated_image_are_not_buildkit_roots(self): with self.assertRaisesRegex(ValueError, "BuildKit descriptor"): oci.verify_archive(archive, meta, docker_inspect=inspection) + def test_metadata_descriptor_size_must_be_integer_even_if_numerically_equal(self): + with tempfile.TemporaryDirectory() as d: + archive, meta, inspection = self.fixture(Path(d)) + report = json.loads(meta.read_text()) + report["containerimage.descriptor"]["size"] = float( + report["containerimage.descriptor"]["size"]) + meta.write_text(json.dumps(report)) + with self.assertRaisesRegex(ValueError, "descriptor identity"): + oci.verify_archive(archive, meta, docker_inspect=inspection) + def test_wrong_buildkit_descriptor_size_is_rejected(self): with tempfile.TemporaryDirectory() as d: archive, meta, inspection = self.fixture( diff --git a/tests/release/test_release_smoke_evidence.py b/tests/release/test_release_smoke_evidence.py index e156e706..743b57e3 100644 --- a/tests/release/test_release_smoke_evidence.py +++ b/tests/release/test_release_smoke_evidence.py @@ -265,6 +265,21 @@ def test_r02_rejects_missing_unknown_ack_or_successful_ack(self): qual.verify_smoke(doc,"r02-white") + def test_r02_rejects_a_successful_response_without_request_identity(self): + doc = fixture("r02-white") + close = next(t for t in doc["transport_trace"] + if t["event"] == "connection_close_requested") + final = doc["transport_trace"][-1] + doc["transport_trace"].append({ + "seq":final["seq"]+1, + "monotonic_ns":final["monotonic_ns"]+1_000_000, + "event":"response_write_completed", + "path":close["path"],"status":200}) + seal(doc) + with self.assertRaisesRegex(ValueError, "request correlation"): + qual.verify_smoke(doc,"r02-white") + + if __name__=="__main__": unittest.main() From 36d64229a2de84de7f8303cab8347278fdfc1ec2 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Wed, 14 Oct 2026 01:42:59 +0200 Subject: [PATCH 032/147] fix(RELEASE-1): bind OCI application platform and attestation subject descriptor size --- scripts/inspect-release-oci.py | 10 +++ scripts/verify-release-oci.py | 20 +++++- tests/release/test_release_oci_integrity.py | 71 +++++++++++++++++++-- 3 files changed, 91 insertions(+), 10 deletions(-) diff --git a/scripts/inspect-release-oci.py b/scripts/inspect-release-oci.py index f6ce9141..da052fc5 100644 --- a/scripts/inspect-release-oci.py +++ b/scripts/inspect-release-oci.py @@ -101,6 +101,16 @@ def traverse(desc, ancestry=()): manifest = document("blobs/sha256/" + oid[7:]) rec["artifact_type"] = manifest.get("artifactType") rec["manifest_subject"] = manifest.get("subject") + if isinstance(manifest.get("subject"), dict): + subject = manifest["subject"] + digest = subject.get("digest") + referenced = (members.get("blobs/sha256/" + digest[7:]) + if isinstance(digest, str) and digest.startswith("sha256:") + else None) + rec["subject_declared_size"] = subject.get("size") + rec["subject_actual_size"] = ( + referenced.size if referenced is not None and referenced.isfile() + else None) rec["config_type"] = (manifest.get("config") or {}).get("mediaType") attested = (rec["annotations"] or {}).get( "vnd.docker.reference.type") == "attestation-manifest" diff --git a/scripts/verify-release-oci.py b/scripts/verify-release-oci.py index 888a4de2..b380fc8e 100644 --- a/scripts/verify-release-oci.py +++ b/scripts/verify-release-oci.py @@ -175,9 +175,9 @@ def verify_attestation_subjects(attestations: list[dict], image: dict) -> set[st if manifest_subject is not None: require(isinstance(manifest_subject, dict) and manifest_subject.get("digest") == image_digest and - manifest_subject.get("mediaType") in ( - "application/vnd.oci.image.manifest.v1+json", - "application/vnd.docker.distribution.manifest.v2+json"), + manifest_subject.get("mediaType") == image["manifest_media_type"] and + type(manifest_subject.get("size")) is int and + manifest_subject["size"] == image["manifest_size"], "OCI attestation manifest subject differs from application image") require(item.get("layer_media_type") == "application/vnd.in-toto+json", "unexpected attestation statement layer type") @@ -381,10 +381,24 @@ def inspect_descriptor(descriptor, depth=0, ancestors=frozenset()): "layer_media_type": layer.get("mediaType"), "statement": json.loads(content)}) else: + # The index descriptor is how an OCI consumer selects a platform. + # A flat manifest may not advertise one, but any advertised + # platform must agree with the configuration bytes. + advertised = descriptor.get("platform") + if advertised is None: + require(root_type in MANIFEST_TYPES, + "nested OCI application descriptor lacks platform") + else: + require(isinstance(advertised, dict) and + advertised.get("os") == "linux" and + advertised.get("architecture") == "amd64", + "OCI application descriptor platform mismatch") require(details.get("architecture") == "amd64" and details.get("os") == "linux", "unexpected non-attestation OCI application manifest") images.append({"manifest_digest": oid, + "manifest_media_type": typ, + "manifest_size": descriptor["size"], "config_digest": cfg["digest"], "config": details, "layers": layers, "layer_count": len(layers)}) diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index 72761ed8..eec83161 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -30,7 +30,8 @@ def fixture(self, target: Path, *, replace_layer=False, bad_subject=False, bad_reference=False, corrupt_rootfs=False, subject_kind="image", metadata_kind="root", manifest_subject_kind="none", invalid_predicate=False, nested=True, wrong_size=None, - slsa_case="valid", bad_metadata_descriptor=False): + slsa_case="valid", bad_metadata_descriptor=False, + application_platform="linux/amd64"): base = b"original uncompressed OCI application layer\n" expanded = (b"substituted uncompressed OCI application layer\n" if replace_layer else base) @@ -65,8 +66,17 @@ def descriptor(kind, digest, **extra): layer_desc["size"] += 1 application = store(encode({ "schemaVersion": 2, "config": cfg_desc, "layers": [layer_desc]})) - app_desc = descriptor(oci_manifest, application, - platform={"os": "linux", "architecture": "amd64"}) + app_desc = descriptor(oci_manifest, application) + if application_platform == "linux/amd64": + app_desc["platform"] = {"os": "linux", "architecture": "amd64"} + elif application_platform == "linux/arm64": + app_desc["platform"] = {"os": "linux", "architecture": "arm64"} + elif application_platform == "windows/amd64": + app_desc["platform"] = {"os": "windows", "architecture": "amd64"} + elif application_platform == "malformed": + app_desc["platform"] = "linux/amd64" + elif application_platform != "absent": + raise ValueError("unknown synthetic application-platform variant") att_config = store(encode({"architecture": "unknown", "os": "unknown"})) attest_layers = [] for predicate in ("https://spdx.dev/Document", @@ -111,10 +121,24 @@ def descriptor(kind, digest, **extra): att_config), "layers": attest_layers} if manifest_subject_kind != "none": - attest_data["subject"] = { - "mediaType": oci_manifest, - "digest": (application if manifest_subject_kind == "image" - else "sha256:" + "f"*64)} + subject = descriptor(oci_manifest, application) + if manifest_subject_kind == "wrong": + subject["digest"] = "sha256:" + "f"*64 + elif manifest_subject_kind == "missing-size": + del subject["size"] + elif manifest_subject_kind == "boolean-size": + subject["size"] = True + elif manifest_subject_kind == "float-size": + subject["size"] = float(subject["size"]) + elif manifest_subject_kind == "negative-size": + subject["size"] = -1 + elif manifest_subject_kind == "incorrect-size": + subject["size"] += 1 + elif manifest_subject_kind == "wrong-media-type": + subject["mediaType"] = oci_index + elif manifest_subject_kind != "image": + raise ValueError("unknown synthetic subject variant") + attest_data["subject"] = subject attest_manifest = store(encode(attest_data)) att_desc = descriptor(oci_manifest, attest_manifest, annotations={"vnd.docker.reference.type": "attestation-manifest", @@ -164,6 +188,38 @@ def descriptor(kind, digest, **extra): inspected.write_text(json.dumps({"Id": config, "Architecture": "amd64"})) return archive, meta, inspected + def test_nested_application_platform_matches_config(self): + for kind in ("linux/arm64", "windows/amd64", "malformed", "absent"): + with self.subTest(platform=kind), tempfile.TemporaryDirectory() as d: + archive, meta, inspection = self.fixture( + Path(d), application_platform=kind) + with self.assertRaisesRegex(ValueError, "platform"): + oci.verify_archive(archive, meta, docker_inspect=inspection) + + def test_flat_manifest_without_platform_is_supported(self): + with tempfile.TemporaryDirectory() as d: + archive, meta, inspection = self.fixture( + Path(d), nested=False, application_platform="absent") + self.assertTrue( + oci.verify_archive(archive, meta, docker_inspect=inspection)[ + "oci_application_identity_verified"]) + + def test_attestation_subject_descriptor_sizes_and_types(self): + for kind in ("missing-size", "boolean-size", "float-size", + "negative-size", "incorrect-size", "wrong-media-type"): + with self.subTest(subject=kind), tempfile.TemporaryDirectory() as d: + archive, meta, inspection = self.fixture( + Path(d), manifest_subject_kind=kind) + with self.assertRaisesRegex(ValueError, "manifest subject"): + oci.verify_archive(archive, meta, docker_inspect=inspection) + + def test_valid_optional_attestation_subject_in_nested_image(self): + with tempfile.TemporaryDirectory() as d: + archive, meta, inspection = self.fixture( + Path(d), manifest_subject_kind="image") + report = oci.verify_archive(archive, meta, docker_inspect=inspection) + self.assertTrue(report["oci_attestation_references_verified"]) + def test_real_small_oci_graph_binds_every_layer_and_attestation(self): with tempfile.TemporaryDirectory() as d: archive,meta,inspection=self.fixture(Path(d)) @@ -306,6 +362,7 @@ def test_diagnostic_structure_is_not_an_integrity_verdict(self): self.assertEqual(report["exported_root_digest"],report["buildkit_digest"]) self.assertEqual(report["application_manifest_digest"],report["index_manifests"][0]["children"][0]["digest"]) attest=report["index_manifests"][0]["children"][1] + self.assertIsNone(attest["manifest_subject"]) self.assertEqual(len(attest["layers"]),2) self.assertEqual(attest["layers"][0]["statements"][0]["subject_count"],1) From 511d101d3e949fbe2238259e1733cc268d50c8ff Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Wed, 14 Oct 2026 01:43:17 +0200 Subject: [PATCH 033/147] fix(RELEASE-1): require move-endpoint unknown ACK for R02 recovery --- scripts/qualify-release-smoke.py | 1 + tests/release/test_release_smoke_evidence.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/scripts/qualify-release-smoke.py b/scripts/qualify-release-smoke.py index 4b7aef31..13d0b359 100644 --- a/scripts/qualify-release-smoke.py +++ b/scripts/qualify-release-smoke.py @@ -298,6 +298,7 @@ def verify_smoke(doc: dict, key: str) -> dict: require(len(closed) == len(all_closes) == len(commits_for_request) == len(unknown_ack) == len(first_submissions) == 1 and + unknown_ack[0].get("endpoint") == "move" and not completed_responses and not acknowledged and commits_for_request[0]["seq"] < closed[0]["seq"] and attempt_index[rid]["seq"] < fault_events[0]["seq"] < diff --git a/tests/release/test_release_smoke_evidence.py b/tests/release/test_release_smoke_evidence.py index 743b57e3..d3da17da 100644 --- a/tests/release/test_release_smoke_evidence.py +++ b/tests/release/test_release_smoke_evidence.py @@ -241,6 +241,22 @@ def test_r02_completed_success_response_fails(self): with self.assertRaisesRegex(ValueError,"lost-ack"): qual.verify_smoke(doc,"r02-white") + def test_r02_unknown_ack_must_identify_move_endpoint(self): + for mode in ("missing", "challenge", "token", "duplicate"): + doc = fixture("r02-white") + event = next(g for g in doc["guard_events"] + if g["event"] == "http_unknown_ack") + if mode == "missing": + del event["endpoint"] + elif mode in ("challenge", "token"): + event["endpoint"] = mode + else: + doc["guard_events"].append({ + **event, "endpoint": "challenge"}) + with self.subTest(mode=mode), self.assertRaisesRegex( + ValueError, "lost-ack"): + qual.verify_smoke(doc, "r02-white") + def test_r02_unrelated_completed_responses_are_allowed(self): doc = fixture("r02-white") target = next(g["request_id"] for g in doc["transport_trace"] From 5d84438c54c9c55885c2843b952d49429c27d91b Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Wed, 14 Oct 2026 01:44:04 +0200 Subject: [PATCH 034/147] fix(RELEASE-1): require client-observed 429 response and persistent cooldown evidence --- scripts/qualify-release-smoke.py | 27 ++++++++++ tests/release/test_release_smoke_evidence.py | 57 ++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/scripts/qualify-release-smoke.py b/scripts/qualify-release-smoke.py index 13d0b359..5fc6fd89 100644 --- a/scripts/qualify-release-smoke.py +++ b/scripts/qualify-release-smoke.py @@ -325,10 +325,37 @@ def verify_smoke(doc: dict, key: str) -> dict: len(phases) == 1 and phases[0].get("observation") == "rate_limited_suspension", "rate-limit suspension missed account-wide 60-second cooldown") + # A server-side 429 is not evidence that the client received it. + # Require the matching HTTP response *and* the guarded transport's + # own observed-rate-limit audit before granting a cooldown witness. + completed_429 = [r for r in trace if + r.get("event") == "response_write_completed" and + r.get("request_id") == rid] + first_epoch = phases[0]["epoch"] + rate_blocks = [g for g in guarded if + isinstance(g, dict) and + g.get("event") == "global_rate_limit_block"] + require(len(completed_429) == 1 and + completed_429[0].get("status") == 429 and + completed_429[0].get("path") == attempt_index[rid]["path"] and + fault_events[0]["seq"] < completed_429[0]["seq"], + "R10 targeted 429 response was not completed") + require(len(rate_blocks) == 1 and + rate_blocks[0].get("owner_epoch") == first_epoch and + rate_blocks[0].get("endpoint") == "move" and + type(rate_blocks[0].get("status")) is int and + rate_blocks[0]["status"] == 429, + "R10 client guard did not observe the move 429") + target_intent = by_ply[attempt_index[rid]["ply_at_receipt"]] + require(target_intent.get("status") == "UNKNOWN_ACK" and + len(journal["cooldown"]) == 1 and + journal["cooldown"][0].get("cause") == "http429:move", + "R10 durable 429 intent/cooldown evidence invalid") hold = [r for r in trace if r.get("event") == "rate_limit_hold_completed" and r.get("action") == "rate_limit_429"] require(len(hold) == 1 and + completed_429[0]["seq"] < hold[0]["seq"] and hold[0]["monotonic_ns"] - fault_events[0]["monotonic_ns"] >= 61_000_000_000, "R10 post-429 observation did not last 61 seconds") diff --git a/tests/release/test_release_smoke_evidence.py b/tests/release/test_release_smoke_evidence.py index d3da17da..3449192b 100644 --- a/tests/release/test_release_smoke_evidence.py +++ b/tests/release/test_release_smoke_evidence.py @@ -55,6 +55,8 @@ def event(kind, **kw): guarded.append({"event":"http_move_submission_attempt", "owner_epoch":first,"game_id":qual.GAME_ID, "ply":ply,"uci":uci}) + guarded.append({"event":"global_rate_limit_block", + "owner_epoch":first,"endpoint":"move","status":429}) intents.append({"game_id":qual.GAME_ID,"epoch":first, "ply_before":ply,"move":uci,"history_json":"[]", "status":"UNKNOWN_ACK"}) @@ -208,6 +210,61 @@ def test_r02_recovery_requires_a_distinct_confirmed_epoch(self): with self.assertRaisesRegex(ValueError,"new-epoch"): qual.verify_smoke(doc,"r02-white") + def test_r10_requires_correlated_completed_server_429(self): + for mode in ("missing", "wrong-request", "duplicate", "wrong-status", + "before-fault"): + doc = fixture("r10-white") + rows = doc["transport_trace"] + response = next(r for r in rows if + r["event"] == "response_write_completed") + if mode == "missing": + rows.remove(response) + elif mode == "wrong-request": + response["request_id"] = "request-999" + elif mode == "duplicate": + rows.append({**response}) + elif mode == "wrong-status": + response["status"] = 200 + else: + response = rows.pop(rows.index(response)) + rows.insert(1, response) # before the fault activation + if mode in ("missing", "duplicate", "before-fault"): + for i, row in enumerate(rows, start=1): + row["seq"] = i + if row["event"] != "rate_limit_hold_completed": + row["monotonic_ns"] = i * 1_000_000 + seal(doc) + with self.subTest(mode=mode), self.assertRaisesRegex( + ValueError, "429|request correlation|status"): + qual.verify_smoke(doc,"r10-white") + + def test_r10_requires_client_observed_rate_limit(self): + for mode in ("missing", "bad-endpoint", "wrong-status", + "wrong-epoch", "duplicate"): + doc = fixture("r10-white") + evidence = doc["guard_events"] + block = next(g for g in evidence + if g["event"] == "global_rate_limit_block") + if mode == "missing": + evidence.remove(block) + elif mode == "bad-endpoint": + block["endpoint"] = "challenge" + elif mode == "wrong-status": + block["status"] = 200 + elif mode == "wrong-epoch": + block["owner_epoch"] = "z"*32 + else: + evidence.append(dict(block)) + with self.subTest(mode=mode), self.assertRaisesRegex( + ValueError, "client guard"): + qual.verify_smoke(doc,"r10-white") + + def test_r10_rejects_durable_intent_without_unknown_ack(self): + doc = fixture("r10-white") + doc["journal"]["intents"][0]["status"] = "ACK_OBSERVED" + with self.assertRaisesRegex(ValueError, "durable 429"): + qual.verify_smoke(doc,"r10-white") + def test_r10_requires_complete_monotonic_observation(self): doc=fixture("r10-white") final=doc["transport_trace"][-1] From c8446972052ba88a395991de90bdd00f8c829932 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Wed, 14 Oct 2026 01:45:12 +0200 Subject: [PATCH 035/147] fix(RELEASE-1): bound signed Ubuntu snapshot retries and falsify illegal POST admission --- .github/workflows/release-candidate.yml | 39 +++++++++++++++++- deploy/release/Dockerfile | 9 ++++- deploy/release/offline_smoke.py | 42 +++++++++++++------- tests/release/test_release_contracts.py | 11 +++++ tests/release/test_release_smoke_evidence.py | 4 +- tests/release/test_release_smoke_failures.py | 17 ++++++++ tests/release/test_workflow_coverage.py | 3 ++ 7 files changed, 105 insertions(+), 20 deletions(-) diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 577258c1..e75b0a27 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -109,10 +109,45 @@ jobs: mkdir -p build/test-results/release1 docker buildx create --driver docker-container --name release1-builder --use docker buildx inspect --bootstrap - docker buildx build --builder release1-builder --platform linux/amd64 \ + BUILD_LOG="$RUNNER_TEMP/release1-offline-image-build.log" + if docker buildx build --builder release1-builder --platform linux/amd64 \ --file deploy/release/Dockerfile --build-arg "BASE_IMAGE=$IMAGE_BASE" \ --metadata-file build/test-results/release1/docker-build-metadata.json \ - --provenance=false --sbom=false --tag allfather/release1:offline --load . + --provenance=false --sbom=false --tag allfather/release1:offline --load . \ + > "$BUILD_LOG" 2>&1; then + tail -n 16 "$BUILD_LOG" + else + # Store only bounded public package-failure metadata, not raw logs, + # images, credentials, or a rewritten/unpinned dependency source. + python3 - "$BUILD_LOG" <<'PY' + import json, os, re, sys + from pathlib import Path + lines = Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace").splitlines() + snapshot = json.loads(Path("qualification/release-image-v1.json").read_text())["apt_snapshot"] + error_lines = [line for line in lines + if any(word in line for word in ( + "503 Service Unavailable", "Failed to fetch", + "Unable to fetch", "failed to solve", "ERROR:"))] + report = { + "schema_version": 1, + "stage": "offline-docker-image-build", + "source_sha": os.environ["SOURCE_SHA"], + "pinned_apt_snapshot": snapshot, + "failure_class": ("ubuntu_snapshot_http_503" + if any("503 Service Unavailable" in l for l in lines) + else "docker_build_failure_unclassified"), + "diagnostics": [ + re.sub(r"\\x1b\\[[0-9;]*m", "", line)[-400:] + for line in error_lines[-12:]], + "candidate_qualified": False, + "public_bot_release": False, + } + output = Path("build/test-results/release1/snapshot-acquisition-failure.json") + output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\\n") + PY + tail -n 45 "$BUILD_LOG" + exit 1 + fi docker image inspect allfather/release1:offline \ > build/test-results/release1/docker-inspect.json docker run --rm --network none --read-only \ diff --git a/deploy/release/Dockerfile b/deploy/release/Dockerfile index de94a6e9..3eedb3df 100644 --- a/deploy/release/Dockerfile +++ b/deploy/release/Dockerfile @@ -27,8 +27,13 @@ RUN set -eu; \ # Once CA trust has been established, ALL remaining APT inputs use the # fixed signed HTTPS Ubuntu snapshot; no bootstrap source survives. COPY deploy/release/ubuntu.sources /etc/apt/sources.list.d/ubuntu.sources -RUN apt-get update -o APT::Update::Error-Mode=any && \ - apt-get install -y --no-install-recommends \ +# A transient 503 on the *same pinned, signed* Ubuntu snapshot may be retried +# within the existing build deadline. No mirror substitution or unlocked +# repository fallback is permitted; persistent snapshot errors fail the build. +RUN apt-get -o Acquire::Retries=2 -o Acquire::https::Timeout=30 \ + update -o APT::Update::Error-Mode=any && \ + apt-get -o Acquire::Retries=2 -o Acquire::https::Timeout=30 \ + install -y --no-install-recommends \ python3 python3-venv python3-pip ca-certificates procps \ libopenblas0-pthread libprotobuf32t64 libstdc++6 libgomp1 zlib1g && \ rm -rf /var/lib/apt/lists/* diff --git a/deploy/release/offline_smoke.py b/deploy/release/offline_smoke.py index de36ce1a..8b194e09 100644 --- a/deploy/release/offline_smoke.py +++ b/deploy/release/offline_smoke.py @@ -99,6 +99,32 @@ def _start(cfg, owner, logpath): return proc, fd +def rejected_http_posts(records: list[dict], scenario_id: str) -> list[dict]: + """Producer-side fail-closed POST gate; host qualification is independent.""" + allowed = {"/api/token/test", + "/api/challenge/off3b001/accept", + "/api/challenge/off3b001/decline"} + prefix = "/api/bot/game/off3b001/move/" + expected_move_status = (200, 429) if scenario_id == "R10" else (200,) + rejected = [] + for record in records: + if not isinstance(record, dict): + rejected.append({"malformed": True}) + continue + if record.get("method") != "POST": + continue + path, status = record.get("path"), record.get("status") + if (type(status) is not int or + (path in allowed and status != 200) or + (isinstance(path, str) and path.startswith(prefix) and + (path[len(prefix):] in ("", "0000") or + status not in expected_move_status)) or + not (path in allowed or + isinstance(path, str) and path.startswith(prefix))): + rejected.append(record) + return rejected + + def run(*, color: str, scenario_id: str, run_id: str): if os.getpid() != 1: raise RuntimeError("image smoke supervisor must be PID 1; no unrestricted signals") @@ -277,21 +303,7 @@ def run(*, color: str, scenario_id: str, run_id: str): else: confirmed = (evidence.get("complete") and outcome["observations"][-1]["observation"] == "complete_confirmed") - allowed_posts = {"/api/token/test", - "/api/challenge/off3b001/accept", - "/api/challenge/off3b001/decline"} - prefix = "/api/bot/game/off3b001/move/" - bad_http = [r for r in records if - r.get("method") == "POST" and - (r.get("status") == 403 or - (r.get("path") in allowed_posts and r.get("status") != 200) or - (isinstance(r.get("path"), str) and r["path"].startswith(prefix) - and r.get("status") not in ( - (200, 429) if scenario_id == "R10" else (200,))) or - not (r.get("path") in allowed_posts or - (isinstance(r.get("path"), str) and - r["path"].startswith(prefix) and - r["path"][len(prefix):] not in ("", "0000"))))] + bad_http = rejected_http_posts(records, scenario_id) if not isinstance(outcome["game"].get("forbidden_attempts"), list) or outcome["game"]["forbidden_attempts"]: outcome["runner_errors"].append("server recorded forbidden HTTP attempts") outcome["rate_cooldown_verified"] = rate_clean if scenario_id == "R10" else None diff --git a/tests/release/test_release_contracts.py b/tests/release/test_release_contracts.py index c4c5b74f..6845fd08 100644 --- a/tests/release/test_release_contracts.py +++ b/tests/release/test_release_contracts.py @@ -103,6 +103,17 @@ def test_pinned_base_and_apt_snapshot_are_not_placeholders(self): docker.index("COPY deploy/release/ubuntu.sources")) self.assertIn("test -s /etc/ssl/certs/ca-certificates.crt",docker) + def test_pinned_snapshot_retries_are_bounded_without_mirror_fallback(self): + docker=(ROOT/"deploy/release/Dockerfile").read_text() + self.assertEqual(docker.count("Acquire::Retries=2"),2) + self.assertEqual(docker.count("Acquire::https::Timeout=30"),2) + self.assertIn("apt-get",docker) + self.assertIn("COPY deploy/release/ubuntu.sources",docker) + for unsafe in ("--fix-missing", "trusted=yes", "allow-unauthenticated", + "Acquire::https::Verify-Peer=false", + "Acquire::https::Verify-Host=false"): + self.assertNotIn(unsafe,docker) + def test_readonly_runtime_requires_separate_replay_bind_mount(self): p=json.loads((ROOT/"qualification/release-image-v1.json").read_text()) runtime=json.loads((ROOT/"deploy/release/runtime-contract.json").read_text()) diff --git a/tests/release/test_release_smoke_evidence.py b/tests/release/test_release_smoke_evidence.py index 3449192b..12c9fd44 100644 --- a/tests/release/test_release_smoke_evidence.py +++ b/tests/release/test_release_smoke_evidence.py @@ -222,7 +222,9 @@ def test_r10_requires_correlated_completed_server_429(self): elif mode == "wrong-request": response["request_id"] = "request-999" elif mode == "duplicate": - rows.append({**response}) + hold_index = next(i for i, row in enumerate(rows) + if row["event"] == "rate_limit_hold_completed") + rows.insert(hold_index, dict(response)) elif mode == "wrong-status": response["status"] = 200 else: diff --git a/tests/release/test_release_smoke_failures.py b/tests/release/test_release_smoke_failures.py index f86790cb..f4ce8d08 100644 --- a/tests/release/test_release_smoke_failures.py +++ b/tests/release/test_release_smoke_failures.py @@ -15,6 +15,23 @@ class SmokeFailureTests(unittest.TestCase): + def test_illegal_move_http_post_cannot_qualify_completed_game(self): + valid = [ + {"method":"POST","path":"/api/token/test","status":200}, + {"method":"POST","path":"/api/challenge/off3b001/accept","status":200}, + {"method":"POST","path":"/api/bot/game/off3b001/move/f2f3","status":200}, + ] + self.assertEqual(smoke.rejected_http_posts(valid,"R00"),[]) + illegal_status = {"method":"POST", + "path":"/api/bot/game/off3b001/move/e7e5", + "status":400} + for scenario in ("R00", "R02", "R10"): + with self.subTest(scenario=scenario): + rejected = smoke.rejected_http_posts(valid+[illegal_status], + scenario) + self.assertEqual(rejected,[illegal_status]) + self.assertFalse(not rejected) # producer ready requires no bad_http + def test_unexpected_exception_cannot_be_swallowed_by_finally(self): class BrokenServer: def __init__(self,*args): diff --git a/tests/release/test_workflow_coverage.py b/tests/release/test_workflow_coverage.py index 4d439dfa..2cd2fa3f 100644 --- a/tests/release/test_workflow_coverage.py +++ b/tests/release/test_workflow_coverage.py @@ -46,6 +46,9 @@ def test_offline_oci_export_is_named_and_diagnostics_precede_admission(self): self.assertLess(content.index("scripts/qualify-release-smoke.py"), content.index("- name: Export standalone OCI archive")) self.assertIn("--oci-archive build/release1-offline.oci.tar",content) + self.assertIn("snapshot-acquisition-failure.json",content) + self.assertIn('exit 1',content.split("snapshot-acquisition-failure.json",1)[1]) + self.assertIn("candidate_qualified",content) self.assertIn("build/test-results/release1/smoke-verdict.json",content) if __name__=="__main__": From 44b6b29ec5a6cc269c516086d8bea44449186aad Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Wed, 14 Oct 2026 01:46:48 +0200 Subject: [PATCH 036/147] docs(RELEASE-1): record four Codex P2 closures and bound diagnostic evidence --- .github/workflows/release-candidate.yml | 4 ++-- deploy/release/offline_smoke.py | 9 +++++---- docs/CODEX_POSTMERGE_CLOSURE.md | 17 +++++++++++++++++ docs/RELEASE_1_OPERATIONS.md | 8 ++++++++ tests/release/test_release_oci_integrity.py | 15 +++++++++++++++ tests/release/test_release_smoke_failures.py | 3 +++ 6 files changed, 50 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index e75b0a27..c860e6d2 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -137,13 +137,13 @@ jobs: if any("503 Service Unavailable" in l for l in lines) else "docker_build_failure_unclassified"), "diagnostics": [ - re.sub(r"\\x1b\\[[0-9;]*m", "", line)[-400:] + re.sub(r"\x1b\[[0-9;]*m", "", line)[-400:] for line in error_lines[-12:]], "candidate_qualified": False, "public_bot_release": False, } output = Path("build/test-results/release1/snapshot-acquisition-failure.json") - output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\\n") + output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n") PY tail -n 45 "$BUILD_LOG" exit 1 diff --git a/deploy/release/offline_smoke.py b/deploy/release/offline_smoke.py index 8b194e09..4bac4e61 100644 --- a/deploy/release/offline_smoke.py +++ b/deploy/release/offline_smoke.py @@ -114,13 +114,14 @@ def rejected_http_posts(records: list[dict], scenario_id: str) -> list[dict]: if record.get("method") != "POST": continue path, status = record.get("path"), record.get("status") + authorized_nonmove = isinstance(path, str) and path in allowed + move_endpoint = isinstance(path, str) and path.startswith(prefix) if (type(status) is not int or - (path in allowed and status != 200) or - (isinstance(path, str) and path.startswith(prefix) and + (authorized_nonmove and status != 200) or + (move_endpoint and (path[len(prefix):] in ("", "0000") or status not in expected_move_status)) or - not (path in allowed or - isinstance(path, str) and path.startswith(prefix))): + not (authorized_nonmove or move_endpoint)): rejected.append(record) return rejected diff --git a/docs/CODEX_POSTMERGE_CLOSURE.md b/docs/CODEX_POSTMERGE_CLOSURE.md index 8c4e8912..7533443c 100644 --- a/docs/CODEX_POSTMERGE_CLOSURE.md +++ b/docs/CODEX_POSTMERGE_CLOSURE.md @@ -65,3 +65,20 @@ The RELEASE-1 host aggregate now also recomputes the actual OCI archive SHA-256 The prior illegal POST and image-manifest subject protections remain enforced. These changes are local qualification and provenance controls, not move-search or HYBRID authorization changes. **Merge hold:** Do not merge PR #71 until final-head fast contracts, RELEASE-1 real OCI and packaged games, all ONLINE/LOCAL/LC0 checks, and fresh code/security review are green; no public deployment or licensing grant. + +## PR #71 final follow-up — four new P2 findings and external build blocker + +The Codex review of `d75f10e976decd80c4c0f0c8e74296fbb65751cc` identified four actionable P2 evidence gaps. The following remedies and destructive fixtures were staged in the existing draft branch; **do not mark these findings closed until final-head tests, actual packaged-OCI evidence and review succeed**. + +| Codex finding | Source repair | Negative proof fixture | +|---|---|---| +| [Application platform](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4263102073) | `scripts/verify-release-oci.py` requires a runnable nested-index descriptor to advertise `linux/amd64` consistent with its config. An optional explicit flat-manifest platform must also match. | Wrong OS, wrong architecture, absent nested platform, malformed platform; legitimate flat omission remains supported. | +| [Attestation subject size](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4263102056) | A present OCI attestation manifest `subject` must equal the independently verified application-manifest descriptor's digest, media type and integer byte size. | Missing, boolean, floating, negative, incorrect sizes and wrong media type, with rehashed parent blobs; valid optional subject accepted. | +| [R02 unknown ACK endpoint](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4263102049) | First-epoch unique `http_unknown_ack` must explicitly have `endpoint="move"` and match the game/owner. | Missing/wrong endpoint and duplicate conflicting unknown ACK. | +| [R10 client-observed HTTP 429](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4263102061) | Require the targeted completed 429 response (request/path/status/order), exactly one first-epoch `global_rate_limit_block` at endpoint `move`, and the persisted `UNKNOWN_ACK` intent with `http429:move` cooldown. | Missing, unrelated, duplicate, premature or wrong-status response; absent/misowned/wrong-endpoint guard and wrong durable intent. | + +An older [illegal-POST review](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4253078070) receives a direct producer-side negative test alongside existing host-side chess legality checks: an otherwise completed game with a 400 illegal-move response cannot set `ready` to true. + +**RELEASE-1 previous exact-head failure:** Docker's pinned Ubuntu snapshot `20261001T000000Z` returned HTTP 503 while downloading native packages, before OCI export or packaged smoke testing. The Dockerfile now uses bounded APT acquisition retries/timeouts against the **same GPG-verified snapshot**. The workflow retains a small, failure-only structured report and exits nonzero if the build remains unavailable. No fallback mirror, floating repository, unsigned package or publication permission was added. + +The proof obligation remains eleven required exact-head workflows green, genuine standalone OCI SBOM/provenance verified, all four installed-image scenarios qualified against real SQLite, final Codex code/security review with no actionable findings, and explicit disposition of the remaining prior review threads. No online play or public distribution grant is part of this PR. diff --git a/docs/RELEASE_1_OPERATIONS.md b/docs/RELEASE_1_OPERATIONS.md index ff4b63e9..295d6d8a 100644 --- a/docs/RELEASE_1_OPERATIONS.md +++ b/docs/RELEASE_1_OPERATIONS.md @@ -219,3 +219,11 @@ The packaged smoke verifier now opens the real state/move-journal.sqlite3 read-o For R02 the fake server records move request IDs in response_write_completed and connection_close_requested, resetting the identifier at every new HTTP/1.1 request. The independent host verifier rejects a completed response for the targeted move and requires the correlated close, first-epoch unknown ACK and subsequent recovery evidence. A server-side 200 status alone does not establish that the client received the response. The pure verify_smoke() function remains useful for synthetic trace-unit tests. Only verify_file() and qualify_directory() verify persisted SQLite durability. The release permissions, source/engine configurations, and outward move authority remain unchanged. + +## Final PR #71 repair contracts (October 2026) + +The independent OCI verifier now checks that the *nested-index runnable-image descriptor* advertises `linux/amd64`, agreeing with the image configuration; a supported flat manifest without a platform descriptor remains permitted, but a declared platform must agree. Any present OCI 1.1 attestation manifest subject is validated against the application's already-hashed manifest descriptor (digest, media type and exact nonboolean integer byte length). The recursive inspector exposes declared and actual subject sizes as **diagnostic-only** data. + +R02 lost acknowledgment requires a unique first-epoch `http_unknown_ack` audit with `endpoint="move"`; a non-move or missing endpoint cannot stand in for that evidence. R10 requires a matching completed HTTP 429 response, a matching first-epoch `global_rate_limit_block` guard audit, an on-disk `UNKNOWN_ACK` intent and `http429:move` cooldown, plus the existing account-wide API freeze and 61-second observation. Each is independently verified; a producer `ready=true` is never sufficient. + +The Dockerfile's snapshot-based APT update/install uses bounded `Acquire::Retries=2` and HTTPS timeouts of 30 seconds. Persistent failure is not ignored: the workflow preserves a bounded failure-only JSON report at `build/test-results/release1/snapshot-acquisition-failure.json`, prints the tail of the temporary build log, and exits unsuccessfully. Package sources, signatures, pinned base and snapshot, and release restrictions remain unchanged. A successful retry is not proof of pinned OS/wheel inventory or release permission. diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index eec83161..05ce5a84 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -349,6 +349,21 @@ def test_slsa_predicates_cannot_be_schema_only(self): with self.assertRaisesRegex(ValueError, "SLSA"): oci.verify_archive(archive, meta, docker_inspect=inspection) + def test_diagnostic_reports_optional_subject_declared_and_actual_sizes(self): + inspect_spec = importlib.util.spec_from_file_location( + "release1_oci_diag_subject", ROOT/"scripts/inspect-release-oci.py") + mod = importlib.util.module_from_spec(inspect_spec) + inspect_spec.loader.exec_module(mod) + with tempfile.TemporaryDirectory() as d: + archive, metadata, _ = self.fixture( + Path(d), manifest_subject_kind="image") + report = mod.inspect(archive, metadata) + self.assertFalse(report["integrity_verified"]) + att = report["index_manifests"][0]["children"][1] + self.assertEqual(att["subject_declared_size"], + att["subject_actual_size"]) + self.assertGreater(att["subject_actual_size"],0) + def test_diagnostic_structure_is_not_an_integrity_verdict(self): inspect_spec=importlib.util.spec_from_file_location( "release1_oci_diagnostic",ROOT/"scripts/inspect-release-oci.py") diff --git a/tests/release/test_release_smoke_failures.py b/tests/release/test_release_smoke_failures.py index f4ce8d08..ab22472f 100644 --- a/tests/release/test_release_smoke_failures.py +++ b/tests/release/test_release_smoke_failures.py @@ -31,6 +31,9 @@ def test_illegal_move_http_post_cannot_qualify_completed_game(self): scenario) self.assertEqual(rejected,[illegal_status]) self.assertFalse(not rejected) # producer ready requires no bad_http + invalid_path = {"method":"POST","path":["bad"],"status":200} + self.assertEqual(smoke.rejected_http_posts(valid+[invalid_path],"R00"), + [invalid_path]) def test_unexpected_exception_cannot_be_swallowed_by_finally(self): class BrokenServer: From 4cab42a8c1c72f54210c0266595fd481bef7d8c4 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Wed, 14 Oct 2026 01:48:33 +0200 Subject: [PATCH 037/147] test(RELEASE-1): inspect active Docker instructions and fail closed on malformed cooldown rows --- scripts/qualify-release-smoke.py | 1 + tests/release/test_release_contracts.py | 5 ++++- tests/release/test_release_smoke_evidence.py | 6 ++++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/scripts/qualify-release-smoke.py b/scripts/qualify-release-smoke.py index 5fc6fd89..75c69829 100644 --- a/scripts/qualify-release-smoke.py +++ b/scripts/qualify-release-smoke.py @@ -349,6 +349,7 @@ def verify_smoke(doc: dict, key: str) -> dict: target_intent = by_ply[attempt_index[rid]["ply_at_receipt"]] require(target_intent.get("status") == "UNKNOWN_ACK" and len(journal["cooldown"]) == 1 and + isinstance(journal["cooldown"][0], dict) and journal["cooldown"][0].get("cause") == "http429:move", "R10 durable 429 intent/cooldown evidence invalid") hold = [r for r in trace if diff --git a/tests/release/test_release_contracts.py b/tests/release/test_release_contracts.py index 6845fd08..a121ddd9 100644 --- a/tests/release/test_release_contracts.py +++ b/tests/release/test_release_contracts.py @@ -109,10 +109,13 @@ def test_pinned_snapshot_retries_are_bounded_without_mirror_fallback(self): self.assertEqual(docker.count("Acquire::https::Timeout=30"),2) self.assertIn("apt-get",docker) self.assertIn("COPY deploy/release/ubuntu.sources",docker) + instructions = "\n".join( + line for line in docker.splitlines() + if not line.lstrip().startswith("#")) for unsafe in ("--fix-missing", "trusted=yes", "allow-unauthenticated", "Acquire::https::Verify-Peer=false", "Acquire::https::Verify-Host=false"): - self.assertNotIn(unsafe,docker) + self.assertNotIn(unsafe,instructions) def test_readonly_runtime_requires_separate_replay_bind_mount(self): p=json.loads((ROOT/"qualification/release-image-v1.json").read_text()) diff --git a/tests/release/test_release_smoke_evidence.py b/tests/release/test_release_smoke_evidence.py index 12c9fd44..d661770a 100644 --- a/tests/release/test_release_smoke_evidence.py +++ b/tests/release/test_release_smoke_evidence.py @@ -261,6 +261,12 @@ def test_r10_requires_client_observed_rate_limit(self): ValueError, "client guard"): qual.verify_smoke(doc,"r10-white") + def test_r10_malformed_cooldown_row_fails_as_a_qualification_error(self): + doc = fixture("r10-white") + doc["journal"]["cooldown"] = [None] + with self.assertRaisesRegex(ValueError, "durable 429"): + qual.verify_smoke(doc, "r10-white") + def test_r10_rejects_durable_intent_without_unknown_ack(self): doc = fixture("r10-white") doc["journal"]["intents"][0]["status"] = "ACK_OBSERVED" From f0f64213a416efc7c0305ee56aef4111305b4bd0 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Wed, 14 Oct 2026 02:35:13 +0200 Subject: [PATCH 038/147] fix(RELEASE-1): validate stopped SQLite and WAL via private read-only staging --- scripts/qualify-release-smoke.py | 113 +++++++++++------- .../test_release_sqlite_persistence.py | 47 +++++++- 2 files changed, 118 insertions(+), 42 deletions(-) diff --git a/scripts/qualify-release-smoke.py b/scripts/qualify-release-smoke.py index 75c69829..971dcc41 100644 --- a/scripts/qualify-release-smoke.py +++ b/scripts/qualify-release-smoke.py @@ -10,7 +10,12 @@ import argparse import hashlib import json +import math +import os +import shutil import sqlite3 +import stat +import tempfile from pathlib import Path from urllib.parse import unquote @@ -369,11 +374,14 @@ def verify_smoke(doc: dict, key: str) -> dict: def verify_persisted_sqlite(folder: Path, journal: dict) -> dict: - """Independently verify committed on-disk state, including live WAL entries. + """Verify SQLite/WAL bytes from the producer without altering that volume. - A producer's JSON snapshot is never authoritative. This is a consistency - check against persisted SQLite, not protection against a writer able to - rewrite both the database and evidence. + The installed-image process owns the persistent directory (UID 10001). + The GitHub runner can read it after chmod a+rX, but must not write there. + Opening a WAL database read-only can nevertheless need to create -shm. + Read the stopped producer's database + WAL into a private writable staging + directory, then open *that copy* with SQLite mode=ro (NOT immutable=1). + Discard its rebuilt shared-memory sidecar after qualification. """ state = folder / "state" db = state / "move-journal.sqlite3" @@ -382,45 +390,70 @@ def verify_persisted_sqlite(folder: Path, journal: dict) -> dict: state.is_dir() and not state.is_symlink() and db.is_file() and not db.is_symlink(), "persisted SQLite journal absent or linked") - for suffix in ("-wal", "-shm"): - sidecar = state / ("move-journal.sqlite3" + suffix) - require(not sidecar.is_symlink(), - "persisted SQLite sidecar cannot be a symlink") - require(db.stat().st_size <= 256 * 1024 * 1024, - "persisted SQLite exceeds bounded verification size") require(type(journal) is dict, "persisted SQLite snapshot absent") - connection = sqlite3.connect( - db.resolve(strict=True).as_uri() + "?mode=ro", - uri=True, isolation_level=None, timeout=5) - connection.row_factory = sqlite3.Row - try: - connection.execute("PRAGMA query_only=ON") - connection.execute("BEGIN") # consistent view of the main file and WAL + + def source(path: Path, *, required: bool) -> bool: + require(not path.is_symlink(), "persisted SQLite sidecar cannot be a symlink") + if not path.exists(): + require(not required, "persisted SQLite database missing") + return False + mode = path.stat() + require(stat.S_ISREG(mode.st_mode) and + 0 < mode.st_size <= 256 * 1024 * 1024, + "persisted SQLite database/sidecar invalid or oversized") + return True + + source(db, required=True) + wal = state / "move-journal.sqlite3-wal" + shm = state / "move-journal.sqlite3-shm" + has_wal = source(wal, required=False) + # The source SHM is disposable indexing state, not authoritative bytes. + # Never follow a linked SHM or reuse it in the private directory. + if shm.exists() or shm.is_symlink(): + source(shm, required=False) + + with tempfile.TemporaryDirectory(prefix="release1-sqlite-verify-") as scratch: + staged = Path(scratch) / "move-journal.sqlite3" + shutil.copyfile(db, staged) + if has_wal: + shutil.copyfile(wal, Path(scratch) / "move-journal.sqlite3-wal") + # Permit SQLite to generate its own -shm only inside scratch. Source + # volume remains read-only, including when the original had live WAL. + connection = sqlite3.connect( + staged.as_uri() + "?mode=ro", + uri=True, isolation_level=None, timeout=5) + connection.row_factory = sqlite3.Row try: - require([tuple(r) for r in connection.execute( - "PRAGMA integrity_check")] == [("ok",)], - "persisted SQLite integrity check failed") - owner = connection.execute( - "SELECT epoch FROM owner WHERE singleton=1").fetchone() - require(owner is not None and - owner["epoch"] == journal.get("owner_epoch"), - "SQLite owner epoch differs from JSON snapshot") - queries = { - "games": "SELECT * FROM games ORDER BY game_id", - "intents": "SELECT * FROM intents ORDER BY game_id,ply_before", - "cooldown": "SELECT * FROM cooldown ORDER BY singleton", - "events": "SELECT * FROM events ORDER BY event_id", - } - observed = {name: [dict(row) for row in connection.execute(query)] - for name, query in queries.items()} - for name, rows in observed.items(): - require(type(journal.get(name)) is list and - rows == journal[name], - "SQLite persisted " + name + " differs from JSON snapshot") + connection.execute("PRAGMA query_only=ON") + connection.execute("BEGIN") + try: + require([tuple(r) for r in connection.execute( + "PRAGMA integrity_check")] == [("ok",)], + "persisted SQLite integrity check failed") + owner = connection.execute( + "SELECT epoch FROM owner WHERE singleton=1").fetchone() + require(owner is not None and + owner["epoch"] == journal.get("owner_epoch"), + "SQLite owner epoch differs from JSON snapshot") + queries = { + "games": "SELECT * FROM games ORDER BY game_id", + "intents": "SELECT * FROM intents ORDER BY game_id,ply_before", + "cooldown": "SELECT * FROM cooldown ORDER BY singleton", + "events": "SELECT * FROM events ORDER BY event_id", + } + observed = { + name: [dict(row) for row in connection.execute(query)] + for name, query in queries.items() + } + for name, rows in observed.items(): + require(type(journal.get(name)) is list and + rows == journal[name], + "SQLite persisted " + name + + " differs from JSON snapshot") + finally: + connection.execute("ROLLBACK") finally: - connection.execute("ROLLBACK") - finally: - connection.close() + connection.close() return {"persisted_sqlite_verified": True, "durable_events": len(observed["events"]), "durable_intents": len(observed["intents"])} diff --git a/tests/release/test_release_sqlite_persistence.py b/tests/release/test_release_sqlite_persistence.py index 5794a84a..7b8768a5 100644 --- a/tests/release/test_release_sqlite_persistence.py +++ b/tests/release/test_release_sqlite_persistence.py @@ -5,8 +5,10 @@ """ from __future__ import annotations +import hashlib import importlib.util import json +import os import sqlite3 import tempfile import unittest @@ -23,9 +25,9 @@ qual = original.qual -def real_fixture(folder: Path, key: str, *, keep_wal=False): +def real_fixture(folder: Path, key: str, *, keep_wal=False, doc=None): """Return an optionally live WAL writer and its complete JSON document.""" - doc = fixture(key) + doc = fixture(key) if doc is None else doc folder.mkdir(parents=True) state = folder / "state" state.mkdir() @@ -76,6 +78,47 @@ def test_uncheckpointed_wal_rows_are_verified_read_only(self): finally: conn.close() + def test_qualifier_never_writes_original_read_only_producer_volume(self): + """Reproduce GitHub runner reading the UID-10001-owned mount.""" + for keep_wal in (False, True): + with self.subTest(live_wal=keep_wal), tempfile.TemporaryDirectory() as d: + folder = Path(d) / "r02-white" + _, conn = real_fixture(folder, "r02-white", keep_wal=keep_wal) + state = folder / "state" + database = state / "move-journal.sqlite3" + wal = state / "move-journal.sqlite3-wal" + originals = [database] + ([wal] if keep_wal else []) + before = {p.name: hashlib.sha256(p.read_bytes()).hexdigest() + for p in originals} + previous_modes = {p: p.stat().st_mode & 0o777 for p in originals} + try: + for path in originals: + os.chmod(path, 0o444) + os.chmod(state, 0o555) + result = qual.verify_file(folder / "result.json", "r02-white") + self.assertTrue(result["persisted_sqlite_verified"]) + self.assertEqual(before, { + p.name: hashlib.sha256(p.read_bytes()).hexdigest() + for p in originals}) + self.assertEqual( + (state / "move-journal.sqlite3-shm").exists(), keep_wal) + finally: + os.chmod(state, 0o700) + for path, mode in previous_modes.items(): + if path.exists(): + os.chmod(path, mode) + if conn is not None: + conn.close() + + def test_qualifier_rejects_mutated_source_without_json_change(self): + with tempfile.TemporaryDirectory() as d: + folder = Path(d) / "r10-white" + real_fixture(folder, "r10-white") + with sqlite3.connect(folder / "state/move-journal.sqlite3") as c: + c.execute("UPDATE cooldown SET cause='http429:other'") + with self.assertRaisesRegex(ValueError, "SQLite persisted cooldown"): + qual.verify_file(folder / "result.json", "r10-white") + def test_missing_required_wal_refuses_unpersisted_snapshot(self): with tempfile.TemporaryDirectory() as directory: folder = Path(directory) / "r02-white" From a651c1f85754b796abbe47a99a3d30006fd74ea7 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Wed, 14 Oct 2026 02:36:21 +0200 Subject: [PATCH 039/147] fix(RELEASE-1): verify nested OCI platform chain and full SPDX document identity --- scripts/verify-release-oci.py | 69 +++++++++++-- tests/release/test_release_oci_integrity.py | 103 +++++++++++++++++++- 2 files changed, 161 insertions(+), 11 deletions(-) diff --git a/scripts/verify-release-oci.py b/scripts/verify-release-oci.py index b380fc8e..8006b811 100644 --- a/scripts/verify-release-oci.py +++ b/scripts/verify-release-oci.py @@ -12,6 +12,7 @@ import json import re import tarfile +from datetime import datetime from pathlib import Path, PurePosixPath from urllib.parse import urlsplit @@ -152,6 +153,50 @@ def validate_slsa(kind: str, predicate: dict) -> None: raise ValueError("unsupported SLSA version") +def validate_spdx_document(predicate: dict) -> None: + """Validate required SPDX 2.x document creation identity and inventory. + + This is schema/structural validation, not an authentication of the tool + that generated the SBOM or of a publisher's signature. + """ + creation = predicate.get("creationInfo") + creators = creation.get("creators") if isinstance(creation, dict) else None + created = creation.get("created") if isinstance(creation, dict) else None + valid_date = (isinstance(created, str) and + re.fullmatch( + r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z", + created) is not None) + if valid_date: + try: + datetime.fromisoformat(created.replace("Z", "+00:00")) + except ValueError: + valid_date = False + require( + predicate.get("spdxVersion") in ("SPDX-2.2", "SPDX-2.3") and + predicate.get("SPDXID") == "SPDXRef-DOCUMENT" and + predicate.get("dataLicense") == "CC0-1.0" and + isinstance(predicate.get("name"), str) and + bool(predicate["name"].strip()) and + valid_identity_uri(predicate.get("documentNamespace")) and + valid_date and + isinstance(creators, list) and bool(creators) and + all(isinstance(creator, str) and + creator.startswith(("Tool: ", "Person: ", "Organization: ")) and + bool(creator.partition(": ")[2].strip()) for creator in creators), + "SPDX SBOM lacks complete document creation identity") + packages = predicate.get("packages") + files = predicate.get("files") + inventory = ((packages if isinstance(packages, list) else []) + + (files if isinstance(files, list) else [])) + require(bool(inventory) and + all(isinstance(item, dict) and + isinstance(item.get("SPDXID"), str) and + item["SPDXID"].startswith("SPDXRef-") and + isinstance(item.get("name"), str) and + bool(item["name"].strip()) for item in inventory), + "SPDX SBOM lacks a usable package/file inventory") + + def verify_attestation_subjects(attestations: list[dict], image: dict) -> set[str]: """Independently bind BuildKit's in-toto subjects to the application manifest. @@ -210,11 +255,7 @@ def verify_attestation_subjects(attestations: list[dict], image: dict) -> set[st require(isinstance(predicate, dict) and bool(predicate), "OCI attestation predicate document empty") if kind == SPDX: - require(isinstance(predicate.get("spdxVersion"), str) - and predicate["spdxVersion"].startswith("SPDX-2.") - and isinstance(predicate.get("SPDXID"), str) - and bool(predicate["SPDXID"]), - "SPDX SBOM predicate lacks document identity") + validate_spdx_document(predicate) else: validate_slsa(kind, predicate) predicates.add(kind) @@ -346,9 +387,21 @@ def inspect_descriptor(descriptor, depth=0, ancestors=frozenset()): children = document.get("manifests") require(isinstance(children, list) and bool(children), "nested OCI index has no child descriptors") + # Validate every *advertised* index platform on the path to + # the runnable image. Attestation-only subindexes do not + # determine the runner's platform. + contains_application = False for child in children: - inspect_descriptor(child, depth + 1, ancestors | {oid}) - return + child_application = inspect_descriptor( + child, depth + 1, ancestors | {oid}) + contains_application = child_application or contains_application + if contains_application and "platform" in descriptor: + advertised = descriptor["platform"] + require(isinstance(advertised, dict) and + advertised.get("os") == "linux" and + advertised.get("architecture") == "amd64", + "OCI nested runnable index descriptor platform mismatch") + return contains_application cfg = document.get("config") require(isinstance(cfg, dict) and cfg.get("mediaType") in CONFIG_TYPES, @@ -380,6 +433,7 @@ def inspect_descriptor(descriptor, depth=0, ancestors=frozenset()): "manifest_subject": document.get("subject"), "layer_media_type": layer.get("mediaType"), "statement": json.loads(content)}) + return False else: # The index descriptor is how an OCI consumer selects a platform. # A flat manifest may not advertise one, but any advertised @@ -402,6 +456,7 @@ def inspect_descriptor(descriptor, depth=0, ancestors=frozenset()): "config_digest": cfg["digest"], "config": details, "layers": layers, "layer_count": len(layers)}) + return True for entry in top: inspect_descriptor(entry) diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index 05ce5a84..abdb80de 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -30,8 +30,11 @@ def fixture(self, target: Path, *, replace_layer=False, bad_subject=False, bad_reference=False, corrupt_rootfs=False, subject_kind="image", metadata_kind="root", manifest_subject_kind="none", invalid_predicate=False, nested=True, wrong_size=None, - slsa_case="valid", bad_metadata_descriptor=False, - application_platform="linux/amd64"): + slsa_case="valid", spdx_case="valid", + bad_metadata_descriptor=False, + application_platform="linux/amd64", + index_platform="absent", inner_index_platform="absent", + double_nested=False): base = b"original uncompressed OCI application layer\n" expanded = (b"substituted uncompressed OCI application layer\n" if replace_layer else base) @@ -85,13 +88,48 @@ def descriptor(kind, digest, **extra): "layer": tar_layer[7:], "empty": None}[subject_kind] if bad_subject: subject = "f" * 64 - body = ({"SPDXID": "SPDXRef-DOCUMENT", "spdxVersion": "SPDX-2.3"} - if predicate == "https://spdx.dev/Document" else { + body = ({ + "SPDXID": "SPDXRef-DOCUMENT", + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "name": "AllfatherChess-offline-image", + "documentNamespace": "https://spdx.org/spdxdocs/allfather-release1-fixture", + "creationInfo": { + "created": "2026-10-14T00:00:00Z", + "creators": ["Tool: BuildKit fixture"]}, + "packages": [{ + "SPDXID": "SPDXRef-Package-offline", + "name": "offline-test-package", + "downloadLocation": "NOASSERTION"}], + } if predicate == "https://spdx.dev/Document" else { "buildDefinition": { "buildType": "https://mobyproject.org/buildkit@v1", "externalParameters": {}}, "runDetails": {"builder": { "id": "https://github.com/moby/buildkit"}}}) + if predicate == "https://spdx.dev/Document": + if spdx_case == "missing-license": + del body["dataLicense"] + elif spdx_case == "wrong-document-id": + body["SPDXID"] = "SPDXRef-Other" + elif spdx_case == "missing-name": + del body["name"] + elif spdx_case == "bad-namespace": + body["documentNamespace"] = "invalid" + elif spdx_case == "missing-created": + del body["creationInfo"]["created"] + elif spdx_case == "invalid-created": + body["creationInfo"]["created"] = "2026-99-14T00:00:00Z" + elif spdx_case == "missing-creators": + body["creationInfo"]["creators"] = [] + elif spdx_case == "bad-creator": + body["creationInfo"]["creators"] = ["anonymous"] + elif spdx_case == "empty-inventory": + body["packages"] = [] + elif spdx_case == "bad-package": + body["packages"] = [{"SPDXID":"SPDXRef-Package-X"}] + elif spdx_case != "valid": + raise ValueError("unknown SPDX fixture variant") if predicate.endswith("/v1"): if slsa_case == "empty": body = {"buildDefinition": {}, "runDetails": {}} @@ -148,10 +186,32 @@ def descriptor(kind, digest, **extra): if wrong_size == "application": app_desc["size"] += 1 children = [app_desc, att_desc] + + def advertise_index(entry, value): + if value == "absent": + return + if value == "linux/amd64": + entry["platform"] = {"os":"linux", "architecture":"amd64"} + elif value == "linux/arm64": + entry["platform"] = {"os":"linux", "architecture":"arm64"} + elif value == "windows/amd64": + entry["platform"] = {"os":"windows", "architecture":"amd64"} + elif value == "malformed": + entry["platform"] = ["linux", "amd64"] + else: + raise ValueError("unrecognized nested-index platform fixture") + if nested: nested_id = store(encode({"schemaVersion": 2, "mediaType": oci_index, "manifests": children})) + if double_nested: + inner_desc = descriptor(oci_index, nested_id) + advertise_index(inner_desc, inner_index_platform) + nested_id = store(encode({ + "schemaVersion": 2, "mediaType": oci_index, + "manifests": [inner_desc]})) root_desc = descriptor(oci_index, nested_id) + advertise_index(root_desc, index_platform) outer_children = [root_desc] else: root_desc = app_desc @@ -220,6 +280,41 @@ def test_valid_optional_attestation_subject_in_nested_image(self): report = oci.verify_archive(archive, meta, docker_inspect=inspection) self.assertTrue(report["oci_attestation_references_verified"]) + def test_nested_index_advertised_platform_must_match_runnable_image(self): + for advertised in ("linux/arm64", "windows/amd64", "malformed"): + for inner in (False, True): + with self.subTest(advertised=advertised, inner=inner): + with tempfile.TemporaryDirectory() as d: + kwargs = ({"inner_index_platform":advertised} + if inner else {"index_platform":advertised}) + archive, meta, inspection = self.fixture( + Path(d), double_nested=inner, **kwargs) + with self.assertRaisesRegex(ValueError, "nested runnable.*platform"): + oci.verify_archive(archive, meta, + docker_inspect=inspection) + + def test_valid_advertised_platform_at_every_nested_index_level(self): + with tempfile.TemporaryDirectory() as d: + archive, meta, inspection = self.fixture( + Path(d), double_nested=True, + index_platform="linux/amd64", + inner_index_platform="linux/amd64") + self.assertTrue( + oci.verify_archive(archive, meta, + docker_inspect=inspection)["integrity_verified"]) + + def test_incomplete_spdx_sbom_never_qualifies(self): + cases = ("missing-license", "wrong-document-id", "missing-name", + "bad-namespace", "missing-created", "invalid-created", + "missing-creators", "bad-creator", "empty-inventory", + "bad-package") + for variant in cases: + with self.subTest(spdx=variant), tempfile.TemporaryDirectory() as d: + archive, meta, inspection = self.fixture( + Path(d), spdx_case=variant) + with self.assertRaisesRegex(ValueError, "SPDX"): + oci.verify_archive(archive, meta, docker_inspect=inspection) + def test_real_small_oci_graph_binds_every_layer_and_attestation(self): with tempfile.TemporaryDirectory() as d: archive,meta,inspection=self.fixture(Path(d)) From 11b4c6dd102a9e8e6f01a5402f55d0371d251cf9 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Wed, 14 Oct 2026 02:37:33 +0200 Subject: [PATCH 040/147] fix(RELEASE-1): bind R10 finite cooldown to durable event and guard clock --- scripts/qualify-release-smoke.py | 48 +++++++++++++++++ tests/release/test_release_smoke_evidence.py | 53 +++++++++++++++++-- .../test_release_sqlite_persistence.py | 21 ++++++++ 3 files changed, 119 insertions(+), 3 deletions(-) diff --git a/scripts/qualify-release-smoke.py b/scripts/qualify-release-smoke.py index 971dcc41..396883d9 100644 --- a/scripts/qualify-release-smoke.py +++ b/scripts/qualify-release-smoke.py @@ -37,6 +37,16 @@ def require(value, reason): raise ValueError(reason) +def finite_number(value) -> bool: + """Reject SQLite TEXT deadlines, booleans, NaN and infinite numbers.""" + if type(value) not in (int, float): + return False + try: + return math.isfinite(value) + except (OverflowError, ValueError): + return False + + def trace_bytes(value) -> bytes: return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8") @@ -357,6 +367,44 @@ def verify_smoke(doc: dict, key: str) -> dict: isinstance(journal["cooldown"][0], dict) and journal["cooldown"][0].get("cause") == "http429:move", "R10 durable 429 intent/cooldown evidence invalid") + cooldown_row = journal["cooldown"][0] + require(type(cooldown_row.get("singleton")) is int and + cooldown_row["singleton"] == 1 and + finite_number(cooldown_row.get("until_wall")), + "R10 persisted cooldown deadline must be finite and numeric") + durable_events = journal.get("events") + require(type(durable_events) is list and + all(isinstance(e, dict) for e in durable_events), + "R10 persisted cooldown audit events absent") + cooldown_events = [e for e in durable_events + if e.get("event") == "cooldown"] + require(len(cooldown_events) == 1, + "R10 durable cooldown event missing or duplicated") + observed_event = cooldown_events[0] + require(observed_event.get("epoch") == first_epoch and + observed_event.get("game_id") is None and + finite_number(observed_event.get("ts")) and + observed_event["ts"] > 0 and + isinstance(observed_event.get("details"), str), + "R10 durable cooldown event identity/time invalid") + details = json.loads(observed_event["details"]) + require(isinstance(details, dict) and + details.get("cause") == "http429:move" and + finite_number(details.get("until_wall")) and + details["until_wall"] == cooldown_row["until_wall"], + "R10 durable cooldown event/details disagree with persisted deadline") + # MoveJournal samples time.time before the transaction and writes + # the cooldown event just after storing the deadline. A one-second + # tolerance accounts for that write, without allowing an arbitrary + # stale or unbounded persistent suspension. + remaining_at_event = cooldown_row["until_wall"] - observed_event["ts"] + require(59.0 <= remaining_at_event <= 86400.0, + "R10 persisted cooldown outside the 60-86400 second contract") + require(finite_number(rate_blocks[0].get("timestamp")) and + observed_event["ts"] - 1 <= rate_blocks[0]["timestamp"] and + rate_blocks[0]["timestamp"] <= observed_event["ts"] + 60 and + rate_blocks[0]["timestamp"] <= cooldown_row["until_wall"], + "R10 guard observation is not aligned with durable cooldown") hold = [r for r in trace if r.get("event") == "rate_limit_hold_completed" and r.get("action") == "rate_limit_429"] diff --git a/tests/release/test_release_smoke_evidence.py b/tests/release/test_release_smoke_evidence.py index d661770a..fa3521e2 100644 --- a/tests/release/test_release_smoke_evidence.py +++ b/tests/release/test_release_smoke_evidence.py @@ -56,7 +56,8 @@ def event(kind, **kw): "owner_epoch":first,"game_id":qual.GAME_ID, "ply":ply,"uci":uci}) guarded.append({"event":"global_rate_limit_block", - "owner_epoch":first,"endpoint":"move","status":429}) + "owner_epoch":first,"endpoint":"move","status":429, + "timestamp":1800000000.25}) intents.append({"game_id":qual.GAME_ID,"epoch":first, "ply_before":ply,"move":uci,"history_json":"[]", "status":"UNKNOWN_ACK"}) @@ -133,9 +134,16 @@ def event(kind, **kw): "intents":intents, "games":[{"game_id":qual.GAME_ID,"history_json":json.dumps(moves), "terminal":int(complete)}], - "cooldown":[{"singleton":1,"until_wall":4000000000.0, + "cooldown":[{"singleton":1,"until_wall":1800000060.0, "cause":"http429:move"}] if scenario=="R10" else [], - "events":[]}, + "events":[{ + "event_id":1, "ts":1800000000.0, "epoch":first, + "event":"cooldown", "game_id":None, + "details":json.dumps({ + "until_wall":1800000060.0, + "cause":"http429:move"}, + sort_keys=True, separators=(",",":")) + }] if scenario=="R10" else []}, } from deploy.lichess.reconcile import position_id for intent in result["journal"]["intents"]: @@ -267,6 +275,45 @@ def test_r10_malformed_cooldown_row_fails_as_a_qualification_error(self): with self.assertRaisesRegex(ValueError, "durable 429"): qual.verify_smoke(doc, "r10-white") + def test_r10_cooldown_event_and_deadline_falsifiers(self): + for mode in ("text", "boolean", "infinite", "negative", + "short", "long", "missing-event", "wrong-epoch", + "wrong-cause", "wrong-event-deadline", + "wrong-guard-time"): + doc = fixture("r10-white") + cooldown = doc["journal"]["cooldown"][0] + event = doc["journal"]["events"][0] + if mode == "text": + cooldown["until_wall"] = "garbage" + elif mode == "boolean": + cooldown["until_wall"] = True + elif mode == "infinite": + cooldown["until_wall"] = float("inf") + elif mode == "negative": + cooldown["until_wall"] = -2.0 + elif mode == "short": + cooldown["until_wall"] = event["ts"] + 30 + elif mode == "long": + cooldown["until_wall"] = event["ts"] + 86401 + elif mode == "missing-event": + doc["journal"]["events"] = [] + elif mode == "wrong-epoch": + event["epoch"] = "f"*32 + elif mode == "wrong-cause": + event["details"] = json.dumps({ + "cause":"http429:challenge", + "until_wall":cooldown["until_wall"]}) + elif mode == "wrong-event-deadline": + event["details"] = json.dumps({ + "cause":"http429:move","until_wall":1234.0}) + else: + block = next(g for g in doc["guard_events"] + if g["event"] == "global_rate_limit_block") + block["timestamp"] = 1700000000.0 + with self.subTest(mode=mode), self.assertRaisesRegex( + ValueError, "cooldown|guard observation"): + qual.verify_smoke(doc,"r10-white") + def test_r10_rejects_durable_intent_without_unknown_ack(self): doc = fixture("r10-white") doc["journal"]["intents"][0]["status"] = "ACK_OBSERVED" diff --git a/tests/release/test_release_sqlite_persistence.py b/tests/release/test_release_sqlite_persistence.py index 7b8768a5..d0335479 100644 --- a/tests/release/test_release_sqlite_persistence.py +++ b/tests/release/test_release_sqlite_persistence.py @@ -173,6 +173,27 @@ def test_forged_snapshot_rows_and_owner_are_rejected(self): with self.assertRaisesRegex(ValueError, "SQLite"): qual.verify_file(folder / "result.json", key) + def test_coherently_forged_sqlite_and_json_cooldown_still_fail(self): + """Matching disk + JSON is insufficient when the deadline is invalid.""" + for mode in ("text", "too-long", "bad-event-cause"): + with self.subTest(mode=mode), tempfile.TemporaryDirectory() as d: + folder = Path(d) / "r10-white" + doc = fixture("r10-white") + if mode == "text": + doc["journal"]["cooldown"][0]["until_wall"] = "garbage" + doc["journal"]["events"][0]["details"] = json.dumps({ + "until_wall":"garbage","cause":"http429:move"}) + elif mode == "too-long": + doc["journal"]["cooldown"][0]["until_wall"] = 1800086461.0 + doc["journal"]["events"][0]["details"] = json.dumps({ + "until_wall":1800086461.0,"cause":"http429:move"}) + else: + doc["journal"]["events"][0]["details"] = json.dumps({ + "until_wall":1800000060.0,"cause":"not-move"}) + real_fixture(folder, "r10-white", doc=doc) + with self.assertRaisesRegex(ValueError, "cooldown"): + qual.verify_file(folder / "result.json", "r10-white") + def test_disk_mutation_is_detected_without_json_mutation(self): with tempfile.TemporaryDirectory() as directory: folder = Path(directory) / "r00-white" From ec660424f8662e7aa294d1eab61a27ca01e3862e Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Wed, 14 Oct 2026 02:38:59 +0200 Subject: [PATCH 041/147] docs(RELEASE-1): record WAL-safe host and P2 evidence requirements --- docs/CODEX_POSTMERGE_CLOSURE.md | 15 +++++++++++++++ docs/RELEASE_1_OPERATIONS.md | 12 ++++++++++++ scripts/qualify-release-smoke.py | 3 ++- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/docs/CODEX_POSTMERGE_CLOSURE.md b/docs/CODEX_POSTMERGE_CLOSURE.md index 7533443c..a4b696f1 100644 --- a/docs/CODEX_POSTMERGE_CLOSURE.md +++ b/docs/CODEX_POSTMERGE_CLOSURE.md @@ -82,3 +82,18 @@ An older [illegal-POST review](https://github.com/HMarcusWH/AllfatherChess/pull/ **RELEASE-1 previous exact-head failure:** Docker's pinned Ubuntu snapshot `20261001T000000Z` returned HTTP 503 while downloading native packages, before OCI export or packaged smoke testing. The Dockerfile now uses bounded APT acquisition retries/timeouts against the **same GPG-verified snapshot**. The workflow retains a small, failure-only structured report and exits nonzero if the build remains unavailable. No fallback mirror, floating repository, unsigned package or publication permission was added. The proof obligation remains eleven required exact-head workflows green, genuine standalone OCI SBOM/provenance verified, all four installed-image scenarios qualified against real SQLite, final Codex code/security review with no actionable findings, and explicit disposition of the remaining prior review threads. No online play or public distribution grant is part of this PR. + +## PR #71 follow-up — SQLite read-only WAL, nested OCI, SPDX and R10 + +Latest Codex review identified three additional P2 admission issues. The real RELEASE-1 packaged-run integration also exposed a host-side SQLite WAL sidecar permission error. These source repairs and destructive fixtures are implemented in the existing draft branch, but successful final-head integration and review are mandatory before closure. + +| Area | Repair | +| --- | --- | +| OCI nested-platform path | The recursive verifier tracks whether each index descendant is the selected runnable image; every advertised platform on this path must be linux/amd64. Tests mutate root and intermediate index platforms with rehashed manifests; attestation-only paths remain separate. | +| SPDX document identity | SPDX 2.2/2.3 predicate now requires SPDXRef-DOCUMENT, CC0-1.0 data license, nonempty document name, URI namespace, valid UTC creation date, creator identification and nonempty identified package/file inventory. Empty schema-shaped SBOMs fail. This is not publisher authentication. | +| R10 cooldown validity | Finite numeric SQLite deadline, exact corresponding single durable cooldown event/cause/deadline, valid owner epoch and event timestamp, 60–86400 second contract with bounded write-delay tolerance, and temporally compatible client guard audit. Coherently corrupted SQLite+JSON fail. | +| SQLite UID boundary | Independent verifier stages the stopped producer's bounded regular database and WAL bytes in a private writable directory before a read-only SQLite transaction; never uses immutable=1 or producer SHM; original source remains untouched. Tests reproduce read-only permissions and uncheckpointed WAL. | + +Run https://github.com/HMarcusWH/AllfatherChess/actions/runs/38486478700 built the candidate image and completed R00 white/black, R02 white and R10 white packaged smoke, but independent qualification failed for all four at SQLite read-only/WAL initialization. OCI export was not yet exercised. + +All changes preserve the original no-publication, no-licensing, no-live-transport and no-search/authority-change constraints. Do not claim complete qualification or resolve review threads until exact-head OCI, packaged smoke, and final Codex review pass. diff --git a/docs/RELEASE_1_OPERATIONS.md b/docs/RELEASE_1_OPERATIONS.md index 295d6d8a..71d212c9 100644 --- a/docs/RELEASE_1_OPERATIONS.md +++ b/docs/RELEASE_1_OPERATIONS.md @@ -227,3 +227,15 @@ The independent OCI verifier now checks that the *nested-index runnable-image de R02 lost acknowledgment requires a unique first-epoch `http_unknown_ack` audit with `endpoint="move"`; a non-move or missing endpoint cannot stand in for that evidence. R10 requires a matching completed HTTP 429 response, a matching first-epoch `global_rate_limit_block` guard audit, an on-disk `UNKNOWN_ACK` intent and `http429:move` cooldown, plus the existing account-wide API freeze and 61-second observation. Each is independently verified; a producer `ready=true` is never sufficient. The Dockerfile's snapshot-based APT update/install uses bounded `Acquire::Retries=2` and HTTPS timeouts of 30 seconds. Persistent failure is not ignored: the workflow preserves a bounded failure-only JSON report at `build/test-results/release1/snapshot-acquisition-failure.json`, prints the tail of the temporary build log, and exits unsuccessfully. Package sources, signatures, pinned base and snapshot, and release restrictions remain unchanged. A successful retry is not proof of pinned OS/wheel inventory or release permission. + +## PR #71 further integrity hardening + +Stopped packaged smoke containers write the persistent recovery SQLite journal as UID 10001. The host runner may read files but cannot create or update WAL shared-memory sidecars there. The independent verifier now copies only the regular SQLite main file and optional WAL bytes into a private host-owned temporary directory, omits producer SHM, and opens the copy through normal SQLite mode=ro, consistent transaction and integrity check. This retains WAL-visible committed rows while leaving original evidence untouched. immutable=1 is never used. Directory/sidecar symlinks, corrupt bytes and JSON mismatches fail qualification. + +For OCI platform selection, every *advertised* platform on a nested-index descriptor leading to the runnable application must match linux/amd64, in addition to the leaf manifest and image configuration checks. Synthetic nested root/intermediate platform falsifiers are covered. Attestation-only index branches are not mistaken for runnable images. + +SPDX 2.x documentation predicates must include the real document-creation identity and a structurally identified nonempty package/file inventory: CC0-1.0 data license, SPDXRef-DOCUMENT, document name and URI namespace, UTC creation timestamp and creator records. Such shape validation never establishes publisher authenticity, licensing or redistribution permission. + +R10 requires a finite numeric SQLite cooldown deadline consistent with the single same-epoch durable cooldown event's recorded deadline, cause and timestamp. The original 60–86400-second duration is checked with a narrow transaction-timestamp tolerance and against the client's 429 guard audit. A matching but semantically forged JSON+SQLite deadline is not sufficient. + +All final-head real-process workflows, standalone OCI attestation verification, additional Codex code/security review, dependency locks and public-release gates remain independently enforceable. diff --git a/scripts/qualify-release-smoke.py b/scripts/qualify-release-smoke.py index 396883d9..92b44731 100644 --- a/scripts/qualify-release-smoke.py +++ b/scripts/qualify-release-smoke.py @@ -447,7 +447,8 @@ def source(path: Path, *, required: bool) -> bool: return False mode = path.stat() require(stat.S_ISREG(mode.st_mode) and - 0 < mode.st_size <= 256 * 1024 * 1024, + (mode.st_size > 0 if required else mode.st_size >= 0) and + mode.st_size <= 256 * 1024 * 1024, "persisted SQLite database/sidecar invalid or oversized") return True From 55d458c77e92728581afbbac00794624538192fe Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Wed, 14 Oct 2026 02:39:24 +0200 Subject: [PATCH 042/147] test(RELEASE-1): falsify cooldown bounds even with matching durable event --- tests/release/test_release_smoke_evidence.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/release/test_release_smoke_evidence.py b/tests/release/test_release_smoke_evidence.py index fa3521e2..b1a2a12f 100644 --- a/tests/release/test_release_smoke_evidence.py +++ b/tests/release/test_release_smoke_evidence.py @@ -310,6 +310,12 @@ def test_r10_cooldown_event_and_deadline_falsifiers(self): block = next(g for g in doc["guard_events"] if g["event"] == "global_rate_limit_block") block["timestamp"] = 1700000000.0 + if mode in ("short", "long"): + # Keep the JSON and persisted event in agreement, so the + # duration-bound check itself must reject this mutation. + event["details"] = json.dumps({ + "until_wall":cooldown["until_wall"], + "cause":"http429:move"}) with self.subTest(mode=mode), self.assertRaisesRegex( ValueError, "cooldown|guard observation"): qual.verify_smoke(doc,"r10-white") From aaa63f76cce91579d6092c99833b858cad6018c5 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Wed, 14 Oct 2026 02:40:50 +0200 Subject: [PATCH 043/147] test(RELEASE-1): accept precise SPDX refusal diagnostic in malformed-predicate regression --- tests/release/test_release_oci_integrity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index abdb80de..c06e5d79 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -382,7 +382,7 @@ def test_attestation_manifest_subject_must_match_image(self): def test_garbled_predicate_cannot_claim_sbom_or_provenance(self): with tempfile.TemporaryDirectory() as d: archive,meta,inspection=self.fixture(Path(d),invalid_predicate=True) - with self.assertRaisesRegex(ValueError,"predicate"): + with self.assertRaisesRegex(ValueError,"SPDX|SLSA|predicate"): oci.verify_archive(archive,meta,docker_inspect=inspection) From 56b4736e067c95ef46cab79cc0ea1dfd27d022c3 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Wed, 14 Oct 2026 15:36:19 +0200 Subject: [PATCH 044/147] fix(RELEASE-1): validate SPDX package/file schemas, namespace and safe inventory diagnostics --- scripts/inspect-release-oci.py | 42 ++++++++++++---- scripts/verify-release-oci.py | 49 ++++++++++++++---- tests/release/test_release_oci_integrity.py | 55 ++++++++++++++++++++- 3 files changed, 126 insertions(+), 20 deletions(-) diff --git a/scripts/inspect-release-oci.py b/scripts/inspect-release-oci.py index da052fc5..fff9021a 100644 --- a/scripts/inspect-release-oci.py +++ b/scripts/inspect-release-oci.py @@ -132,15 +132,39 @@ def traverse(desc, ancestry=()): statements = statement.get("statements") if not isinstance(statements, list): statements = [statement] - row["statements"] = [{ - "_type": st.get("_type"), - "predicate_type": st.get("predicateType"), - "subject_count": len(st.get("subject", [])) - if isinstance(st.get("subject"), list) else None, - "subjects": st.get("subject"), - "predicate_keys": sorted((st.get("predicate") or {}).keys()) - if isinstance(st.get("predicate"), dict) else [], - } for st in statements if isinstance(st, dict)] + row["statements"] = [] + for st in statements: + if not isinstance(st, dict): + continue + predicate = st.get("predicate") + safe_keys = (sorted(predicate.keys()) + if isinstance(predicate, dict) else []) + summary = { + "_type": st.get("_type"), + "predicate_type": st.get("predicateType"), + "subject_count": len(st.get("subject", [])) + if isinstance(st.get("subject"), list) else None, + "subjects": st.get("subject"), + "predicate_keys": safe_keys[:32], + } + if st.get("predicateType") == "https://spdx.dev/Document": + packages = (predicate.get("packages") + if isinstance(predicate, dict) else None) + files = (predicate.get("files") + if isinstance(predicate, dict) else None) + summary["spdx_package_count"] = ( + len(packages) if type(packages) is list else None) + summary["spdx_file_count"] = ( + len(files) if type(files) is list else None) + summary["spdx_package_field_keys"] = ( + sorted({key for item in packages[:32] + if isinstance(item, dict) for key in item})[:32] + if type(packages) is list else []) + summary["spdx_file_field_keys"] = ( + sorted({key for item in files[:32] + if isinstance(item, dict) for key in item})[:32] + if type(files) is list else []) + row["statements"].append(summary) rec["layers"].append(row) return rec diff --git a/scripts/verify-release-oci.py b/scripts/verify-release-oci.py index 8006b811..df330ac4 100644 --- a/scripts/verify-release-oci.py +++ b/scripts/verify-release-oci.py @@ -178,23 +178,52 @@ def validate_spdx_document(predicate: dict) -> None: isinstance(predicate.get("name"), str) and bool(predicate["name"].strip()) and valid_identity_uri(predicate.get("documentNamespace")) and + "#" not in predicate["documentNamespace"] and valid_date and isinstance(creators, list) and bool(creators) and all(isinstance(creator, str) and creator.startswith(("Tool: ", "Person: ", "Organization: ")) and bool(creator.partition(": ")[2].strip()) for creator in creators), "SPDX SBOM lacks complete document creation identity") - packages = predicate.get("packages") - files = predicate.get("files") - inventory = ((packages if isinstance(packages, list) else []) + - (files if isinstance(files, list) else [])) - require(bool(inventory) and - all(isinstance(item, dict) and - isinstance(item.get("SPDXID"), str) and - item["SPDXID"].startswith("SPDXRef-") and - isinstance(item.get("name"), str) and - bool(item["name"].strip()) for item in inventory), + # SPDX 2.3 distinguishes package.name from file.fileName. + # A single shared 'name' predicate wrongly rejected genuine Syft files. + packages = predicate.get("packages", []) + files = predicate.get("files", []) + require(type(packages) is list and type(files) is list and + bool(packages or files), "SPDX SBOM lacks a usable package/file inventory") + unique = {"SPDXRef-DOCUMENT"} + + def spdx_id(item): + identifier = item.get("SPDXID") if isinstance(item, dict) else None + require(isinstance(identifier, str) and + re.fullmatch(r"SPDXRef-[A-Za-z0-9.-]+", identifier) is not None and + identifier not in unique, + "SPDX SBOM inventory has malformed or duplicate SPDXID") + unique.add(identifier) + + for item in packages: + spdx_id(item) + require(isinstance(item.get("name"), str) and + bool(item["name"].strip()) and + isinstance(item.get("downloadLocation"), str) and + bool(item["downloadLocation"].strip()), + "SPDX SBOM package lacks name or downloadLocation") + for item in files: + spdx_id(item) + checksums = item.get("checksums") + require(isinstance(item.get("fileName"), str) and + bool(item["fileName"].strip()) and + type(checksums) is list and bool(checksums), + "SPDX SBOM file lacks fileName or checksums") + require(all(isinstance(checksum, dict) and + isinstance(checksum.get("algorithm"), str) and + bool(checksum["algorithm"].strip()) and + isinstance(checksum.get("checksumValue"), str) and + re.fullmatch(r"[A-Fa-f0-9]+", + checksum["checksumValue"]) is not None + for checksum in checksums), + "SPDX SBOM file contains malformed checksums") def verify_attestation_subjects(attestations: list[dict], image: dict) -> set[str]: diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index c06e5d79..fdf95ab1 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -128,6 +128,33 @@ def descriptor(kind, digest, **extra): body["packages"] = [] elif spdx_case == "bad-package": body["packages"] = [{"SPDXID":"SPDXRef-Package-X"}] + elif spdx_case == "missing-download": + del body["packages"][0]["downloadLocation"] + elif spdx_case == "file-only-valid": + body["packages"] = [] + body["files"] = [{"SPDXID": "SPDXRef-File-A", + "fileName": "./usr/bin/allfather", + "checksums": [{"algorithm": "SHA256", + "checksumValue": "a" * 64}]}] + elif spdx_case == "missing-file-checksum": + body["packages"] = [] + body["files"] = [{"SPDXID": "SPDXRef-File-A", + "fileName": "./usr/bin/allfather"}] + elif spdx_case == "bad-file-checksum": + body["packages"] = [] + body["files"] = [{"SPDXID": "SPDXRef-File-A", + "fileName": "./usr/bin/allfather", + "checksums": [{"algorithm":"SHA256", + "checksumValue":"not-hex"}]}] + elif spdx_case == "duplicate-id": + body["files"] = [{"SPDXID":"SPDXRef-Package-offline", + "fileName":"./file", + "checksums":[{"algorithm":"SHA256", + "checksumValue":"a"*64}]}] + elif spdx_case == "namespace-fragment": + body["documentNamespace"] += "#other" + elif spdx_case == "namespace-trailing-fragment": + body["documentNamespace"] += "#" elif spdx_case != "valid": raise ValueError("unknown SPDX fixture variant") if predicate.endswith("/v1"): @@ -303,11 +330,37 @@ def test_valid_advertised_platform_at_every_nested_index_level(self): oci.verify_archive(archive, meta, docker_inspect=inspection)["integrity_verified"]) + def test_spdx_file_only_inventory_uses_fileName_not_package_name(self): + with tempfile.TemporaryDirectory() as d: + archive, meta, inspect = self.fixture(Path(d), spdx_case="file-only-valid") + self.assertTrue(oci.verify_archive( + archive, meta, docker_inspect=inspect)["oci_sbom_verified"]) + + def test_sanitized_diagnostics_report_spdx_shape_without_file_names(self): + mod_spec = importlib.util.spec_from_file_location( + "release1_inventory_diagnostic", + ROOT/"scripts/inspect-release-oci.py") + mod = importlib.util.module_from_spec(mod_spec) + mod_spec.loader.exec_module(mod) + with tempfile.TemporaryDirectory() as d: + archive, meta, _ = self.fixture(Path(d), spdx_case="file-only-valid") + summary = mod.inspect(archive, meta) + attest = summary["index_manifests"][0]["children"][1] + spdx = next(s for l in attest["layers"] for s in l["statements"] + if s["predicate_type"] == "https://spdx.dev/Document") + self.assertEqual(spdx["spdx_package_count"], 0) + self.assertEqual(spdx["spdx_file_count"], 1) + self.assertIn("fileName", spdx["spdx_file_field_keys"]) + self.assertNotIn("./usr/bin/allfather", json.dumps(summary)) + self.assertFalse(summary["integrity_verified"]) + def test_incomplete_spdx_sbom_never_qualifies(self): cases = ("missing-license", "wrong-document-id", "missing-name", "bad-namespace", "missing-created", "invalid-created", "missing-creators", "bad-creator", "empty-inventory", - "bad-package") + "bad-package", "missing-download", "missing-file-checksum", + "bad-file-checksum", "duplicate-id", "namespace-fragment", + "namespace-trailing-fragment") for variant in cases: with self.subTest(spdx=variant), tempfile.TemporaryDirectory() as d: archive, meta, inspection = self.fixture( From ff2603297ef1046857881241408bc618b14d6942 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Wed, 14 Oct 2026 15:38:30 +0200 Subject: [PATCH 045/147] fix(RELEASE-1): enforce OCI platform requirements and embedded document media types --- scripts/verify-release-oci.py | 40 +++++++-- tests/release/test_release_oci_integrity.py | 93 +++++++++++++++++++-- 2 files changed, 119 insertions(+), 14 deletions(-) diff --git a/scripts/verify-release-oci.py b/scripts/verify-release-oci.py index df330ac4..5a12aeaf 100644 --- a/scripts/verify-release-oci.py +++ b/scripts/verify-release-oci.py @@ -226,6 +226,33 @@ def spdx_id(item): "SPDX SBOM file contains malformed checksums") +def validate_target_platform(platform: object) -> None: + """Fail closed on unqualified runtime requirements for linux/amd64 v1.""" + require(type(platform) is dict and + platform.get("os") == "linux" and + platform.get("architecture") == "amd64", + "OCI runnable platform must be linux/amd64") + allowed = {"os", "architecture", "variant", "os.version", + "os.features", "features"} + require(not (set(platform) - allowed), + "OCI runnable platform contains unsupported requirements") + if "variant" in platform: + require(type(platform["variant"]) is str and + platform["variant"] == "v1", + "OCI runnable platform variant not qualified") + if "os.version" in platform: + require(type(platform["os.version"]) is str and + platform["os.version"] == "", + "OCI runnable OS version requirement not qualified") + for field in ("os.features", "features"): + if field in platform: + require(type(platform[field]) is list and + all(type(item) is str and bool(item.strip()) + for item in platform[field]) and + not platform[field], + "OCI runnable platform feature requirements not qualified") + + def verify_attestation_subjects(attestations: list[dict], image: dict) -> set[str]: """Independently bind BuildKit's in-toto subjects to the application manifest. @@ -412,6 +439,8 @@ def inspect_descriptor(descriptor, depth=0, ancestors=frozenset()): require(isinstance(document, dict) and document.get("schemaVersion") == 2, "invalid nested OCI index or image manifest") + require(document.get("mediaType") in (None, typ), + "OCI document media type differs from descriptor") if typ == INDEX_TYPE: children = document.get("manifests") require(isinstance(children, list) and bool(children), @@ -425,11 +454,7 @@ def inspect_descriptor(descriptor, depth=0, ancestors=frozenset()): child, depth + 1, ancestors | {oid}) contains_application = child_application or contains_application if contains_application and "platform" in descriptor: - advertised = descriptor["platform"] - require(isinstance(advertised, dict) and - advertised.get("os") == "linux" and - advertised.get("architecture") == "amd64", - "OCI nested runnable index descriptor platform mismatch") + validate_target_platform(descriptor["platform"]) return contains_application cfg = document.get("config") require(isinstance(cfg, dict) and @@ -472,10 +497,7 @@ def inspect_descriptor(descriptor, depth=0, ancestors=frozenset()): require(root_type in MANIFEST_TYPES, "nested OCI application descriptor lacks platform") else: - require(isinstance(advertised, dict) and - advertised.get("os") == "linux" and - advertised.get("architecture") == "amd64", - "OCI application descriptor platform mismatch") + validate_target_platform(advertised) require(details.get("architecture") == "amd64" and details.get("os") == "linux", "unexpected non-attestation OCI application manifest") diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index fdf95ab1..f1686a57 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -34,7 +34,7 @@ def fixture(self, target: Path, *, replace_layer=False, bad_subject=False, bad_metadata_descriptor=False, application_platform="linux/amd64", index_platform="absent", inner_index_platform="absent", - double_nested=False): + double_nested=False, embedded_media="absent"): base = b"original uncompressed OCI application layer\n" expanded = (b"substituted uncompressed OCI application layer\n" if replace_layer else base) @@ -67,11 +67,37 @@ def descriptor(kind, digest, **extra): cfg_desc["size"] += 1 if wrong_size == "layer": layer_desc["size"] += 1 - application = store(encode({ - "schemaVersion": 2, "config": cfg_desc, "layers": [layer_desc]})) + application_doc = { + "schemaVersion": 2, "config": cfg_desc, "layers": [layer_desc]} + if embedded_media in ("app-valid", "app-wrong"): + application_doc["mediaType"] = ( + oci_manifest if embedded_media == "app-valid" else oci_index) + application = store(encode(application_doc)) app_desc = descriptor(oci_manifest, application) if application_platform == "linux/amd64": app_desc["platform"] = {"os": "linux", "architecture": "amd64"} + elif application_platform == "valid-extras": + app_desc["platform"] = {"os": "linux", "architecture": "amd64", + "variant": "v1", "os.features": [], + "features": [], "os.version": ""} + elif application_platform == "variant-v3": + app_desc["platform"] = {"os": "linux", "architecture": "amd64", + "variant": "v3"} + elif application_platform == "os-features-text": + app_desc["platform"] = {"os": "linux", "architecture": "amd64", + "os.features": "avx512"} + elif application_platform == "os-features-extra": + app_desc["platform"] = {"os": "linux", "architecture": "amd64", + "os.features": ["avx512"]} + elif application_platform == "features-extra": + app_desc["platform"] = {"os": "linux", "architecture": "amd64", + "features": ["unqualified"]} + elif application_platform == "os-version-extra": + app_desc["platform"] = {"os": "linux", "architecture": "amd64", + "os.version": "2026"} + elif application_platform == "unknown-extra": + app_desc["platform"] = {"os": "linux", "architecture": "amd64", + "arbitrary": "not qualified"} elif application_platform == "linux/arm64": app_desc["platform"] = {"os": "linux", "architecture": "arm64"} elif application_platform == "windows/amd64": @@ -225,12 +251,30 @@ def advertise_index(entry, value): entry["platform"] = {"os":"windows", "architecture":"amd64"} elif value == "malformed": entry["platform"] = ["linux", "amd64"] + elif value in ("valid-extras", "variant-v3", "os-features-text", + "os-features-extra", "features-extra", + "os-version-extra", "unknown-extra"): + platform = {"os":"linux", "architecture":"amd64"} + changes = { + "valid-extras": {"variant":"v1", "os.features": [], + "features": [], "os.version": ""}, + "variant-v3": {"variant":"v3"}, + "os-features-text": {"os.features":"bad"}, + "os-features-extra": {"os.features":["bad"]}, + "features-extra": {"features":["bad"]}, + "os-version-extra": {"os.version":"22"}, + "unknown-extra": {"unknown-feature":"bad"}, + } + entry["platform"] = {**platform, **changes[value]} else: raise ValueError("unrecognized nested-index platform fixture") if nested: - nested_id = store(encode({"schemaVersion": 2, "mediaType": oci_index, - "manifests": children})) + nested_id = store(encode({ + "schemaVersion": 2, + "mediaType": (oci_manifest if embedded_media == "index-wrong" + else oci_index), + "manifests": children})) if double_nested: inner_desc = descriptor(oci_index, nested_id) advertise_index(inner_desc, inner_index_platform) @@ -307,6 +351,45 @@ def test_valid_optional_attestation_subject_in_nested_image(self): report = oci.verify_archive(archive, meta, docker_inspect=inspection) self.assertTrue(report["oci_attestation_references_verified"]) + def test_nested_document_embedded_media_type_matches_descriptor(self): + for variant in ("app-wrong", "index-wrong"): + with self.subTest(variant=variant), tempfile.TemporaryDirectory() as d: + archive, meta, inspection = self.fixture( + Path(d), embedded_media=variant) + with self.assertRaisesRegex(ValueError, "media type"): + oci.verify_archive(archive, meta, docker_inspect=inspection) + with tempfile.TemporaryDirectory() as d: + archive, meta, inspection = self.fixture( + Path(d), embedded_media="app-valid") + self.assertTrue(oci.verify_archive( + archive, meta, docker_inspect=inspection)["integrity_verified"]) + + def test_runtime_platform_optional_fields_fail_closed(self): + invalid = ("variant-v3", "os-features-text", "os-features-extra", + "features-extra", "os-version-extra", "unknown-extra") + for where in ("root", "inner", "app"): + for variant in invalid: + with self.subTest(where=where, kind=variant): + with tempfile.TemporaryDirectory() as d: + kwargs = ({"index_platform":variant} + if where=="root" else + {"inner_index_platform":variant} + if where=="inner" else + {"application_platform":variant}) + archive, meta, inspection = self.fixture( + Path(d), double_nested=True, **kwargs) + with self.assertRaisesRegex(ValueError, "OCI runnable platform"): + oci.verify_archive(archive, meta, + docker_inspect=inspection) + with tempfile.TemporaryDirectory() as d: + archive, meta, inspection = self.fixture( + Path(d), double_nested=True, + index_platform="valid-extras", + inner_index_platform="valid-extras", + application_platform="valid-extras") + self.assertTrue(oci.verify_archive( + archive, meta, docker_inspect=inspection)["integrity_verified"]) + def test_nested_index_advertised_platform_must_match_runnable_image(self): for advertised in ("linux/arm64", "windows/amd64", "malformed"): for inner in (False, True): From d975462d76e7dad61147648154a06c2e9c4847b4 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Wed, 14 Oct 2026 15:40:31 +0200 Subject: [PATCH 046/147] fix(RELEASE-1): anchor R10 supervisor and verifier to completed targeted 429 --- deploy/release/offline_smoke.py | 36 +++++++++++++++++--- scripts/qualify-release-smoke.py | 3 +- tests/release/test_release_smoke_evidence.py | 26 +++++++++++--- tests/release/test_release_smoke_failures.py | 12 +++++++ 4 files changed, 67 insertions(+), 10 deletions(-) diff --git a/deploy/release/offline_smoke.py b/deploy/release/offline_smoke.py index 4bac4e61..dd7feef9 100644 --- a/deploy/release/offline_smoke.py +++ b/deploy/release/offline_smoke.py @@ -126,6 +126,24 @@ def rejected_http_posts(records: list[dict], scenario_id: str) -> list[dict]: return rejected +R10_POST_RESPONSE_HOLD_NS = 61_250_000_000 # min 61 sec + scheduler margin + + +def completed_rate_limit_response(trace: list[dict], request_id: str): + """Return only the single request-correlated HTTP 429 completion.""" + found = [r for r in trace if r.get("event") == "response_write_completed" + and r.get("request_id") == request_id + and r.get("status") == 429] + return found[0] if len(found) == 1 else None + + +def r10_hold_ready(trace: list[dict], request_id: str, now_ns: int) -> bool: + response = completed_rate_limit_response(trace, request_id) + return bool(response is not None and + type(response.get("monotonic_ns")) is int and + now_ns - response["monotonic_ns"] >= R10_POST_RESPONSE_HOLD_NS) + + def run(*, color: str, scenario_id: str, run_id: str): if os.getpid() != 1: raise RuntimeError("image smoke supervisor must be PID 1; no unrestricted signals") @@ -209,11 +227,19 @@ def run(*, color: str, scenario_id: str, run_id: str): observation = "restart_after_lost_ack" break if scenario_id == "R10" and activated is not None: - if time.monotonic()-activated > 61: - observation = "rate_limited_suspension" - game.trace.record("rate_limit_hold_completed", - action="rate_limit_429") - break + activations = game.fault.evidence()["activations"] + request_ids = [entry.get("request_id") + for entry in activations + if entry.get("action") == "rate_limit_429"] + if len(request_ids) == 1 and isinstance(request_ids[0], str): + rid = request_ids[0] + if r10_hold_ready(game.trace.snapshot(), rid, + time.monotonic_ns()): + observation = "rate_limited_suspension" + game.trace.record("rate_limit_hold_completed", + action="rate_limit_429", + request_id=rid) + break if proc.poll() is not None: observation = "bridge_exited" break diff --git a/scripts/qualify-release-smoke.py b/scripts/qualify-release-smoke.py index 92b44731..b7510c2a 100644 --- a/scripts/qualify-release-smoke.py +++ b/scripts/qualify-release-smoke.py @@ -409,8 +409,9 @@ def verify_smoke(doc: dict, key: str) -> dict: r.get("event") == "rate_limit_hold_completed" and r.get("action") == "rate_limit_429"] require(len(hold) == 1 and + hold[0].get("request_id") == rid and completed_429[0]["seq"] < hold[0]["seq"] and - hold[0]["monotonic_ns"] - fault_events[0]["monotonic_ns"] + hold[0]["monotonic_ns"] - completed_429[0]["monotonic_ns"] >= 61_000_000_000, "R10 post-429 observation did not last 61 seconds") diff --git a/tests/release/test_release_smoke_evidence.py b/tests/release/test_release_smoke_evidence.py index b1a2a12f..ef24b0fa 100644 --- a/tests/release/test_release_smoke_evidence.py +++ b/tests/release/test_release_smoke_evidence.py @@ -45,10 +45,11 @@ def event(kind, **kw): rid = f"request-{request['seq']}" fault = event("fault_activated",action="rate_limit_429", endpoint="move_post",request_id=rid) - event("response_write_completed",status=429,path=path, - request_id=rid) - row = event("rate_limit_hold_completed",action="rate_limit_429") - row["monotonic_ns"] = fault["monotonic_ns"] + 61_500_000_000 + completed = event("response_write_completed",status=429,path=path, + request_id=rid) + row = event("rate_limit_hold_completed",action="rate_limit_429", + request_id=rid) + row["monotonic_ns"] = completed["monotonic_ns"] + 61_500_000_000 activations.append({"action":"rate_limit_429","request_id":rid}) requests.append({"method":"POST","path":path,"status":429, "monotonic_ms":1000,"request_id":rid}) @@ -326,6 +327,23 @@ def test_r10_rejects_durable_intent_without_unknown_ack(self): with self.assertRaisesRegex(ValueError, "durable 429"): qual.verify_smoke(doc,"r10-white") + def test_r10_window_cannot_be_counted_before_completed_response(self): + doc = fixture("r10-white") + response = next(r for r in doc["transport_trace"] + if r["event"] == "response_write_completed") + hold = doc["transport_trace"][-1] + response["monotonic_ns"] = hold["monotonic_ns"] - 500_000_000 + seal(doc) + with self.assertRaisesRegex(ValueError, "post-429 observation"): + qual.verify_smoke(doc, "r10-white") + + def test_r10_hold_event_must_bind_targeted_request(self): + doc = fixture("r10-white") + doc["transport_trace"][-1]["request_id"] = "request-unrelated" + seal(doc) + with self.assertRaisesRegex(ValueError, "post-429 observation"): + qual.verify_smoke(doc, "r10-white") + def test_r10_requires_complete_monotonic_observation(self): doc=fixture("r10-white") final=doc["transport_trace"][-1] diff --git a/tests/release/test_release_smoke_failures.py b/tests/release/test_release_smoke_failures.py index ab22472f..935faf37 100644 --- a/tests/release/test_release_smoke_failures.py +++ b/tests/release/test_release_smoke_failures.py @@ -15,6 +15,18 @@ class SmokeFailureTests(unittest.TestCase): + def test_producer_r10_waits_full_time_from_correlated_429_completion(self): + event = {"event":"response_write_completed", + "request_id":"request-1", "monotonic_ns":1_000_000_000, + "status":429} + start = event["monotonic_ns"] + wait = smoke.R10_POST_RESPONSE_HOLD_NS + self.assertFalse(smoke.r10_hold_ready([event], "request-1", start+wait-1)) + self.assertTrue(smoke.r10_hold_ready([event], "request-1", start+wait)) + self.assertFalse(smoke.r10_hold_ready([event], "request-2", start+wait)) + self.assertFalse(smoke.r10_hold_ready([event, dict(event)], + "request-1", start+wait)) + def test_illegal_move_http_post_cannot_qualify_completed_game(self): valid = [ {"method":"POST","path":"/api/token/test","status":200}, From c845a7171593ffc4182043df2c4b5c8e4e2e9db4 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Wed, 14 Oct 2026 15:42:09 +0200 Subject: [PATCH 047/147] build(RELEASE-1): pin SPDX scanner OCI index and minimize retained metadata --- .github/workflows/release-candidate.yml | 28 ++++++++++++++++++++----- qualification/release-image-v1.json | 4 +++- tests/release/test_release_contracts.py | 11 ++++++++++ tests/release/test_workflow_coverage.py | 13 ++++++++++++ 4 files changed, 50 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index c860e6d2..8ba9787b 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -194,13 +194,18 @@ jobs: - name: Export standalone OCI archive with SBOM/provenance attestations timeout-minutes: 20 run: | + set -euo pipefail IMAGE_BASE="$(python3 -c 'import json; print(json.load(open("qualification/release-image-v1.json"))["base_image"])')" + SCANNER="$(python3 -c 'import json; print(json.load(open("qualification/release-image-v1.json"))["sbom_generator_image"])')" + # The pinned OCI index is a reviewed historic scanner baseline; + # never resolve the mutable stable-1 tag during qualification. + docker buildx imagetools inspect "$SCANNER" --raw > /dev/null docker buildx build --builder release1-builder --platform linux/amd64 \ --file deploy/release/Dockerfile --build-arg "BASE_IMAGE=$IMAGE_BASE" \ --tag allfather/release1:offline \ --metadata-file build/test-results/release1/oci-build-metadata.json \ --attest type=provenance,mode=min,builder-id=https://github.com/HMarcusWH/AllfatherChess/actions/workflows/release-candidate.yml \ - --attest type=sbom \ + --attest "type=sbom,generator=$SCANNER" \ --output type=oci,dest=build/release1-offline.oci.tar . python3 scripts/inspect-release-oci.py \ --archive build/release1-offline.oci.tar \ @@ -226,14 +231,27 @@ jobs: --wheel-inventory build/release1-stage/wheels/wheel-attestation.json \ --source-sha "$SOURCE_SHA" \ --output build/test-results/release1/aggregate.json - - name: Do not publish combined image; retain only non-binary evidence + - name: Do not publish combined image; retain only approved evidence if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: release1-offline-evidence + # Allowlisted diagnostic summaries; never upload raw BuildKit + # provenance, GitHub event payload, Docker inspection, OCI tar or SBOM. path: | - build/test-results/release1/*.json - build/test-results/release1/*.txt - build/test-results/release1/*.log + build/test-results/release1/stage-report.json + build/test-results/release1/verify-output.json + build/test-results/release1/snapshot-acquisition-failure.json + build/test-results/release1/smoke-verdict.json + build/test-results/release1/oci-attestation-structure.json + build/test-results/release1/oci-report.json + build/test-results/release1/host-report.json + build/test-results/release1/aggregate.json + build/test-results/release1/os-packages.txt + build/test-results/release1/python-packages.txt + build/test-results/release1/r00-white.log + build/test-results/release1/r00-black.log + build/test-results/release1/r02-white.log + build/test-results/release1/r10-white.log if-no-files-found: warn retention-days: 30 diff --git a/qualification/release-image-v1.json b/qualification/release-image-v1.json index 1bb9beee..769fe154 100644 --- a/qualification/release-image-v1.json +++ b/qualification/release-image-v1.json @@ -60,5 +60,7 @@ "ca_bootstrap_signed_http_only": true, "ca_bootstrap_snapshot_locked": false, "ca_bootstrap_sources": "deploy/release/bootstrap.sources", - "ca_bootstrap_source_reason": "Pinned minimal base has no usable CA trust before first HTTPS APT access; only signed native Ubuntu APT metadata may install ca-certificates. Exact bootstrap package version is part of generated runtime inventory; publication blocked until reviewed." + "ca_bootstrap_source_reason": "Pinned minimal base has no usable CA trust before first HTTPS APT access; only signed native Ubuntu APT metadata may install ca-certificates. Exact bootstrap package version is part of generated runtime inventory; publication blocked until reviewed.", + "sbom_generator_image": "docker.io/docker/buildkit-syft-scanner@sha256:79e7b013cbec16bbb436f312819a49a4a57752b2270c1a9332ae1a10fcc82a68", + "sbom_generator_evidence": "Docker Hub stable-1 multi-platform OCI index digest (reviewed pinned baseline; no floating tag at build time)" } diff --git a/tests/release/test_release_contracts.py b/tests/release/test_release_contracts.py index a121ddd9..6e16372d 100644 --- a/tests/release/test_release_contracts.py +++ b/tests/release/test_release_contracts.py @@ -117,6 +117,17 @@ def test_pinned_snapshot_retries_are_bounded_without_mirror_fallback(self): "Acquire::https::Verify-Host=false"): self.assertNotIn(unsafe,instructions) + def test_sbom_generator_is_reviewed_immutable_oci_index(self): + image=json.loads((ROOT/"qualification/release-image-v1.json").read_text()) + scanner=image["sbom_generator_image"] + self.assertRegex(scanner, + r"^docker\.io/docker/buildkit-syft-scanner@sha256:[0-9a-f]{64}$") + content=(ROOT/".github/workflows/release-candidate.yml").read_text() + self.assertIn('["sbom_generator_image"]',content) + self.assertIn('--attest "type=sbom,generator=$SCANNER"',content) + self.assertIn('docker buildx imagetools inspect "$SCANNER" --raw',content) + self.assertNotIn("--attest type=sbom \\",content) + def test_readonly_runtime_requires_separate_replay_bind_mount(self): p=json.loads((ROOT/"qualification/release-image-v1.json").read_text()) runtime=json.loads((ROOT/"deploy/release/runtime-contract.json").read_text()) diff --git a/tests/release/test_workflow_coverage.py b/tests/release/test_workflow_coverage.py index 2cd2fa3f..1dba50e7 100644 --- a/tests/release/test_workflow_coverage.py +++ b/tests/release/test_workflow_coverage.py @@ -35,6 +35,19 @@ def test_operations_docker_command_is_valid_shell(self): self.assertIn(fragment,command) + def test_release_artifacts_exclude_raw_provenance_and_image_bytes(self): + content=(ROOT/".github/workflows/release-candidate.yml").read_text() + upload=content.split("- name: Do not publish combined image",1)[1] + self.assertIn("oci-attestation-structure.json", upload) + self.assertIn("smoke-verdict.json",upload) + self.assertIn("aggregate.json",upload) + for forbidden in ("/*.json", "/*.txt", "/*.log", + "build/test-results/release1/oci-build-metadata.json", + "build/test-results/release1/docker-build-metadata.json", + "build/test-results/release1/docker-inspect.json", + "build/release1-offline.oci.tar"): + self.assertNotIn(forbidden,upload) + def test_offline_oci_export_is_named_and_diagnostics_precede_admission(self): content=(ROOT/".github/workflows/release-candidate.yml").read_text() export=content.split("- name: Export standalone OCI archive",1)[1] From e90fc9381f80a68d1a09007da2f13c02d98bf23f Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Wed, 14 Oct 2026 15:45:38 +0200 Subject: [PATCH 048/147] docs(RELEASE-1): record final P2 repairs, source boundaries and retention policy --- docs/CODEX_POSTMERGE_CLOSURE.md | 16 ++++++++++++++++ docs/RELEASE_1_OPERATIONS.md | 12 ++++++++++++ scripts/verify-release-oci.py | 2 +- tests/release/test_release_oci_integrity.py | 2 +- 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/docs/CODEX_POSTMERGE_CLOSURE.md b/docs/CODEX_POSTMERGE_CLOSURE.md index a4b696f1..3bbd3b5c 100644 --- a/docs/CODEX_POSTMERGE_CLOSURE.md +++ b/docs/CODEX_POSTMERGE_CLOSURE.md @@ -97,3 +97,19 @@ Latest Codex review identified three additional P2 admission issues. The real RE Run https://github.com/HMarcusWH/AllfatherChess/actions/runs/38486478700 built the candidate image and completed R00 white/black, R02 white and R10 white packaged smoke, but independent qualification failed for all four at SQLite read-only/WAL initialization. OCI export was not yet exercised. All changes preserve the original no-publication, no-licensing, no-live-transport and no-search/authority-change constraints. Do not claim complete qualification or resolve review threads until exact-head OCI, packaged smoke, and final Codex review pass. + +## PR #71 final repair wave: SPDX, OCI, 429 response-time hold, immutable scanner + +Grounded in exact-head aaa63f76cce91579d6092c99833b858cad6018c5; RELEASE-1 built the candidate, independently qualified all four packaged games and SQLite, exported an OCI index, then rejected the real SPDX predicate for a supposedly missing usable package/file inventory. The prior verifier incorrectly required `name` on SPDX `files[]` instead of the SPDX 2.3 `fileName` field. Actual inventory counts were not present in retained diagnostics, so success on real BuildKit output remains a mandatory new CI gate. + +| Review / finding | Implementation and falsifiers | +| --- | --- | +| [SPDX fragment namespace](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4265635092) | Reject any literal `#` in `documentNamespace`, including a trailing `#`. Negative fixtures test both. | +| [R10 response-relative hold](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4265635097) | Packaged supervisor waits at least 61.25 seconds after targeted `response_write_completed(429)`, then records request-bound hold. Host checks at least 61 seconds after the same response. Negative tests falsify delayed response, wrong hold request ID and premature producer deadline. Existing guard and durable SQLite checks stay mandatory. | +| [Nested OCI platform extras](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4265635102) | One strict linux/amd64 baseline-v1 validator applies to every advertised index/leaf application platform; unsupported optional OS/features/variant requirements fail. Nested and leaf synthetic falsifiers are rehashed to the root. | +| [Nested OCI document media types](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4265635112) | Every reachable OCI index/image-manifest document must omit embedded `mediaType` or match its parent descriptor. Tests cover both index-as-manifest and manifest-as-index with recomputed digest graph. | +| SPDX package/file validity | Validate `SPDXID` uniqueness, packages (`name`, `downloadLocation`) and files (`fileName`, nonempty checksums) separately. Require a nonempty combined inventory and fail closed for malformed records; diagnostic-only inspection emits bounded counts and field keys without file or package names. | +| Frozen SBOM scanner | `qualification/release-image-v1.json` now pins the historically published Docker BuildKit Syft scanner multi-platform OCI index (`sha256:79e7b013cbec16bbb436f312819a49a4a57752b2270c1a9332ae1a10fcc82a68`). The workflow explicitly requests that digest rather than the mutable `stable-1` tag. Validate actual availability and generated SPDX content through CI. This is not a publisher signature or distribution clearance. | +| Evidence minimization | The RELEASE-1 upload explicitly allowlists sanitized reports and scenario logs. It no longer retains raw `oci-build-metadata.json`, `docker-build-metadata.json`, `docker-inspect.json`, OCI tarball, or unfiltered PR event payloads. Real verifier still receives full metadata during execution. | + +These repairs have been staged as sequential Git commit objects. Source/fast tests, all eleven workflows, actual BuildKit SBOM/SLSA verification, host aggregate and subsequent final-head Codex code/security review must all pass before these findings can be disposed as closed. No changes to engine search, move authority, offline isolation, budgets, licensing, OS/wheel lock completion, public-bot release or deployment permissions. diff --git a/docs/RELEASE_1_OPERATIONS.md b/docs/RELEASE_1_OPERATIONS.md index 71d212c9..0ee20afe 100644 --- a/docs/RELEASE_1_OPERATIONS.md +++ b/docs/RELEASE_1_OPERATIONS.md @@ -239,3 +239,15 @@ SPDX 2.x documentation predicates must include the real document-creation identi R10 requires a finite numeric SQLite cooldown deadline consistent with the single same-epoch durable cooldown event's recorded deadline, cause and timestamp. The original 60–86400-second duration is checked with a narrow transaction-timestamp tolerance and against the client's 429 guard audit. A matching but semantically forged JSON+SQLite deadline is not sufficient. All final-head real-process workflows, standalone OCI attestation verification, additional Codex code/security review, dependency locks and public-release gates remain independently enforceable. + +## Final PR #71 SPDX, OCI, observation-window and evidence workflow + +SBOM validation: in-toto SPDX predicate document must have supported 2.2/2.3 creation identity, legal namespace without `#`, identified file/package records, unique SPDX IDs, valid package `name`+`downloadLocation`, and file `fileName`+nonempty checksum inventory. The offline release contract additionally requires nonempty combined inventory. The diagnostic-only inspector reports bounded counts and SPDX field names, never arbitrary file names or full predicate contents. A genuine nonempty SPDX file-only SBOM is valid; an absent or malformed inventory cannot be admitted. + +OCI runtime selection: declared platforms on every selected runnable index/manifest path must match the qualified linux/amd64 baseline; unqualified nonempty OS/CPU requirements, invalid field types, unsupported variants and additional unknown platform requirements are rejected. Each embedded index/manifest `mediaType`, if present, must match the referencing descriptor; all child descriptors remain digest/size verified. + +R10: the `rate_limit_hold_completed` event must be request-correlated and recorded after 61.25 seconds have elapsed since the targeted real HTTP 429 response completion. The independent host verifier rejects a completed hold less than 61 seconds after this event. A server write does not by itself prove client receipt: the same first-epoch `global_rate_limit_block` guard audit and persistent SQLite cooldown evidence are independently required. The producer's fixed 100-second scenario deadline remains a bounded fail-closed timeout. + +SBOM generator: the release image policy specifies the digest-pinned docker/buildkit-syft-scanner multi-platform image, with remote digest preflight. The default mutable scanner tag is not used during qualification. The pinned image digest is a historically available Docker Hub index and must be exercised in exact-head CI before claiming a valid generator. CI still computes full BuildKit metadata to verify attestation, but only allowlisted sanitized JSON reports, OS inventory and four local smoke logs are retained as download artifacts; private GitHub event payload and full provenance metadata are not retained. + +The green supporting workflows and full image smoke testing remain necessary but insufficient: standalone OCI attestation verification, final host aggregate, dependency qualification and a fresh review must pass. The publication/licensing/live-game prohibitions are unchanged. diff --git a/scripts/verify-release-oci.py b/scripts/verify-release-oci.py index 5a12aeaf..31001d34 100644 --- a/scripts/verify-release-oci.py +++ b/scripts/verify-release-oci.py @@ -243,7 +243,7 @@ def validate_target_platform(platform: object) -> None: if "os.version" in platform: require(type(platform["os.version"]) is str and platform["os.version"] == "", - "OCI runnable OS version requirement not qualified") + "OCI runnable platform OS version requirement not qualified") for field in ("os.features", "features"): if field in platform: require(type(platform[field]) is list and diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index f1686a57..b8a9ee25 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -399,7 +399,7 @@ def test_nested_index_advertised_platform_must_match_runnable_image(self): if inner else {"index_platform":advertised}) archive, meta, inspection = self.fixture( Path(d), double_nested=inner, **kwargs) - with self.assertRaisesRegex(ValueError, "nested runnable.*platform"): + with self.assertRaisesRegex(ValueError, "OCI runnable platform"): oci.verify_archive(archive, meta, docker_inspect=inspection) From e66330c4453359b9862cb6734361e559440110c2 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Wed, 14 Oct 2026 16:27:52 +0200 Subject: [PATCH 049/147] fix(RELEASE-1): validate SPDX locations and canonical versioned checksums --- scripts/verify-release-oci.py | 85 +++++++++++++++++---- tests/release/test_release_oci_integrity.py | 56 +++++++++++++- 2 files changed, 125 insertions(+), 16 deletions(-) diff --git a/scripts/verify-release-oci.py b/scripts/verify-release-oci.py index 31001d34..d6a58b1f 100644 --- a/scripts/verify-release-oci.py +++ b/scripts/verify-release-oci.py @@ -153,6 +153,70 @@ def validate_slsa(kind: str, predicate: dict) -> None: raise ValueError("unsupported SLSA version") +# Supported canonical SPDX 2.2/2.3 checksum algorithms and output widths. +# MD6 is enumerated by SPDX but its length is parameterized; this release +# profile refuses MD6 rather than accepting an arbitrary-length hex digest. +SPDX_CHECKSUM_HEX_LENGTH = { + "SHA1": 40, "SHA224": 56, "SHA256": 64, "SHA384": 96, + "SHA512": 128, "SHA3-256": 64, "SHA3-384": 96, + "SHA3-512": 128, "BLAKE2b-256": 64, "BLAKE2b-384": 96, + "BLAKE2b-512": 128, "BLAKE3": 64, "MD2": 32, "MD4": 32, + "MD5": 32, "ADLER32": 8, +} + + +def valid_spdx_download_location(value: object) -> bool: + """Supported SPDX Download Location values: special token or URL/VCS URI. + + This is syntax and release-profile validation, NOT reachability, + authenticity or evidence the package bytes came from this URI. + """ + if value in ("NONE", "NOASSERTION"): + return True + if not isinstance(value, str) or not value or value.strip() != value: + return False + if any(ord(c) < 33 or ord(c) == 127 for c in value): + return False + try: + uri = urlsplit(value) + supported = { + "http", "https", "ftp", "ftps", "ssh", "git", "git+https", + "git+http", "git+ssh", "git+git", "svn", "svn+https", + "svn+ssh", "hg", "hg+https", "hg+ssh", + } + if uri.scheme not in supported or not uri.netloc or not uri.hostname: + return False + if uri.password is not None: + return False + # A public Git/VCS SSH URL may use git@host; web download URLs + # may not contain credentials at all. + if uri.username is not None and not ( + uri.scheme.endswith("+ssh") or uri.scheme == "ssh"): + return False + return True + except ValueError: + return False + + +def valid_spdx_checksums(value: object) -> bool: + """Check every listed digest against the supported SPDX algorithm width.""" + if type(value) is not list or not value: + return False + seen = set() + for checksum in value: + if not isinstance(checksum, dict): + return False + algorithm = checksum.get("algorithm") + digest = checksum.get("checksumValue") + width = SPDX_CHECKSUM_HEX_LENGTH.get(algorithm) if isinstance(algorithm, str) else None + if (width is None or algorithm in seen or + type(digest) is not str or len(digest) != width or + re.fullmatch(r"[0-9a-f]+", digest) is None): + return False + seen.add(algorithm) + return True + + def validate_spdx_document(predicate: dict) -> None: """Validate required SPDX 2.x document creation identity and inventory. @@ -206,24 +270,17 @@ def spdx_id(item): spdx_id(item) require(isinstance(item.get("name"), str) and bool(item["name"].strip()) and - isinstance(item.get("downloadLocation"), str) and - bool(item["downloadLocation"].strip()), - "SPDX SBOM package lacks name or downloadLocation") + valid_spdx_download_location(item.get("downloadLocation")), + "SPDX SBOM package has invalid downloadLocation") + if "checksums" in item: + require(valid_spdx_checksums(item["checksums"]), + "SPDX SBOM package contains invalid checksums") for item in files: spdx_id(item) - checksums = item.get("checksums") require(isinstance(item.get("fileName"), str) and bool(item["fileName"].strip()) and - type(checksums) is list and bool(checksums), - "SPDX SBOM file lacks fileName or checksums") - require(all(isinstance(checksum, dict) and - isinstance(checksum.get("algorithm"), str) and - bool(checksum["algorithm"].strip()) and - isinstance(checksum.get("checksumValue"), str) and - re.fullmatch(r"[A-Fa-f0-9]+", - checksum["checksumValue"]) is not None - for checksum in checksums), - "SPDX SBOM file contains malformed checksums") + valid_spdx_checksums(item.get("checksums")), + "SPDX SBOM file contains invalid fileName or checksums") def validate_target_platform(platform: object) -> None: diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index b8a9ee25..10f393e6 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -156,6 +156,23 @@ def descriptor(kind, digest, **extra): body["packages"] = [{"SPDXID":"SPDXRef-Package-X"}] elif spdx_case == "missing-download": del body["packages"][0]["downloadLocation"] + elif spdx_case == "invalid-download": + body["packages"][0]["downloadLocation"] = "garbage" + elif spdx_case == "bad-package-checksum": + body["packages"][0]["checksums"] = [ + {"algorithm":"BOGUS","checksumValue":"0"}] + elif spdx_case == "short-sha256": + body["packages"] = [] + body["files"] = [{"SPDXID":"SPDXRef-File-A", + "fileName":"./opt/example", + "checksums":[{"algorithm":"SHA256", + "checksumValue":"0"}]}] + elif spdx_case == "bogus-algorithm": + body["packages"] = [] + body["files"] = [{"SPDXID":"SPDXRef-File-A", + "fileName":"./opt/example", + "checksums":[{"algorithm":"BOGUS", + "checksumValue":"0"}]}] elif spdx_case == "file-only-valid": body["packages"] = [] body["files"] = [{"SPDXID": "SPDXRef-File-A", @@ -413,6 +430,40 @@ def test_valid_advertised_platform_at_every_nested_index_level(self): oci.verify_archive(archive, meta, docker_inspect=inspection)["integrity_verified"]) + def test_supported_spdx_download_locations(self): + accepted = ("NONE", "NOASSERTION", + "https://example.test/archive.tar.gz", + "ftp://example.test/private.tar.gz", + "git+https://example.test/repo.git@5d2fa", + "git+ssh://git@example.test/repo.git@5d2fa") + refused = ("", "garbage", "noassertion", "none", "example.test/repo", + "https:///missing-host", "https://example.test/a b", + "https://user:password@example.test/x", + "http://user@example.test/x", "javascript:evil()", + "https://[bad-ipv6/x", "NOASSERTION\n") + for location in accepted: + with self.subTest(location=location): + self.assertTrue(oci.valid_spdx_download_location(location)) + for location in refused: + with self.subTest(location=location): + self.assertFalse(oci.valid_spdx_download_location(location)) + + def test_spdx_algorithm_lengths_and_digest_canonicality(self): + for algorithm, width in oci.SPDX_CHECKSUM_HEX_LENGTH.items(): + with self.subTest(algorithm=algorithm): + self.assertTrue(oci.valid_spdx_checksums([ + {"algorithm": algorithm, "checksumValue": "a" * width}])) + for bad in ("a"*(width-1), "A"*width, "g"*width, "a"*(width+1)): + self.assertFalse(oci.valid_spdx_checksums([ + {"algorithm": algorithm, "checksumValue": bad}])) + self.assertFalse(oci.valid_spdx_checksums( + [{"algorithm":"BOGUS","checksumValue":"0"}])) + self.assertFalse(oci.valid_spdx_checksums( + [{"algorithm":"MD6","checksumValue":"a"*64}])) + self.assertFalse(oci.valid_spdx_checksums( + [{"algorithm":"SHA256","checksumValue":"a"*64}]*2)) + self.assertFalse(oci.valid_spdx_checksums([])) + def test_spdx_file_only_inventory_uses_fileName_not_package_name(self): with tempfile.TemporaryDirectory() as d: archive, meta, inspect = self.fixture(Path(d), spdx_case="file-only-valid") @@ -441,8 +492,9 @@ def test_incomplete_spdx_sbom_never_qualifies(self): cases = ("missing-license", "wrong-document-id", "missing-name", "bad-namespace", "missing-created", "invalid-created", "missing-creators", "bad-creator", "empty-inventory", - "bad-package", "missing-download", "missing-file-checksum", - "bad-file-checksum", "duplicate-id", "namespace-fragment", + "bad-package", "missing-download", "invalid-download", + "bad-package-checksum", "short-sha256", "bogus-algorithm", + "missing-file-checksum", "bad-file-checksum", "duplicate-id", "namespace-fragment", "namespace-trailing-fragment") for variant in cases: with self.subTest(spdx=variant), tempfile.TemporaryDirectory() as d: From f5aa02eeadc18ae8786cdb1ffed40345bf1800a5 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Wed, 14 Oct 2026 16:29:11 +0200 Subject: [PATCH 050/147] fix(RELEASE-1): bind OCI config platform requirements to selected runtime --- scripts/verify-release-oci.py | 19 +++++++--- tests/release/test_release_contracts.py | 1 + tests/release/test_release_oci_integrity.py | 39 +++++++++++++++++++-- 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/scripts/verify-release-oci.py b/scripts/verify-release-oci.py index d6a58b1f..447a5a52 100644 --- a/scripts/verify-release-oci.py +++ b/scripts/verify-release-oci.py @@ -53,8 +53,10 @@ def verify_image_config_link(*, image_config_digest: str, require(metadata_config_digest == image_config_digest, "BuildKit config metadata contradicts OCI archive image") if docker_inspection is not None: - require(docker_inspection.get("Id") == image_config_digest and - docker_inspection.get("Architecture") == "amd64", + require(isinstance(docker_inspection, dict) and + docker_inspection.get("Id") == image_config_digest and + docker_inspection.get("Architecture") == "amd64" and + docker_inspection.get("Os", "linux") == "linux", "tested Docker image differs from exported OCI config") require(metadata_config_digest is not None or docker_inspection is not None, "OCI config identity has neither BuildKit metadata nor image inspection") @@ -555,9 +557,15 @@ def inspect_descriptor(descriptor, depth=0, ancestors=frozenset()): "nested OCI application descriptor lacks platform") else: validate_target_platform(advertised) - require(details.get("architecture") == "amd64" and - details.get("os") == "linux", - "unexpected non-attestation OCI application manifest") + # Only OCI configuration platform fields participate in + # platform selection. Legitimate config/rootfs/history data + # must never be mistaken for descriptor platform extras. + image_platform = { + field: details[field] for field in + ("os", "architecture", "variant", "os.version", "os.features") + if field in details + } + validate_target_platform(image_platform) images.append({"manifest_digest": oid, "manifest_media_type": typ, "manifest_size": descriptor["size"], @@ -597,6 +605,7 @@ def inspect_descriptor(descriptor, depth=0, ancestors=frozenset()): require(reported_manifest == root_descriptor["digest"], "BuildKit root descriptor changed during traversal") config=image["config"].get("config") or {} + require(isinstance(config, dict), "malformed runnable OCI configuration") require(config.get("User") in ("10001:10001", "10001") and config.get("Entrypoint")==[ "/opt/online3b/bin/python", diff --git a/tests/release/test_release_contracts.py b/tests/release/test_release_contracts.py index 6e16372d..fd71f6b0 100644 --- a/tests/release/test_release_contracts.py +++ b/tests/release/test_release_contracts.py @@ -158,6 +158,7 @@ def test_oci_index_metadata_without_config_uses_exact_loaded_image(self): ("sha256:"+"b"*64, observed), (None, {"Id":"sha256:"+"b"*64,"Architecture":"amd64"}), (None, {"Id":digest,"Architecture":"arm64"}), + (None, {"Id":digest,"Architecture":"amd64","Os":"windows"}), ): with self.subTest(metadata=metadata, inspected=inspection), self.assertRaises(ValueError): oci.verify_image_config_link( diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index 10f393e6..b012193c 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -34,7 +34,8 @@ def fixture(self, target: Path, *, replace_layer=False, bad_subject=False, bad_metadata_descriptor=False, application_platform="linux/amd64", index_platform="absent", inner_index_platform="absent", - double_nested=False, embedded_media="absent"): + double_nested=False, embedded_media="absent", + config_platform="valid"): base = b"original uncompressed OCI application layer\n" expanded = (b"substituted uncompressed OCI application layer\n" if replace_layer else base) @@ -53,13 +54,28 @@ def descriptor(kind, digest, **extra): oci_index = "application/vnd.oci.image.index.v1+json" tar_layer = store(gzip.compress(expanded, mtime=0)) diff_id = sha(b"mismatched expected diff" if corrupt_rootfs else base) - config = store(encode({ + cfg = { "architecture": "amd64", "os": "linux", "rootfs": {"type": "layers", "diff_ids": [diff_id]}, "config": {"User": "10001:10001", "Entrypoint": ["/opt/online3b/bin/python", "/opt/release-tools/deploy/release/entrypoint.py"]} - })) + } + config_changes = { + "valid": {}, + "variant-v3": {"variant": "v3"}, + "valid-baseline": {"variant": "v1", "os.features": [], + "os.version": ""}, + "os-features-text": {"os.features": "avx512"}, + "os-features-extra": {"os.features": ["avx512"]}, + "os-version-extra": {"os.version": "2026"}, + "wrong-os": {"os": "windows"}, + "wrong-arch": {"architecture": "arm64"}, + } + if config_platform not in config_changes: + raise ValueError("unsupported image configuration fixture variant") + cfg.update(config_changes[config_platform]) + config = store(encode(cfg)) cfg_desc = descriptor("application/vnd.oci.image.config.v1+json", config) layer_desc = descriptor("application/vnd.oci.image.layer.v1.tar+gzip", tar_layer) @@ -381,6 +397,23 @@ def test_nested_document_embedded_media_type_matches_descriptor(self): self.assertTrue(oci.verify_archive( archive, meta, docker_inspect=inspection)["integrity_verified"]) + def test_actual_image_config_platform_is_independently_qualified(self): + invalid = ("variant-v3", "os-features-text", "os-features-extra", + "os-version-extra", "wrong-os", "wrong-arch") + for variant in invalid: + with self.subTest(config=variant), tempfile.TemporaryDirectory() as d: + archive, meta, inspected = self.fixture( + Path(d), config_platform=variant) + # Fixture rehashes config -> manifest -> nested index -> + # BuildKit metadata, and inspected image ID, by construction. + with self.assertRaisesRegex(ValueError, "OCI runnable platform"): + oci.verify_archive(archive, meta, docker_inspect=inspected) + with tempfile.TemporaryDirectory() as d: + archive, meta, inspected = self.fixture( + Path(d), config_platform="valid-baseline") + self.assertTrue(oci.verify_archive( + archive, meta, docker_inspect=inspected)["integrity_verified"]) + def test_runtime_platform_optional_fields_fail_closed(self): invalid = ("variant-v3", "os-features-text", "os-features-extra", "features-extra", "os-version-extra", "unknown-extra") From dbae0858ff2cef6d6fb5aa51850a6fbaf533210a Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Wed, 14 Oct 2026 16:33:51 +0200 Subject: [PATCH 051/147] fix(RELEASE-1): bound signed pinned-snapshot APT acquisition and falsifiers --- deploy/release/Dockerfile | 14 +--- deploy/release/Dockerfile.dockerignore | 1 + deploy/release/install-pinned-apt.sh | 56 ++++++++++++++ scripts/build-release-candidate.py | 1 + tests/release/test_release_contracts.py | 12 ++- tests/release/test_release_snapshot_retry.py | 80 ++++++++++++++++++++ 6 files changed, 151 insertions(+), 13 deletions(-) create mode 100644 deploy/release/install-pinned-apt.sh create mode 100644 tests/release/test_release_snapshot_retry.py diff --git a/deploy/release/Dockerfile b/deploy/release/Dockerfile index 3eedb3df..1fd2926f 100644 --- a/deploy/release/Dockerfile +++ b/deploy/release/Dockerfile @@ -27,16 +27,10 @@ RUN set -eu; \ # Once CA trust has been established, ALL remaining APT inputs use the # fixed signed HTTPS Ubuntu snapshot; no bootstrap source survives. COPY deploy/release/ubuntu.sources /etc/apt/sources.list.d/ubuntu.sources -# A transient 503 on the *same pinned, signed* Ubuntu snapshot may be retried -# within the existing build deadline. No mirror substitution or unlocked -# repository fallback is permitted; persistent snapshot errors fail the build. -RUN apt-get -o Acquire::Retries=2 -o Acquire::https::Timeout=30 \ - update -o APT::Update::Error-Mode=any && \ - apt-get -o Acquire::Retries=2 -o Acquire::https::Timeout=30 \ - install -y --no-install-recommends \ - python3 python3-venv python3-pip ca-certificates procps \ - libopenblas0-pthread libprotobuf32t64 libstdc++6 libgomp1 zlib1g && \ - rm -rf /var/lib/apt/lists/* +# Only the pinned signed snapshot may be queried; no floating fallback. +COPY deploy/release/install-pinned-apt.sh /usr/local/bin/install-pinned-apt +RUN /bin/sh /usr/local/bin/install-pinned-apt && \ + rm /usr/local/bin/install-pinned-apt COPY build/release1-stage/allfather/ /opt/allfather/ COPY build/release1-stage/bridge/ /opt/bridge/ diff --git a/deploy/release/Dockerfile.dockerignore b/deploy/release/Dockerfile.dockerignore index 46d98674..5a07e14d 100644 --- a/deploy/release/Dockerfile.dockerignore +++ b/deploy/release/Dockerfile.dockerignore @@ -7,3 +7,4 @@ !build/ !build/release1-stage/ !build/release1-stage/** +!deploy/release/install-pinned-apt.sh diff --git a/deploy/release/install-pinned-apt.sh b/deploy/release/install-pinned-apt.sh new file mode 100644 index 00000000..20d40d82 --- /dev/null +++ b/deploy/release/install-pinned-apt.sh @@ -0,0 +1,56 @@ +#!/bin/sh +# APT acquisition from the immutable, GPG-signed Ubuntu snapshot configured +# in ubuntu.sources. Only transient 429/502/503/504 responses may be retried. +# All signature, hash and certificate errors fail immediately. +set -eu +APT_LISTS_DIR="${APT_LISTS_DIR:-/var/lib/apt/lists}" +APT_RETRY_BACKOFF_SECONDS="${APT_RETRY_BACKOFF_SECONDS:-4}" +APT_LOG="${APT_LOG:-/tmp/allfather-release-apt-$$.log}" +trap 'rm -f "$APT_LOG"' 0 HUP INT TERM + +retryable() { + grep -Eq '(^|[[:space:]])(429|502|503|504)([[:space:]]|$)|Service Unavailable|Too Many Requests|Bad Gateway|Gateway Timeout|Gateway Time-out' "$APT_LOG" +} + +integrity_error() { + grep -Eiq 'NO_PUBKEY|BADSIG|EXPKEYSIG|REVKEYSIG|Hash Sum mismatch|certificate (verification|verify)|signatures could not|not signed|does not have a Release file|clearsigned file.*invalid|File has unexpected size|401 Unauthorized|403 Forbidden|404 Not Found' "$APT_LOG" +} + +apt_attempt() { + phase="$1" + shift + attempt=1 + while [ "$attempt" -le 4 ]; do + if [ "$phase" = "update" ]; then + # Every failure destroys partial/stale lists before retrying. + rm -rf "$APT_LISTS_DIR"/* + mkdir -p "$APT_LISTS_DIR/partial" + fi + if apt-get \ + -o "Dir::State::lists=$APT_LISTS_DIR" \ + -o Acquire::Retries=1 \ + -o Acquire::https::Timeout=25 \ + "$phase" "$@" > "$APT_LOG" 2>&1; then + echo "Pinned signed APT $phase completed on attempt $attempt" + return 0 + fi + cat "$APT_LOG" >&2 + if integrity_error || ! retryable; then + echo "Pinned signed APT $phase integrity/nontransient failure" >&2 + return 100 + fi + if [ "$attempt" -eq 4 ]; then + echo "Pinned signed APT $phase exhausted four bounded attempts" >&2 + return 100 + fi + sleep $((attempt * APT_RETRY_BACKOFF_SECONDS)) + attempt=$((attempt + 1)) + done + return 100 +} + +apt_attempt update -o APT::Update::Error-Mode=any +apt_attempt install -y --no-install-recommends \ + python3 python3-venv python3-pip ca-certificates procps \ + libopenblas0-pthread libprotobuf32t64 libstdc++6 libgomp1 zlib1g +rm -rf "$APT_LISTS_DIR"/* diff --git a/scripts/build-release-candidate.py b/scripts/build-release-candidate.py index 66374dad..da78b281 100644 --- a/scripts/build-release-candidate.py +++ b/scripts/build-release-candidate.py @@ -44,6 +44,7 @@ SOURCE_FILES = (POLICY, BRIDGE_LOCK, "qualification/release-qualification-v1.json", "qualification/license-compliance-v1.json", "deploy/release/ubuntu.sources", "deploy/release/bootstrap.sources", + "deploy/release/install-pinned-apt.sh", "deploy/release/Dockerfile", "deploy/release/Dockerfile.dockerignore", "scripts/build-release-candidate.py", diff --git a/tests/release/test_release_contracts.py b/tests/release/test_release_contracts.py index fd71f6b0..5ba068b0 100644 --- a/tests/release/test_release_contracts.py +++ b/tests/release/test_release_contracts.py @@ -105,9 +105,14 @@ def test_pinned_base_and_apt_snapshot_are_not_placeholders(self): def test_pinned_snapshot_retries_are_bounded_without_mirror_fallback(self): docker=(ROOT/"deploy/release/Dockerfile").read_text() - self.assertEqual(docker.count("Acquire::Retries=2"),2) - self.assertEqual(docker.count("Acquire::https::Timeout=30"),2) - self.assertIn("apt-get",docker) + helper=(ROOT/"deploy/release/install-pinned-apt.sh").read_text() + self.assertIn('while [ "$attempt" -le 4 ]',helper) + self.assertIn('Acquire::Retries=1',helper) + self.assertIn('Acquire::https::Timeout=25',helper) + self.assertIn("apt-get",helper) + self.assertIn('integrity_error || ! retryable',helper) + self.assertIn("COPY deploy/release/install-pinned-apt.sh",docker) + self.assertIn("/bin/sh /usr/local/bin/install-pinned-apt",docker) self.assertIn("COPY deploy/release/ubuntu.sources",docker) instructions = "\n".join( line for line in docker.splitlines() @@ -116,6 +121,7 @@ def test_pinned_snapshot_retries_are_bounded_without_mirror_fallback(self): "Acquire::https::Verify-Peer=false", "Acquire::https::Verify-Host=false"): self.assertNotIn(unsafe,instructions) + self.assertNotIn(unsafe,helper) def test_sbom_generator_is_reviewed_immutable_oci_index(self): image=json.loads((ROOT/"qualification/release-image-v1.json").read_text()) diff --git a/tests/release/test_release_snapshot_retry.py b/tests/release/test_release_snapshot_retry.py new file mode 100644 index 00000000..e55bdfe6 --- /dev/null +++ b/tests/release/test_release_snapshot_retry.py @@ -0,0 +1,80 @@ +"""Simulated APT 503 and signature failures without touching host APT.""" +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT=Path(__file__).resolve().parents[2] +SCRIPT=ROOT/"deploy/release/install-pinned-apt.sh" + +FAKE_APT='''#!/bin/sh +case " $* " in + *" update "*) phase=update ;; + *" install "*) phase=install ;; + *) echo "unexpected fake apt arguments" >&2; exit 99 ;; +esac +file="$APT_TEST_COUNTER/$phase" +count=0 +[ ! -f "$file" ] || count=$(cat "$file") +count=$((count+1)) +printf "%s" "$count" > "$file" +case "$APT_TEST_MODE" in + recover) + if [ "$phase" = update ] && [ "$count" -le 2 ]; then + echo "E: Failed to fetch InRelease 503 Service Unavailable" >&2 + exit 100 + fi ;; + always503) echo "E: InRelease 503 Service Unavailable" >&2; exit 100 ;; + badsignature) + echo "E: BADSIG GPG signature failure" >&2 + echo "E: InRelease 503 Service Unavailable" >&2 + exit 100 ;; + badhash) echo "E: Hash Sum mismatch" >&2; exit 100 ;; + missing) echo "E: 404 Not Found" >&2; exit 100 ;; +esac +exit 0 +''' + +class PinnedSnapshotRetryTests(unittest.TestCase): + def execute(self, mode): + with tempfile.TemporaryDirectory() as td: + d=Path(td) + (d/"bin").mkdir() + apt=d/"bin/apt-get" + apt.write_text(FAKE_APT) + apt.chmod(0o755) + (d/"lists").mkdir() + stale=d/"lists/old-index" + stale.write_text("not admissible") + env={**os.environ,"PATH":str(d/"bin")+":"+os.environ["PATH"], + "APT_TEST_COUNTER":str(d),"APT_TEST_MODE":mode, + "APT_LISTS_DIR":str(d/"lists"),"APT_RETRY_BACKOFF_SECONDS":"0", + "APT_LOG":str(d/"trace.log")} + outcome=subprocess.run(["/bin/sh",str(SCRIPT)],env=env, + capture_output=True,text=True,timeout=15) + counts={k:int((d/k).read_text()) if (d/k).exists() else 0 + for k in ("update","install")} + self.assertFalse(stale.exists()) + self.assertFalse((d/"trace.log").exists()) + return outcome,counts + + def test_transient_outage_then_signed_success(self): + result,counts=self.execute("recover") + self.assertEqual(result.returncode,0,result.stderr) + self.assertEqual(counts,{"update":3,"install":1}) + + def test_persistent_503_is_bounded_and_blocks_install(self): + result,counts=self.execute("always503") + self.assertNotEqual(result.returncode,0) + self.assertEqual(counts,{"update":4,"install":0}) + + def test_signature_hash_and_missing_source_fail_without_retries(self): + for mode in ("badsignature","badhash","missing"): + with self.subTest(mode=mode): + result,counts=self.execute(mode) + self.assertNotEqual(result.returncode,0) + self.assertEqual(counts,{"update":1,"install":0}) + +if __name__=="__main__": + unittest.main() From 0b4858e1def5246d6b603dc0ea7b8963eed83c88 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Wed, 14 Oct 2026 16:35:34 +0200 Subject: [PATCH 052/147] test(RELEASE-1): preflight exact Ubuntu snapshot before costly builds --- .github/workflows/release-candidate.yml | 8 ++ scripts/build-release-candidate.py | 3 +- scripts/check-release-snapshot.py | 79 +++++++++++++++++++ .../test_release_snapshot_preflight.py | 67 ++++++++++++++++ tests/release/test_workflow_coverage.py | 3 + 5 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 scripts/check-release-snapshot.py create mode 100644 tests/release/test_release_snapshot_preflight.py diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 8ba9787b..84323d9b 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -65,6 +65,13 @@ jobs: assert bootstrap.count('Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg') == 2 assert 'trusted=yes' not in bootstrap and 'allow-unauthenticated' not in bootstrap PY + - name: Early signed-snapshot availability preflight + timeout-minutes: 3 + run: | + python3 scripts/check-release-snapshot.py \ + --policy qualification/release-image-v1.json \ + --sources deploy/release/ubuntu.sources \ + --output build/test-results/release1/snapshot-availability.json - name: Install exact engine and wheel preparation toolchain run: | bash scripts/ci-install-apt-deps.sh build-essential meson ninja-build pkg-config libprotobuf-dev protobuf-compiler zlib1g-dev libopenblas-dev python3-pip python3-venv @@ -242,6 +249,7 @@ jobs: build/test-results/release1/stage-report.json build/test-results/release1/verify-output.json build/test-results/release1/snapshot-acquisition-failure.json + build/test-results/release1/snapshot-availability.json build/test-results/release1/smoke-verdict.json build/test-results/release1/oci-attestation-structure.json build/test-results/release1/oci-report.json diff --git a/scripts/build-release-candidate.py b/scripts/build-release-candidate.py index da78b281..c0b1e57d 100644 --- a/scripts/build-release-candidate.py +++ b/scripts/build-release-candidate.py @@ -49,7 +49,8 @@ "deploy/release/Dockerfile.dockerignore", "scripts/build-release-candidate.py", "scripts/qualify-release-smoke.py", - "scripts/inspect-release-oci.py", *TOOL_FILES) + "scripts/inspect-release-oci.py", + "scripts/check-release-snapshot.py", *TOOL_FILES) def fail(condition: bool, reason: str): diff --git a/scripts/check-release-snapshot.py b/scripts/check-release-snapshot.py new file mode 100644 index 00000000..f87e93ec --- /dev/null +++ b/scripts/check-release-snapshot.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Early availability-only check; Docker APT still enforces signed archives.""" +from __future__ import annotations +import argparse +import json +import re +import time +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.request import urlopen + +SUITES = ("noble", "noble-updates", "noble-backports", "noble-security") +RETRY_HTTP = (429, 502, 503, 504) + + +def require(cond, reason): + if not cond: + raise ValueError(reason) + + +def probe(snapshot: str, *, opener=urlopen, sleeper=time.sleep) -> dict: + require(re.fullmatch(r"\d{8}T\d{6}Z", snapshot) is not None, + "invalid pinned Ubuntu snapshot identity") + results = {} + for suite in SUITES: + url = (f"https://snapshot.ubuntu.com/ubuntu/{snapshot}/dists/" + f"{suite}/InRelease") + for attempt in range(1, 4): + try: + with opener(url, timeout=10) as response: + response.read(512) + results[suite] = {"reachable": True, "attempts": attempt} + break + except HTTPError as exc: + if exc.code not in RETRY_HTTP or attempt == 3: + raise ValueError( + f"pinned snapshot {suite} unavailable HTTP {exc.code}") from exc + except URLError as exc: + if attempt == 3: + raise ValueError( + f"pinned snapshot {suite} unavailable network") from exc + sleeper(attempt * 3) + return {"schema_version": 1, "pinned_snapshot": snapshot, + "availability_only": True, "signature_verified": False, + "suites": results, "public_bot_release": False} + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--policy", type=Path, required=True) + ap.add_argument("--sources", type=Path, required=True) + ap.add_argument("--output", type=Path, required=True) + args = ap.parse_args() + policy = json.loads(args.policy.read_text()) + snapshot = policy.get("apt_snapshot") + sources = args.sources.read_text() + require(isinstance(snapshot, str) and + sources.count("Snapshot: " + snapshot) == 2 and + "Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" in sources, + "unpinned or unsigned Ubuntu snapshot policy") + report = {"schema_version": 1, "availability_only": True, + "signature_verified": False, + "pinned_snapshot": snapshot, "public_bot_release": False} + try: + report.update(probe(snapshot)) + code = 0 + except ValueError as exc: + # Fixed, bounded error classification; no user-controlled URLs/headers. + report["error"] = str(exc) + report["unavailable"] = True + code = 2 + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(report,sort_keys=True,indent=2)+"\n") + print(json.dumps(report,sort_keys=True)) + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/release/test_release_snapshot_preflight.py b/tests/release/test_release_snapshot_preflight.py new file mode 100644 index 00000000..f4fe7430 --- /dev/null +++ b/tests/release/test_release_snapshot_preflight.py @@ -0,0 +1,67 @@ +"""Early signed snapshot diagnostic; no APT, network or engine builds.""" +from __future__ import annotations +import importlib.util +import io +import tempfile +import unittest +from pathlib import Path +from urllib.error import HTTPError + +ROOT=Path(__file__).resolve().parents[2] +spec=importlib.util.spec_from_file_location("release1_snapshot_check", + ROOT/"scripts/check-release-snapshot.py") +checker=importlib.util.module_from_spec(spec) +spec.loader.exec_module(checker) + + +class DummyResponse: + def __enter__(self): + return self + def __exit__(self,*args): + pass + def read(self,n): + return b"signed index is independently verified by Docker APT"[:n] + + +class SnapshotAvailabilityTests(unittest.TestCase): + def test_transient_503_retries_only_same_frozen_suite(self): + attempts={} + def opener(url,timeout): + attempts[url]=attempts.get(url,0)+1 + if attempts[url]==1: + raise HTTPError(url,503,"temporary upstream outage",{},None) + return DummyResponse() + result=checker.probe("20261001T000000Z",opener=opener, + sleeper=lambda seconds:None) + self.assertEqual(set(result["suites"]),set(checker.SUITES)) + self.assertEqual(set(attempts.values()),{2}) + self.assertFalse(result["signature_verified"]) + self.assertTrue(all("20261001T000000Z" in url for url in attempts)) + + def test_nontransient_status_is_immediately_refused(self): + requests=[] + def opener(url,timeout): + requests.append(url) + raise HTTPError(url,404,"not found",{},None) + with self.assertRaisesRegex(ValueError,"HTTP 404"): + checker.probe("20261001T000000Z",opener=opener, + sleeper=lambda seconds:None) + self.assertEqual(len(requests),1) + + def test_persistent_503_is_bounded(self): + requests=[] + def opener(url,timeout): + requests.append(url) + raise HTTPError(url,503,"temporary outage",{},None) + with self.assertRaisesRegex(ValueError,"HTTP 503"): + checker.probe("20261001T000000Z",opener=opener, + sleeper=lambda seconds:None) + self.assertEqual(len(requests),3) + + def test_injected_snapshot_identity_rejected_before_network(self): + with self.assertRaisesRegex(ValueError,"invalid pinned"): + checker.probe("20261001T000000Z/../latest", + opener=lambda *args,**kw: self.fail("network was called")) + +if __name__=="__main__": + unittest.main() diff --git a/tests/release/test_workflow_coverage.py b/tests/release/test_workflow_coverage.py index 1dba50e7..64846539 100644 --- a/tests/release/test_workflow_coverage.py +++ b/tests/release/test_workflow_coverage.py @@ -60,6 +60,9 @@ def test_offline_oci_export_is_named_and_diagnostics_precede_admission(self): content.index("- name: Export standalone OCI archive")) self.assertIn("--oci-archive build/release1-offline.oci.tar",content) self.assertIn("snapshot-acquisition-failure.json",content) + self.assertIn("snapshot-availability.json", content) + self.assertLess(content.index("scripts/check-release-snapshot.py"), + content.index("- name: Install exact engine")) self.assertIn('exit 1',content.split("snapshot-acquisition-failure.json",1)[1]) self.assertIn("candidate_qualified",content) self.assertIn("build/test-results/release1/smoke-verdict.json",content) From ac877c7ccf6bad504c17871d065f3f58dd346e2d Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Wed, 14 Oct 2026 16:38:44 +0200 Subject: [PATCH 053/147] fix(RELEASE-1): require durable SQLite and precise downstream HTTP/OCI evidence --- scripts/qualify-release-aggregate.py | 53 ++++++++++++++++++------ tests/release/test_release_aggregate.py | 55 ++++++++++++++++++++++++- 2 files changed, 94 insertions(+), 14 deletions(-) diff --git a/scripts/qualify-release-aggregate.py b/scripts/qualify-release-aggregate.py index bdb227e5..85ded59f 100644 --- a/scripts/qualify-release-aggregate.py +++ b/scripts/qualify-release-aggregate.py @@ -57,6 +57,33 @@ def check_smoke_evidence(path:Path, key:str) -> dict: return mod.verify_file(path,key) +def allowed_release_http(row: object, case: str) -> bool: + """Defense in depth after the independent chess/request correlation check.""" + if (type(row) is not dict or + row.get("method") not in ("GET", "POST") or + type(row.get("status")) is not int or + not isinstance(row.get("path"), str) or + row["status"] == 403): + return False + if row["method"] == "GET": + return row.get("request_id") is None + path = row["path"] + approved = {"/api/token/test", "/api/challenge/off3b001/accept", + "/api/challenge/off3b001/decline"} + if path in approved: + return row["status"] == 200 and row.get("request_id") is None + prefix = "/api/bot/game/off3b001/move/" + if not path.startswith(prefix): + return False + move = path[len(prefix):] + # Position legality and uniqueness are verified by verify_file; this + # layer enforces only status, endpoint and request-ID compatibility. + return (move not in ("", "0000") and + isinstance(row.get("request_id"), str) and + bool(row["request_id"]) and + row["status"] in ((200, 429) if case == "r10-white" else (200,))) + + def qualify(*, stage:Path, oci:Path, oci_archive:Path, host:Path, smoke_dir:Path, os_inventory:Path, wheel_inventory:Path, source_sha:str, root:Path=ROOT): source= subprocess.check_output(["git","-C",str(root),"rev-parse","HEAD"], @@ -92,6 +119,11 @@ def qualify(*, stage:Path, oci:Path, oci_archive:Path, host:Path, smoke_dir:Path "invalid OCI "+key) require(is_sha256_hex(oci_report.get("oci_archive_sha256")), "invalid OCI archive SHA-256 digest") + require(is_sha256_hex(oci_report.get("oci_exported_root_digest"), + prefixed=True) and + oci_report.get("buildkit_digest") == + oci_report.get("oci_exported_root_digest"), + "OCI BuildKit digest differs from selected exported root") require(oci_archive.is_file() and not oci_archive.is_symlink() and digest(oci_archive) == oci_report["oci_archive_sha256"], "OCI archive identity does not match independently verified report") @@ -111,10 +143,15 @@ def qualify(*, stage:Path, oci:Path, oci_archive:Path, host:Path, smoke_dir:Path require(target.is_file(), "release-image smoke scenario missing: "+key) result=load(target) independent=check_smoke_evidence(target,key) + scenario,color=key.split("-",1) require(independent.get("independent_qualified") is True and + independent.get("persisted_sqlite_verified") is True and + independent.get("release_blocked") is True and independent.get("run_id") == key and + independent.get("scenario") == scenario.upper() and + independent.get("color") == color and independent.get("public_bot_release") is False, - "independent chess/HTTP evidence failed: "+key) + "independent chess/HTTP/SQLite evidence failed: "+key) require(result.get("offline") is True and result.get("ready") is True and result.get("runner_errors")==[] and @@ -126,18 +163,8 @@ def qualify(*, stage:Path, oci:Path, oci_archive:Path, host:Path, smoke_dir:Path requests=result.get("http") require(isinstance(requests,list), "release-image complete HTTP audit missing: "+key) - allow={"/api/token/test","/api/challenge/off3b001/accept", - "/api/challenge/off3b001/decline"} - prefix="/api/bot/game/off3b001/move/" - require(all(isinstance(r,dict) and - (r.get("method")!="POST" or - (r.get("path") in allow or - (isinstance(r.get("path"),str) - and r["path"].startswith(prefix) - and r["path"][len(prefix):] not in ("","0000"))) - and r.get("status")!=403) - for r in requests), - "release image HTTP contains prohibited POST: "+key) + require(all(allowed_release_http(r,key) for r in requests), + "release image HTTP contains prohibited request: "+key) if key=="r10-white": require(result.get("rate_cooldown_verified") is True, "release-image 429 cooldown witness absent") diff --git a/tests/release/test_release_aggregate.py b/tests/release/test_release_aggregate.py index 8fb55b54..b22d5347 100644 --- a/tests/release/test_release_aggregate.py +++ b/tests/release/test_release_aggregate.py @@ -65,6 +65,8 @@ def setUp(self): "oci_image_manifest_digest": "sha256:" + "c" * 64, "oci_config_digest": "sha256:" + "d" * 64, "oci_archive_sha256": aggregate.digest(self.oci_archive), + "oci_exported_root_digest": "sha256:" + "e"*64, + "buildkit_digest": "sha256:" + "e"*64, }) write_json(self.host, { "test_host_observed": True, @@ -110,7 +112,12 @@ def qualify(self, **overrides): with mock.patch.object(aggregate.subprocess, "check_output", side_effect=self.git), \ mock.patch.object(aggregate, "check_smoke_evidence", side_effect=lambda path,key: {"independent_qualified":True, - "run_id":key,"public_bot_release":False}): + "persisted_sqlite_verified":True, + "release_blocked":True, + "run_id":key, + "scenario":key.split("-")[0].upper(), + "color":key.split("-")[1], + "public_bot_release":False}): return aggregate.qualify(**arguments) def test_sha1_git_vs_sha256_artifacts(self): @@ -128,6 +135,52 @@ def test_sha1_git_vs_sha256_artifacts(self): with self.subTest(digest=bad, prefixed=prefixed): self.assertFalse(aggregate.is_sha256_hex(bad, prefixed=prefixed)) + def test_http_admission_is_consistent_with_independent_verifier(self): + token={"method":"POST","path":"/api/token/test","status":200} + move={"method":"POST", + "path":"/api/bot/game/off3b001/move/e2e4", + "status":200,"request_id":"request-1"} + self.assertTrue(aggregate.allowed_release_http(token,"r00-white")) + self.assertTrue(aggregate.allowed_release_http(move,"r00-white")) + for bad in ({**move,"status":400}, + {**move,"status":429}, + {**move,"request_id":None}, + {**move,"path":"/api/bot/game/off3b001/abort"}, + {**token,"status":500}, + {**token,"status":True}): + self.assertFalse(aggregate.allowed_release_http(bad,"r00-white")) + self.assertTrue(aggregate.allowed_release_http( + {**move,"status":429},"r10-white")) + + def test_aggregate_requires_persisted_sqlite_qualified_and_specific_case(self): + for mutation in ("without-sqlite","wrong-case","wrong-disposition"): + def fake(path,key): + report={"independent_qualified":True, + "persisted_sqlite_verified":True, + "release_blocked":True, + "run_id":key,"scenario":key.split("-")[0].upper(), + "color":key.split("-")[1],"public_bot_release":False} + if key=="r10-white": + if mutation=="without-sqlite": + report["persisted_sqlite_verified"]=False + elif mutation=="wrong-case": + report["scenario"]="R00" + else: + report["release_blocked"]=False + return report + with self.subTest(mutation=mutation), \ + mock.patch.object(aggregate.subprocess,"check_output", + side_effect=self.git), \ + mock.patch.object(aggregate,"check_smoke_evidence", + side_effect=fake): + with self.assertRaisesRegex(ValueError,"chess/HTTP/SQLite"): + aggregate.qualify( + stage=self.stage,oci=self.oci, + oci_archive=self.oci_archive,host=self.host, + smoke_dir=self.smoke_dir,os_inventory=self.os_inventory, + wheel_inventory=self.wheel_inventory, + source_sha=self.source,root=self.root) + def test_valid_synthetic_aggregate_cannot_promote_public_release(self): report = self.qualify() self.assertEqual(report["source_commit"], self.source) From 900c87690e30edfbad54da0c9f776e5d961f0829 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Wed, 14 Oct 2026 16:41:38 +0200 Subject: [PATCH 054/147] fix(RELEASE-1): sanitize OCI diagnostic subjects and annotations --- scripts/inspect-release-oci.py | 123 ++++++++++++++++---- tests/release/test_release_oci_integrity.py | 14 +++ 2 files changed, 115 insertions(+), 22 deletions(-) diff --git a/scripts/inspect-release-oci.py b/scripts/inspect-release-oci.py index fff9021a..339359c6 100644 --- a/scripts/inspect-release-oci.py +++ b/scripts/inspect-release-oci.py @@ -13,6 +13,50 @@ from pathlib import Path MAX_JSON = 8 * 1024 * 1024 +SAFE_ANNOTATION_KEYS = {"vnd.docker.reference.type", "vnd.docker.reference.digest"} +SAFE_SPDX_KEYS = { + "SPDXID", "spdxVersion", "name", "downloadLocation", "fileName", + "checksums", "filesAnalyzed", "licenseConcluded", "licenseDeclared", + "licenseInfoFromFiles", "licenseInfoInFiles", "copyrightText", + "versionInfo", "externalRefs", "primaryPackagePurpose", + "sourceInfo", "supplier", "originator", "verificationCode", + "packageVerificationCode", "fileTypes", "description", +} + + +def digest_only(value): + if (isinstance(value, str) and len(value) == 71 and + value.startswith("sha256:") and + all(ch in "0123456789abcdef" for ch in value[7:])): + return value + return None + + +def approved_annotations(value): + if type(value) is not dict: + return {} + result = {} + for key in SAFE_ANNOTATION_KEYS: + observed = value.get(key) + if key.endswith(".digest"): + verified = digest_only(observed) + if verified: + result[key] = verified + elif observed == "attestation-manifest": + result[key] = observed + return result + + +def approved_platform(value): + if type(value) is not dict: + return None + return { + "os": value.get("os") if value.get("os") in ("linux", "unknown") else "other", + "architecture": value.get("architecture") + if value.get("architecture") in ("amd64", "unknown") else "other", + "additional_field_count": max(len(value)-2, 0), + } + def inspect(archive: Path, metadata: Path) -> dict: @@ -20,8 +64,7 @@ def inspect(archive: Path, metadata: Path) -> dict: result = {"schema_version": 1, "diagnostic_only": True, "integrity_verified": False, "public_bot_release": False} build = json.loads(metadata.read_text(encoding="utf-8")) - result["buildkit_image_name"] = build.get("image.name") - result["buildkit_digest"] = build.get("containerimage.digest") + result["buildkit_digest"] = digest_only(build.get("containerimage.digest")) with tarfile.open(archive, "r:*") as tar: members = {} for member in tar.getmembers(): @@ -62,11 +105,14 @@ def bounded_descriptor(desc, ancestry): actual = members.get(path) if actual is None or not actual.isfile(): raise ValueError("missing OCI referenced blob") - return {"digest": oid, "media_type": typ, + return {"digest": oid, + "media_type": typ if isinstance(typ, str) and + typ.startswith("application/vnd.") and len(typ)<100 + else "unrecognized", "declared_size": size, "actual_size": actual.size, "descriptor_path": list(ancestry) + [oid], - "platform": desc.get("platform"), - "annotations": desc.get("annotations", {})} + "platform": approved_platform(desc.get("platform")), + "annotations": approved_annotations(desc.get("annotations"))} index = document("index.json") if not isinstance(index, dict) or not isinstance(index.get("manifests"), list): @@ -99,18 +145,31 @@ def traverse(desc, ancestry=()): "application/vnd.docker.distribution.manifest.v2+json"): raise ValueError("unexpected descriptor media type") manifest = document("blobs/sha256/" + oid[7:]) - rec["artifact_type"] = manifest.get("artifactType") - rec["manifest_subject"] = manifest.get("subject") - if isinstance(manifest.get("subject"), dict): - subject = manifest["subject"] - digest = subject.get("digest") - referenced = (members.get("blobs/sha256/" + digest[7:]) - if isinstance(digest, str) and digest.startswith("sha256:") - else None) - rec["subject_declared_size"] = subject.get("size") + rec["artifact_type"] = ( + "application" if not ( + (rec["annotations"] or {}).get("vnd.docker.reference.type") + == "attestation-manifest") else "attestation") + subject = manifest.get("subject") + if isinstance(subject, dict): + oid_subject = digest_only(subject.get("digest")) + referenced = (members.get("blobs/sha256/" + oid_subject[7:]) + if oid_subject else None) + rec["manifest_subject"] = { + "digest": oid_subject, + "media_type": subject.get("mediaType") + if subject.get("mediaType") in ( + "application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json") + else "unrecognized", + "size": subject.get("size") if type(subject.get("size")) is int + else None, + } + rec["subject_declared_size"] = rec["manifest_subject"]["size"] rec["subject_actual_size"] = ( referenced.size if referenced is not None and referenced.isfile() else None) + else: + rec["manifest_subject"] = None rec["config_type"] = (manifest.get("config") or {}).get("mediaType") attested = (rec["annotations"] or {}).get( "vnd.docker.reference.type") == "attestation-manifest" @@ -122,10 +181,18 @@ def traverse(desc, ancestry=()): for layer in manifest.get("layers", []): lid = layer.get("digest", "") member = members.get("blobs/sha256/" + str(lid)[7:]) - row = {"digest": lid, - "media_type": layer.get("mediaType"), - "annotations": layer.get("annotations", {}), - "declared_size": layer.get("size"), + row = {"digest": digest_only(lid), + "media_type": layer.get("mediaType") + if layer.get("mediaType") in ( + "application/vnd.in-toto+json", + "application/vnd.oci.image.layer.v1.tar", + "application/vnd.oci.image.layer.v1.tar+gzip", + "application/vnd.docker.image.rootfs.diff.tar", + "application/vnd.docker.image.rootfs.diff.tar.gzip") + else "unrecognized", + "annotations": approved_annotations(layer.get("annotations")), + "declared_size": layer.get("size") if type(layer.get("size")) is int + else None, "actual_size": member.size if member else None} if attested and layer.get("mediaType") == "application/vnd.in-toto+json": statement = document("blobs/sha256/" + str(lid)[7:]) @@ -144,8 +211,18 @@ def traverse(desc, ancestry=()): "predicate_type": st.get("predicateType"), "subject_count": len(st.get("subject", [])) if isinstance(st.get("subject"), list) else None, - "subjects": st.get("subject"), - "predicate_keys": safe_keys[:32], + # Subject names and untrusted predicate values + # must never reach retained CI artifacts. + "subject_digests": sorted({ + digest for sub in st.get("subject", []) + if isinstance(sub, dict) + and isinstance(sub.get("digest"), dict) + for digest in [digest_only( + "sha256:" + str(sub["digest"].get("sha256", "")))] + if digest + })[:8] if type(st.get("subject")) is list else [], + "predicate_keys": [k for k in safe_keys + if k in SAFE_SPDX_KEYS][:32], } if st.get("predicateType") == "https://spdx.dev/Document": packages = (predicate.get("packages") @@ -158,11 +235,13 @@ def traverse(desc, ancestry=()): len(files) if type(files) is list else None) summary["spdx_package_field_keys"] = ( sorted({key for item in packages[:32] - if isinstance(item, dict) for key in item})[:32] + if isinstance(item, dict) for key in item + if key in SAFE_SPDX_KEYS})[:32] if type(packages) is list else []) summary["spdx_file_field_keys"] = ( sorted({key for item in files[:32] - if isinstance(item, dict) for key in item})[:32] + if isinstance(item, dict) for key in item + if key in SAFE_SPDX_KEYS})[:32] if type(files) is list else []) row["statements"].append(summary) rec["layers"].append(row) diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index b012193c..687c2126 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -665,6 +665,20 @@ def test_slsa_predicates_cannot_be_schema_only(self): with self.assertRaisesRegex(ValueError, "SLSA"): oci.verify_archive(archive, meta, docker_inspect=inspection) + def test_retained_oci_diagnostics_do_not_echo_untrusted_subject_names(self): + mod_spec=importlib.util.spec_from_file_location( + "release1_safe_diag",ROOT/"scripts/inspect-release-oci.py") + mod=importlib.util.module_from_spec(mod_spec) + mod_spec.loader.exec_module(mod) + with tempfile.TemporaryDirectory() as d: + archive, meta, _ = self.fixture(Path(d)) + report=mod.inspect(archive,meta) + payload=json.dumps(report) + self.assertNotIn("offline-fixture",payload) + self.assertNotIn("SPDXRef-Package-offline",payload) + self.assertNotIn("offline-test-package",payload) + self.assertFalse(report["integrity_verified"]) + def test_diagnostic_reports_optional_subject_declared_and_actual_sizes(self): inspect_spec = importlib.util.spec_from_file_location( "release1_oci_diag_subject", ROOT/"scripts/inspect-release-oci.py") From 39bb44604d0d11d093e726f56314863712407361 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Wed, 14 Oct 2026 16:43:47 +0200 Subject: [PATCH 055/147] test(RELEASE-1): independently qualify pinned SBOM scanner before engine build --- .github/workflows/release-candidate.yml | 39 +++++- scripts/build-release-candidate.py | 3 +- scripts/check-release-scanner.py | 115 ++++++++++++++++++ .../release/test_release_scanner_contract.py | 41 +++++++ tests/release/test_workflow_coverage.py | 12 ++ 5 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 scripts/check-release-scanner.py create mode 100644 tests/release/test_release_scanner_contract.py diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 84323d9b..162e76c3 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -37,9 +37,46 @@ jobs: path: build/test-results/release1/promotion-policy.json if-no-files-found: warn retention-days: 30 - release-oci-integration: + scanner-compatibility: needs: release-contracts runs-on: ubuntu-24.04 + timeout-minutes: 12 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ env.SOURCE_SHA }} + - name: Independently qualify pinned scanner protocol without engine build + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$SOURCE_SHA" + BASE="$(python3 -c 'import json;print(json.load(open("qualification/release-image-v1.json"))["base_image"])')" + SCANNER="$(python3 -c 'import json;print(json.load(open("qualification/release-image-v1.json"))["sbom_generator_image"])')" + docker buildx create --driver docker-container --name release1-scanner-probe --use + docker buildx inspect --bootstrap + docker buildx imagetools inspect "$SCANNER" --raw > /dev/null + mkdir -p build/test-results/release1-scanner + cat > "$RUNNER_TEMP/Scanner.Dockerfile" <<'EOF' + ARG BASE_IMAGE + FROM --platform=linux/amd64 ${BASE_IMAGE} + EOF + docker buildx build --platform linux/amd64 \ + -f "$RUNNER_TEMP/Scanner.Dockerfile" --build-arg "BASE_IMAGE=$BASE" \ + --tag allfather/scanner-probe:offline \ + --attest "type=sbom,generator=$SCANNER" \ + --output type=oci,dest=build/test-results/release1-scanner/scanner.oci.tar . + python3 scripts/check-release-scanner.py \ + --archive build/test-results/release1-scanner/scanner.oci.tar \ + --output build/test-results/release1-scanner/scanner-contract.json + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + if: always() + with: + name: release1-scanner-contract + path: build/test-results/release1-scanner/scanner-contract.json + if-no-files-found: warn + retention-days: 30 + release-oci-integration: + needs: [release-contracts, scanner-compatibility] + runs-on: ubuntu-24.04 timeout-minutes: 115 env: JOBS: '2' diff --git a/scripts/build-release-candidate.py b/scripts/build-release-candidate.py index c0b1e57d..217fc14d 100644 --- a/scripts/build-release-candidate.py +++ b/scripts/build-release-candidate.py @@ -50,7 +50,8 @@ "scripts/build-release-candidate.py", "scripts/qualify-release-smoke.py", "scripts/inspect-release-oci.py", - "scripts/check-release-snapshot.py", *TOOL_FILES) + "scripts/check-release-snapshot.py", + "scripts/check-release-scanner.py", *TOOL_FILES) def fail(condition: bool, reason: str): diff --git a/scripts/check-release-scanner.py b/scripts/check-release-scanner.py new file mode 100644 index 00000000..4905d4c5 --- /dev/null +++ b/scripts/check-release-scanner.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Independently check pinned scanner SPDX output, never authorize a release.""" +from __future__ import annotations +import argparse +import hashlib +import importlib.util +import json +import tarfile +from pathlib import Path + +MAX_JSON=8*1024*1024 +ROOT=Path(__file__).resolve().parents[1] +spec=importlib.util.spec_from_file_location( + "release1_oci_contract", ROOT/"scripts/verify-release-oci.py") +contract=importlib.util.module_from_spec(spec) +spec.loader.exec_module(contract) + +def require(condition, reason): + if not condition: + raise ValueError(reason) + +def inspect_scanner_output(archive: Path) -> dict: + """This scanner-only probe verifies SPDX syntax, not image authority.""" + with tarfile.open(archive,"r:*") as tar: + members={m.name:m for m in tar.getmembers() + if m.isfile() and not m.issym() and not m.islnk()} + require(len(members)<=256, "scanner probe exceeds blob budget") + def read(path, oid=None, expected_size=None): + m=members.get(path) + require(m is not None and m.size<=MAX_JSON, + "scanner probe missing or oversized JSON") + if expected_size is not None: + require(type(expected_size) is int and + m.size==expected_size,"scanner descriptor byte mismatch") + stream=tar.extractfile(m) + require(stream is not None,"scanner archive cannot read JSON") + raw=stream.read(MAX_JSON+1) + require(len(raw)<=MAX_JSON,"scanner probe JSON too large") + if oid is not None: + require("sha256:"+hashlib.sha256(raw).hexdigest()==oid, + "scanner descriptor digest mismatch") + return json.loads(raw) + index=read("index.json") + require(type(index) is dict and + type(index.get("manifests")) is list, + "scanner output missing OCI index") + spdx=[] + def walk(descriptor,depth=0): + require(depth<=8 and type(descriptor) is dict, + "scanner descriptor nesting invalid") + oid=descriptor.get("digest") + require(isinstance(oid,str) and + oid.startswith("sha256:") and len(oid)==71 and + all(c in "0123456789abcdef" for c in oid[7:]), + "scanner OCI digest invalid") + typ=descriptor.get("mediaType") + data=read("blobs/sha256/"+oid[7:],oid,descriptor.get("size")) + if typ=="application/vnd.oci.image.index.v1+json": + require(type(data.get("manifests")) is list, + "scanner image index malformed") + for child in data["manifests"]: + walk(child,depth+1) + return + require(typ in ("application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json"), + "scanner unknown OCI manifest") + annotations=descriptor.get("annotations") or {} + if annotations.get("vnd.docker.reference.type")!="attestation-manifest": + return + for layer in data.get("layers",[]): + require(type(layer) is dict and + layer.get("mediaType")=="application/vnd.in-toto+json", + "scanner attestation layer format invalid") + lid=layer.get("digest") + require(isinstance(lid,str) and lid.startswith("sha256:"), + "scanner attestation statement digest malformed") + statement=read("blobs/sha256/"+lid[7:],lid,layer.get("size")) + rows=statement.get("statements") + if rows is None: + rows=[statement] + require(type(rows) is list, "scanner statement envelope invalid") + for row in rows: + if isinstance(row,dict) and row.get("predicateType")==contract.SPDX: + require(row.get("_type")==contract.STATEMENT, + "scanner in-toto statement invalid") + pred=row.get("predicate") + require(type(pred) is dict,"scanner SPDX predicate malformed") + contract.validate_spdx_document(pred) + spdx.append((len(pred.get("packages",[])), + len(pred.get("files",[])))) + for root in index["manifests"]: + walk(root) + require(len(spdx)==1 and sum(spdx[0])>=1, + "scanner must export exactly one nonempty valid SPDX statement") + return {"schema_version":1,"scanner_spdx_contract_verified":True, + "spdx_document_count":len(spdx), + "spdx_package_count":spdx[0][0], + "spdx_file_count":spdx[0][1], + "integrity_verified":False,"release_qualified":False, + "public_bot_release":False} + + +def main(): + ap=argparse.ArgumentParser() + ap.add_argument("--archive",type=Path,required=True) + ap.add_argument("--output",type=Path,required=True) + args=ap.parse_args() + report=inspect_scanner_output(args.archive) + args.output.parent.mkdir(parents=True,exist_ok=True) + args.output.write_text(json.dumps(report,indent=2,sort_keys=True)+"\n") + print(json.dumps(report,sort_keys=True)) + + +if __name__=="__main__": + main() diff --git a/tests/release/test_release_scanner_contract.py b/tests/release/test_release_scanner_contract.py new file mode 100644 index 00000000..f7dda202 --- /dev/null +++ b/tests/release/test_release_scanner_contract.py @@ -0,0 +1,41 @@ +"""Early real-scanner attestation contract uses strict SPDX verifier.""" +from __future__ import annotations +import importlib.util +import tempfile +import unittest +from pathlib import Path + +ROOT=Path(__file__).resolve().parents[2] + +def module(path,name): + spec=importlib.util.spec_from_file_location(name,ROOT/path) + result=importlib.util.module_from_spec(spec) + spec.loader.exec_module(result) + return result + +probe=module("scripts/check-release-scanner.py","release1_scanner_probe") +oci_fixture=module("tests/release/test_release_oci_integrity.py", + "release1_fixture_scanner") + +class ScannerProbeContractTests(unittest.TestCase): + def test_valid_spdx_probe_never_claims_release_authority(self): + with tempfile.TemporaryDirectory() as tmp: + tar, _, _ = oci_fixture.OCIProofTests().fixture(Path(tmp)) + report=probe.inspect_scanner_output(tar) + self.assertEqual(report["spdx_document_count"],1) + self.assertGreater(sum((report["spdx_package_count"], + report["spdx_file_count"])),0) + self.assertFalse(report["integrity_verified"]) + self.assertFalse(report["release_qualified"]) + + def test_malformed_spdx_refused_at_scanner_boundary(self): + for mode in ("missing-download","bogus-algorithm", + "namespace-fragment","empty-inventory"): + with self.subTest(mode=mode), tempfile.TemporaryDirectory() as tmp: + tar,_,_=oci_fixture.OCIProofTests().fixture( + Path(tmp),spdx_case=mode) + with self.assertRaisesRegex(ValueError,"SPDX"): + probe.inspect_scanner_output(tar) + +if __name__=="__main__": + unittest.main() diff --git a/tests/release/test_workflow_coverage.py b/tests/release/test_workflow_coverage.py index 64846539..b4478b42 100644 --- a/tests/release/test_workflow_coverage.py +++ b/tests/release/test_workflow_coverage.py @@ -35,6 +35,18 @@ def test_operations_docker_command_is_valid_shell(self): self.assertIn(fragment,command) + def test_pinned_scanner_is_exercised_without_chess_build(self): + source=(ROOT/".github/workflows/release-candidate.yml").read_text() + self.assertIn("scanner-compatibility:",source) + self.assertIn("needs: [release-contracts, scanner-compatibility]",source) + self.assertIn("scripts/check-release-scanner.py",source) + self.assertIn("--attest \"type=sbom,generator=$SCANNER\"",source) + self.assertIn("scanner-contract.json",source) + scanner=source.split(" scanner-compatibility:",1)[1].split( + " release-oci-integration:",1)[0] + self.assertNotIn("make build-online-engine-opt-v2",scanner) + self.assertNotIn("release1-scanner/scanner.oci.tar\n if",scanner) + def test_release_artifacts_exclude_raw_provenance_and_image_bytes(self): content=(ROOT/".github/workflows/release-candidate.yml").read_text() upload=content.split("- name: Do not publish combined image",1)[1] From f2e067ec7503d51ec4503e459be234a52e824ebf Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Wed, 14 Oct 2026 16:46:55 +0200 Subject: [PATCH 056/147] docs(RELEASE-1): account for upstream/downstream validation, scanner and snapshot gates --- docs/CODEX_POSTMERGE_CLOSURE.md | 16 ++++++++++++++++ docs/RELEASE_1_OPERATIONS.md | 14 ++++++++++++++ scripts/verify-release-oci.py | 3 +++ tests/release/test_release_oci_integrity.py | 3 ++- 4 files changed, 35 insertions(+), 1 deletion(-) diff --git a/docs/CODEX_POSTMERGE_CLOSURE.md b/docs/CODEX_POSTMERGE_CLOSURE.md index 3bbd3b5c..3aee0fa2 100644 --- a/docs/CODEX_POSTMERGE_CLOSURE.md +++ b/docs/CODEX_POSTMERGE_CLOSURE.md @@ -113,3 +113,19 @@ Grounded in exact-head aaa63f76cce91579d6092c99833b858cad6018c5; RELEASE-1 built | Evidence minimization | The RELEASE-1 upload explicitly allowlists sanitized reports and scenario logs. It no longer retains raw `oci-build-metadata.json`, `docker-build-metadata.json`, `docker-inspect.json`, OCI tarball, or unfiltered PR event payloads. Real verifier still receives full metadata during execution. | These repairs have been staged as sequential Git commit objects. Source/fast tests, all eleven workflows, actual BuildKit SBOM/SLSA verification, host aggregate and subsequent final-head Codex code/security review must all pass before these findings can be disposed as closed. No changes to engine search, move authority, offline isolation, budgets, licensing, OS/wheel lock completion, public-bot release or deployment permissions. + +## PR #71 — upstream/downstream assurance follow-up (October 14, 2026) + +This entry documents implementation intent and staged source changes, **not** approval or a green exact-head CI result. The previous head e90fc938 had 97/97 RELEASE-1 fast tests green but the actual OCI integration failed during APT acquisition due to repeated HTTP 503 responses at the pinned 20261001T000000Z Ubuntu snapshot. Other ONLINE and controller checks were green; this was an external acquisition blocker before image execution, SPDX or SLSA export. + +| Finding / boundary | Implemented repair and regression | +| --- | --- | +| [Codex P2 SPDX download location](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4271701069) | Support uppercase NONE/NOASSERTION or explicitly supported URL/VCS URI forms with valid authority, no embedded passwords/control characters. Reject arbitrary strings, invalid host/port, malformed syntax. Validation is syntax/policy, **not** a claim the package is actually downloadable. | +| [Codex P2 SPDX checksums](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4271701077) | Validate supported SPDX 2.x checksum algorithms and fixed canonical hex widths on file checksums and optional package checksums. Unsupported variable-width MD6 deliberately fails closed; duplicate algorithm and malformed/short/upper-case values rejected. | +| [Codex P2 OCI image-config platform](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4271701092) | Project image config platform-only fields into the already reviewed linux/amd64 baseline checker without rejecting legitimate rootfs, history and config keys. Rehashed archive/config/BuildKit metadata fixtures reject contradictory variant, OS features/version, wrong OS and architecture. Loaded Docker image OS is also checked if present. | +| Pinned Ubuntu snapshot acquisition | Dockerfile uses new deploy/release/install-pinned-apt.sh (included in build context and source attestation) for four bounded APT attempts. It clears prior index lists between failed attempts, retries only classified 429/502/503/504 conditions, and immediately rejects signature, checksum, certificate and untrusted-source errors. Early scripts/check-release-snapshot.py preflight checks reachability of all four fixed suites *before* native engine compilation. APT still validates signed indexes; no unlocked archive fallback. Fake APT tests exercise 503 recovery/permanent outage/signature/hash/404 cases. | +| Downstream RELEASE-1 aggregate | Require independent persisted SQLite verification, explicit blocked disposition, correct case/scenario/color, exact selected BuildKit OCI root digest, and status-specific allowable move POSTs. Existing host verifier remains authoritative for full chess legality and request/intent correlation. Destructive tests include forged green bits. | +| Pinned scanner | Added a standalone scanner-compatibility CI job that exports an OCI SBOM from the pinned Ubuntu **base image only**, with the same fixed Syft scanner OCI digest, before the costly three-engine build. It validates the genuine in-toto SPDX predicate using the same host verifier, but cannot grant release or publisher authority. Only a minimal sanitized scanner-count report is retained, never its OCI bytes. | +| Diagnostic artifact privacy | Diagnostic inspector now allowlists OCI annotations and SPDX field keys, summarizes only subject digests/counts and strips untrusted subject names/annotations and raw predicate contents. Negative tests assert no raw subject names or package inventory values appear in retained output. | + +**Unmodified non-grants:** move/search authority, runtime budgets, external network permissions, runtime dependency locks, licensing, publisher signing, destination-host admission, real Lichess credentials, public release, Elo or strength claims. All 11 exact-head workflows and final Codex code/security reviews must complete before merging draft PR #71. New scanner-compatibility is an additional job inside RELEASE-1, not a replacement for the real packaged image, OCI and smoke qualifications. diff --git a/docs/RELEASE_1_OPERATIONS.md b/docs/RELEASE_1_OPERATIONS.md index 0ee20afe..d19fdda3 100644 --- a/docs/RELEASE_1_OPERATIONS.md +++ b/docs/RELEASE_1_OPERATIONS.md @@ -251,3 +251,17 @@ R10: the `rate_limit_hold_completed` event must be request-correlated and record SBOM generator: the release image policy specifies the digest-pinned docker/buildkit-syft-scanner multi-platform image, with remote digest preflight. The default mutable scanner tag is not used during qualification. The pinned image digest is a historically available Docker Hub index and must be exercised in exact-head CI before claiming a valid generator. CI still computes full BuildKit metadata to verify attestation, but only allowlisted sanitized JSON reports, OS inventory and four local smoke logs are retained as download artifacts; private GitHub event payload and full provenance metadata are not retained. The green supporting workflows and full image smoke testing remain necessary but insufficient: standalone OCI attestation verification, final host aggregate, dependency qualification and a fresh review must pass. The publication/licensing/live-game prohibitions are unchanged. + +## RELEASE-1 snapshot, SBOM and downstream evidence qualification + +**Pinned snapshot acquisition:** The image still starts from the digest-pinned Ubuntu base and two native APT sources with GPG Signed-By and the frozen Snapshot field. The minimal base bootstraps CA trust from signed native HTTP indexes as before. Remaining packages are installed with deploy/release/install-pinned-apt.sh: up to four bounded attempts for transient HTTP 429/502/503/504 responses, with old index lists discarded after failure. Hash, signature, certificate or unrecognized download failures are fatal; there is no mirror or unsigned fallback. An availability-only snapshot preflight runs before expensive engine compilation and emits a sanitized JSON diagnostic. It does not replace APT signature validation. + +**Scanner preflight:** A separate scanner-compatibility job builds a minimal OCI candidate from only the pinned base image, without the chess engines or extra APT packages, and requests an SPDX SBOM from the digest-pinned Syft generator. scripts/check-release-scanner.py scans the real in-toto SPDX statement and verifies the supported document/inventory contract. This check proves generator compatibility, **not** full application subject binding, publisher signature, licensing, or release readiness. The actual RELEASE-1 OCI export still requires the independent full image/config/layers/SBOM/SLSA verifier after four packaged offline smoke scenarios. + +**SPDX source rules:** Package downloadLocation may be NONE, NOASSERTION, or a supported syntactically valid URL/VCS location without embedded secrets. File and optional package checksums require supported exact algorithm and canonical lowercase fixed-width digest. Variable-width MD6 is explicitly unsupported in the release profile. A structurally valid SPDX document is not evidence of actual package-download provenance. The SPDX schema and release-profile policy are separate claims. + +**Image runtime platform:** Both every selected runnable descriptor platform and the OCI image configuration platform fields must match the qualified linux/amd64 baseline. The image config retains its legitimate rootfs, history, and runtime config values; strict descriptor-only requirements are not applied indiscriminately to the entire image JSON. The full graph is hashed and linked through BuildKit metadata and the loaded Docker image inspection. + +**Admission and evidence:** scripts/qualify-release-aggregate.py now requires an independent host smoke verdict with persisted SQLite matching actual disk, exact case identity, no publisher/public grant, and release_blocked=True. The aggregate also checks the selected exported OCI root digest against BuildKit's declared digest. Allowed HTTP statuses and paths are rechecked as defense in depth; legal move authorization and no-blind-retry stay exclusively with the strict host verifier. Generated diagnostic artifacts are allowlisted, free of raw BuildKit metadata, image layers, subject names and unreviewed annotations. + +**Stop condition:** An exact-head test runner must pass the complete RELEASE-1 fast contracts, scanner compatibility, pinned signed APT, installed Docker image, R00/R02/R10 persisted real-process cases, OCI attestation and SLSA validation, and final aggregate; all other workflow gates plus fresh Codex review must be green before draft PR merge. The OS/wheel locks remain incomplete and public distribution remains prohibited. diff --git a/scripts/verify-release-oci.py b/scripts/verify-release-oci.py index 447a5a52..1d59dd9b 100644 --- a/scripts/verify-release-oci.py +++ b/scripts/verify-release-oci.py @@ -190,6 +190,9 @@ def valid_spdx_download_location(value: object) -> bool: return False if uri.password is not None: return False + # Force urllib to validate the port rather than treating an invalid + # textual authority as a legitimate download location. + _ = uri.port # A public Git/VCS SSH URL may use git@host; web download URLs # may not contain credentials at all. if uri.username is not None and not ( diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index 687c2126..a9f63dd7 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -473,7 +473,8 @@ def test_supported_spdx_download_locations(self): "https:///missing-host", "https://example.test/a b", "https://user:password@example.test/x", "http://user@example.test/x", "javascript:evil()", - "https://[bad-ipv6/x", "NOASSERTION\n") + "https://[bad-ipv6/x", "https://example.test:invalid/path", + "NOASSERTION\n") for location in accepted: with self.subTest(location=location): self.assertTrue(oci.valid_spdx_download_location(location)) From e2a55d94f53104a51e3141188c6a49dbd210e073 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 18:53:21 +0200 Subject: [PATCH 057/147] fix(RELEASE-1): bound complete signed snapshot preflight and report failures --- scripts/check-release-snapshot.py | 150 ++++++++++++---- .../test_release_snapshot_preflight.py | 160 ++++++++++++++---- 2 files changed, 249 insertions(+), 61 deletions(-) diff --git a/scripts/check-release-snapshot.py b/scripts/check-release-snapshot.py index f87e93ec..b3616ec1 100644 --- a/scripts/check-release-snapshot.py +++ b/scripts/check-release-snapshot.py @@ -1,45 +1,136 @@ #!/usr/bin/env python3 -"""Early availability-only check; Docker APT still enforces signed archives.""" +"""Bounded snapshot reachability preflight; signed APT remains authoritative. + +The preflight rejects missing/truncated clear-signed indexes, but DOES NOT +cryptographically authenticate Ubuntu's InRelease metadata. Docker APT must. +""" from __future__ import annotations import argparse import json import re import time +from http.client import IncompleteRead from pathlib import Path from urllib.error import HTTPError, URLError from urllib.request import urlopen SUITES = ("noble", "noble-updates", "noble-backports", "noble-security") RETRY_HTTP = (429, 502, 503, 504) +MAX_INRELEASE_BYTES = 4 * 1024 * 1024 +MIN_INRELEASE_BYTES = 512 +TOTAL_BUDGET_SECONDS = 145 -def require(cond, reason): - if not cond: +def require(ok, reason): + if not ok: raise ValueError(reason) -def probe(snapshot: str, *, opener=urlopen, sleeper=time.sleep) -> dict: - require(re.fullmatch(r"\d{8}T\d{6}Z", snapshot) is not None, +def validate_sources(content: str, snapshot: str) -> None: + """No extra source, unsigned stanza or changed Ubuntu suite.""" + expected = ( + (("noble", "noble-updates", "noble-backports"), + "https://archive.ubuntu.com/ubuntu"), + (("noble-security",), "https://security.ubuntu.com/ubuntu"), + ) + stanzas = [s for s in re.split(r"\n[ \t]*\n", content.strip()) + if s.strip() and not s.lstrip().startswith("#")] + require(len(stanzas) == 2, "unexpected pinned Ubuntu sources count") + for stanza, (suites, origin) in zip(stanzas, expected): + fields = {} + for raw in stanza.splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + require(":" in line, "malformed Ubuntu source stanza") + key, value = line.split(":", 1) + require(key not in fields, "duplicate Ubuntu source key") + fields[key] = value.strip() + require(set(fields) == {"Types", "URIs", "Suites", "Components", + "Architectures", "Signed-By", "Snapshot"} and + fields["Types"] == "deb" and fields["URIs"] == origin and + tuple(fields["Suites"].split()) == suites and + fields["Architectures"] == "amd64" and + fields["Snapshot"] == snapshot and + fields["Signed-By"] == + "/usr/share/keyrings/ubuntu-archive-keyring.gpg" and + set(fields["Components"].split()) == + {"main", "universe", "restricted", "multiverse"}, + "unapproved pinned Ubuntu source identity") + + +def validate_inrelease(body: bytes, suite: str) -> None: + """A bounded, complete, recognizable PGP clear-signed Release envelope.""" + require(MIN_INRELEASE_BYTES <= len(body) <= MAX_INRELEASE_BYTES and + b"\x00" not in body, "empty or truncated Ubuntu InRelease") + try: + content = body.decode("utf-8") + except UnicodeDecodeError as exc: + raise ValueError("unreadable Ubuntu InRelease") from exc + require(content.startswith("-----BEGIN PGP SIGNED MESSAGE-----\n") and + "\nHash: SHA" in content[:150] and + "\n\n" in content[:160], "missing signed Ubuntu InRelease header") + begin = content.find("\n-----BEGIN PGP SIGNATURE-----\n") + ending = content.find("\n-----END PGP SIGNATURE-----", begin) + require(begin > 300 and ending > begin + 48 and + content[ending + len("\n-----END PGP SIGNATURE-----"):].strip() == "", + "incomplete Ubuntu InRelease signature envelope") + signed_text = content[:begin] + require("Origin: Ubuntu" in signed_text and + re.search(r"(?m)^Suite: " + re.escape(suite) + r"\s*$", + signed_text) is not None and + "Architectures:" in signed_text and + "Components:" in signed_text, + "Ubuntu InRelease suite or release metadata invalid") + + +def read_complete(response, expected_url: str, suite: str) -> None: + observed = response.geturl() if hasattr(response, "geturl") else expected_url + require(observed == expected_url, + "Ubuntu snapshot redirected outside frozen suite URL") + content = bytearray() + while True: + part = response.read(min(65536, MAX_INRELEASE_BYTES + 1 - len(content))) + if not part: + break + content.extend(part) + require(len(content) <= MAX_INRELEASE_BYTES, + "Ubuntu InRelease exceeds preflight size budget") + declared = getattr(response, "headers", {}).get("Content-Length") + if declared is not None: + require(declared.isdecimal() and int(declared) == len(content), + "Ubuntu InRelease HTTP body truncated") + validate_inrelease(bytes(content), suite) + + +def probe(snapshot: str, *, opener=urlopen, sleeper=time.sleep, + clock=time.monotonic) -> dict: + require(isinstance(snapshot, str) and + re.fullmatch(r"\d{8}T\d{6}Z", snapshot) is not None, "invalid pinned Ubuntu snapshot identity") + deadline = clock() + TOTAL_BUDGET_SECONDS results = {} for suite in SUITES: url = (f"https://snapshot.ubuntu.com/ubuntu/{snapshot}/dists/" f"{suite}/InRelease") for attempt in range(1, 4): + left = deadline - clock() + require(left > 1.0, "pinned snapshot global preflight budget exhausted") try: - with opener(url, timeout=10) as response: - response.read(512) + with opener(url, timeout=min(10.0, left - 0.5)) as response: + read_complete(response, url, suite) results[suite] = {"reachable": True, "attempts": attempt} break except HTTPError as exc: - if exc.code not in RETRY_HTTP or attempt == 3: - raise ValueError( - f"pinned snapshot {suite} unavailable HTTP {exc.code}") from exc - except URLError as exc: - if attempt == 3: - raise ValueError( - f"pinned snapshot {suite} unavailable network") from exc - sleeper(attempt * 3) + require(exc.code in RETRY_HTTP and attempt != 3, + f"pinned snapshot {suite} HTTP {exc.code} unavailable") + except (URLError, TimeoutError, ConnectionResetError, + BrokenPipeError, IncompleteRead, OSError) as exc: + require(attempt != 3, + f"pinned snapshot {suite} network/read unavailable") + left = deadline - clock() + require(left > 1.0, "pinned snapshot global preflight budget exhausted") + sleeper(min(attempt * 3, max(0.0, left - 1.0))) return {"schema_version": 1, "pinned_snapshot": snapshot, "availability_only": True, "signature_verified": False, "suites": results, "public_bot_release": False} @@ -51,27 +142,26 @@ def main() -> int: ap.add_argument("--sources", type=Path, required=True) ap.add_argument("--output", type=Path, required=True) args = ap.parse_args() - policy = json.loads(args.policy.read_text()) - snapshot = policy.get("apt_snapshot") - sources = args.sources.read_text() - require(isinstance(snapshot, str) and - sources.count("Snapshot: " + snapshot) == 2 and - "Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" in sources, - "unpinned or unsigned Ubuntu snapshot policy") report = {"schema_version": 1, "availability_only": True, - "signature_verified": False, - "pinned_snapshot": snapshot, "public_bot_release": False} + "signature_verified": False, "public_bot_release": False} try: + policy = json.loads(args.policy.read_text(encoding="utf-8")) + snapshot = policy.get("apt_snapshot") + require(isinstance(snapshot, str), "invalid pinned snapshot in policy") + validate_sources(args.sources.read_text(encoding="utf-8"), snapshot) + report["pinned_snapshot"] = snapshot report.update(probe(snapshot)) code = 0 - except ValueError as exc: - # Fixed, bounded error classification; no user-controlled URLs/headers. - report["error"] = str(exc) - report["unavailable"] = True + except (ValueError, OSError, TypeError, KeyError) as exc: + # All expected filesystem, socket and HTTP acquisition failures + # create bounded, structured evidence; no raw response is retained. + report["error_class"] = type(exc).__name__ + report["failure_reason"] = str(exc)[:180] + report["unavailable_or_invalid"] = True code = 2 args.output.parent.mkdir(parents=True, exist_ok=True) - args.output.write_text(json.dumps(report,sort_keys=True,indent=2)+"\n") - print(json.dumps(report,sort_keys=True)) + args.output.write_text(json.dumps(report, sort_keys=True, indent=2) + "\n") + print(json.dumps(report, sort_keys=True)) return code diff --git a/tests/release/test_release_snapshot_preflight.py b/tests/release/test_release_snapshot_preflight.py index f4fe7430..2761a601 100644 --- a/tests/release/test_release_snapshot_preflight.py +++ b/tests/release/test_release_snapshot_preflight.py @@ -1,42 +1,61 @@ -"""Early signed snapshot diagnostic; no APT, network or engine builds.""" +"""Snapshot preflight: complete signed envelopes, bounded I/O and fail-closed report.""" from __future__ import annotations import importlib.util import io +import json import tempfile import unittest from pathlib import Path from urllib.error import HTTPError -ROOT=Path(__file__).resolve().parents[2] -spec=importlib.util.spec_from_file_location("release1_snapshot_check", - ROOT/"scripts/check-release-snapshot.py") -checker=importlib.util.module_from_spec(spec) +ROOT = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location( + "release1_snapshot_check", ROOT / "scripts/check-release-snapshot.py") +checker = importlib.util.module_from_spec(spec) spec.loader.exec_module(checker) -class DummyResponse: - def __enter__(self): - return self - def __exit__(self,*args): - pass - def read(self,n): - return b"signed index is independently verified by Docker APT"[:n] +def envelope(suite: str) -> bytes: + metadata = ("Origin: Ubuntu\nLabel: Ubuntu\nSuite: " + suite + + "\nArchitectures: amd64\nComponents: main universe restricted multiverse\n" + + "Date: Thu, 01 Oct 2026 00:00:00 UTC\n" + "SHA256:\n" + + ("a" * 72 + " 170 Contents-amd64.gz\n") * 20) + return ("-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA512\n\n" + + metadata + "-----BEGIN PGP SIGNATURE-----\n" + + "iQIz" + "a" * 120 + "\n=ABCD\n-----END PGP SIGNATURE-----\n").encode() + + +class Response: + def __init__(self, url, data, *, final_url=None, declared=None, + error=None): + self.url = final_url or url + self.stream = io.BytesIO(data) + self.headers = {} if declared is None else {"Content-Length":str(declared)} + self.error = error + def __enter__(self): return self + def __exit__(self, *args): return None + def geturl(self): return self.url + def read(self, n): + if self.error: + raise self.error + return self.stream.read(n) class SnapshotAvailabilityTests(unittest.TestCase): - def test_transient_503_retries_only_same_frozen_suite(self): - attempts={} - def opener(url,timeout): - attempts[url]=attempts.get(url,0)+1 - if attempts[url]==1: - raise HTTPError(url,503,"temporary upstream outage",{},None) - return DummyResponse() - result=checker.probe("20261001T000000Z",opener=opener, - sleeper=lambda seconds:None) + def test_complete_signed_reachable_after_transient_503(self): + tries = {} + def opener(url, timeout): + suite = url.split("/dists/")[1].split("/")[0] + tries[url] = tries.get(url, 0) + 1 + if tries[url] == 1: + raise HTTPError(url, 503, "temporary outage", {}, None) + content = envelope(suite) + return Response(url,content,declared=len(content)) + result = checker.probe("20261001T000000Z",opener=opener, + sleeper=lambda _:None) self.assertEqual(set(result["suites"]),set(checker.SUITES)) - self.assertEqual(set(attempts.values()),{2}) + self.assertEqual(set(tries.values()), {2}) self.assertFalse(result["signature_verified"]) - self.assertTrue(all("20261001T000000Z" in url for url in attempts)) def test_nontransient_status_is_immediately_refused(self): requests=[] @@ -45,23 +64,102 @@ def opener(url,timeout): raise HTTPError(url,404,"not found",{},None) with self.assertRaisesRegex(ValueError,"HTTP 404"): checker.probe("20261001T000000Z",opener=opener, - sleeper=lambda seconds:None) + sleeper=lambda _:None) self.assertEqual(len(requests),1) def test_persistent_503_is_bounded(self): - requests=[] + attempts=[] def opener(url,timeout): - requests.append(url) - raise HTTPError(url,503,"temporary outage",{},None) + attempts.append(url) + raise HTTPError(url,503,"temporary",{},None) with self.assertRaisesRegex(ValueError,"HTTP 503"): checker.probe("20261001T000000Z",opener=opener, - sleeper=lambda seconds:None) - self.assertEqual(len(requests),3) + sleeper=lambda _:None) + self.assertEqual(len(attempts),3) + + def test_network_timeout_retries_then_classifies_failure(self): + attempts=[] + def opener(url,timeout): + attempts.append(url) + raise TimeoutError("read operation timed out") + with self.assertRaisesRegex(ValueError,"network/read unavailable"): + checker.probe("20261001T000000Z",opener=opener, + sleeper=lambda _:None) + self.assertEqual(len(attempts),3) + + def test_read_timeout_retries_and_does_not_skip_suite(self): + attempts=[] + def opener(url,timeout): + attempts.append(url) + suite=url.split("/dists/")[1].split("/")[0] + return Response(url,envelope(suite),error=TimeoutError("read timeout")) + with self.assertRaisesRegex(ValueError,"network/read unavailable"): + checker.probe("20261001T000000Z",opener=opener, + sleeper=lambda _:None) + self.assertEqual(len(attempts),3) - def test_injected_snapshot_identity_rejected_before_network(self): + def test_empty_truncated_invalid_suite_and_redirect_refused(self): + for kind in ("empty","truncated","missing-signature","wrong-suite", + "redirect","wrong-content-length","oversized"): + attempts=[] + def opener(url,timeout): + attempts.append(url) + suite=url.split("/dists/")[1].split("/")[0] + body=envelope(suite) + kwargs={} + if kind=="empty": body=b"" + elif kind=="truncated": body=body[:400] + elif kind=="missing-signature": body=body.split( + b"-----BEGIN PGP SIGNATURE-----")[0] + elif kind=="wrong-suite": body=envelope("wrong-suite") + elif kind=="redirect": kwargs["final_url"]="https://evil.test/other" + elif kind=="wrong-content-length": kwargs["declared"]=len(body)+3 + elif kind=="oversized": body=b"x"*(checker.MAX_INRELEASE_BYTES+1) + return Response(url,body,**kwargs) + with self.subTest(kind=kind), self.assertRaisesRegex( + ValueError,"InRelease|redirected|suite"): + checker.probe("20261001T000000Z",opener=opener, + sleeper=lambda _:None) + self.assertEqual(len(attempts),1) + + def test_sources_require_exact_two_signed_stanzas(self): + raw=(ROOT/"deploy/release/ubuntu.sources").read_text() + checker.validate_sources(raw,"20261001T000000Z") + for poisoned in ( + raw+"\nTypes: deb\nURIs: http://untrusted.test\n", + raw.replace("Signed-By:", "Signed-Of:"), + raw.replace("Architectures: amd64","Architectures: arm64"), + raw.replace("https://security.ubuntu.com", + "http://security.ubuntu.com"), + ): + with self.subTest(change=poisoned[-60:]), self.assertRaises(ValueError): + checker.validate_sources(poisoned,"20261001T000000Z") + + def test_bad_snapshot_identity_rejected_before_network(self): with self.assertRaisesRegex(ValueError,"invalid pinned"): checker.probe("20261001T000000Z/../latest", - opener=lambda *args,**kw: self.fail("network was called")) + opener=lambda *a,**kw:self.fail("network called")) + + def test_main_failure_report_survives_invalid_sources(self): + with tempfile.TemporaryDirectory() as td: + root=Path(td) + policy=root/"policy.json" + sources=root/"sources" + output=root/"evidence.json" + policy.write_text(json.dumps({"apt_snapshot":"20261001T000000Z"})) + sources.write_text("Types: deb\nURIs: http://unsafe.test\n") + original_argv=checker.__dict__.get("_test_argv") + import sys + old=sys.argv + try: + sys.argv=["snapshot", "--policy",str(policy), + "--sources",str(sources),"--output",str(output)] + self.assertEqual(checker.main(),2) + finally: + sys.argv=old + report=json.loads(output.read_text()) + self.assertFalse(report["signature_verified"]) + self.assertTrue(report["unavailable_or_invalid"]) if __name__=="__main__": unittest.main() From c126193d6a26aff83642ce57e13088f3de84e6b7 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 18:54:43 +0200 Subject: [PATCH 058/147] fix(RELEASE-1): retry APT only when every acquisition failure is transient --- deploy/release/install-pinned-apt.sh | 72 +++++++++++++++++--- tests/release/test_release_snapshot_retry.py | 41 +++++++++++ 2 files changed, 105 insertions(+), 8 deletions(-) diff --git a/deploy/release/install-pinned-apt.sh b/deploy/release/install-pinned-apt.sh index 20d40d82..20776c97 100644 --- a/deploy/release/install-pinned-apt.sh +++ b/deploy/release/install-pinned-apt.sh @@ -8,12 +8,68 @@ APT_RETRY_BACKOFF_SECONDS="${APT_RETRY_BACKOFF_SECONDS:-4}" APT_LOG="${APT_LOG:-/tmp/allfather-release-apt-$$.log}" trap 'rm -f "$APT_LOG"' 0 HUP INT TERM -retryable() { - grep -Eq '(^|[[:space:]])(429|502|503|504)([[:space:]]|$)|Service Unavailable|Too Many Requests|Bad Gateway|Gateway Timeout|Gateway Time-out' "$APT_LOG" -} - -integrity_error() { - grep -Eiq 'NO_PUBKEY|BADSIG|EXPKEYSIG|REVKEYSIG|Hash Sum mismatch|certificate (verification|verify)|signatures could not|not signed|does not have a Release file|clearsigned file.*invalid|File has unexpected size|401 Unauthorized|403 Forbidden|404 Not Found' "$APT_LOG" +only_transient_acquisition_failures() { + # APT writes one Err: plus its indented reason, and often repeats + # it as "E: Failed to fetch". Every recorded failure must have an + # explicitly allowed transient HTTP status. One unrelated DNS/TLS/hash/ + # signature/unclassified error vetoes retry even alongside a valid 503. + LC_ALL=C awk ' + function transient(s) { + return s ~ /(^|[[:space:]])(429|502|503|504)([[:space:]]|$)/ + } + function invalid_status(s, a,n,i,t) { + n=split(s,a,/[[:space:]]+/) + for(i=1;i<=n;i++) { + t=a[i] + gsub(/^[^0-9]+|[^0-9]+$/, "", t) + if(t ~ /^[45][0-9][0-9]$/ && + t!="429" && t!="502" && t!="503" && t!="504") + return 1 + } + return 0 + } + function nontransient(s) { + return s ~ /NO_PUBKEY|BADSIG|EXPKEYSIG|REVKEYSIG|Hash Sum mismatch|certificate|Certificate|TLS|SSL|signatures could not|not signed|does not have a Release file|clearsigned|unexpected size|Temporary failure resolving|Could not resolve|Name or service not known|Connection refused|Network is unreachable|Unable to locate package|Could not get lock|Permission denied/ || + invalid_status(s) + } + function finish_err() { + if (in_err && !err_retryable) bad=1 + in_err=0 + err_retryable=0 + } + { + line=$0 + if (nontransient(line)) bad=1 + if (line ~ /^Err:[0-9]+[[:space:]]/) { + finish_err() + in_err=1 + err_retryable=transient(line) + seen++ + next + } + if (in_err && line ~ /^[[:space:]]+/) { + if(transient(line)) err_retryable=1 + next + } + finish_err() + if (line ~ /^E: Failed to fetch /) { + seen++ + if (!transient(line)) bad=1 + next + } + if (line ~ /^E:/) { + # This APT summary follows individually classified errors. + if (line !~ /^E: Some index files failed to download/) + bad=1 + next + } + if (line ~ /^(error:|Error:|fatal:)/) bad=1 + } + END { + finish_err() + exit !(seen>0 && bad==0) + } + ' "$APT_LOG" } apt_attempt() { @@ -35,8 +91,8 @@ apt_attempt() { return 0 fi cat "$APT_LOG" >&2 - if integrity_error || ! retryable; then - echo "Pinned signed APT $phase integrity/nontransient failure" >&2 + if ! only_transient_acquisition_failures; then + echo "Pinned signed APT $phase unclassified/integrity failure" >&2 return 100 fi if [ "$attempt" -eq 4 ]; then diff --git a/tests/release/test_release_snapshot_retry.py b/tests/release/test_release_snapshot_retry.py index e55bdfe6..d11eda26 100644 --- a/tests/release/test_release_snapshot_retry.py +++ b/tests/release/test_release_snapshot_retry.py @@ -32,6 +32,34 @@ exit 100 ;; badhash) echo "E: Hash Sum mismatch" >&2; exit 100 ;; missing) echo "E: 404 Not Found" >&2; exit 100 ;; + mixed-dns) + echo "Err:1 https://snapshot.ubuntu.com/ubuntu noble InRelease" >&2 + echo " 503 Service Unavailable" >&2 + echo "Err:2 https://snapshot.ubuntu.com/ubuntu noble-security InRelease" >&2 + echo " Temporary failure resolving snapshot.ubuntu.com" >&2 + echo "E: Failed to fetch noble/InRelease 503 Service Unavailable" >&2 + echo "E: Failed to fetch noble-security/InRelease Temporary failure resolving" >&2 + exit 100 ;; + mixed-401) + echo "E: Failed to fetch noble/InRelease 503 Service Unavailable" >&2 + echo "E: Failed to fetch noble-security/InRelease 401 Unauthorized" >&2 + exit 100 ;; + mixed-signature) + echo "E: Failed to fetch noble/InRelease 503 Service Unavailable" >&2 + echo "E: BADSIG: invalid signature" >&2 + exit 100 ;; + mixed-unclassified) + echo "E: Failed to fetch noble/InRelease 503 Service Unavailable" >&2 + echo "E: Some unknown acquisition condition" >&2 + exit 100 ;; + two503) + echo "Err:1 https://snapshot.ubuntu.com/ubuntu noble InRelease" >&2 + echo " 503 Service Unavailable" >&2 + echo "Err:2 https://snapshot.ubuntu.com/ubuntu noble-security InRelease" >&2 + echo " 503 Service Unavailable" >&2 + echo "E: Failed to fetch noble/InRelease 503 Service Unavailable" >&2 + echo "E: Failed to fetch noble-security/InRelease 503 Service Unavailable" >&2 + exit 100 ;; esac exit 0 ''' @@ -69,6 +97,19 @@ def test_persistent_503_is_bounded_and_blocks_install(self): self.assertNotEqual(result.returncode,0) self.assertEqual(counts,{"update":4,"install":0}) + def test_mixed_error_class_never_retries(self): + for mode in ("mixed-dns","mixed-401","mixed-signature", + "mixed-unclassified"): + with self.subTest(mode=mode): + result,counts=self.execute(mode) + self.assertNotEqual(result.returncode,0) + self.assertEqual(counts,{"update":1,"install":0}) + + def test_multiple_503s_are_individually_classified_and_bounded(self): + result,counts=self.execute("two503") + self.assertNotEqual(result.returncode,0) + self.assertEqual(counts,{"update":4,"install":0}) + def test_signature_hash_and_missing_source_fail_without_retries(self): for mode in ("badsignature","badhash","missing"): with self.subTest(mode=mode): From 364b6f2149940ddf101b5c26551195954e552328 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 18:56:29 +0200 Subject: [PATCH 059/147] fix(RELEASE-1): share exact GET/POST request admission between evidence gates --- scripts/build-release-candidate.py | 1 + scripts/qualify-release-aggregate.py | 36 +++++--------- scripts/qualify-release-smoke.py | 16 +++++- scripts/release_http_contract.py | 51 ++++++++++++++++++++ tests/release/test_release_aggregate.py | 16 ++++++ tests/release/test_release_http_contract.py | 50 +++++++++++++++++++ tests/release/test_release_smoke_evidence.py | 27 +++++++++++ 7 files changed, 170 insertions(+), 27 deletions(-) create mode 100644 scripts/release_http_contract.py create mode 100644 tests/release/test_release_http_contract.py diff --git a/scripts/build-release-candidate.py b/scripts/build-release-candidate.py index 217fc14d..e82d6333 100644 --- a/scripts/build-release-candidate.py +++ b/scripts/build-release-candidate.py @@ -49,6 +49,7 @@ "deploy/release/Dockerfile.dockerignore", "scripts/build-release-candidate.py", "scripts/qualify-release-smoke.py", + "scripts/release_http_contract.py", "scripts/inspect-release-oci.py", "scripts/check-release-snapshot.py", "scripts/check-release-scanner.py", *TOOL_FILES) diff --git a/scripts/qualify-release-aggregate.py b/scripts/qualify-release-aggregate.py index 85ded59f..b3ce8383 100644 --- a/scripts/qualify-release-aggregate.py +++ b/scripts/qualify-release-aggregate.py @@ -57,33 +57,19 @@ def check_smoke_evidence(path:Path, key:str) -> dict: return mod.verify_file(path,key) -def allowed_release_http(row: object, case: str) -> bool: - """Defense in depth after the independent chess/request correlation check.""" - if (type(row) is not dict or - row.get("method") not in ("GET", "POST") or - type(row.get("status")) is not int or - not isinstance(row.get("path"), str) or - row["status"] == 403): - return False - if row["method"] == "GET": - return row.get("request_id") is None - path = row["path"] - approved = {"/api/token/test", "/api/challenge/off3b001/accept", - "/api/challenge/off3b001/decline"} - if path in approved: - return row["status"] == 200 and row.get("request_id") is None - prefix = "/api/bot/game/off3b001/move/" - if not path.startswith(prefix): - return False - move = path[len(prefix):] - # Position legality and uniqueness are verified by verify_file; this - # layer enforces only status, endpoint and request-ID compatibility. - return (move not in ("", "0000") and - isinstance(row.get("request_id"), str) and - bool(row["request_id"]) and - row["status"] in ((200, 429) if case == "r10-white" else (200,))) +# Read the exact same host-only HTTP admission contract as the independent +# smoke verifier; the aggregate cannot silently widen allowed GETs. +_http_spec = importlib.util.spec_from_file_location( + "release1_http_aggregate", ROOT/"scripts/release_http_contract.py") +require(_http_spec is not None and _http_spec.loader is not None, + "shared RELEASE-1 HTTP admission contract missing") +http_contract = importlib.util.module_from_spec(_http_spec) +_http_spec.loader.exec_module(http_contract) +def allowed_release_http(row: object, case: str) -> bool: + return http_contract.allowed_release_http(row, case) + def qualify(*, stage:Path, oci:Path, oci_archive:Path, host:Path, smoke_dir:Path, os_inventory:Path, wheel_inventory:Path, source_sha:str, root:Path=ROOT): source= subprocess.check_output(["git","-C",str(root),"rev-parse","HEAD"], diff --git a/scripts/qualify-release-smoke.py b/scripts/qualify-release-smoke.py index b7510c2a..05191ce6 100644 --- a/scripts/qualify-release-smoke.py +++ b/scripts/qualify-release-smoke.py @@ -32,6 +32,18 @@ MAX_EVIDENCE_BYTES = 16 * 1024 * 1024 +# The same host-only HTTP admission contract is used by the downstream +# RELEASE-1 aggregate; neither verifier trusts a producer success bit. +import importlib.util as _http_importlib +_http_spec = _http_importlib.spec_from_file_location( + "release1_http_contract", Path(__file__).with_name("release_http_contract.py")) +require_spec = _http_spec is not None and _http_spec.loader is not None +if not require_spec: + raise ValueError("independent HTTP contract module missing") +http_contract = _http_importlib.module_from_spec(_http_spec) +_http_spec.loader.exec_module(http_contract) + + def require(value, reason): if not value: raise ValueError(reason) @@ -175,9 +187,9 @@ def verify_smoke(doc: dict, key: str) -> dict: isinstance(request.get("path"), str) and request["status"] != 403, "unexpected or malformed HTTP request evidence") + require(http_contract.allowed_release_http(request, key), + "unapproved GET or POST endpoint/status in HTTP audit") if request["method"] != "POST": - require(request.get("request_id") is None, - "GET request has move request identity") continue path = request["path"] if path.startswith(MOVE_PREFIX): diff --git a/scripts/release_http_contract.py b/scripts/release_http_contract.py new file mode 100644 index 00000000..c4edf231 --- /dev/null +++ b/scripts/release_http_contract.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Authoritative RELEASE-1 HTTP request-evidence shape, shared by two gates. + +The fake server enforces these endpoints. This module only verifies recorded +HTTP method/path/status/request ID. Independent host verification separately +reconstructs chess legality, receipt timing, epochs and durable intent. +""" +from __future__ import annotations +import re + +GAME_ID = "off3b001" +MOVE_PREFIX = f"/api/bot/game/{GAME_ID}/move/" +ALLOWED_POSTS = frozenset({ + "/api/token/test", + f"/api/challenge/{GAME_ID}/accept", + f"/api/challenge/{GAME_ID}/decline", +}) +ALLOWED_GETS = frozenset({ + "/api/stream/event", + f"/api/bot/game/stream/{GAME_ID}", + "/api/account", + "/api/account/playing", + "/api/users/status", + f"/game/export/{GAME_ID}", +}) +UCI_TOKEN = re.compile(r"[a-h][1-8][a-h][1-8][qrbn]?\Z") + + +def allowed_release_http(row: object, key: str) -> bool: + """No implicit wildcard GET and no successful forbidden HTTP response.""" + if (type(row) is not dict or key not in + ("r00-white", "r00-black", "r02-white", "r10-white")): + return False + method = row.get("method") + path = row.get("path") + status = row.get("status") + rid = row.get("request_id") + if (method not in ("GET", "POST") or + type(path) is not str or type(status) is not int): + return False + if method == "GET": + return path in ALLOWED_GETS and status == 200 and rid is None + if path in ALLOWED_POSTS: + return status == 200 and rid is None + if not path.startswith(MOVE_PREFIX): + return False + uci = path[len(MOVE_PREFIX):] + return (UCI_TOKEN.fullmatch(uci) is not None and + len(uci) in (4, 5) and + isinstance(rid, str) and bool(rid.strip()) and + status in ((200, 429) if key == "r10-white" else (200,))) diff --git a/tests/release/test_release_aggregate.py b/tests/release/test_release_aggregate.py index b22d5347..bfa4e3a4 100644 --- a/tests/release/test_release_aggregate.py +++ b/tests/release/test_release_aggregate.py @@ -135,6 +135,22 @@ def test_sha1_git_vs_sha256_artifacts(self): with self.subTest(digest=bad, prefixed=prefixed): self.assertFalse(aggregate.is_sha256_hex(bad, prefixed=prefixed)) + def test_forged_get_does_not_pass_aggregate(self): + case = "r00-white" + target = self.smoke_dir/case/"result.json" + original = json.loads(target.read_text()) + for path, status in (("/api/account/email",200), + ("/api/account",401), + ("/api/stream/event?token=secret",200)): + with self.subTest(path=path): + target.write_text(json.dumps({**original, + "http":original["http"]+[{ + "method":"GET","path":path,"status":status}]})) + with self.assertRaisesRegex(ValueError, + "HTTP contains prohibited request"): + self.qualify() + target.write_text(json.dumps(original)) + def test_http_admission_is_consistent_with_independent_verifier(self): token={"method":"POST","path":"/api/token/test","status":200} move={"method":"POST", diff --git a/tests/release/test_release_http_contract.py b/tests/release/test_release_http_contract.py new file mode 100644 index 00000000..767cc037 --- /dev/null +++ b/tests/release/test_release_http_contract.py @@ -0,0 +1,50 @@ +"""Shared fail-closed HTTP admission across smoke and aggregate.""" +from __future__ import annotations +import importlib.util +import unittest +from pathlib import Path +ROOT=Path(__file__).resolve().parents[2] + +def load(path, ident): + spec=importlib.util.spec_from_file_location(ident,ROOT/path) + module=importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + +smoke=load("scripts/qualify-release-smoke.py","release1_smoke_http_test") +aggregate=load("scripts/qualify-release-aggregate.py","release1_aggregate_http_test") + + +class SharedHTTPAdmissionTests(unittest.TestCase): + def test_each_allowed_get_and_case_agrees_across_both_verifiers(self): + for case in smoke.CASES: + for path in smoke.http_contract.ALLOWED_GETS: + row={"method":"GET","path":path,"status":200} + with self.subTest(case=case,path=path): + self.assertTrue(smoke.http_contract.allowed_release_http( + row,case)) + self.assertEqual(aggregate.allowed_release_http(row,case), + smoke.http_contract.allowed_release_http( + row,case)) + + def test_no_unauthorized_get_or_failed_status_is_admissible(self): + for row in ( + {"method":"GET","path":"/api/account/email","status":200}, + {"method":"GET","path":"/api/account","status":401}, + {"method":"GET","path":"/api/account","status":429}, + {"method":"GET","path":"/api/account","status":200, + "request_id":"move-123"}, + {"method":"GET","path":"/api/account?x=1","status":200}, + {"method":"GET","path":"/api/account","status":True}, + {"method":"POST","path":"/api/token/test","status":500}, + {"method":"POST", + "path":"/api/bot/game/off3b001/move/%65%32e4","status":200, + "request_id":"request-1"}, + ): + with self.subTest(row=row): + self.assertFalse(smoke.http_contract.allowed_release_http( + row,"r00-white")) + self.assertFalse(aggregate.allowed_release_http(row,"r00-white")) + +if __name__=="__main__": + unittest.main() diff --git a/tests/release/test_release_smoke_evidence.py b/tests/release/test_release_smoke_evidence.py index ef24b0fa..8bf05ed1 100644 --- a/tests/release/test_release_smoke_evidence.py +++ b/tests/release/test_release_smoke_evidence.py @@ -166,6 +166,33 @@ def test_all_four_realistic_cases_have_valid_independent_witnesses(self): self.assertTrue(result["independent_qualified"]) self.assertFalse(result["public_bot_release"]) + def test_get_allowlist_matches_fake_api_without_wildcard(self): + doc = fixture("r00-white") + for endpoint in sorted(qual.http_contract.ALLOWED_GETS): + doc["http"].append({"method":"GET", "path":endpoint, + "status":200, "monotonic_ms":30}) + self.assertTrue(qual.verify_smoke(doc, "r00-white")[ + "independent_qualified"]) + + def test_forged_get_never_qualifies_producer_success(self): + for path, status, rid in ( + ("/api/account/email",200,None), + ("/api/account",401,None), + ("/api/account",429,None), + ("/api/stream/event",403,None), + ("/api/stream/event",200,"request-1"), + ("/api/account?private=true",200,None), + ("/api/bot/game/stream/off3b001/../other",200,None), + ): + with self.subTest(path=path,status=status,rid=rid): + doc=fixture("r00-white") + doc["http"].append({"method":"GET","path":path, + "status":status,"request_id":rid, + "monotonic_ms":30}) + with self.assertRaisesRegex(ValueError, + "unapproved GET or POST"): + qual.verify_smoke(doc,"r00-white") + def test_position_illegal_uci_fails_even_when_the_producer_says_ready(self): doc=fixture("r10-white") uci="e7e5" # syntactically valid, illegal on White's first turn From fda6ecdcc41b189208526e40b744a9b9588f946c Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 18:57:47 +0200 Subject: [PATCH 060/147] fix(ONLINE-4): tie actual move URL to canonical traced UCI across v1/v2 --- scripts/qualify-online-recovery-v2.py | 14 +++++++-- scripts/qualify-online-recovery.py | 13 +++++++++ tests/lichess/test_recovery_evidence.py | 14 ++++++++- tests/lichess/test_recovery_v2_evidence.py | 34 ++++++++++++++++++++++ 4 files changed, 72 insertions(+), 3 deletions(-) diff --git a/scripts/qualify-online-recovery-v2.py b/scripts/qualify-online-recovery-v2.py index e4512f5e..b9584aa6 100644 --- a/scripts/qualify-online-recovery-v2.py +++ b/scripts/qualify-online-recovery-v2.py @@ -116,7 +116,14 @@ def verify_guarded_requests(requests: list[dict], trace: list[dict], "unexpected guarded POST operation") if path.startswith(prefix): uci = unquote(path[len(prefix):]) - require(uci and "/" not in uci and "?" not in uci, + require(uci and "/" not in uci and "?" not in uci and + path == prefix + uci, + "noncanonical guarded move URL") + try: + canonical = chess.Move.from_uci(uci) + except (ValueError, TypeError) as exc: + raise ValueError("malformed guarded move URL") from exc + require(canonical != chess.Move.null() and canonical.uci() == uci, "noncanonical guarded move URL") http_moves.append((path, uci)) raw_attempts = [r for r in trace if r.get("event") == "move_attempt"] @@ -157,7 +164,10 @@ def verify_guarded_requests(requests: list[dict], trace: list[dict], move = chess.Move.from_uci(uci) except (ValueError,TypeError) as exc: raise ValueError("malformed HTTP UCI move submission") from exc - require(move != chess.Move.null() and move in board.legal_moves, + require(move != chess.Move.null() and move.uci() == uci and + event.get("path") == prefix + uci, + "guarded move URL differs from traced canonical UCI") + require(move in board.legal_moves, "position-illegal HTTP move submission") key = (gid, ply, uci) require(key not in seen, "blind repeat HTTP move submission") diff --git a/scripts/qualify-online-recovery.py b/scripts/qualify-online-recovery.py index 233fc433..f26b8885 100644 --- a/scripts/qualify-online-recovery.py +++ b/scripts/qualify-online-recovery.py @@ -72,6 +72,19 @@ def verify_trace(trace: list[dict], game: dict) -> dict: ensure(seq == list(range(1, len(trace) + 1)), "trace ordering or numbering invalid") board = chess.Board() attempts = {f"request-{r['seq']}": r for r in trace if r.get("event") == "move_attempt"} + # A legal UCI in the trace cannot stand in for a different real HTTP + # endpoint. Reject aliases and encoded spellings rather than trusting + # a producer-controlled attempt extract. + prefix = "/api/bot/game/off3b001/move/" + for event in attempts.values(): + uci = event.get("uci") + try: + move = chess.Move.from_uci(uci) + except (ValueError, TypeError) as exc: + raise ValueError("invalid traced HTTP move UCI") from exc + ensure(move != chess.Move.null() and move.uci() == uci and + event.get("path") == prefix + uci, + "traced HTTP move path differs from canonical UCI") commits = [r for r in trace if r.get("event") == "board_commit"] ensure(isinstance(game.get("moves"), list), "authoritative move history missing") for item in commits: diff --git a/tests/lichess/test_recovery_evidence.py b/tests/lichess/test_recovery_evidence.py index aaf6eda5..783888ad 100644 --- a/tests/lichess/test_recovery_evidence.py +++ b/tests/lichess/test_recovery_evidence.py @@ -29,7 +29,8 @@ def trace(self): board.push_uci("e2e4") return [ {"seq": 1, "event": "move_attempt", "game_id": "off3b001", - "uci": "e2e4", "ply_at_receipt": 0}, + "uci": "e2e4", "ply_at_receipt": 0, + "path": "/api/bot/game/off3b001/move/e2e4"}, {"seq": 2, "event": "board_commit", "game_id": "off3b001", "origin": "allfather", "request_id": "request-1", "uci": "e2e4", "ply_before": 0, "ply_after": 1, @@ -45,6 +46,17 @@ def test_independently_replays_accepted_move(self): self.assertEqual(x["bot_commits"], 1) self.assertEqual(x["http_attempts"], 1) + def test_legal_trace_move_must_match_actual_url(self): + for path in ("/api/bot/game/off3b001/move/d2d4", + "/api/bot/game/off3b001/move/%65%32e4", + "/api/bot/game/off3b001/move/e2e4?x=1", + "/api/bot/game/off3b001/move/0000"): + rows=self.trace() + rows[0]["path"]=path + with self.subTest(path=path),self.assertRaisesRegex( + ValueError,"move path differs"): + QUALIFIER.verify_trace(rows,self.game()) + def test_rejects_invalid_sequence_and_illegal_commit(self): t = self.trace() for bad in ( diff --git a/tests/lichess/test_recovery_v2_evidence.py b/tests/lichess/test_recovery_v2_evidence.py index 25affe53..94317613 100644 --- a/tests/lichess/test_recovery_v2_evidence.py +++ b/tests/lichess/test_recovery_v2_evidence.py @@ -244,6 +244,40 @@ def test_guarded_request_ledger_rejects_duplicate_illegal_and_forbidden(self): with self.subTest(mutated=mutated),self.assertRaises(ValueError): qual.verify_guarded_requests(*mutated) + def test_legal_but_different_guarded_move_url_is_not_traced_intent(self): + gid="off3b001" + path=f"/api/bot/game/{gid}/move/e2e4" + requests=[{"method":"POST","path":path,"status":200}] + # d2d4 is independently legal, committed and durably prepared; + # the actual server URL submitted e2e4 instead. + trace=[{"seq":1,"event":"move_attempt","game_id":gid, + "uci":"d2d4","path":path,"ply_at_receipt":0}, + {"seq":2,"event":"board_commit","game_id":gid, + "uci":"d2d4","origin":"allfather","ply_before":0}] + game={"game_id":gid,"color":"white","moves":["d2d4"], + "forbidden_attempts":[]} + intents=[{"game_id":gid,"ply_before":0,"move":"d2d4", + "history_json":"[]"}] + with self.assertRaisesRegex(ValueError, + "guarded move URL differs from traced"): + qual.verify_guarded_requests(requests,trace,game,intents) + + def test_encoded_move_url_aliases_are_rejected(self): + gid="off3b001" + encoded=f"/api/bot/game/{gid}/move/%65%32e4" + trace=[{"seq":1,"event":"move_attempt","game_id":gid, + "uci":"e2e4","path":encoded,"ply_at_receipt":0}, + {"seq":2,"event":"board_commit","game_id":gid, + "uci":"e2e4","origin":"allfather","ply_before":0}] + game={"game_id":gid,"color":"white","moves":["e2e4"], + "forbidden_attempts":[]} + intents=[{"game_id":gid,"ply_before":0,"move":"e2e4", + "history_json":"[]"}] + with self.assertRaisesRegex(ValueError,"noncanonical guarded move URL"): + qual.verify_guarded_requests( + [{"method":"POST","path":encoded,"status":200}], + trace,game,intents) + def test_journal_history_must_equal_authoritative_prefix(self): from deploy.lichess.reconcile import position_id gid, epoch = "off3b001", "a"*32 From 396df13e162c307e55567a8e9017f2576d347f60 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 18:58:45 +0200 Subject: [PATCH 061/147] fix(RELEASE-1): validate SPDX percent encoding and URI authority before admission --- scripts/verify-release-oci.py | 39 +++++++++++++++++---- tests/release/test_release_oci_integrity.py | 18 ++++++++-- 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/scripts/verify-release-oci.py b/scripts/verify-release-oci.py index 1d59dd9b..d5722a66 100644 --- a/scripts/verify-release-oci.py +++ b/scripts/verify-release-oci.py @@ -11,6 +11,7 @@ import zlib import json import re +import ipaddress import tarfile from datetime import datetime from pathlib import Path, PurePosixPath @@ -179,6 +180,13 @@ def valid_spdx_download_location(value: object) -> bool: return False if any(ord(c) < 33 or ord(c) == 127 for c in value): return False + # urlsplit is not a URI validator: %zz and a trailing % in the + # authority/path survive splitting. Allow valid escaping in the *path* + # without permitting ambiguous encoded hosts or control bytes. + if (re.search(r"%(?![0-9A-Fa-f]{2})", value) or + re.search(r"%(?:0[0-9A-Fa-f]|1[0-9A-Fa-f]|7[fF])", + value, re.IGNORECASE)): + return False try: uri = urlsplit(value) supported = { @@ -186,18 +194,35 @@ def valid_spdx_download_location(value: object) -> bool: "git+http", "git+ssh", "git+git", "svn", "svn+https", "svn+ssh", "hg", "hg+https", "hg+ssh", } - if uri.scheme not in supported or not uri.netloc or not uri.hostname: + if (uri.scheme not in supported or not uri.netloc or not uri.hostname + or "%" in uri.netloc or uri.netloc.count("@") > 1): return False if uri.password is not None: return False - # Force urllib to validate the port rather than treating an invalid - # textual authority as a legitimate download location. - _ = uri.port - # A public Git/VCS SSH URL may use git@host; web download URLs - # may not contain credentials at all. + # Force port range/decimal syntax validation, including :not-a-port. + port = uri.port + if port is not None and not (1 <= port <= 65535): + return False + # Only the standard public Git SSH user is supported; userinfo must + # not be carried in download locations for HTTP, FTP or other VCS. if uri.username is not None and not ( - uri.scheme.endswith("+ssh") or uri.scheme == "ssh"): + (uri.scheme == "ssh" or uri.scheme.endswith("+ssh")) and + uri.username == "git"): return False + host = uri.hostname + if ":" in host: + # Bracketed IPv6 is allowed, but IPv6 zone IDs are not pinned. + try: + ipaddress.IPv6Address(host) + except ValueError: + return False + else: + labels = host.split(".") + if (len(host) > 253 or + not all(re.fullmatch( + r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?", + label) for label in labels)): + return False return True except ValueError: return False diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index a9f63dd7..ae77178b 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -174,6 +174,16 @@ def descriptor(kind, digest, **extra): del body["packages"][0]["downloadLocation"] elif spdx_case == "invalid-download": body["packages"][0]["downloadLocation"] = "garbage" + elif spdx_case == "invalid-percent": + body["packages"][0]["downloadLocation"] = "https://example.test/%zz" + elif spdx_case == "invalid-percent-host": + body["packages"][0]["downloadLocation"] = "https://%zz/path" + elif spdx_case == "invalid-port": + body["packages"][0]["downloadLocation"] = "https://example.test:bad/p" + elif spdx_case == "invalid-authority": + body["packages"][0]["downloadLocation"] = "https://bad..host/path" + elif spdx_case == "encoded-control": + body["packages"][0]["downloadLocation"] = "https://example.test/%00" elif spdx_case == "bad-package-checksum": body["packages"][0]["checksums"] = [ {"algorithm":"BOGUS","checksumValue":"0"}] @@ -466,6 +476,7 @@ def test_valid_advertised_platform_at_every_nested_index_level(self): def test_supported_spdx_download_locations(self): accepted = ("NONE", "NOASSERTION", "https://example.test/archive.tar.gz", + "https://example.test/archive%20name.tar.gz", "ftp://example.test/private.tar.gz", "git+https://example.test/repo.git@5d2fa", "git+ssh://git@example.test/repo.git@5d2fa") @@ -474,7 +485,9 @@ def test_supported_spdx_download_locations(self): "https://user:password@example.test/x", "http://user@example.test/x", "javascript:evil()", "https://[bad-ipv6/x", "https://example.test:invalid/path", - "NOASSERTION\n") + "https://%zz/path", "https://example.test/a%2", + "https://example.test/%00", "https://bad..host/p", + "https://user@@example.test/p", "NOASSERTION\n") for location in accepted: with self.subTest(location=location): self.assertTrue(oci.valid_spdx_download_location(location)) @@ -527,7 +540,8 @@ def test_incomplete_spdx_sbom_never_qualifies(self): "bad-namespace", "missing-created", "invalid-created", "missing-creators", "bad-creator", "empty-inventory", "bad-package", "missing-download", "invalid-download", - "bad-package-checksum", "short-sha256", "bogus-algorithm", + "invalid-percent", "invalid-percent-host", "invalid-port", + "invalid-authority", "encoded-control", "bad-package-checksum", "short-sha256", "bogus-algorithm", "missing-file-checksum", "bad-file-checksum", "duplicate-id", "namespace-fragment", "namespace-trailing-fragment") for variant in cases: From 442ea6b06a0d11647bb3a8360c484f569393c9af Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 19:00:58 +0200 Subject: [PATCH 062/147] fix(RELEASE-1): allowlist retained OCI statement strings and failure diagnostics --- scripts/inspect-release-oci.py | 40 ++++++++++++++---- tests/release/test_release_oci_integrity.py | 47 ++++++++++++++++----- 2 files changed, 69 insertions(+), 18 deletions(-) diff --git a/scripts/inspect-release-oci.py b/scripts/inspect-release-oci.py index 339359c6..55be8176 100644 --- a/scripts/inspect-release-oci.py +++ b/scripts/inspect-release-oci.py @@ -14,6 +14,26 @@ MAX_JSON = 8 * 1024 * 1024 SAFE_ANNOTATION_KEYS = {"vnd.docker.reference.type", "vnd.docker.reference.digest"} +SAFE_DESCRIPTOR_TYPES = frozenset({ + "application/vnd.oci.image.index.v1+json", + "application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json", +}) +SAFE_CONFIG_TYPES = frozenset({ + "application/vnd.oci.image.config.v1+json", + "application/vnd.docker.container.image.v1+json", + "application/vnd.oci.empty.v1+json", +}) +SAFE_PREDICATE_TYPES = frozenset({ + "https://spdx.dev/Document", + "https://slsa.dev/provenance/v0.2", + "https://slsa.dev/provenance/v1", +}) +INTOTO_V1 = "https://in-toto.io/Statement/v1" + + +def approved_type(value, allowed): + return value if type(value) is str and value in allowed else "unrecognized" SAFE_SPDX_KEYS = { "SPDXID", "spdxVersion", "name", "downloadLocation", "fileName", "checksums", "filesAnalyzed", "licenseConcluded", "licenseDeclared", @@ -106,9 +126,7 @@ def bounded_descriptor(desc, ancestry): if actual is None or not actual.isfile(): raise ValueError("missing OCI referenced blob") return {"digest": oid, - "media_type": typ if isinstance(typ, str) and - typ.startswith("application/vnd.") and len(typ)<100 - else "unrecognized", + "media_type": approved_type(typ, SAFE_DESCRIPTOR_TYPES), "declared_size": size, "actual_size": actual.size, "descriptor_path": list(ancestry) + [oid], "platform": approved_platform(desc.get("platform")), @@ -121,7 +139,7 @@ def bounded_descriptor(desc, ancestry): result["index_manifests"] = [] result["application_manifest_digest"] = None result["attestation_manifest_digests"] = [] - result["exported_root_digest"] = build.get("containerimage.digest") + result["exported_root_digest"] = digest_only(build.get("containerimage.digest")) inspected = [0] def traverse(desc, ancestry=()): @@ -170,7 +188,9 @@ def traverse(desc, ancestry=()): else None) else: rec["manifest_subject"] = None - rec["config_type"] = (manifest.get("config") or {}).get("mediaType") + rec["config_type"] = approved_type( + (manifest.get("config") or {}).get("mediaType"), + SAFE_CONFIG_TYPES) attested = (rec["annotations"] or {}).get( "vnd.docker.reference.type") == "attestation-manifest" if attested: @@ -207,8 +227,9 @@ def traverse(desc, ancestry=()): safe_keys = (sorted(predicate.keys()) if isinstance(predicate, dict) else []) summary = { - "_type": st.get("_type"), - "predicate_type": st.get("predicateType"), + "_type": approved_type(st.get("_type"), {INTOTO_V1}), + "predicate_type": approved_type( + st.get("predicateType"), SAFE_PREDICATE_TYPES), "subject_count": len(st.get("subject", [])) if isinstance(st.get("subject"), list) else None, # Subject names and untrusted predicate values @@ -263,7 +284,10 @@ def main() -> int: except (OSError, ValueError, TypeError, KeyError, tarfile.TarError) as exc: report = {"schema_version": 1, "diagnostic_only": True, "integrity_verified": False, "public_bot_release": False, - "inspection_error": type(exc).__name__ + ": " + str(exc)} + # No untrusted archive path, metadata or statement string + # may be published when verification refuses an archive. + "inspection_error": type(exc).__name__, + "inspection_failed": True} args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index ae77178b..0e2cdd48 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -35,7 +35,7 @@ def fixture(self, target: Path, *, replace_layer=False, bad_subject=False, application_platform="linux/amd64", index_platform="absent", inner_index_platform="absent", double_nested=False, embedded_media="absent", - config_platform="valid"): + config_platform="valid", diagnostic_canary=None): base = b"original uncompressed OCI application layer\n" expanded = (b"substituted uncompressed OCI application layer\n" if replace_layer else base) @@ -76,7 +76,9 @@ def descriptor(kind, digest, **extra): raise ValueError("unsupported image configuration fixture variant") cfg.update(config_changes[config_platform]) config = store(encode(cfg)) - cfg_desc = descriptor("application/vnd.oci.image.config.v1+json", config) + cfg_desc = descriptor( + diagnostic_canary if diagnostic_canary else + "application/vnd.oci.image.config.v1+json", config) layer_desc = descriptor("application/vnd.oci.image.layer.v1.tar+gzip", tar_layer) if wrong_size == "config": @@ -238,10 +240,10 @@ def descriptor(kind, digest, **extra): if invalid_predicate: body = {"schema_version": 1} statement = encode({ - "_type": "https://in-toto.io/Statement/v1", - "predicateType": predicate, + "_type": diagnostic_canary or "https://in-toto.io/Statement/v1", + "predicateType": diagnostic_canary or predicate, "subject": ([] if subject is None else - [{"name": "offline-fixture", + [{"name": diagnostic_canary or "offline-fixture", "digest": {"sha256": subject}}]), "predicate": body}) lid = store(statement) @@ -274,10 +276,13 @@ def descriptor(kind, digest, **extra): raise ValueError("unknown synthetic subject variant") attest_data["subject"] = subject attest_manifest = store(encode(attest_data)) + annotations={"vnd.docker.reference.type": "attestation-manifest", + "vnd.docker.reference.digest": ( + "sha256:" + "f"*64 if bad_reference else application)} + if diagnostic_canary: + annotations["secret.debug"] = diagnostic_canary att_desc = descriptor(oci_manifest, attest_manifest, - annotations={"vnd.docker.reference.type": "attestation-manifest", - "vnd.docker.reference.digest": ( - "sha256:" + "f"*64 if bad_reference else application)}, + annotations=annotations, platform={"os": "unknown", "architecture": "unknown"}) if wrong_size == "application": app_desc["size"] += 1 @@ -355,8 +360,8 @@ def advertise_index(entry, value): if bad_metadata_descriptor: reported_desc["size"] += 1 meta.write_text(json.dumps({ - "image.name": "docker.io/allfather/release1:offline", - "containerimage.digest": reported, + "image.name": diagnostic_canary or "docker.io/allfather/release1:offline", + "containerimage.digest": diagnostic_canary or reported, "containerimage.descriptor": reported_desc})) inspected = target / "docker-inspect.json" inspected.write_text(json.dumps({"Id": config, "Architecture": "amd64"})) @@ -680,6 +685,28 @@ def test_slsa_predicates_cannot_be_schema_only(self): with self.assertRaisesRegex(ValueError, "SLSA"): oci.verify_archive(archive, meta, docker_inspect=inspection) + def test_all_diagnostic_statement_values_are_allowlisted(self): + mod_spec=importlib.util.spec_from_file_location( + "release1_diag_canary",ROOT/"scripts/inspect-release-oci.py") + mod=importlib.util.module_from_spec(mod_spec) + mod_spec.loader.exec_module(mod) + marker="CANARY-private-access-token-DO-NOT-RETAIN" + with tempfile.TemporaryDirectory() as d: + archive,meta,_=self.fixture(Path(d),diagnostic_canary=marker) + report=mod.inspect(archive,meta) + serialized=json.dumps(report) + self.assertNotIn(marker,serialized) + self.assertFalse(report["integrity_verified"]) + self.assertIsNone(report["buildkit_digest"]) + attest=report["index_manifests"][0]["children"][1] + for layer in attest["layers"]: + for statement in layer["statements"]: + self.assertEqual(statement["_type"],"unrecognized") + self.assertEqual(statement["predicate_type"],"unrecognized") + self.assertEqual( + report["index_manifests"][0]["children"][0]["config_type"], + "unrecognized") + def test_retained_oci_diagnostics_do_not_echo_untrusted_subject_names(self): mod_spec=importlib.util.spec_from_file_location( "release1_safe_diag",ROOT/"scripts/inspect-release-oci.py") From a471677ace2b0f838fec683dddeb83324b90de5e Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 19:03:46 +0200 Subject: [PATCH 063/147] docs(RELEASE-1): reconcile latest source-linked P2 fixes and CI stop conditions --- docs/CODEX_POSTMERGE_CLOSURE.md | 15 +++++++++++++++ docs/RELEASE_1_OPERATIONS.md | 12 ++++++++++++ scripts/check-release-snapshot.py | 9 +++++++-- tests/release/test_release_snapshot_preflight.py | 13 +++++++++++++ 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/docs/CODEX_POSTMERGE_CLOSURE.md b/docs/CODEX_POSTMERGE_CLOSURE.md index 3aee0fa2..ad27b688 100644 --- a/docs/CODEX_POSTMERGE_CLOSURE.md +++ b/docs/CODEX_POSTMERGE_CLOSURE.md @@ -129,3 +129,18 @@ This entry documents implementation intent and staged source changes, **not** ap | Diagnostic artifact privacy | Diagnostic inspector now allowlists OCI annotations and SPDX field keys, summarizes only subject digests/counts and strips untrusted subject names/annotations and raw predicate contents. Negative tests assert no raw subject names or package inventory values appear in retained output. | **Unmodified non-grants:** move/search authority, runtime budgets, external network permissions, runtime dependency locks, licensing, publisher signing, destination-host admission, real Lichess credentials, public release, Elo or strength claims. All 11 exact-head workflows and final Codex code/security reviews must complete before merging draft PR #71. New scanner-compatibility is an additional job inside RELEASE-1, not a replacement for the real packaged image, OCI and smoke qualifications. + +## PR #71 October 17 — newest Codex P2 closure campaign (source staged; CI unverified) + +Current 2026-10-17 repair scope is source-linked against PR head f2e067ec. The previous exact-head workflow had 10/11 green, RELEASE-1 failed during `scripts/check-release-snapshot.py` HTTP body read with an unhandled TimeoutError; 113/113 fast contracts and the real digest-pinned scanner (one SPDX document: 93 packages, 2,265 files) passed. The full packaged image, OCI/SLSA export and aggregate did **not** execute at that head. All rows below mean IMPLEMENTED / AWAITING FINAL-HEAD CI unless independent evidence later changes the status. + +| Codex thread | Source change / test mapping | +| --- | --- | +| [GET allowlist / status](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4272459243) | `scripts/release_http_contract.py` is the single pure GET/POST admission function, dynamically reused by both `scripts/qualify-release-smoke.py` and `scripts/qualify-release-aggregate.py`. Exact six fake-server GET endpoints and status 200 required; no wildcard GET. `tests/release/test_release_http_contract.py`, `tests/release/test_release_smoke_evidence.py`, `tests/release/test_release_aggregate.py` challenge fabricated 200/401/429/403 and query-like endpoints. New helper is source-attested through `scripts/build-release-candidate.py`. | +| [OCI retained statement types](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4272459258) | `scripts/inspect-release-oci.py` now allowlists `_type`, `predicateType`, descriptor and config mediaType, sanitizes exported BuildKit digests and strips exception-derived freeform strings. Canary injection in `tests/release/test_release_oci_integrity.py` covers statement, subject name, annotation, config type and BuildKit metadata with an otherwise traversable archive. Diagnostic-only claim remains `integrity_verified=False`. | +| [SPDX malformed percent escape](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4272459266) | `scripts/verify-release-oci.py` requires well-formed `%HH`, no percent-escaped controls or encoded authorities; checks valid host labels, bracketed IPv6, port, and limited SSH username. `tests/release/test_release_oci_integrity.py` includes direct and fully rehashed attestation cases for malformed authority/percent/port and valid encoded path. | +| [ONLINE-4B actual URL vs UCI](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4272459279) | `scripts/qualify-online-recovery-v2.py` matches canonical URL `prefix+uci` separately for HTTP and trace before legal board/intent admission, rejects percent-encoded alternate move spellings, and preserves the existing counter/request journal checks. Also tightened `scripts/qualify-online-recovery.py` cross-generation trace witness. Adversarial controls in `tests/lichess/test_recovery_v2_evidence.py` and `tests/lichess/test_recovery_evidence.py`. | +| [Empty/truncated snapshot preflight](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4272459292) | `scripts/check-release-snapshot.py` consumes a bounded complete response, verifies a plausible PGP clear-signed Ubuntu InRelease envelope and exact suite/URL/source identity, rejects redirect/empty/truncated bodies, catches network/read timeout and reports structured failure with a global deadline. `tests/release/test_release_snapshot_preflight.py` covers 503, timeout at open/read, malformed signed envelope, unsupported suite, wrong content length, source-stanza alteration, slow drip, and artifact-on-failure. This is availability-only, never a signature-verification grant. | +| [Mixed APT failures](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4272459302) | `deploy/release/install-pinned-apt.sh` classifies each `Err:` / `E: Failed to fetch` acquisition, permits retry only for 429/502/503/504 and refuses concurrent DNS, signature, hash, TLS, 401, 404 or unknown errors. `tests/release/test_release_snapshot_retry.py` exercises multi-suite 503 and mixed-failure refusal. Snapshot, signing keys, package versions and old-index cleanup remain unchanged. | + +The adjacent source-attestation and runtime contracts remain unchanged in authority. This is **not** OS/wheel lock promotion, proof of a publisher signature, live Lichess authority, software distribution approval, a claimed Elo/playing-strength result, or merge approval. Final gate: all 11 exact-head workflows green, true offline packaged R00/R02/R10 and independent SQLite/OCI/SLSA/aggregate, then fresh Codex code and security reviews. Do not resolve review threads without tested evidence. diff --git a/docs/RELEASE_1_OPERATIONS.md b/docs/RELEASE_1_OPERATIONS.md index d19fdda3..83227122 100644 --- a/docs/RELEASE_1_OPERATIONS.md +++ b/docs/RELEASE_1_OPERATIONS.md @@ -265,3 +265,15 @@ The green supporting workflows and full image smoke testing remain necessary but **Admission and evidence:** scripts/qualify-release-aggregate.py now requires an independent host smoke verdict with persisted SQLite matching actual disk, exact case identity, no publisher/public grant, and release_blocked=True. The aggregate also checks the selected exported OCI root digest against BuildKit's declared digest. Allowed HTTP statuses and paths are rechecked as defense in depth; legal move authorization and no-blind-retry stay exclusively with the strict host verifier. Generated diagnostic artifacts are allowlisted, free of raw BuildKit metadata, image layers, subject names and unreviewed annotations. **Stop condition:** An exact-head test runner must pass the complete RELEASE-1 fast contracts, scanner compatibility, pinned signed APT, installed Docker image, R00/R02/R10 persisted real-process cases, OCI attestation and SLSA validation, and final aggregate; all other workflow gates plus fresh Codex review must be green before draft PR merge. The OS/wheel locks remain incomplete and public distribution remains prohibited. + +## PR #71 October 17: fault-evidence admission and snapshot/diagnostic follow-up + +The authoritative RELEASE-1 HTTP request contract is now `scripts/release_http_contract.py` (host-only, source-attested). Exact GET endpoints are `/api/stream/event`, `/api/bot/game/stream/off3b001`, `/api/account`, `/api/account/playing`, `/api/users/status`, and `/game/export/off3b001`, status 200, with no move request ID. No wildcard GET or query variant is admitted. The independent packaged-smoke verifier and downstream aggregate both call this same contract, while the independent verifier additionally validates legal UCI/board, durable SQLite rows, ACK handling and hold windows. This is evidence admission, not authorization for live external services. + +ONLINE-4A/4B source evidence now refuses a recorded legal UCI that differs from the actually submitted canonical move URL; percent-encoded alias paths are not accepted as a substitute for the canonical path. Counter matching alone does not establish move/intent identity. Real fake-server request and immutable trace evidence remain the inputs; no chess engine move-selection behavior is changed. + +Snapshot availability preflight reads the complete bounded signed-cleartext InRelease document, checks required beginning/end delimiters and suite identity, prohibits unexpected redirects, checks exact Deb822 source stanzas, handles TimeoutError and socket read failures, enforces a global budget, and writes a sanitized failure report. This preflight remains diagnostic only: cryptographic GPG verification is still performed by pinned Ubuntu APT during image building. The APT retry classifier permits retry only if **all** acquisition failures are among transient HTTP 429/502/503/504; unknown and mixed errors fail closed. + +The OCI diagnostic inspector runs before strict verification and may be uploaded on failure. It therefore now allowlists statement `_type`, `predicateType`, descriptor/config media types, accepted annotations and subject digests, and does not echo arbitrary error text or source metadata. Validation itself still requires the complete application config/layer/SPDX/SLSA subject graph; sanitized diagnostics do not establish qualification. + +Do not mark these changes proven until exact-head CI passes. Retain the frozen no-publication, no-credential, no-host-admission, no-live-transport, no-license-promotion and incomplete OS/wheel-lock requirements. Run original RELEASE-1, ONLINE-4A/4B, baseline/LOCAL-1/LC0 and policy workflows, then request final Codex reviews. diff --git a/scripts/check-release-snapshot.py b/scripts/check-release-snapshot.py index b3616ec1..4f8fafce 100644 --- a/scripts/check-release-snapshot.py +++ b/scripts/check-release-snapshot.py @@ -84,12 +84,16 @@ def validate_inrelease(body: bytes, suite: str) -> None: "Ubuntu InRelease suite or release metadata invalid") -def read_complete(response, expected_url: str, suite: str) -> None: +def read_complete(response, expected_url: str, suite: str, *, + deadline: float | None = None, + clock=time.monotonic) -> None: observed = response.geturl() if hasattr(response, "geturl") else expected_url require(observed == expected_url, "Ubuntu snapshot redirected outside frozen suite URL") content = bytearray() while True: + require(deadline is None or clock() < deadline, + "snapshot global preflight budget expired during body read") part = response.read(min(65536, MAX_INRELEASE_BYTES + 1 - len(content))) if not part: break @@ -118,7 +122,8 @@ def probe(snapshot: str, *, opener=urlopen, sleeper=time.sleep, require(left > 1.0, "pinned snapshot global preflight budget exhausted") try: with opener(url, timeout=min(10.0, left - 0.5)) as response: - read_complete(response, url, suite) + read_complete(response, url, suite, + deadline=deadline, clock=clock) results[suite] = {"reachable": True, "attempts": attempt} break except HTTPError as exc: diff --git a/tests/release/test_release_snapshot_preflight.py b/tests/release/test_release_snapshot_preflight.py index 2761a601..c19c6e18 100644 --- a/tests/release/test_release_snapshot_preflight.py +++ b/tests/release/test_release_snapshot_preflight.py @@ -135,6 +135,19 @@ def test_sources_require_exact_two_signed_stanzas(self): with self.subTest(change=poisoned[-60:]), self.assertRaises(ValueError): checker.validate_sources(poisoned,"20261001T000000Z") + def test_streaming_read_cannot_extend_global_deadline_indefinitely(self): + moment = [0.0] + class DelayedResponse(Response): + def read(self,n): + moment[0] = 150.0 + return b"a" + def opener(url,timeout): + return DelayedResponse(url,envelope("noble")) + with self.assertRaisesRegex(ValueError,"budget expired"): + checker.probe("20261001T000000Z", + opener=opener, sleeper=lambda _:None, + clock=lambda:moment[0]) + def test_bad_snapshot_identity_rejected_before_network(self): with self.assertRaisesRegex(ValueError,"invalid pinned"): checker.probe("20261001T000000Z/../latest", From 43d76fee940635e6abfbc31d19f5ba6bcec7c65d Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 19:05:55 +0200 Subject: [PATCH 064/147] test(RELEASE-1): align retry and rejection assertions with strict shared contracts --- scripts/qualify-release-smoke.py | 2 ++ tests/release/test_release_contracts.py | 3 ++- tests/release/test_release_smoke_evidence.py | 2 +- tests/release/test_release_snapshot_retry.py | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/qualify-release-smoke.py b/scripts/qualify-release-smoke.py index 05191ce6..e28806b3 100644 --- a/scripts/qualify-release-smoke.py +++ b/scripts/qualify-release-smoke.py @@ -42,6 +42,8 @@ raise ValueError("independent HTTP contract module missing") http_contract = _http_importlib.module_from_spec(_http_spec) _http_spec.loader.exec_module(http_contract) +ALLOWED_POSTS = http_contract.ALLOWED_POSTS +MOVE_PREFIX = http_contract.MOVE_PREFIX def require(value, reason): diff --git a/tests/release/test_release_contracts.py b/tests/release/test_release_contracts.py index 5ba068b0..5b536fed 100644 --- a/tests/release/test_release_contracts.py +++ b/tests/release/test_release_contracts.py @@ -110,7 +110,8 @@ def test_pinned_snapshot_retries_are_bounded_without_mirror_fallback(self): self.assertIn('Acquire::Retries=1',helper) self.assertIn('Acquire::https::Timeout=25',helper) self.assertIn("apt-get",helper) - self.assertIn('integrity_error || ! retryable',helper) + self.assertIn('only_transient_acquisition_failures',helper) + self.assertIn('if ! only_transient_acquisition_failures',helper) self.assertIn("COPY deploy/release/install-pinned-apt.sh",docker) self.assertIn("/bin/sh /usr/local/bin/install-pinned-apt",docker) self.assertIn("COPY deploy/release/ubuntu.sources",docker) diff --git a/tests/release/test_release_smoke_evidence.py b/tests/release/test_release_smoke_evidence.py index 8bf05ed1..5e1754d4 100644 --- a/tests/release/test_release_smoke_evidence.py +++ b/tests/release/test_release_smoke_evidence.py @@ -190,7 +190,7 @@ def test_forged_get_never_qualifies_producer_success(self): "status":status,"request_id":rid, "monotonic_ms":30}) with self.assertRaisesRegex(ValueError, - "unapproved GET or POST"): + "unapproved GET or POST|unexpected or malformed HTTP"): qual.verify_smoke(doc,"r00-white") def test_position_illegal_uci_fails_even_when_the_producer_says_ready(self): diff --git a/tests/release/test_release_snapshot_retry.py b/tests/release/test_release_snapshot_retry.py index d11eda26..c652984c 100644 --- a/tests/release/test_release_snapshot_retry.py +++ b/tests/release/test_release_snapshot_retry.py @@ -25,7 +25,7 @@ echo "E: Failed to fetch InRelease 503 Service Unavailable" >&2 exit 100 fi ;; - always503) echo "E: InRelease 503 Service Unavailable" >&2; exit 100 ;; + always503) echo "E: Failed to fetch noble/InRelease 503 Service Unavailable" >&2; exit 100 ;; badsignature) echo "E: BADSIG GPG signature failure" >&2 echo "E: InRelease 503 Service Unavailable" >&2 From 8d4a378cff1a327c06d9d3476e02050bc2988dc5 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 19:39:17 +0200 Subject: [PATCH 065/147] fix(RELEASE-1): apply strict shared URI grammar to SPDX namespace and SLSA --- scripts/verify-release-oci.py | 129 ++++++++++---------- tests/release/test_release_oci_integrity.py | 28 ++++- 2 files changed, 90 insertions(+), 67 deletions(-) diff --git a/scripts/verify-release-oci.py b/scripts/verify-release-oci.py index d5722a66..3bbf2427 100644 --- a/scripts/verify-release-oci.py +++ b/scripts/verify-release-oci.py @@ -120,15 +120,63 @@ def verify_layer_diff_ids(archive: tarfile.TarFile, members: dict, STATEMENT = "https://in-toto.io/Statement/v1" -def valid_identity_uri(value: object) -> bool: - """Require an actual URI for a SLSA build type or builder identity.""" - if not isinstance(value, str) or not value.strip() or value != value.strip(): +def valid_uri_syntax(value: object, allowed_schemes: set[str], *, + allow_git_ssh_user: bool = False) -> bool: + """One URI grammar for SPDX identities, downloads and SLSA identities. + + Field-specific scheme, special-token and fragment policy is applied by + callers; urlsplit() alone does not check escapes, credentials or ports. + """ + if not isinstance(value, str) or not value or value != value.strip(): + return False + if any(ord(c) < 33 or ord(c) == 127 for c in value): + return False + if (re.search(r"%(?![0-9A-Fa-f]{2})", value) or + re.search(r"%(?:0[0-9A-Fa-f]|1[0-9A-Fa-f]|7[fF])", + value, re.IGNORECASE)): + return False + try: + uri = urlsplit(value) + if uri.scheme not in allowed_schemes: + return False + if uri.scheme == "urn": + return not uri.netloc and bool(uri.path) and ":" in uri.path + if (not uri.netloc or not uri.hostname or + "%" in uri.netloc or uri.netloc.count("@") > 1 or + uri.password is not None): + return False + port = uri.port + if port is not None and not (1 <= port <= 65535): + return False + if uri.username is not None and not ( + allow_git_ssh_user and uri.username == "git" and + (uri.scheme == "ssh" or uri.scheme.endswith("+ssh"))): + return False + host = uri.hostname + if ":" in host: + try: + ipaddress.IPv6Address(host) + except ValueError: + return False + else: + labels = host.split(".") + if (len(host) > 253 or not all(re.fullmatch( + r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?", + label) for label in labels)): + return False + return True + except ValueError: return False - parsed = urlsplit(value) - return ((parsed.scheme in ("https", "http") and bool(parsed.netloc) - and not any(ch.isspace() for ch in value)) - or (parsed.scheme == "urn" and bool(parsed.path) - and ":" in parsed.path and not any(ch.isspace() for ch in value))) + + +def valid_identity_uri(value: object) -> bool: + """SLSA identities support HTTP(S) and valid URNs, not user credentials.""" + return valid_uri_syntax(value, {"https", "http", "urn"}) + + +def valid_spdx_namespace(value: object) -> bool: + """SPDX namespace uses a valid identity URI without any # delimiter.""" + return valid_identity_uri(value) and "#" not in value def validate_slsa(kind: str, predicate: dict) -> None: @@ -169,64 +217,14 @@ def validate_slsa(kind: str, predicate: dict) -> None: def valid_spdx_download_location(value: object) -> bool: - """Supported SPDX Download Location values: special token or URL/VCS URI. - - This is syntax and release-profile validation, NOT reachability, - authenticity or evidence the package bytes came from this URI. - """ + """Recognize SPDX special tokens or supported strictly valid URL/VCS URI.""" if value in ("NONE", "NOASSERTION"): return True - if not isinstance(value, str) or not value or value.strip() != value: - return False - if any(ord(c) < 33 or ord(c) == 127 for c in value): - return False - # urlsplit is not a URI validator: %zz and a trailing % in the - # authority/path survive splitting. Allow valid escaping in the *path* - # without permitting ambiguous encoded hosts or control bytes. - if (re.search(r"%(?![0-9A-Fa-f]{2})", value) or - re.search(r"%(?:0[0-9A-Fa-f]|1[0-9A-Fa-f]|7[fF])", - value, re.IGNORECASE)): - return False - try: - uri = urlsplit(value) - supported = { - "http", "https", "ftp", "ftps", "ssh", "git", "git+https", - "git+http", "git+ssh", "git+git", "svn", "svn+https", - "svn+ssh", "hg", "hg+https", "hg+ssh", - } - if (uri.scheme not in supported or not uri.netloc or not uri.hostname - or "%" in uri.netloc or uri.netloc.count("@") > 1): - return False - if uri.password is not None: - return False - # Force port range/decimal syntax validation, including :not-a-port. - port = uri.port - if port is not None and not (1 <= port <= 65535): - return False - # Only the standard public Git SSH user is supported; userinfo must - # not be carried in download locations for HTTP, FTP or other VCS. - if uri.username is not None and not ( - (uri.scheme == "ssh" or uri.scheme.endswith("+ssh")) and - uri.username == "git"): - return False - host = uri.hostname - if ":" in host: - # Bracketed IPv6 is allowed, but IPv6 zone IDs are not pinned. - try: - ipaddress.IPv6Address(host) - except ValueError: - return False - else: - labels = host.split(".") - if (len(host) > 253 or - not all(re.fullmatch( - r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?", - label) for label in labels)): - return False - return True - except ValueError: - return False - + return valid_uri_syntax(value, { + "http", "https", "ftp", "ftps", "ssh", "git", "git+https", + "git+http", "git+ssh", "git+git", "svn", "svn+https", + "svn+ssh", "hg", "hg+https", "hg+ssh", + }, allow_git_ssh_user=True) def valid_spdx_checksums(value: object) -> bool: """Check every listed digest against the supported SPDX algorithm width.""" @@ -271,8 +269,7 @@ def validate_spdx_document(predicate: dict) -> None: predicate.get("dataLicense") == "CC0-1.0" and isinstance(predicate.get("name"), str) and bool(predicate["name"].strip()) and - valid_identity_uri(predicate.get("documentNamespace")) and - "#" not in predicate["documentNamespace"] and + valid_spdx_namespace(predicate.get("documentNamespace")) and valid_date and isinstance(creators, list) and bool(creators) and all(isinstance(creator, str) and diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index 0e2cdd48..85e46cbc 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -222,6 +222,16 @@ def descriptor(kind, digest, **extra): "fileName":"./file", "checksums":[{"algorithm":"SHA256", "checksumValue":"a"*64}]}] + elif spdx_case == "namespace-percent": + body["documentNamespace"] = "https://%zz/path" + elif spdx_case == "namespace-port": + body["documentNamespace"] = "https://example.test:bogus/x" + elif spdx_case == "namespace-control": + body["documentNamespace"] = "https://example.test/path\\x00" + elif spdx_case == "namespace-userinfo": + body["documentNamespace"] = "https://user:pw@example.test/a" + elif spdx_case == "namespace-encoded-path": + body["documentNamespace"] = "https://example.test/a%zz" elif spdx_case == "namespace-fragment": body["documentNamespace"] += "#other" elif spdx_case == "namespace-trailing-fragment": @@ -516,6 +526,20 @@ def test_spdx_algorithm_lengths_and_digest_canonicality(self): [{"algorithm":"SHA256","checksumValue":"a"*64}]*2)) self.assertFalse(oci.valid_spdx_checksums([])) + def test_uri_grammar_is_shared_without_collapsing_identity_policies(self): + for uri in ("https://example.test/build@v1", "urn:allfather:build:v1"): + self.assertTrue(oci.valid_identity_uri(uri)) + self.assertTrue(oci.valid_spdx_namespace( + "https://example.test/spdx%20namespace")) + for uri in ("https://%zz/path", "https://example.test:bogus/x", + "https://user:secret@example.test/x", + "https://example.test/a\\x00", "https://example.test/a%2", + "https://example.test/foo#"): + with self.subTest(uri=uri): + self.assertFalse(oci.valid_spdx_namespace(uri)) + self.assertTrue(oci.valid_spdx_download_location("NOASSERTION")) + self.assertFalse(oci.valid_spdx_namespace("NOASSERTION")) + def test_spdx_file_only_inventory_uses_fileName_not_package_name(self): with tempfile.TemporaryDirectory() as d: archive, meta, inspect = self.fixture(Path(d), spdx_case="file-only-valid") @@ -548,7 +572,9 @@ def test_incomplete_spdx_sbom_never_qualifies(self): "invalid-percent", "invalid-percent-host", "invalid-port", "invalid-authority", "encoded-control", "bad-package-checksum", "short-sha256", "bogus-algorithm", "missing-file-checksum", "bad-file-checksum", "duplicate-id", "namespace-fragment", - "namespace-trailing-fragment") + "namespace-trailing-fragment", "namespace-percent", + "namespace-port", "namespace-control", "namespace-userinfo", + "namespace-encoded-path") for variant in cases: with self.subTest(spdx=variant), tempfile.TemporaryDirectory() as d: archive, meta, inspection = self.fixture( From c1d528f69fa706eb78268e72c5fbcf8ae6100937 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 19:41:12 +0200 Subject: [PATCH 066/147] fix(RELEASE-1): preserve canonical full HTTP request-target semantics and redact disallowed queries --- scripts/build-release-candidate.py | 1 + scripts/release_http_contract.py | 30 ++++----- tests/lichess/fake_lichess_api.py | 22 ++++++- tests/lichess/http_target_contract.py | 68 ++++++++++++++++++++ tests/lichess/test_fake_api.py | 28 ++++++++ tests/release/test_release_aggregate.py | 10 ++- tests/release/test_release_http_contract.py | 22 ++++++- tests/release/test_release_smoke_evidence.py | 5 ++ 8 files changed, 162 insertions(+), 24 deletions(-) create mode 100644 tests/lichess/http_target_contract.py diff --git a/scripts/build-release-candidate.py b/scripts/build-release-candidate.py index e82d6333..a0abcfe8 100644 --- a/scripts/build-release-candidate.py +++ b/scripts/build-release-candidate.py @@ -34,6 +34,7 @@ "deploy/lichess/bridge_policy.py", "deploy/lichess/log_capture.py", "tests/lichess/fake_lichess_api.py", + "tests/lichess/http_target_contract.py", "tests/lichess/scripted_opponent.py", "tests/lichess/fault_plan.py", "tests/lichess/stream_sessions.py", diff --git a/scripts/release_http_contract.py b/scripts/release_http_contract.py index c4edf231..975ad029 100644 --- a/scripts/release_http_contract.py +++ b/scripts/release_http_contract.py @@ -8,23 +8,13 @@ from __future__ import annotations import re -GAME_ID = "off3b001" -MOVE_PREFIX = f"/api/bot/game/{GAME_ID}/move/" -ALLOWED_POSTS = frozenset({ - "/api/token/test", - f"/api/challenge/{GAME_ID}/accept", - f"/api/challenge/{GAME_ID}/decline", -}) -ALLOWED_GETS = frozenset({ - "/api/stream/event", - f"/api/bot/game/stream/{GAME_ID}", - "/api/account", - "/api/account/playing", - "/api/users/status", - f"/game/export/{GAME_ID}", -}) -UCI_TOKEN = re.compile(r"[a-h][1-8][a-h][1-8][qrbn]?\Z") +from tests.lichess.http_target_contract import ( + GAME_ID, MOVE_PREFIX, APPROVED_GETS, APPROVED_POSTS, UCI, + canonical_target) +ALLOWED_GETS = APPROVED_GETS +ALLOWED_POSTS = APPROVED_POSTS +UCI_TOKEN = UCI def allowed_release_http(row: object, key: str) -> bool: """No implicit wildcard GET and no successful forbidden HTTP response.""" @@ -35,11 +25,15 @@ def allowed_release_http(row: object, key: str) -> bool: path = row.get("path") status = row.get("status") rid = row.get("request_id") + target = row.get("request_target") if (method not in ("GET", "POST") or - type(path) is not str or type(status) is not int): + type(path) is not str or type(status) is not int or + row.get("target_canonical") is not True or + not canonical_target(method, target) or + path != target.split("?", 1)[0]): return False if method == "GET": - return path in ALLOWED_GETS and status == 200 and rid is None + return status == 200 and rid is None if path in ALLOWED_POSTS: return status == 200 and rid is None if not path.startswith(MOVE_PREFIX): diff --git a/tests/lichess/fake_lichess_api.py b/tests/lichess/fake_lichess_api.py index 41f78c91..f968e328 100644 --- a/tests/lichess/fake_lichess_api.py +++ b/tests/lichess/fake_lichess_api.py @@ -21,6 +21,8 @@ from tests.lichess.fault_plan import FaultScheduler from tests.lichess.stream_sessions import StreamSessions from tests.lichess.transport_trace import TransportTrace, state_digest +from tests.lichess.http_target_contract import ( + canonical_target, path_of, safe_audit_fields) TOKEN = "offline-fixture-token" GAME_ID = "off3b001" @@ -196,6 +198,8 @@ def chunk(self, payload): def auth(self) -> bool: if self.headers.get("Authorization") != "Bearer " + TOKEN: + # Even an early rejected request remains an HTTP witness. + self.record(self.command, path_of(self.path), 401) self.respond(401, {"error": "synthetic token required"}) return False return True @@ -205,7 +209,8 @@ def record(self, kind: str, path: str, status: int, with server.lock: row = { "monotonic_ms": round((time.monotonic() - server.start_time) * 1000), - "method": kind, "path": path, "status": status + "method": kind, "status": status, + **safe_audit_fields(kind, self.path), } if request_id is not None: row["request_id"] = request_id @@ -215,7 +220,12 @@ def do_GET(self): self._move_request_id = None # never reuse an HTTP/1.1 request identity if not self.auth(): return - path = urllib.parse.urlparse(self.path).path + path = path_of(self.path) + if not canonical_target("GET", self.path): + safe = safe_audit_fields("GET", self.path)["path"] + self.record("GET", safe, 403) + server.attempted_forbidden.append("GET " + safe) + return self.respond(403, {"error": "offline request-target prohibited"}) if path == "/api/stream/event": self.record("GET", path, 200) if server.sessions is not None: @@ -280,7 +290,13 @@ def do_POST(self): return self.respond(413, {"error": "body too large"}) if length: self.rfile.read(length) - path = urllib.parse.urlparse(self.path).path + path = path_of(self.path) + if not canonical_target("POST", self.path): + safe = safe_audit_fields("POST", self.path)["path"] + with server.lock: + server.attempted_forbidden.append("POST " + safe) + self.record("POST", safe, 403) + return self.respond(403, {"error": "offline request-target prohibited"}) if path == "/api/token/test": self.record("POST", path, 200) return self.respond(200, {TOKEN: {"scopes": "bot:play"}}) diff --git a/tests/lichess/http_target_contract.py b/tests/lichess/http_target_contract.py new file mode 100644 index 00000000..ed44667a --- /dev/null +++ b/tests/lichess/http_target_contract.py @@ -0,0 +1,68 @@ +"""Canonical HTTP request-target policy for the pinned offline Lichess fixture. + +This is used by the fake server, RELEASE-1 smoke verifier and aggregate. +Do not store arbitrary raw query strings; unexpected values may be secrets. +""" +from __future__ import annotations + +import hashlib +import re + +GAME_ID = "off3b001" +MOVE_PREFIX = f"/api/bot/game/{GAME_ID}/move/" +APPROVED_GETS = frozenset({ + "/api/stream/event", f"/api/bot/game/stream/{GAME_ID}", + "/api/account", "/api/account/playing", "/api/users/status", + f"/game/export/{GAME_ID}", +}) +APPROVED_POSTS = frozenset({ + "/api/token/test", f"/api/challenge/{GAME_ID}/accept", + f"/api/challenge/{GAME_ID}/decline", +}) +UCI = re.compile(r"[a-h][1-8][a-h][1-8][qrbn]?\Z") +SAFE_PATH = re.compile(r"/[A-Za-z0-9_./-]{0,220}\Z") + + +def path_of(target: object) -> str: + if not isinstance(target, str): + return "__invalid_path__" + path = target.split("?", 1)[0] + return path if SAFE_PATH.fullmatch(path) else "__invalid_path__" + + +def canonical_target(method: object, raw: object) -> bool: + """Admit only raw HTTP origin-form requests matched to pinned bridge usage.""" + if (method not in ("GET", "POST") or not isinstance(raw, str) + or not 1 <= len(raw) <= 255 or not raw.startswith("/") + or raw.startswith("//") or raw.count("?") > 1 + or any(ord(ch) < 33 or ord(ch) > 126 for ch in raw) + or "#" in raw or "%" in raw or "\\" in raw): + return False + path, sep, query = raw.partition("?") + if not SAFE_PATH.fullmatch(path): + return False + if method == "GET": + if path not in APPROVED_GETS: + return False + if not sep: + return True + # The frozen upstream is_online() sends params={"ids": user_id}. + return path == "/api/users/status" and query == "ids=allfatherfixture" + if path in APPROVED_POSTS: + return not sep + if not path.startswith(MOVE_PREFIX) or not UCI.fullmatch( + path[len(MOVE_PREFIX):]): + return False + # Frozen upstream make_move() calls params={"offeringDraw":"true|false"}. + return not sep or query in ("offeringDraw=true", "offeringDraw=false") + + +def safe_audit_fields(method: object, raw: object) -> dict: + """An approved raw target is retained; rejected raw values are hashed.""" + path = path_of(raw) + if canonical_target(method, raw): + return {"path": path, "request_target": raw, + "target_canonical": True} + body = raw.encode("utf-8", errors="replace") if isinstance(raw, str) else b"" + return {"path": path, "request_target": None, "target_canonical": False, + "request_target_sha256": hashlib.sha256(body).hexdigest()} diff --git a/tests/lichess/test_fake_api.py b/tests/lichess/test_fake_api.py index 199327fc..c30eddda 100644 --- a/tests/lichess/test_fake_api.py +++ b/tests/lichess/test_fake_api.py @@ -39,6 +39,34 @@ def test_post_body_and_session_reuse(self): self.assertEqual(p.status_code, 200) self.assertEqual(p.json()["title"], "BOT") + def test_complete_canonical_target_and_query_values_are_observed(self): + self.assertEqual(self.call("GET", + "/api/users/status?ids=allfatherfixture"), 200) + good = self.fake.requests[-1] + self.assertEqual(good["request_target"], + "/api/users/status?ids=allfatherfixture") + self.assertTrue(good["target_canonical"]) + self.assertEqual(good["path"], "/api/users/status") + # A rejected query is never stored verbatim in downloadable evidence. + for target in ("/api/account?access_token=private-test-secret", + "/api/users/status?ids=unapproved", + "/api/users/status?ids=allfatherfixture&other=yes", + "/api/account?x=1"): + self.assertEqual(self.call("GET", target), 403) + record = self.fake.requests[-1] + self.assertFalse(record["target_canonical"]) + self.assertIsNone(record["request_target"]) + self.assertIn("request_target_sha256", record) + self.assertNotIn("private-test-secret", str(record)) + self.assertEqual(self.call( + "POST", "/api/challenge/"+GAME_ID+"/accept"), 200) + for target in ("/api/bot/game/"+GAME_ID+"/move/e2e4?offeringDraw=false", + "/api/bot/game/"+GAME_ID+"/move/e2e4?offeringDraw=true"): + self.assertIn(self.call("POST", target), (200,409)) + record=self.fake.requests[-1] + self.assertTrue(record["target_canonical"]) + self.assertEqual(record["request_target"], target) + def test_duplicate_accept_rejected(self): path = f"/api/challenge/{GAME_ID}/accept" self.assertEqual(self.call("POST", path), 200) diff --git a/tests/release/test_release_aggregate.py b/tests/release/test_release_aggregate.py index bfa4e3a4..0c5d2431 100644 --- a/tests/release/test_release_aggregate.py +++ b/tests/release/test_release_aggregate.py @@ -87,7 +87,9 @@ def setUp(self): "public_bot_release": False, "game": {"complete": key != "r10-white", "accepted": ["e2e4"], "forbidden_attempts": []}, - "http": [{"method": "POST", "path": "/api/token/test", "status": 200}], + "http": [{"method": "POST", "path": "/api/token/test", + "request_target": "/api/token/test", + "target_canonical":True, "status": 200}], "rate_cooldown_verified": key == "r10-white", "fresh_second_epoch_commits": 1 if key == "r02-white" else None, "observations": [{"phase": "synthetic"}], @@ -152,9 +154,13 @@ def test_forged_get_does_not_pass_aggregate(self): target.write_text(json.dumps(original)) def test_http_admission_is_consistent_with_independent_verifier(self): - token={"method":"POST","path":"/api/token/test","status":200} + token={"method":"POST","path":"/api/token/test", + "request_target":"/api/token/test", + "target_canonical":True,"status":200} move={"method":"POST", "path":"/api/bot/game/off3b001/move/e2e4", + "request_target":"/api/bot/game/off3b001/move/e2e4", + "target_canonical":True, "status":200,"request_id":"request-1"} self.assertTrue(aggregate.allowed_release_http(token,"r00-white")) self.assertTrue(aggregate.allowed_release_http(move,"r00-white")) diff --git a/tests/release/test_release_http_contract.py b/tests/release/test_release_http_contract.py index 767cc037..fdc2e115 100644 --- a/tests/release/test_release_http_contract.py +++ b/tests/release/test_release_http_contract.py @@ -19,7 +19,8 @@ class SharedHTTPAdmissionTests(unittest.TestCase): def test_each_allowed_get_and_case_agrees_across_both_verifiers(self): for case in smoke.CASES: for path in smoke.http_contract.ALLOWED_GETS: - row={"method":"GET","path":path,"status":200} + row={"method":"GET","path":path,"status":200, + "request_target":path,"target_canonical":True} with self.subTest(case=case,path=path): self.assertTrue(smoke.http_contract.allowed_release_http( row,case)) @@ -40,11 +41,30 @@ def test_no_unauthorized_get_or_failed_status_is_admissible(self): {"method":"POST", "path":"/api/bot/game/off3b001/move/%65%32e4","status":200, "request_id":"request-1"}, + {"method":"GET","path":"/api/account","status":200, + "request_target":"/api/account?access_token=leak", + "target_canonical":True}, + {"method":"GET","path":"/api/users/status","status":200, + "request_target":"/api/users/status?ids=unauthorized", + "target_canonical":True}, ): with self.subTest(row=row): self.assertFalse(smoke.http_contract.allowed_release_http( row,"r00-white")) self.assertFalse(aggregate.allowed_release_http(row,"r00-white")) + def test_legitimate_upstream_query_forms_are_admitted(self): + for method, raw, rid in ( + ("GET", "/api/users/status?ids=allfatherfixture", None), + ("POST", "/api/bot/game/off3b001/move/e2e4?offeringDraw=false", + "request-1"), + ("POST", "/api/bot/game/off3b001/move/e2e4?offeringDraw=true", + "request-1"), + ): + row = {"method":method, "path":raw.split("?")[0], + "request_target":raw, "target_canonical":True, + "status":200,"request_id":rid} + self.assertTrue(aggregate.allowed_release_http(row,"r00-white")) + if __name__=="__main__": unittest.main() diff --git a/tests/release/test_release_smoke_evidence.py b/tests/release/test_release_smoke_evidence.py index 5e1754d4..2e48ea6d 100644 --- a/tests/release/test_release_smoke_evidence.py +++ b/tests/release/test_release_smoke_evidence.py @@ -149,6 +149,9 @@ def event(kind, **kw): from deploy.lichess.reconcile import position_id for intent in result["journal"]["intents"]: intent["position_id"] = position_id(json.loads(intent["history_json"])) + for request in result["http"]: + request.setdefault("request_target", request["path"]) + request.setdefault("target_canonical", True) seal(result) return result @@ -170,6 +173,8 @@ def test_get_allowlist_matches_fake_api_without_wildcard(self): doc = fixture("r00-white") for endpoint in sorted(qual.http_contract.ALLOWED_GETS): doc["http"].append({"method":"GET", "path":endpoint, + "request_target":endpoint, + "target_canonical":True, "status":200, "monotonic_ms":30}) self.assertTrue(qual.verify_smoke(doc, "r00-white")[ "independent_qualified"]) From 3ce1eb33c0a32817958d84887bb7a3eee43d3a29 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 19:42:14 +0200 Subject: [PATCH 067/147] fix(ONLINE-4A): bind all move POST audit records to request-correlated trace attempts --- scripts/qualify-online-recovery.py | 34 ++++++++++++++++++++++--- tests/lichess/test_recovery_evidence.py | 23 +++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/scripts/qualify-online-recovery.py b/scripts/qualify-online-recovery.py index f26b8885..a7f92baa 100644 --- a/scripts/qualify-online-recovery.py +++ b/scripts/qualify-online-recovery.py @@ -9,6 +9,7 @@ from __future__ import annotations import argparse +from collections import Counter import hashlib import json import subprocess @@ -280,6 +281,34 @@ def invalid_move_submissions(http_requests: list[dict]) -> list[dict]: return invalid +def verify_exact_http_move_audit( + http_requests: list[dict], trace: list[dict]) -> int: + """An observed move POST must have exactly one server trace attempt. + + Multisets include request IDs, not only path membership: a legal extra + POST is not made safe by the trace of a different legal POST. + """ + prefix = "/api/bot/game/off3b001/move/" + require_rows = lambda rows: isinstance(rows, list) and all( + isinstance(row, dict) for row in rows) + ensure(require_rows(http_requests) and require_rows(trace), + "invalid HTTP or trace move audit") + observed = [r for r in http_requests if + r.get("method") == "POST" and + isinstance(r.get("path"), str) and + r["path"].startswith(prefix)] + attempted = [r for r in trace if r.get("event") == "move_attempt"] + ensure(all(type(t.get("seq")) is int and t["seq"] > 0 and + t.get("path") == prefix + t.get("uci", "") and + isinstance(t.get("uci"), str) for t in attempted), + "traced move endpoint identity invalid") + server_rows = Counter((r["path"], r.get("request_id")) for r in observed) + trace_rows = Counter((r["path"], f"request-{r['seq']}") for r in attempted) + ensure(server_rows == trace_rows, + "HTTP move records and trace attempts differ") + return sum(trace_rows.values()) + + def validate_experiment(case_dir: Path, scenario: dict, color: str, source_sha: str, bridge_sha: str) -> dict: result = read_json(case_dir / "result.json") @@ -309,10 +338,7 @@ def validate_experiment(case_dir: Path, scenario: dict, color: str, isinstance(r.get("path"), str) and type(r.get("status")) is int for r in http_requests), "malformed server HTTP request audit") - attempted_paths = [r["path"] for r in http_requests if r["method"] == "POST"] - ensure(all(r.get("path") in attempted_paths for r in trace - if r.get("event") == "move_attempt"), - "HTTP attempt trace has no underlying request audit") + verify_exact_http_move_audit(http_requests, trace) verify_sources(read_json(case_dir / "source-manifest.json"), source_sha) ensure(ndjson(case_dir / "http-attempts.ndjson") == [r for r in trace if r["event"] == "move_attempt"], "HTTP attempt extract drift") diff --git a/tests/lichess/test_recovery_evidence.py b/tests/lichess/test_recovery_evidence.py index 783888ad..e7efde2a 100644 --- a/tests/lichess/test_recovery_evidence.py +++ b/tests/lichess/test_recovery_evidence.py @@ -71,6 +71,29 @@ def test_rejects_invalid_sequence_and_illegal_commit(self): with self.subTest(bad=bad), self.assertRaises(ValueError): QUALIFIER.verify_trace(changed, self.game()) + def test_move_audit_requires_exact_request_id_and_multiset(self): + first="/api/bot/game/off3b001/move/e2e4" + second="/api/bot/game/off3b001/move/d2d4" + trace=[{"seq":1,"event":"move_attempt", + "path":first,"uci":"e2e4","game_id":"off3b001"}] + http=[{"method":"POST","path":first, + "request_id":"request-1","status":200}] + self.assertEqual(QUALIFIER.verify_exact_http_move_audit(http,trace),1) + invalid=[ + http+[{"method":"POST","path":second,"request_id":"request-7", + "status":200}], + http+[dict(http[0])], + [{**http[0],"request_id":"request-2"}], + [], + ] + for rows in invalid: + with self.subTest(rows=rows),self.assertRaisesRegex( + ValueError,"HTTP move records and trace attempts differ"): + QUALIFIER.verify_exact_http_move_audit(rows,trace) + with self.assertRaisesRegex(ValueError,"traced move endpoint identity"): + QUALIFIER.verify_exact_http_move_audit( + http,[{**trace[0],"uci":"d2d4"}]) + def test_does_not_confuse_http_504_with_uncommitted_move(self): t = self.trace() t.insert(1, {"seq": 2, "event": "fault_activated", "action": "commit_then_504", From e218def3b6b5a3bc64805b6c7c717e4c5645bf12 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 19:43:34 +0200 Subject: [PATCH 068/147] fix(RELEASE-1): handle malformed OCI diagnostic objects without leaking or crashing --- scripts/inspect-release-oci.py | 30 +++++++++++----- tests/release/test_release_oci_integrity.py | 40 ++++++++++++++++++++- 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/scripts/inspect-release-oci.py b/scripts/inspect-release-oci.py index 55be8176..b3c3b482 100644 --- a/scripts/inspect-release-oci.py +++ b/scripts/inspect-release-oci.py @@ -84,6 +84,8 @@ def inspect(archive: Path, metadata: Path) -> dict: result = {"schema_version": 1, "diagnostic_only": True, "integrity_verified": False, "public_bot_release": False} build = json.loads(metadata.read_text(encoding="utf-8")) + if type(build) is not dict: + raise ValueError("invalid OCI diagnostic BuildKit metadata") result["buildkit_digest"] = digest_only(build.get("containerimage.digest")) with tarfile.open(archive, "r:*") as tar: members = {} @@ -133,7 +135,7 @@ def bounded_descriptor(desc, ancestry): "annotations": approved_annotations(desc.get("annotations"))} index = document("index.json") - if not isinstance(index, dict) or not isinstance(index.get("manifests"), list): + if type(index) is not dict or type(index.get("manifests")) is not list: raise ValueError("invalid layout index") roots = index["manifests"] result["index_manifests"] = [] @@ -163,6 +165,14 @@ def traverse(desc, ancestry=()): "application/vnd.docker.distribution.manifest.v2+json"): raise ValueError("unexpected descriptor media type") manifest = document("blobs/sha256/" + oid[7:]) + if type(manifest) is not dict: + raise ValueError("invalid OCI diagnostic manifest object") + config = manifest.get("config") + layers = manifest.get("layers") + if type(config) is not dict or type(layers) is not list: + raise ValueError("invalid OCI diagnostic config/layers container") + if any(type(layer) is not dict for layer in layers): + raise ValueError("invalid OCI diagnostic layer descriptor") rec["artifact_type"] = ( "application" if not ( (rec["annotations"] or {}).get("vnd.docker.reference.type") @@ -189,8 +199,7 @@ def traverse(desc, ancestry=()): else: rec["manifest_subject"] = None rec["config_type"] = approved_type( - (manifest.get("config") or {}).get("mediaType"), - SAFE_CONFIG_TYPES) + config.get("mediaType"), SAFE_CONFIG_TYPES) attested = (rec["annotations"] or {}).get( "vnd.docker.reference.type") == "attestation-manifest" if attested: @@ -198,7 +207,7 @@ def traverse(desc, ancestry=()): else: result["application_manifest_digest"] = oid rec["layers"] = [] - for layer in manifest.get("layers", []): + for layer in layers: lid = layer.get("digest", "") member = members.get("blobs/sha256/" + str(lid)[7:]) row = {"digest": digest_only(lid), @@ -216,13 +225,15 @@ def traverse(desc, ancestry=()): "actual_size": member.size if member else None} if attested and layer.get("mediaType") == "application/vnd.in-toto+json": statement = document("blobs/sha256/" + str(lid)[7:]) + if type(statement) is not dict: + raise ValueError("invalid OCI diagnostic attestation object") statements = statement.get("statements") - if not isinstance(statements, list): - statements = [statement] + if type(statements) is not list: + raise ValueError("invalid OCI diagnostic statement list") row["statements"] = [] for st in statements: - if not isinstance(st, dict): - continue + if type(st) is not dict: + raise ValueError("invalid OCI diagnostic statement object") predicate = st.get("predicate") safe_keys = (sorted(predicate.keys()) if isinstance(predicate, dict) else []) @@ -281,7 +292,8 @@ def main() -> int: args = ap.parse_args() try: report = inspect(args.archive, args.metadata) - except (OSError, ValueError, TypeError, KeyError, tarfile.TarError) as exc: + except (OSError, ValueError, TypeError, AttributeError, IndexError, + KeyError, tarfile.TarError) as exc: report = {"schema_version": 1, "diagnostic_only": True, "integrity_verified": False, "public_bot_release": False, # No untrusted archive path, metadata or statement string diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index 85e46cbc..22572001 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -35,7 +35,8 @@ def fixture(self, target: Path, *, replace_layer=False, bad_subject=False, application_platform="linux/amd64", index_platform="absent", inner_index_platform="absent", double_nested=False, embedded_media="absent", - config_platform="valid", diagnostic_canary=None): + config_platform="valid", diagnostic_canary=None, + malformed_diagnostic=None): base = b"original uncompressed OCI application layer\n" expanded = (b"substituted uncompressed OCI application layer\n" if replace_layer else base) @@ -87,6 +88,12 @@ def descriptor(kind, digest, **extra): layer_desc["size"] += 1 application_doc = { "schemaVersion": 2, "config": cfg_desc, "layers": [layer_desc]} + if malformed_diagnostic == "app-config-list": + application_doc["config"] = [cfg_desc] + elif malformed_diagnostic == "app-layers-null": + application_doc["layers"] = None + elif malformed_diagnostic == "app-layer-scalar": + application_doc["layers"] = [7] if embedded_media in ("app-valid", "app-wrong"): application_doc["mediaType"] = ( oci_manifest if embedded_media == "app-valid" else oci_index) @@ -266,6 +273,10 @@ def descriptor(kind, digest, **extra): "config": descriptor("application/vnd.oci.image.config.v1+json", att_config), "layers": attest_layers} + if malformed_diagnostic == "attest-config-list": + attest_data["config"] = [attest_data["config"]] + elif malformed_diagnostic == "attest-layer-null": + attest_data["layers"] = [None] if manifest_subject_kind != "none": subject = descriptor(oci_manifest, application) if manifest_subject_kind == "wrong": @@ -733,6 +744,33 @@ def test_all_diagnostic_statement_values_are_allowlisted(self): report["index_manifests"][0]["children"][0]["config_type"], "unrecognized") + def test_malformed_oci_containers_yield_sanitized_error_and_strict_refusal(self): + mod_spec=importlib.util.spec_from_file_location( + "release1_bad_oci_diagnostic",ROOT/"scripts/inspect-release-oci.py") + mod=importlib.util.module_from_spec(mod_spec) + mod_spec.loader.exec_module(mod) + import sys + for mode in ("app-config-list", "app-layers-null", + "app-layer-scalar", "attest-config-list", + "attest-layer-null"): + with self.subTest(mode=mode),tempfile.TemporaryDirectory() as d: + archive,meta,inspection=self.fixture( + Path(d),malformed_diagnostic=mode) + output=Path(d)/"diagnostic.json" + before=sys.argv + try: + sys.argv=["diag","--archive",str(archive), + "--metadata",str(meta),"--output",str(output)] + self.assertEqual(mod.main(),0) + finally: + sys.argv=before + report=json.loads(output.read_text()) + self.assertFalse(report["integrity_verified"]) + self.assertTrue(report["inspection_failed"]) + self.assertEqual(report["inspection_error"],"ValueError") + with self.assertRaises((ValueError, TypeError)): + oci.verify_archive(archive,meta,docker_inspect=inspection) + def test_retained_oci_diagnostics_do_not_echo_untrusted_subject_names(self): mod_spec=importlib.util.spec_from_file_location( "release1_safe_diag",ROOT/"scripts/inspect-release-oci.py") From c337268ea80a275baab5cb5fe170e9249f6fc6dc Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 19:44:43 +0200 Subject: [PATCH 069/147] fix(RELEASE-1): bind OCI digest to same verified inode and refuse archive races --- scripts/verify-release-oci.py | 542 +++++++++++--------- tests/release/test_release_aggregate.py | 9 + tests/release/test_release_oci_integrity.py | 36 ++ 3 files changed, 331 insertions(+), 256 deletions(-) diff --git a/scripts/verify-release-oci.py b/scripts/verify-release-oci.py index 3bbf2427..c9b1b7b7 100644 --- a/scripts/verify-release-oci.py +++ b/scripts/verify-release-oci.py @@ -6,6 +6,8 @@ """ from __future__ import annotations import argparse +import os +import stat import hashlib import gzip import zlib @@ -37,6 +39,28 @@ def file_digest(path: Path): return sha.hexdigest() +def digest_verified_archive(fileobj, archive: Path, before) -> str: + """Hash the *opened/verified inode*, not a possibly replaced pathname. + + Detect both in-place mutation and atomic pathname replacement. A later + aggregate recomputes the digest from the supplied archive path, so any + replacement after this function also fails admission. + """ + fileobj.seek(0) + h = hashlib.sha256() + for part in iter(lambda: fileobj.read(1 << 20), b""): + h.update(part) + after = os.fstat(fileobj.fileno()) + path_stat = os.stat(archive, follow_symlinks=False) + identity = lambda s: (s.st_dev, s.st_ino, s.st_size, + s.st_mtime_ns, s.st_ctime_ns) + require(stat.S_ISREG(after.st_mode) and + stat.S_ISREG(path_stat.st_mode) and + identity(before) == identity(after) == identity(path_stat), + "OCI archive file was mutated or replaced during verification") + return h.hexdigest() + + def verify_image_config_link(*, image_config_digest: str, metadata_config_digest: str | None, docker_inspection: dict | None) -> bool: @@ -407,264 +431,270 @@ def verify_attestation_subjects(attestations: list[dict], image: dict) -> set[st def verify_archive(archive: Path, metadata: Path, *, docker_inspect: Path | None = None): meta = json.loads(metadata.read_text(encoding="utf-8")) blobs = {} - with tarfile.open(archive, "r:*") as inp: - members = inp.getmembers() - by_name = {} - for obj in members: - name = str(PurePosixPath(obj.name)) - require(not obj.issym() and not obj.islnk() and - not PurePosixPath(name).is_absolute() and - ".." not in PurePosixPath(name).parts and - name not in by_name, - "OCI archive includes unsafe or duplicate path") - by_name[name] = obj - require("oci-layout" in by_name and "index.json" in by_name, - "not an OCI-layout archive") - require(json.load(inp.extractfile(by_name["oci-layout"])).get("imageLayoutVersion") == - "1.0.0", "unsupported OCI layout") - index_member = inp.extractfile(by_name["index.json"]) - require(index_member is not None, "OCI index not readable") - index_raw = index_member.read() - index_digest = "sha256:" + _digest(index_raw) - index = json.loads(index_raw) - require(index.get("schemaVersion") == 2 and - index.get("mediaType") in (None,"application/vnd.oci.image.index.v1+json"), - "invalid OCI index") - referenced = set() - - def get(oid, expected_size): - """Hash the actual tar member and validate its descriptor byte length.""" - require(isinstance(oid, str) and oid.startswith("sha256:") and - HEX.fullmatch(oid[7:]) is not None, "invalid OCI digest") - require(type(expected_size) is int and expected_size >= 0, - "missing or invalid OCI descriptor size") - name = "blobs/sha256/" + oid[7:] - member = by_name.get(name) - require(member is not None and member.isfile(), "missing OCI blob " + oid) - require(member.size == expected_size, - "OCI descriptor size differs from referenced blob") - if name in blobs: - return blobs[name] - stream = inp.extractfile(member) - require(stream is not None, "unreadable OCI blob") - sha = hashlib.sha256() - body = bytearray() if member.size <= 8 * 1024 * 1024 else None - with stream: - while True: - data = stream.read(1 << 20) - if not data: - break - sha.update(data) - if body is not None: - body.extend(data) - require(sha.hexdigest() == oid[7:], "OCI blob hash mismatch " + oid) - referenced.add(name) - if body is not None: - blobs[name] = bytes(body) - return blobs[name] - return None - - INDEX_TYPE = "application/vnd.oci.image.index.v1+json" - MANIFEST_TYPES = ("application/vnd.oci.image.manifest.v1+json", - "application/vnd.docker.distribution.manifest.v2+json") - CONFIG_TYPES = ("application/vnd.oci.image.config.v1+json", - "application/vnd.docker.container.image.v1+json", - "application/vnd.oci.empty.v1+json") - LAYER_TYPES = ("application/vnd.oci.image.layer.v1.tar", - "application/vnd.oci.image.layer.v1.tar+gzip", - "application/vnd.docker.image.rootfs.diff.tar", - "application/vnd.docker.image.rootfs.diff.tar.gzip") - top = index.get("manifests") - require(isinstance(top, list) and bool(top), - "OCI layout has no root descriptors") - reported_manifest = meta.get("containerimage.digest") - matches = [d for d in top if isinstance(d, dict) - and d.get("digest") == reported_manifest] - require(len(matches) == 1, - "BuildKit descriptor digest does not match exported OCI root") - root_descriptor = matches[0] - root_type = root_descriptor.get("mediaType") - require(root_type == INDEX_TYPE or root_type in MANIFEST_TYPES, - "BuildKit root descriptor has unsupported media type") - if root_type == INDEX_TYPE: - require(len(top) == 1, - "nested BuildKit root must be the sole layout root") - else: - require(all(d is root_descriptor or - (isinstance(d, dict) and - (d.get("annotations") or {}).get( - "vnd.docker.reference.type") == "attestation-manifest") - for d in top), - "flat OCI layout has unrelated root descriptors") - reported_descriptor = meta.get("containerimage.descriptor") - require(isinstance(reported_descriptor, dict) and - type(reported_descriptor.get("size")) is int and - all(reported_descriptor.get(k) == root_descriptor.get(k) - for k in ("digest", "mediaType", "size")) and - type(root_descriptor.get("size")) is int, - "BuildKit image descriptor identity/size differs from OCI root") - images, attestations = [], [] - visited_nodes = [0] - - def inspect_descriptor(descriptor, depth=0, ancestors=frozenset()): - require(depth <= 8 and isinstance(descriptor, dict), - "OCI descriptor nesting invalid") - visited_nodes[0] += 1 - require(visited_nodes[0] <= 256, - "OCI descriptor graph exceeds node budget") - typ = descriptor.get("mediaType") - require(typ == INDEX_TYPE or typ in MANIFEST_TYPES, - "unsupported OCI descriptor kind") - oid = descriptor.get("digest") - require(oid not in ancestors, "OCI descriptor graph contains a cycle") - raw = get(oid, descriptor.get("size")) - require(raw is not None, "OCI descriptor JSON too large or missing") - document = json.loads(raw) - require(isinstance(document, dict) and - document.get("schemaVersion") == 2, - "invalid nested OCI index or image manifest") - require(document.get("mediaType") in (None, typ), - "OCI document media type differs from descriptor") - if typ == INDEX_TYPE: - children = document.get("manifests") - require(isinstance(children, list) and bool(children), - "nested OCI index has no child descriptors") - # Validate every *advertised* index platform on the path to - # the runnable image. Attestation-only subindexes do not - # determine the runner's platform. - contains_application = False - for child in children: - child_application = inspect_descriptor( - child, depth + 1, ancestors | {oid}) - contains_application = child_application or contains_application - if contains_application and "platform" in descriptor: - validate_target_platform(descriptor["platform"]) - return contains_application - cfg = document.get("config") - require(isinstance(cfg, dict) and - cfg.get("mediaType") in CONFIG_TYPES, - "OCI image has invalid configuration media type") - config_bytes = get(cfg.get("digest"), cfg.get("size")) - require(config_bytes is not None, - "OCI config cannot be independently parsed") - details = json.loads(config_bytes) - require(isinstance(details, dict), "malformed OCI configuration") - layers = document.get("layers") - require(isinstance(layers, list) and bool(layers), - "OCI image missing layers") - attested = (descriptor.get("annotations") or {}).get( - "vnd.docker.reference.type") == "attestation-manifest" - for layer in layers: - require(isinstance(layer, dict) and - layer.get("mediaType") in ( - ("application/vnd.in-toto+json",) if attested - else LAYER_TYPES), - "unsupported OCI statement or application layer mediaType") - get(layer.get("digest"), layer.get("size")) - if attested: - for layer in layers: - content = get(layer["digest"], layer["size"]) - require(content is not None, - "oversized attestation statement refused") - attestations.append({ - "descriptor": descriptor, - "manifest_subject": document.get("subject"), - "layer_media_type": layer.get("mediaType"), - "statement": json.loads(content)}) - return False + flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + fd = os.open(archive, flags) + with os.fdopen(fd, "rb") as archive_fp: + opened = os.fstat(archive_fp.fileno()) + require(stat.S_ISREG(opened.st_mode), + "OCI archive is not a regular immutable file") + with tarfile.open(fileobj=archive_fp, mode="r:*") as inp: + members = inp.getmembers() + by_name = {} + for obj in members: + name = str(PurePosixPath(obj.name)) + require(not obj.issym() and not obj.islnk() and + not PurePosixPath(name).is_absolute() and + ".." not in PurePosixPath(name).parts and + name not in by_name, + "OCI archive includes unsafe or duplicate path") + by_name[name] = obj + require("oci-layout" in by_name and "index.json" in by_name, + "not an OCI-layout archive") + require(json.load(inp.extractfile(by_name["oci-layout"])).get("imageLayoutVersion") == + "1.0.0", "unsupported OCI layout") + index_member = inp.extractfile(by_name["index.json"]) + require(index_member is not None, "OCI index not readable") + index_raw = index_member.read() + index_digest = "sha256:" + _digest(index_raw) + index = json.loads(index_raw) + require(index.get("schemaVersion") == 2 and + index.get("mediaType") in (None,"application/vnd.oci.image.index.v1+json"), + "invalid OCI index") + referenced = set() + + def get(oid, expected_size): + """Hash the actual tar member and validate its descriptor byte length.""" + require(isinstance(oid, str) and oid.startswith("sha256:") and + HEX.fullmatch(oid[7:]) is not None, "invalid OCI digest") + require(type(expected_size) is int and expected_size >= 0, + "missing or invalid OCI descriptor size") + name = "blobs/sha256/" + oid[7:] + member = by_name.get(name) + require(member is not None and member.isfile(), "missing OCI blob " + oid) + require(member.size == expected_size, + "OCI descriptor size differs from referenced blob") + if name in blobs: + return blobs[name] + stream = inp.extractfile(member) + require(stream is not None, "unreadable OCI blob") + sha = hashlib.sha256() + body = bytearray() if member.size <= 8 * 1024 * 1024 else None + with stream: + while True: + data = stream.read(1 << 20) + if not data: + break + sha.update(data) + if body is not None: + body.extend(data) + require(sha.hexdigest() == oid[7:], "OCI blob hash mismatch " + oid) + referenced.add(name) + if body is not None: + blobs[name] = bytes(body) + return blobs[name] + return None + + INDEX_TYPE = "application/vnd.oci.image.index.v1+json" + MANIFEST_TYPES = ("application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json") + CONFIG_TYPES = ("application/vnd.oci.image.config.v1+json", + "application/vnd.docker.container.image.v1+json", + "application/vnd.oci.empty.v1+json") + LAYER_TYPES = ("application/vnd.oci.image.layer.v1.tar", + "application/vnd.oci.image.layer.v1.tar+gzip", + "application/vnd.docker.image.rootfs.diff.tar", + "application/vnd.docker.image.rootfs.diff.tar.gzip") + top = index.get("manifests") + require(isinstance(top, list) and bool(top), + "OCI layout has no root descriptors") + reported_manifest = meta.get("containerimage.digest") + matches = [d for d in top if isinstance(d, dict) + and d.get("digest") == reported_manifest] + require(len(matches) == 1, + "BuildKit descriptor digest does not match exported OCI root") + root_descriptor = matches[0] + root_type = root_descriptor.get("mediaType") + require(root_type == INDEX_TYPE or root_type in MANIFEST_TYPES, + "BuildKit root descriptor has unsupported media type") + if root_type == INDEX_TYPE: + require(len(top) == 1, + "nested BuildKit root must be the sole layout root") else: - # The index descriptor is how an OCI consumer selects a platform. - # A flat manifest may not advertise one, but any advertised - # platform must agree with the configuration bytes. - advertised = descriptor.get("platform") - if advertised is None: - require(root_type in MANIFEST_TYPES, - "nested OCI application descriptor lacks platform") + require(all(d is root_descriptor or + (isinstance(d, dict) and + (d.get("annotations") or {}).get( + "vnd.docker.reference.type") == "attestation-manifest") + for d in top), + "flat OCI layout has unrelated root descriptors") + reported_descriptor = meta.get("containerimage.descriptor") + require(isinstance(reported_descriptor, dict) and + type(reported_descriptor.get("size")) is int and + all(reported_descriptor.get(k) == root_descriptor.get(k) + for k in ("digest", "mediaType", "size")) and + type(root_descriptor.get("size")) is int, + "BuildKit image descriptor identity/size differs from OCI root") + images, attestations = [], [] + visited_nodes = [0] + + def inspect_descriptor(descriptor, depth=0, ancestors=frozenset()): + require(depth <= 8 and isinstance(descriptor, dict), + "OCI descriptor nesting invalid") + visited_nodes[0] += 1 + require(visited_nodes[0] <= 256, + "OCI descriptor graph exceeds node budget") + typ = descriptor.get("mediaType") + require(typ == INDEX_TYPE or typ in MANIFEST_TYPES, + "unsupported OCI descriptor kind") + oid = descriptor.get("digest") + require(oid not in ancestors, "OCI descriptor graph contains a cycle") + raw = get(oid, descriptor.get("size")) + require(raw is not None, "OCI descriptor JSON too large or missing") + document = json.loads(raw) + require(isinstance(document, dict) and + document.get("schemaVersion") == 2, + "invalid nested OCI index or image manifest") + require(document.get("mediaType") in (None, typ), + "OCI document media type differs from descriptor") + if typ == INDEX_TYPE: + children = document.get("manifests") + require(isinstance(children, list) and bool(children), + "nested OCI index has no child descriptors") + # Validate every *advertised* index platform on the path to + # the runnable image. Attestation-only subindexes do not + # determine the runner's platform. + contains_application = False + for child in children: + child_application = inspect_descriptor( + child, depth + 1, ancestors | {oid}) + contains_application = child_application or contains_application + if contains_application and "platform" in descriptor: + validate_target_platform(descriptor["platform"]) + return contains_application + cfg = document.get("config") + require(isinstance(cfg, dict) and + cfg.get("mediaType") in CONFIG_TYPES, + "OCI image has invalid configuration media type") + config_bytes = get(cfg.get("digest"), cfg.get("size")) + require(config_bytes is not None, + "OCI config cannot be independently parsed") + details = json.loads(config_bytes) + require(isinstance(details, dict), "malformed OCI configuration") + layers = document.get("layers") + require(isinstance(layers, list) and bool(layers), + "OCI image missing layers") + attested = (descriptor.get("annotations") or {}).get( + "vnd.docker.reference.type") == "attestation-manifest" + for layer in layers: + require(isinstance(layer, dict) and + layer.get("mediaType") in ( + ("application/vnd.in-toto+json",) if attested + else LAYER_TYPES), + "unsupported OCI statement or application layer mediaType") + get(layer.get("digest"), layer.get("size")) + if attested: + for layer in layers: + content = get(layer["digest"], layer["size"]) + require(content is not None, + "oversized attestation statement refused") + attestations.append({ + "descriptor": descriptor, + "manifest_subject": document.get("subject"), + "layer_media_type": layer.get("mediaType"), + "statement": json.loads(content)}) + return False else: - validate_target_platform(advertised) - # Only OCI configuration platform fields participate in - # platform selection. Legitimate config/rootfs/history data - # must never be mistaken for descriptor platform extras. - image_platform = { - field: details[field] for field in - ("os", "architecture", "variant", "os.version", "os.features") - if field in details - } - validate_target_platform(image_platform) - images.append({"manifest_digest": oid, - "manifest_media_type": typ, - "manifest_size": descriptor["size"], - "config_digest": cfg["digest"], - "config": details, "layers": layers, - "layer_count": len(layers)}) - return True - - for entry in top: - inspect_descriptor(entry) - require(len(images) == 1, - "require exactly one linux/amd64 application image") - image = images[0] - if root_type in MANIFEST_TYPES: - require(image["manifest_digest"] == root_descriptor["digest"], - "flat OCI root is not the runnable application") - diff_ids=verify_layer_diff_ids(inp,by_name,image["layers"], - image["config"].get("rootfs") or {}) - reported_manifest=meta.get("containerimage.digest") - reported_config=meta.get("containerimage.config.digest") - # BuildKit OCI index metadata can omit this config digest when - # SBOM/provenance attestation manifests are attached. In that case - # use the independent loaded-image inspection as the mandatory link. - inspection = None - if docker_inspect: - inspection = json.loads(docker_inspect.read_text(encoding="utf-8")) - if isinstance(inspection,list): - require(len(inspection) == 1, "ambiguous Docker inspection") - inspection = inspection[0] - verify_image_config_link( - image_config_digest=image["config_digest"], - metadata_config_digest=reported_config, - docker_inspection=inspection, - ) - # The identity was selected from the exported layout root descriptor, - # not from an unrestricted set of digests reachable in the archive. - require(reported_manifest == root_descriptor["digest"], - "BuildKit root descriptor changed during traversal") - config=image["config"].get("config") or {} - require(isinstance(config, dict), "malformed runnable OCI configuration") - require(config.get("User") in ("10001:10001", "10001") and - config.get("Entrypoint")==[ - "/opt/online3b/bin/python", - "/opt/release-tools/deploy/release/entrypoint.py"], - "OCI image launched with unapproved identity/entrypoint") - require(not config.get("ExposedPorts"), "offline candidate must not expose ports") - predicates=verify_attestation_subjects(attestations,image) - # The OCI layout may contain unreferenced internal artifacts that are - # independently hashed but cannot grant authority. - return { - "schema_version":1,"oci_archive_sha256":file_digest(archive), - "oci_image_manifest_digest":image["manifest_digest"], - "oci_index_digest":index_digest, - "oci_exported_root_digest":root_descriptor["digest"], - "oci_exported_root_size":root_descriptor["size"], - "oci_config_digest":image["config_digest"], - "buildkit_digest":reported_manifest, - "buildkit_config_digest_metadata_present": reported_config is not None, - "independent_loaded_image_digest_verified": inspection is not None, - "buildkit_digest_kind":("oci-index" if root_type == INDEX_TYPE - else "application-manifest"), - "oci_application_identity_verified":True, - "oci_attestation_references_verified":True, - "oci_sbom_verified":SPDX in predicates, - "oci_provenance_verified":bool(predicates.intersection(SLSA)), - "oci_layers_verified":True, - "attestation_predicate_types":sorted(predicates), - "oci_layers":image["layer_count"], - "oci_diff_ids_verified":True, - "oci_diff_id_count":len(diff_ids), - "oci_attestation_subjects_verified":True, - "integrity_verified":True,"publisher_signature_verified":False, - "public_bot_release":False,"artifact_distribution_authorized":False, - } + # The index descriptor is how an OCI consumer selects a platform. + # A flat manifest may not advertise one, but any advertised + # platform must agree with the configuration bytes. + advertised = descriptor.get("platform") + if advertised is None: + require(root_type in MANIFEST_TYPES, + "nested OCI application descriptor lacks platform") + else: + validate_target_platform(advertised) + # Only OCI configuration platform fields participate in + # platform selection. Legitimate config/rootfs/history data + # must never be mistaken for descriptor platform extras. + image_platform = { + field: details[field] for field in + ("os", "architecture", "variant", "os.version", "os.features") + if field in details + } + validate_target_platform(image_platform) + images.append({"manifest_digest": oid, + "manifest_media_type": typ, + "manifest_size": descriptor["size"], + "config_digest": cfg["digest"], + "config": details, "layers": layers, + "layer_count": len(layers)}) + return True + + for entry in top: + inspect_descriptor(entry) + require(len(images) == 1, + "require exactly one linux/amd64 application image") + image = images[0] + if root_type in MANIFEST_TYPES: + require(image["manifest_digest"] == root_descriptor["digest"], + "flat OCI root is not the runnable application") + diff_ids=verify_layer_diff_ids(inp,by_name,image["layers"], + image["config"].get("rootfs") or {}) + reported_manifest=meta.get("containerimage.digest") + reported_config=meta.get("containerimage.config.digest") + # BuildKit OCI index metadata can omit this config digest when + # SBOM/provenance attestation manifests are attached. In that case + # use the independent loaded-image inspection as the mandatory link. + inspection = None + if docker_inspect: + inspection = json.loads(docker_inspect.read_text(encoding="utf-8")) + if isinstance(inspection,list): + require(len(inspection) == 1, "ambiguous Docker inspection") + inspection = inspection[0] + verify_image_config_link( + image_config_digest=image["config_digest"], + metadata_config_digest=reported_config, + docker_inspection=inspection, + ) + # The identity was selected from the exported layout root descriptor, + # not from an unrestricted set of digests reachable in the archive. + require(reported_manifest == root_descriptor["digest"], + "BuildKit root descriptor changed during traversal") + config=image["config"].get("config") or {} + require(isinstance(config, dict), "malformed runnable OCI configuration") + require(config.get("User") in ("10001:10001", "10001") and + config.get("Entrypoint")==[ + "/opt/online3b/bin/python", + "/opt/release-tools/deploy/release/entrypoint.py"], + "OCI image launched with unapproved identity/entrypoint") + require(not config.get("ExposedPorts"), "offline candidate must not expose ports") + predicates=verify_attestation_subjects(attestations,image) + # The OCI layout may contain unreferenced internal artifacts that are + # independently hashed but cannot grant authority. + return { + "schema_version":1,"oci_archive_sha256":digest_verified_archive(archive_fp,archive,opened), + "oci_image_manifest_digest":image["manifest_digest"], + "oci_index_digest":index_digest, + "oci_exported_root_digest":root_descriptor["digest"], + "oci_exported_root_size":root_descriptor["size"], + "oci_config_digest":image["config_digest"], + "buildkit_digest":reported_manifest, + "buildkit_config_digest_metadata_present": reported_config is not None, + "independent_loaded_image_digest_verified": inspection is not None, + "buildkit_digest_kind":("oci-index" if root_type == INDEX_TYPE + else "application-manifest"), + "oci_application_identity_verified":True, + "oci_attestation_references_verified":True, + "oci_sbom_verified":SPDX in predicates, + "oci_provenance_verified":bool(predicates.intersection(SLSA)), + "oci_layers_verified":True, + "attestation_predicate_types":sorted(predicates), + "oci_layers":image["layer_count"], + "oci_diff_ids_verified":True, + "oci_diff_id_count":len(diff_ids), + "oci_attestation_subjects_verified":True, + "integrity_verified":True,"publisher_signature_verified":False, + "public_bot_release":False,"artifact_distribution_authorized":False, + } def main(): diff --git a/tests/release/test_release_aggregate.py b/tests/release/test_release_aggregate.py index 0c5d2431..cd0e6489 100644 --- a/tests/release/test_release_aggregate.py +++ b/tests/release/test_release_aggregate.py @@ -325,6 +325,15 @@ def test_no_producer_ready_bit_bypasses_independent_checker(self): root=self.root) + def test_atomically_replaced_archive_fails_downstream_verdict(self): + import os + with tempfile.TemporaryDirectory() as d: + replacement=Path(d)/"replacement.oci.tar" + replacement.write_bytes(b"other valid looking but unverified OCI bytes") + os.replace(replacement,self.oci_archive) + with self.assertRaisesRegex(ValueError,"OCI archive identity"): + self.qualify() + def test_verifier_report_must_match_actual_oci_archive_bytes(self): self.oci_archive.write_bytes(b"tampered OCI archive") with self.assertRaisesRegex(ValueError,"OCI archive identity"): diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index 22572001..4ec07002 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -602,6 +602,42 @@ def test_real_small_oci_graph_binds_every_layer_and_attestation(self): self.assertEqual(report["oci_diff_id_count"],1) self.assertTrue(report["integrity_verified"]) + def test_replacing_oci_path_during_verification_cannot_swap_digest(self): + import os + from unittest import mock + with tempfile.TemporaryDirectory() as d: + folder=Path(d) + first=folder/"first" + second=folder/"second" + first.mkdir() + second.mkdir() + archive,meta,inspection=self.fixture(first) + replacement,_,_=self.fixture(second,spdx_case="file-only-valid") + orig=oci.verify_attestation_subjects + def swap(*args): + os.replace(replacement,archive) + return orig(*args) + with mock.patch.object(oci,"verify_attestation_subjects", + side_effect=swap): + with self.assertRaisesRegex(ValueError, + "archive file was mutated or replaced"): + oci.verify_archive(archive,meta,docker_inspect=inspection) + + def test_oci_mutated_in_place_during_verification_is_refused(self): + from unittest import mock + with tempfile.TemporaryDirectory() as d: + archive,meta,inspection=self.fixture(Path(d)) + orig=oci.verify_attestation_subjects + def mutate(*args): + with archive.open("ab") as writable: + writable.write(b"tampered trailing bytes") + return orig(*args) + with mock.patch.object(oci,"verify_attestation_subjects", + side_effect=mutate): + with self.assertRaisesRegex(ValueError, + "archive file was mutated or replaced"): + oci.verify_archive(archive,meta,docker_inspect=inspection) + def test_rehashed_replacement_layer_still_rejected_by_rootfs(self): with tempfile.TemporaryDirectory() as d: archive,meta,inspection=self.fixture(Path(d),replace_layer=True) From 4826ad0af18b4845a1e33ab3c99ec67c089a821d Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 19:45:49 +0200 Subject: [PATCH 070/147] fix(RELEASE-1): classify contextual HTTP status without mistaking HTTPS port 443 --- deploy/release/install-pinned-apt.sh | 36 +++++++++++--------- tests/release/test_release_snapshot_retry.py | 19 +++++++++++ 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/deploy/release/install-pinned-apt.sh b/deploy/release/install-pinned-apt.sh index 20776c97..4f6ed9d5 100644 --- a/deploy/release/install-pinned-apt.sh +++ b/deploy/release/install-pinned-apt.sh @@ -14,23 +14,24 @@ only_transient_acquisition_failures() { # explicitly allowed transient HTTP status. One unrelated DNS/TLS/hash/ # signature/unclassified error vetoes retry even alongside a valid 503. LC_ALL=C awk ' - function transient(s) { - return s ~ /(^|[[:space:]])(429|502|503|504)([[:space:]]|$)/ + function transient_reason(s, x) { + x=s + sub(/^[[:space:]]+/, "", x) + return x ~ /^(429|502|503|504)[[:space:]]/ } - function invalid_status(s, a,n,i,t) { - n=split(s,a,/[[:space:]]+/) - for(i=1;i<=n;i++) { - t=a[i] - gsub(/^[^0-9]+|[^0-9]+$/, "", t) - if(t ~ /^[45][0-9][0-9]$/ && - t!="429" && t!="502" && t!="503" && t!="504") - return 1 - } - return 0 + function transient_fetch(s) { + # Status immediately after the URL, not a three-digit token in + # an [IP: address port] suffix. Port 443 must never be parsed + # as an HTTP 443 error. + return s ~ /^E: Failed to fetch [^[:space:]]+[[:space:]]+(429|502|503|504)[[:space:]]/ + } + function transient_header(s, x) { + x=s + gsub(/\[IP:[^]]+\]/, "", x) + return x ~ /[[:space:]](429|502|503|504)[[:space:]]/ } function nontransient(s) { - return s ~ /NO_PUBKEY|BADSIG|EXPKEYSIG|REVKEYSIG|Hash Sum mismatch|certificate|Certificate|TLS|SSL|signatures could not|not signed|does not have a Release file|clearsigned|unexpected size|Temporary failure resolving|Could not resolve|Name or service not known|Connection refused|Network is unreachable|Unable to locate package|Could not get lock|Permission denied/ || - invalid_status(s) + return s ~ /NO_PUBKEY|BADSIG|EXPKEYSIG|REVKEYSIG|Hash Sum mismatch|certificate|Certificate|TLS|SSL|signatures could not|not signed|does not have a Release file|clearsigned|unexpected size|Temporary failure resolving|Could not resolve|Name or service not known|Connection refused|Network is unreachable|Unable to locate package|Could not get lock|Permission denied|401 Unauthorized|403 Forbidden|404 Not Found/ } function finish_err() { if (in_err && !err_retryable) bad=1 @@ -43,18 +44,19 @@ only_transient_acquisition_failures() { if (line ~ /^Err:[0-9]+[[:space:]]/) { finish_err() in_err=1 - err_retryable=transient(line) + err_retryable=transient_header(line) seen++ next } if (in_err && line ~ /^[[:space:]]+/) { - if(transient(line)) err_retryable=1 + if (transient_reason(line)) err_retryable=1 + else if (line !~ /^[[:space:]]*\[IP:/) bad=1 next } finish_err() if (line ~ /^E: Failed to fetch /) { seen++ - if (!transient(line)) bad=1 + if (!transient_fetch(line)) bad=1 next } if (line ~ /^E:/) { diff --git a/tests/release/test_release_snapshot_retry.py b/tests/release/test_release_snapshot_retry.py index c652984c..5542911b 100644 --- a/tests/release/test_release_snapshot_retry.py +++ b/tests/release/test_release_snapshot_retry.py @@ -52,6 +52,17 @@ echo "E: Failed to fetch noble/InRelease 503 Service Unavailable" >&2 echo "E: Some unknown acquisition condition" >&2 exit 100 ;; + real503port) + echo "Err:4 https://snapshot.ubuntu.com/ubuntu noble InRelease" >&2 + echo " 503 Service Unavailable [IP: 185.125.189.69 443]" >&2 + echo "E: Failed to fetch https://snapshot.ubuntu.com/ubuntu/noble/InRelease 503 Service Unavailable [IP: 185.125.189.69 443]" >&2 + echo "E: Some index files failed to download. They have been ignored, or old ones used instead." >&2 + exit 100 ;; + real504port) + echo "Err:4 https://snapshot.ubuntu.com/ubuntu noble InRelease" >&2 + echo " 504 Gateway Time-out [IP: 185.125.189.69 443]" >&2 + echo "E: Failed to fetch https://snapshot.ubuntu.com/ubuntu/noble/InRelease 504 Gateway Time-out [IP: 185.125.189.69 443]" >&2 + exit 100 ;; two503) echo "Err:1 https://snapshot.ubuntu.com/ubuntu noble InRelease" >&2 echo " 503 Service Unavailable" >&2 @@ -105,6 +116,14 @@ def test_mixed_error_class_never_retries(self): self.assertNotEqual(result.returncode,0) self.assertEqual(counts,{"update":1,"install":0}) + def test_realistic_503_and_504_logs_with_https_port_are_retryable(self): + for mode in ("real503port","real504port"): + with self.subTest(mode=mode): + result,counts=self.execute(mode) + self.assertNotEqual(result.returncode,0) + self.assertEqual(counts,{"update":4,"install":0}) + self.assertIn("exhausted four bounded attempts",result.stderr) + def test_multiple_503s_are_individually_classified_and_bounded(self): result,counts=self.execute("two503") self.assertNotEqual(result.returncode,0) From 0bebeccefd8af76de7f86d54ca7f1d5282077446 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 19:47:58 +0200 Subject: [PATCH 071/147] test(RELEASE-1): harden URI controls and real request-correlation fixture behavior --- scripts/qualify-online-recovery.py | 9 ++++++--- scripts/verify-release-oci.py | 2 +- tests/lichess/test_fake_api.py | 2 +- tests/release/test_release_oci_integrity.py | 4 ++-- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/scripts/qualify-online-recovery.py b/scripts/qualify-online-recovery.py index a7f92baa..ce0e7e89 100644 --- a/scripts/qualify-online-recovery.py +++ b/scripts/qualify-online-recovery.py @@ -299,10 +299,13 @@ def verify_exact_http_move_audit( r["path"].startswith(prefix)] attempted = [r for r in trace if r.get("event") == "move_attempt"] ensure(all(type(t.get("seq")) is int and t["seq"] > 0 and - t.get("path") == prefix + t.get("uci", "") and - isinstance(t.get("uci"), str) for t in attempted), + isinstance(t.get("uci"), str) and + t.get("path") == prefix + t["uci"] for t in attempted), "traced move endpoint identity invalid") - server_rows = Counter((r["path"], r.get("request_id")) for r in observed) + ensure(all(isinstance(r.get("request_id"), str) and + bool(r["request_id"]) for r in observed), + "HTTP move request identity missing") + server_rows = Counter((r["path"], r["request_id"]) for r in observed) trace_rows = Counter((r["path"], f"request-{r['seq']}") for r in attempted) ensure(server_rows == trace_rows, "HTTP move records and trace attempts differ") diff --git a/scripts/verify-release-oci.py b/scripts/verify-release-oci.py index c9b1b7b7..ee1cd45f 100644 --- a/scripts/verify-release-oci.py +++ b/scripts/verify-release-oci.py @@ -153,7 +153,7 @@ def valid_uri_syntax(value: object, allowed_schemes: set[str], *, """ if not isinstance(value, str) or not value or value != value.strip(): return False - if any(ord(c) < 33 or ord(c) == 127 for c in value): + if "\\" in value or any(ord(c) < 33 or ord(c) == 127 for c in value): return False if (re.search(r"%(?![0-9A-Fa-f]{2})", value) or re.search(r"%(?:0[0-9A-Fa-f]|1[0-9A-Fa-f]|7[fF])", diff --git a/tests/lichess/test_fake_api.py b/tests/lichess/test_fake_api.py index c30eddda..6d6b3eac 100644 --- a/tests/lichess/test_fake_api.py +++ b/tests/lichess/test_fake_api.py @@ -62,7 +62,7 @@ def test_complete_canonical_target_and_query_values_are_observed(self): "POST", "/api/challenge/"+GAME_ID+"/accept"), 200) for target in ("/api/bot/game/"+GAME_ID+"/move/e2e4?offeringDraw=false", "/api/bot/game/"+GAME_ID+"/move/e2e4?offeringDraw=true"): - self.assertIn(self.call("POST", target), (200,409)) + self.assertIn(self.call("POST", target), (200,400,409)) record=self.fake.requests[-1] self.assertTrue(record["target_canonical"]) self.assertEqual(record["request_target"], target) diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index 4ec07002..3a87760c 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -234,7 +234,7 @@ def descriptor(kind, digest, **extra): elif spdx_case == "namespace-port": body["documentNamespace"] = "https://example.test:bogus/x" elif spdx_case == "namespace-control": - body["documentNamespace"] = "https://example.test/path\\x00" + body["documentNamespace"] = "https://example.test/path\x00" elif spdx_case == "namespace-userinfo": body["documentNamespace"] = "https://user:pw@example.test/a" elif spdx_case == "namespace-encoded-path": @@ -544,7 +544,7 @@ def test_uri_grammar_is_shared_without_collapsing_identity_policies(self): "https://example.test/spdx%20namespace")) for uri in ("https://%zz/path", "https://example.test:bogus/x", "https://user:secret@example.test/x", - "https://example.test/a\\x00", "https://example.test/a%2", + "https://example.test/a\x00", "https://example.test/a%2", "https://example.test/foo#"): with self.subTest(uri=uri): self.assertFalse(oci.valid_spdx_namespace(uri)) From d31b46fab954517e891e5cf4186aff7eced726bc Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 19:49:43 +0200 Subject: [PATCH 072/147] docs(RELEASE-1): reconcile six reviewed evidence boundaries and final CI requirements --- docs/CODEX_POSTMERGE_CLOSURE.md | 18 ++++++++++++++++++ docs/RELEASE_1_OPERATIONS.md | 12 ++++++++++++ tests/release/test_release_http_contract.py | 10 ++++++++++ tests/release/test_release_smoke_evidence.py | 2 ++ 4 files changed, 42 insertions(+) diff --git a/docs/CODEX_POSTMERGE_CLOSURE.md b/docs/CODEX_POSTMERGE_CLOSURE.md index ad27b688..f5b07ca8 100644 --- a/docs/CODEX_POSTMERGE_CLOSURE.md +++ b/docs/CODEX_POSTMERGE_CLOSURE.md @@ -144,3 +144,21 @@ Current 2026-10-17 repair scope is source-linked against PR head f2e067ec. The p | [Mixed APT failures](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4272459302) | `deploy/release/install-pinned-apt.sh` classifies each `Err:` / `E: Failed to fetch` acquisition, permits retry only for 429/502/503/504 and refuses concurrent DNS, signature, hash, TLS, 401, 404 or unknown errors. `tests/release/test_release_snapshot_retry.py` exercises multi-suite 503 and mixed-failure refusal. Snapshot, signing keys, package versions and old-index cleanup remain unchanged. | The adjacent source-attestation and runtime contracts remain unchanged in authority. This is **not** OS/wheel lock promotion, proof of a publisher signature, live Lichess authority, software distribution approval, a claimed Elo/playing-strength result, or merge approval. Final gate: all 11 exact-head workflows green, true offline packaged R00/R02/R10 and independent SQLite/OCI/SLSA/aggregate, then fresh Codex code and security reviews. Do not resolve review threads without tested evidence. + +## October 17 — six P2 cross-boundary repair commits (pending exact-head validation) + +**Previous verified exact head:** `43d76fee940635e6abfbc31d19f5ba6bcec7c65d`. +The full RELEASE-1 OCI + packaged R00/R02/R10 and independent SQLite/HTTP/OCI/SPDX/SLSA qualification **passed at this prior head**. Its aggregate still correctly set `offline_candidate_qualified=false` because OS/wheel locks and legal/operator/host/live-transport gates remain closed. ONLINE-3A/3B/4A/4B, baseline, LC0, controller, merge and publication jobs also passed; LOCAL-1 was still in progress at planning time. + +The following six source-linked Codex findings were found against that exact head. The implementation uses a single branch update after staging; **none is closed by documentation alone**. + +| Review thread | Repair | Adversarial tests | +| --- | --- | --- | +| [r4303673549](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303673549) SPDX namespace URI | `scripts/verify-release-oci.py`: the same strict percent encoding / control / authority / hostname / port / credentials grammar now underlies SPDX namespace, package download and SLSA identities, with distinct schemes and fragment policies | `tests/release/test_release_oci_integrity.py` rehashed namespace mutations plus real pinned Syft scanner positive | +| [r4303673561](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303673561) full GET request-target | `tests/lichess/http_target_contract.py` is source-attested, staged for packaged fake server, used by `tests/lichess/fake_lichess_api.py` and delegated to by `scripts/release_http_contract.py` for RELEASE-1 smoke and aggregate. Exact approved upstream `GET /api/users/status?ids=allfatherfixture` and `POST move?offeringDraw=true|false` are allowed; arbitrary queries, aliases, secrets and duplicate parameters rejected. Only approved complete targets enter retained records, rejected target values replaced with safe digest/disposition | `tests/lichess/test_fake_api.py`, `tests/release/test_release_http_contract.py`, smoke and aggregate GET/POST forgery controls | +| [r4303673565](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303673565) ONLINE-4A missing HTTP attempts | `scripts/qualify-online-recovery.py` checks every move POST against the matching `request-{trace.seq}` using an exact multiset rather than subset membership; canonical traced UCI/URL and chess/legal journal requirements remain | `tests/lichess/test_recovery_evidence.py` extra independently legal request, duplicate, swapped/missing ID, and mismatched trace URL | +| [r4303673570](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303673570) unsafe OCI diagnostics | `scripts/inspect-release-oci.py` validates nested manifest/config/layer/statement types before traversal; output remains diagnostic-only and sanitized even on malformed input | `tests/release/test_release_oci_integrity.py` fully rehashed invalid config/list/null/scalar fixture, sanitized report and independent strict refusal | +| [r4303673578](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303673578) archive pathname TOCTOU | `scripts/verify-release-oci.py` opens OCI archive with nofollow on one descriptor and hashes the same verified open inode. Initial/final fstat and final lstat identity are compared; aggregate still independently hashes its supplied archive | `tests/release/test_release_oci_integrity.py` atomic pathname replacement + in-place append during verification; `tests/release/test_release_aggregate.py` downstream replacement rejection | +| [r4303673584](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303673584) HTTP-443 APT parser | `deploy/release/install-pinned-apt.sh` classifies statuses only in `Err:` reason and `E: Failed to fetch` context. Ports/IPs are never treated as HTTP status. Mixed DNS/signature/hash/unclassified failures remain immediate fatal errors. Snapshot and GPG trust pins unchanged | `tests/release/test_release_snapshot_retry.py` realistic 503/504 and `[IP: ... 443]` plus mixed errors | + +**Do not resolve any thread until exact-head CI and fresh code/security reviewer evidence are available.** No merge, live bot tokens, public artifact, licensure approval, OS/wheel locks, deployment rights or playing-strength claim is implied. diff --git a/docs/RELEASE_1_OPERATIONS.md b/docs/RELEASE_1_OPERATIONS.md index 83227122..f0d2afff 100644 --- a/docs/RELEASE_1_OPERATIONS.md +++ b/docs/RELEASE_1_OPERATIONS.md @@ -277,3 +277,15 @@ Snapshot availability preflight reads the complete bounded signed-cleartext InRe The OCI diagnostic inspector runs before strict verification and may be uploaded on failure. It therefore now allowlists statement `_type`, `predicateType`, descriptor/config media types, accepted annotations and subject digests, and does not echo arbitrary error text or source metadata. Validation itself still requires the complete application config/layer/SPDX/SLSA subject graph; sanitized diagnostics do not establish qualification. Do not mark these changes proven until exact-head CI passes. Retain the frozen no-publication, no-credential, no-host-admission, no-live-transport, no-license-promotion and incomplete OS/wheel-lock requirements. Run original RELEASE-1, ONLINE-4A/4B, baseline/LOCAL-1/LC0 and policy workflows, then request final Codex reviews. + +## October 17 — raw HTTP targets, exact move evidence and OCI archive continuity + +Retained offline HTTP evidence now represents the raw request-target when explicitly approved by `tests/lichess/http_target_contract.py`, staged alongside the `FakeLichess` server and source-attested. Rejected arbitrary targets are recorded as disapproved plus a SHA-256 fingerprint and a safe path, not as unredacted query values. The pinned upstream Lichess bot sends `GET /api/users/status?ids=allfatherfixture` and `POST /api/bot/game/off3b001/move/{uci}?offeringDraw=false|true`; both documented forms are admitted, but unrelated/duplicated/query-bearing paths are not. `scripts/release_http_contract.py` uses the exact same classification for independent smoke and downstream aggregate. Live tokens remain forbidden. + +ONLINE-4A evidence matching is two-way: every actual move HTTP POST (including an accepted, failed or fault-injected request) must be matched to an exactly one-to-one move trace entry by method, path and request ID; the trace still must match a canonical UCI and authoritative board/legal intent. A legal but untraced POST is an evidence failure, not a valid extra submission. + +RELEASE-1 OCI verification now reads and hashes its archive through the same opened inode, requires stable inode/size/mtime/ctime and rejects an atomically replaced file before reporting `oci_archive_sha256`. The later aggregate independently rehashes the pathname and will reject a different archive. OCI structure inspection is diagnostic-only, tolerant of malformed JSON container types with a safe, bounded failure report, never an integrity or publisher-signature grant. + +The native GPG-signed, fixed Ubuntu snapshot remains authoritative for APT. Retry classification ignores bracketed HTTPS peer address/port metadata (`443` is not an HTTP status); retries remain bounded to explicitly recognized 429/502/503/504 acquisition failures and immediate refusal on mixed or integrity failures. + +Full exactly pinned offline OCI/SQLite/engine, complete merge/check matrix and final Codex reviews are **still required** for any new head. No release-policy permissions or dependency-lock qualification are raised. diff --git a/tests/release/test_release_http_contract.py b/tests/release/test_release_http_contract.py index fdc2e115..b3577b36 100644 --- a/tests/release/test_release_http_contract.py +++ b/tests/release/test_release_http_contract.py @@ -53,6 +53,16 @@ def test_no_unauthorized_get_or_failed_status_is_admissible(self): row,"r00-white")) self.assertFalse(aggregate.allowed_release_http(row,"r00-white")) + def test_rejected_raw_query_is_hashed_before_audit(self): + from tests.lichess.http_target_contract import safe_audit_fields + original="/api/account?access_token=PRIVATE-CANARY" + safe=safe_audit_fields("GET",original) + self.assertFalse(safe["target_canonical"]) + self.assertIsNone(safe["request_target"]) + self.assertEqual(safe["path"],"/api/account") + self.assertNotIn("PRIVATE-CANARY",repr(safe)) + self.assertEqual(len(safe["request_target_sha256"]),64) + def test_legitimate_upstream_query_forms_are_admitted(self): for method, raw, rid in ( ("GET", "/api/users/status?ids=allfatherfixture", None), diff --git a/tests/release/test_release_smoke_evidence.py b/tests/release/test_release_smoke_evidence.py index 2e48ea6d..6bf839e3 100644 --- a/tests/release/test_release_smoke_evidence.py +++ b/tests/release/test_release_smoke_evidence.py @@ -388,6 +388,8 @@ def test_r10_requires_complete_monotonic_observation(self): def test_r10_rejects_all_api_requests_during_cooldown(self): doc=fixture("r10-white") doc["http"].append({"method":"GET","path":"/api/account/playing", + "request_target":"/api/account/playing", + "target_canonical":True, "status":200,"monotonic_ms":1200}) with self.assertRaisesRegex(ValueError,"cooldown"): qual.verify_smoke(doc,"r10-white") From 66e56b898f254a53cbb0bbb8a36ca7f2574174dd Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 19:52:35 +0200 Subject: [PATCH 073/147] fix(RELEASE-1): retain valid singleton in-toto statements in safe OCI diagnostics --- scripts/inspect-release-oci.py | 6 +++++- tests/release/test_release_oci_integrity.py | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/scripts/inspect-release-oci.py b/scripts/inspect-release-oci.py index b3c3b482..a529d1b6 100644 --- a/scripts/inspect-release-oci.py +++ b/scripts/inspect-release-oci.py @@ -228,7 +228,11 @@ def traverse(desc, ancestry=()): if type(statement) is not dict: raise ValueError("invalid OCI diagnostic attestation object") statements = statement.get("statements") - if type(statements) is not list: + if statements is None: + # BuildKit normally puts one in-toto statement in + # each layer; arrays are also supported. + statements = [statement] + if type(statements) is not list or not statements: raise ValueError("invalid OCI diagnostic statement list") row["statements"] = [] for st in statements: diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index 3a87760c..4e4cb642 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -821,6 +821,20 @@ def test_retained_oci_diagnostics_do_not_echo_untrusted_subject_names(self): self.assertNotIn("offline-test-package",payload) self.assertFalse(report["integrity_verified"]) + def test_diagnostic_accepts_valid_single_statement_envelope(self): + spec=importlib.util.spec_from_file_location( + "release1_single_statement_diag",ROOT/"scripts/inspect-release-oci.py") + mod=importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + with tempfile.TemporaryDirectory() as d: + archive,meta,_=self.fixture(Path(d)) + report=mod.inspect(archive,meta) + attest=report["index_manifests"][0]["children"][1] + self.assertEqual(len(attest["layers"]),2) + self.assertEqual( + sum(len(layer["statements"]) for layer in attest["layers"]),2) + self.assertFalse(report["integrity_verified"]) + def test_diagnostic_reports_optional_subject_declared_and_actual_sizes(self): inspect_spec = importlib.util.spec_from_file_location( "release1_oci_diag_subject", ROOT/"scripts/inspect-release-oci.py") From 22c75d9208a8980f846233cbdda108c803995ce9 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 20:27:24 +0200 Subject: [PATCH 074/147] fix(RELEASE-1): load source-attested HTTP policy by file in standalone CLI --- scripts/release_http_contract.py | 21 ++++++++++++++++++--- tests/release/test_release_http_contract.py | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/scripts/release_http_contract.py b/scripts/release_http_contract.py index 975ad029..13e4b080 100644 --- a/scripts/release_http_contract.py +++ b/scripts/release_http_contract.py @@ -7,10 +7,25 @@ """ from __future__ import annotations import re +import importlib.util +from pathlib import Path -from tests.lichess.http_target_contract import ( - GAME_ID, MOVE_PREFIX, APPROVED_GETS, APPROVED_POSTS, UCI, - canonical_target) +# Resolve the single approved policy by file identity. RELEASE-1's standalone +# host verifier runs as a script, often with sys.path[0] = /repo/scripts; +# importing a top-level "tests" package then depends on CWD/PYTHONPATH. +_POLICY = Path(__file__).resolve().parents[1] / "tests/lichess/http_target_contract.py" +_spec = importlib.util.spec_from_file_location("release1_http_target_policy", _POLICY) +if _spec is None or _spec.loader is None: + raise ValueError("RELEASE-1 HTTP target policy not available") +policy = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(policy) + +GAME_ID = policy.GAME_ID +MOVE_PREFIX = policy.MOVE_PREFIX +APPROVED_GETS = policy.APPROVED_GETS +APPROVED_POSTS = policy.APPROVED_POSTS +UCI = policy.UCI +canonical_target = policy.canonical_target ALLOWED_GETS = APPROVED_GETS ALLOWED_POSTS = APPROVED_POSTS diff --git a/tests/release/test_release_http_contract.py b/tests/release/test_release_http_contract.py index b3577b36..698c0fe9 100644 --- a/tests/release/test_release_http_contract.py +++ b/tests/release/test_release_http_contract.py @@ -1,6 +1,10 @@ """Shared fail-closed HTTP admission across smoke and aggregate.""" from __future__ import annotations import importlib.util +import os +import subprocess +import sys +import tempfile import unittest from pathlib import Path ROOT=Path(__file__).resolve().parents[2] @@ -16,6 +20,20 @@ def load(path, ident): class SharedHTTPAdmissionTests(unittest.TestCase): + def test_standalone_release_verifiers_import_policy_without_pythonpath(self): + # Identical invocation mode to the real OCI release workflow. + with tempfile.TemporaryDirectory() as tmp: + environment = {k:v for k,v in os.environ.items() if k != "PYTHONPATH"} + for entry in ("scripts/qualify-release-smoke.py", + "scripts/qualify-release-aggregate.py"): + with self.subTest(cli=entry): + outcome = subprocess.run( + [sys.executable, "-I", str(ROOT / entry), "--help"], + cwd=tmp, env=environment, capture_output=True, + text=True, timeout=20) + self.assertEqual(outcome.returncode, 0, outcome.stderr) + self.assertIn("usage:", outcome.stdout.lower()) + def test_each_allowed_get_and_case_agrees_across_both_verifiers(self): for case in smoke.CASES: for path in smoke.http_contract.ALLOWED_GETS: From 1c778b7106f8d773027cf28921476db0293f9252 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 20:28:11 +0200 Subject: [PATCH 075/147] fix(ONLINE-4): attest executable HTTP request-target policy in both evidence closures --- qualification/online-recovery-v1.json | 1 + qualification/online-recovery-v2.json | 1 + tests/lichess/test_recovery_evidence.py | 15 +++++++++++++++ tests/lichess/test_recovery_v2_evidence.py | 15 +++++++++++++++ 4 files changed, 32 insertions(+) diff --git a/qualification/online-recovery-v1.json b/qualification/online-recovery-v1.json index cd61ef81..2d8ecab0 100644 --- a/qualification/online-recovery-v1.json +++ b/qualification/online-recovery-v1.json @@ -60,6 +60,7 @@ "tests/fixtures/lichess/recovery_scenarios.json", "tests/lichess/fake_lichess_api.py", "tests/lichess/fault_plan.py", + "tests/lichess/http_target_contract.py", "tests/lichess/scripted_opponent.py", "tests/lichess/stream_sessions.py", "tests/lichess/test_fault_plan.py", diff --git a/qualification/online-recovery-v2.json b/qualification/online-recovery-v2.json index 80e293b9..4fa04f20 100644 --- a/qualification/online-recovery-v2.json +++ b/qualification/online-recovery-v2.json @@ -77,6 +77,7 @@ "scripts/qualify-online-recovery.py", "tests/fixtures/lichess/recovery_scenarios.json", "tests/lichess/fake_lichess_api.py", + "tests/lichess/http_target_contract.py", "tests/lichess/scripted_opponent.py", "tests/lichess/stream_sessions.py", "tests/lichess/test_guarded_transport.py", diff --git a/tests/lichess/test_recovery_evidence.py b/tests/lichess/test_recovery_evidence.py index e7efde2a..4d189c3a 100644 --- a/tests/lichess/test_recovery_evidence.py +++ b/tests/lichess/test_recovery_evidence.py @@ -6,6 +6,7 @@ from __future__ import annotations import copy import importlib.util +import json import tempfile import unittest from pathlib import Path @@ -23,6 +24,20 @@ class IndependentEvidenceTests(unittest.TestCase): + def test_http_target_policy_is_source_attested_and_tamper_detected(self): + exact_sha="a"*40 + names=json.loads((ROOT/"qualification/online-recovery-v1.json") + .read_text())["attested_source_paths"] + path="tests/lichess/http_target_contract.py" + self.assertEqual(names.count(path),1) + file_hashes={name:QUALIFIER.digest(ROOT/name) for name in names} + document={"source_commit":exact_sha,"files":file_hashes} + QUALIFIER.verify_sources(document,exact_sha) + forged=copy.deepcopy(document) + forged["files"][path]="0"*64 + with self.assertRaisesRegex(ValueError,"source file identity drift"): + QUALIFIER.verify_sources(forged,exact_sha) + def trace(self): board = chess.Board() before = board.fen() diff --git a/tests/lichess/test_recovery_v2_evidence.py b/tests/lichess/test_recovery_v2_evidence.py index 94317613..bfd06172 100644 --- a/tests/lichess/test_recovery_v2_evidence.py +++ b/tests/lichess/test_recovery_v2_evidence.py @@ -2,6 +2,7 @@ from __future__ import annotations import copy import importlib.util +import json import tempfile import unittest from pathlib import Path @@ -16,6 +17,20 @@ class RecoveryV2EvidenceTests(unittest.TestCase): + def test_http_target_policy_is_in_exact_guarded_source_closure(self): + exact_sha="b"*40 + names=json.loads((ROOT/"qualification/online-recovery-v2.json") + .read_text())["attested_paths"] + path="tests/lichess/http_target_contract.py" + self.assertEqual(names.count(path),1) + file_hashes={name:qual.hash_file(ROOT/name) for name in names} + document={"source_commit":exact_sha,"files":file_hashes} + qual.attest(document,exact_sha,names) + forged=copy.deepcopy(document) + forged["files"][path]="0"*64 + with self.assertRaisesRegex(ValueError,"source hash mismatch"): + qual.attest(forged,exact_sha,names) + def test_persisted_journal_verifies_and_mutation_is_detected(self): with tempfile.TemporaryDirectory() as d: folder = Path(d) From deddc4e948a4636f02d861a4238e6d46e7fe7a59 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 20:29:28 +0200 Subject: [PATCH 076/147] fix(ONLINE-4): attest original wire request targets before HTTP parser normalization --- tests/lichess/fake_lichess_api.py | 35 ++++++++++++++++++++++------- tests/lichess/test_fake_api.py | 24 ++++++++++++++++++++ tests/lichess/test_recovery_http.py | 16 +++++++++++++ 3 files changed, 67 insertions(+), 8 deletions(-) diff --git a/tests/lichess/fake_lichess_api.py b/tests/lichess/fake_lichess_api.py index f968e328..3f518cf7 100644 --- a/tests/lichess/fake_lichess_api.py +++ b/tests/lichess/fake_lichess_api.py @@ -62,6 +62,25 @@ class Handler(BaseHTTPRequestHandler): def log_message(self, fmt, *args): pass + def parse_request(self): + # CPython normalizes a leading // in BaseHTTPRequestHandler + # *before* do_GET/do_POST. Capture the original wire target + # from raw_requestline to prevent normalization-based bypass. + self._raw_http_target = "" + try: + line = self.raw_requestline.decode("iso-8859-1").rstrip("\r\n") + parts = line.split(" ") + if (len(parts) == 3 and all(parts) and + parts[0] in ("GET", "POST") and + parts[2] in ("HTTP/1.0", "HTTP/1.1")): + self._raw_http_target = parts[1] + except UnicodeError: + pass + return super().parse_request() + + def raw_target(self) -> str: + return getattr(self, "_raw_http_target", "") + def respond(self, status: int, obj: dict): data = json.dumps(obj, separators=(",", ":")).encode() self.send_response(status) @@ -199,7 +218,7 @@ def chunk(self, payload): def auth(self) -> bool: if self.headers.get("Authorization") != "Bearer " + TOKEN: # Even an early rejected request remains an HTTP witness. - self.record(self.command, path_of(self.path), 401) + self.record(self.command, path_of(self.raw_target()), 401) self.respond(401, {"error": "synthetic token required"}) return False return True @@ -210,7 +229,7 @@ def record(self, kind: str, path: str, status: int, row = { "monotonic_ms": round((time.monotonic() - server.start_time) * 1000), "method": kind, "status": status, - **safe_audit_fields(kind, self.path), + **safe_audit_fields(kind, self.raw_target()), } if request_id is not None: row["request_id"] = request_id @@ -220,9 +239,9 @@ def do_GET(self): self._move_request_id = None # never reuse an HTTP/1.1 request identity if not self.auth(): return - path = path_of(self.path) - if not canonical_target("GET", self.path): - safe = safe_audit_fields("GET", self.path)["path"] + path = path_of(self.raw_target()) + if not canonical_target("GET", self.raw_target()): + safe = safe_audit_fields("GET", self.raw_target())["path"] self.record("GET", safe, 403) server.attempted_forbidden.append("GET " + safe) return self.respond(403, {"error": "offline request-target prohibited"}) @@ -290,9 +309,9 @@ def do_POST(self): return self.respond(413, {"error": "body too large"}) if length: self.rfile.read(length) - path = path_of(self.path) - if not canonical_target("POST", self.path): - safe = safe_audit_fields("POST", self.path)["path"] + path = path_of(self.raw_target()) + if not canonical_target("POST", self.raw_target()): + safe = safe_audit_fields("POST", self.raw_target())["path"] with server.lock: server.attempted_forbidden.append("POST " + safe) self.record("POST", safe, 403) diff --git a/tests/lichess/test_fake_api.py b/tests/lichess/test_fake_api.py index 6d6b3eac..920d4c29 100644 --- a/tests/lichess/test_fake_api.py +++ b/tests/lichess/test_fake_api.py @@ -67,6 +67,30 @@ def test_complete_canonical_target_and_query_values_are_observed(self): self.assertTrue(record["target_canonical"]) self.assertEqual(record["request_target"], target) + def test_network_path_bypasses_cpython_normalization_only_if_not_observed(self): + import socket + for target in ("//api/account", "///api/account", + "//api/users/status?ids=allfatherfixture"): + with self.subTest(target=target): + with socket.create_connection(self.fake.http.server_address, + timeout=4) as connection: + connection.settimeout(4) + query=(f"GET {target} HTTP/1.1\r\n" + "Host: 127.0.0.1\r\n" + f"Authorization: Bearer {TOKEN}\r\n" + "Connection: close\r\n\r\n").encode() + connection.sendall(query) + answer=connection.recv(1024) + self.assertIn(b"403",answer) + observed=self.fake.requests[-1] + self.assertEqual(observed["method"],"GET") + self.assertEqual(observed["status"],403) + self.assertFalse(observed["target_canonical"]) + self.assertIsNone(observed["request_target"]) + self.assertEqual(len(observed["request_target_sha256"]),64) + self.assertIn("GET "+target.split("?",1)[0], + self.fake.attempted_forbidden) + def test_duplicate_accept_rejected(self): path = f"/api/challenge/{GAME_ID}/accept" self.assertEqual(self.call("POST", path), 200) diff --git a/tests/lichess/test_recovery_http.py b/tests/lichess/test_recovery_http.py index 23f1a0d6..2e635c91 100644 --- a/tests/lichess/test_recovery_http.py +++ b/tests/lichess/test_recovery_http.py @@ -37,6 +37,22 @@ def run_fault(self, case, expected_commit, expected_status=None): finally: fake.close() + def test_attempt_record_retains_actual_upstream_query_target(self): + fake=FakeLichess("white") + fake.start() + try: + reply=requests.get(fake.url+"api/users/status", + params={"ids":"allfatherfixture"}, + headers=HEADERS,timeout=4) + self.assertEqual(reply.status_code,200) + row=fake.evidence()["requests"][-1] + self.assertEqual(row["path"],"/api/users/status") + self.assertEqual(row["request_target"], + "/api/users/status?ids=allfatherfixture") + self.assertTrue(row["target_canonical"]) + finally: + fake.close() + def test_request_reaches_handler_but_drops_before_commit(self): self.run_fault("R01", 0) From 2f1f97b20798013a2b4f2e646323b1616474b773 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 20:30:39 +0200 Subject: [PATCH 077/147] fix(RELEASE-1): reject explicitly null in-toto statement wrappers --- scripts/inspect-release-oci.py | 9 ++-- scripts/verify-release-oci.py | 11 +++-- tests/release/test_release_oci_integrity.py | 46 +++++++++++++++++++-- 3 files changed, 54 insertions(+), 12 deletions(-) diff --git a/scripts/inspect-release-oci.py b/scripts/inspect-release-oci.py index a529d1b6..d80e0c88 100644 --- a/scripts/inspect-release-oci.py +++ b/scripts/inspect-release-oci.py @@ -227,10 +227,11 @@ def traverse(desc, ancestry=()): statement = document("blobs/sha256/" + str(lid)[7:]) if type(statement) is not dict: raise ValueError("invalid OCI diagnostic attestation object") - statements = statement.get("statements") - if statements is None: - # BuildKit normally puts one in-toto statement in - # each layer; arrays are also supported. + # Only an *absent* wrapper authorizes singleton form. + # Present null must never be reinterpreted as singleton. + if "statements" in statement: + statements = statement["statements"] + else: statements = [statement] if type(statements) is not list or not statements: raise ValueError("invalid OCI diagnostic statement list") diff --git a/scripts/verify-release-oci.py b/scripts/verify-release-oci.py index ee1cd45f..924ad14e 100644 --- a/scripts/verify-release-oci.py +++ b/scripts/verify-release-oci.py @@ -391,11 +391,14 @@ def verify_attestation_subjects(attestations: list[dict], image: dict) -> set[st require(item.get("layer_media_type") == "application/vnd.in-toto+json", "unexpected attestation statement layer type") require(isinstance(statement, dict), "OCI attestation statement missing") - statements = statement.get("statements") - if statements is None: + # Absent wrapper means BuildKit's valid singleton in-toto statement. + # An explicitly present null/object/string/empty wrapper is invalid. + if "statements" in statement: + statements = statement["statements"] + else: statements = [statement] - require(isinstance(statements, list) and bool(statements), - "OCI attestation contains no in-toto statements") + require(type(statements) is list and bool(statements), + "OCI attestation has invalid in-toto statements wrapper") for row in statements: require(isinstance(row, dict) and row.get("_type") == STATEMENT, "OCI attestation is not a supported in-toto statement") diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index 4e4cb642..5f0b49bb 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -36,7 +36,7 @@ def fixture(self, target: Path, *, replace_layer=False, bad_subject=False, index_platform="absent", inner_index_platform="absent", double_nested=False, embedded_media="absent", config_platform="valid", diagnostic_canary=None, - malformed_diagnostic=None): + malformed_diagnostic=None, statement_wrapper="singleton"): base = b"original uncompressed OCI application layer\n" expanded = (b"substituted uncompressed OCI application layer\n" if replace_layer else base) @@ -256,14 +256,24 @@ def descriptor(kind, digest, **extra): del body["buildDefinition"]["externalParameters"] if invalid_predicate: body = {"schema_version": 1} - statement = encode({ + statement_document = { "_type": diagnostic_canary or "https://in-toto.io/Statement/v1", "predicateType": diagnostic_canary or predicate, "subject": ([] if subject is None else [{"name": diagnostic_canary or "offline-fixture", "digest": {"sha256": subject}}]), - "predicate": body}) - lid = store(statement) + "predicate": body} + if predicate == "https://spdx.dev/Document": + if statement_wrapper == "array": + statement_document = {"statements": [statement_document]} + elif statement_wrapper in ("null", "object", "empty", "string"): + statement_document["statements"] = { + "null": None, "object": {}, "empty": [], + "string": "malformed", + }[statement_wrapper] + elif statement_wrapper != "singleton": + raise ValueError("unsupported statement-wrapper fixture") + lid = store(encode(statement_document)) layer = descriptor("application/vnd.in-toto+json", lid) if wrong_size == "statement": layer["size"] += 1 @@ -593,6 +603,34 @@ def test_incomplete_spdx_sbom_never_qualifies(self): with self.assertRaisesRegex(ValueError, "SPDX"): oci.verify_archive(archive, meta, docker_inspect=inspection) + def test_in_toto_wrapper_requires_absence_or_nonempty_array(self): + diagnostic_spec=importlib.util.spec_from_file_location( + "release1_statement_wrapper_diagnostic", + ROOT/"scripts/inspect-release-oci.py") + mod=importlib.util.module_from_spec(diagnostic_spec) + diagnostic_spec.loader.exec_module(mod) + for kind in ("singleton", "array"): + with self.subTest(valid=kind),tempfile.TemporaryDirectory() as d: + archive,meta,inspection=self.fixture( + Path(d),statement_wrapper=kind) + self.assertTrue(oci.verify_archive( + archive,meta,docker_inspect=inspection)["integrity_verified"]) + summary=mod.inspect(archive,meta) + self.assertFalse(summary["integrity_verified"]) + self.assertEqual( + len(summary["index_manifests"][0]["children"][1] + ["layers"][0]["statements"]),1) + for kind in ("null", "object", "empty", "string"): + with self.subTest(invalid=kind),tempfile.TemporaryDirectory() as d: + archive,meta,inspection=self.fixture( + Path(d),statement_wrapper=kind) + with self.assertRaisesRegex(ValueError, + "in-toto statements wrapper"): + oci.verify_archive(archive,meta,docker_inspect=inspection) + with self.assertRaisesRegex(ValueError, + "invalid OCI diagnostic statement list"): + mod.inspect(archive,meta) + def test_real_small_oci_graph_binds_every_layer_and_attestation(self): with tempfile.TemporaryDirectory() as d: archive,meta,inspection=self.fixture(Path(d)) From 600c21488c4a269f2007f9d47951b88dc6336eea Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 20:31:32 +0200 Subject: [PATCH 078/147] fix(RELEASE-1): require RFC3986 ASCII URI grammar for SPDX and SLSA identities --- scripts/verify-release-oci.py | 8 +++++++- tests/release/test_release_oci_integrity.py | 22 ++++++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/scripts/verify-release-oci.py b/scripts/verify-release-oci.py index 924ad14e..e45e441d 100644 --- a/scripts/verify-release-oci.py +++ b/scripts/verify-release-oci.py @@ -153,7 +153,13 @@ def valid_uri_syntax(value: object, allowed_schemes: set[str], *, """ if not isinstance(value, str) or not value or value != value.strip(): return False - if "\\" in value or any(ord(c) < 33 or ord(c) == 127 for c in value): + # RFC 3986: URI input is ASCII unreserved + gen/sub-delimiters and + # percent escapes. Raw Unicode, braces, pipes, quotes and carets are NOT + # URIs; callers must percent-encode non-ASCII octets before validation. + if re.fullmatch(r"[A-Za-z0-9._~:/?#\\[\\]@! if "\\" in value or any(ord(c) < 33 or ord(c) == 127 for c in value): + return False + if (re.search(r"%(?![0-9A-Fa-f]{2})", value) or'()*+,;=%-]+", value, + re.ASCII) is None: return False if (re.search(r"%(?![0-9A-Fa-f]{2})", value) or re.search(r"%(?:0[0-9A-Fa-f]|1[0-9A-Fa-f]|7[fF])", diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index 5f0b49bb..cc6b4798 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -183,6 +183,12 @@ def descriptor(kind, digest, **extra): del body["packages"][0]["downloadLocation"] elif spdx_case == "invalid-download": body["packages"][0]["downloadLocation"] = "garbage" + elif spdx_case == "download-unicode": + body["packages"][0]["downloadLocation"] = "https://example.test/☃" + elif spdx_case == "download-braces": + body["packages"][0]["downloadLocation"] = "https://example.test/{bad}" + elif spdx_case == "download-pipe": + body["packages"][0]["downloadLocation"] = "https://example.test/a|b" elif spdx_case == "invalid-percent": body["packages"][0]["downloadLocation"] = "https://example.test/%zz" elif spdx_case == "invalid-percent-host": @@ -229,6 +235,12 @@ def descriptor(kind, digest, **extra): "fileName":"./file", "checksums":[{"algorithm":"SHA256", "checksumValue":"a"*64}]}] + elif spdx_case == "namespace-unicode": + body["documentNamespace"] = "https://example.test/snowman-☃" + elif spdx_case == "namespace-braces": + body["documentNamespace"] = "https://example.test/{bad}" + elif spdx_case == "namespace-pipe": + body["documentNamespace"] = "https://example.test/a|b" elif spdx_case == "namespace-percent": body["documentNamespace"] = "https://%zz/path" elif spdx_case == "namespace-port": @@ -552,9 +564,15 @@ def test_uri_grammar_is_shared_without_collapsing_identity_policies(self): self.assertTrue(oci.valid_identity_uri(uri)) self.assertTrue(oci.valid_spdx_namespace( "https://example.test/spdx%20namespace")) + self.assertTrue(oci.valid_spdx_namespace( + "https://example.test/%E2%98%83")) + self.assertTrue(oci.valid_identity_uri( + "https://example.test/build?key=value&v=2")) for uri in ("https://%zz/path", "https://example.test:bogus/x", "https://user:secret@example.test/x", "https://example.test/a\x00", "https://example.test/a%2", + "https://example.test/☃", "https://example.test/{bad}", + "https://example.test/a|b", "https://example.test/^x", "https://example.test/foo#"): with self.subTest(uri=uri): self.assertFalse(oci.valid_spdx_namespace(uri)) @@ -590,12 +608,14 @@ def test_incomplete_spdx_sbom_never_qualifies(self): "bad-namespace", "missing-created", "invalid-created", "missing-creators", "bad-creator", "empty-inventory", "bad-package", "missing-download", "invalid-download", + "download-unicode", "download-braces", "download-pipe", "invalid-percent", "invalid-percent-host", "invalid-port", "invalid-authority", "encoded-control", "bad-package-checksum", "short-sha256", "bogus-algorithm", "missing-file-checksum", "bad-file-checksum", "duplicate-id", "namespace-fragment", "namespace-trailing-fragment", "namespace-percent", "namespace-port", "namespace-control", "namespace-userinfo", - "namespace-encoded-path") + "namespace-encoded-path", "namespace-unicode", "namespace-braces", + "namespace-pipe") for variant in cases: with self.subTest(spdx=variant), tempfile.TemporaryDirectory() as d: archive, meta, inspection = self.fixture( From 1e4ebbf5a0d2b873dacb7d60ba4c37002e7f84d9 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 20:32:37 +0200 Subject: [PATCH 079/147] fix(RELEASE-1): preserve literal RFC3986 reserved delimiters in strict URI validator --- scripts/verify-release-oci.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/scripts/verify-release-oci.py b/scripts/verify-release-oci.py index e45e441d..805747b6 100644 --- a/scripts/verify-release-oci.py +++ b/scripts/verify-release-oci.py @@ -146,19 +146,15 @@ def verify_layer_diff_ids(archive: tarfile.TarFile, members: dict, def valid_uri_syntax(value: object, allowed_schemes: set[str], *, allow_git_ssh_user: bool = False) -> bool: - """One URI grammar for SPDX identities, downloads and SLSA identities. + """One RFC3986 URI grammar for SPDX identities, downloads and SLSA. - Field-specific scheme, special-token and fragment policy is applied by - callers; urlsplit() alone does not check escapes, credentials or ports. + URI syntax is common; field-specific schemes, fragments and special tokens + remain caller policy. Require percent-encoding for non-ASCII bytes. """ if not isinstance(value, str) or not value or value != value.strip(): return False - # RFC 3986: URI input is ASCII unreserved + gen/sub-delimiters and - # percent escapes. Raw Unicode, braces, pipes, quotes and carets are NOT - # URIs; callers must percent-encode non-ASCII octets before validation. - if re.fullmatch(r"[A-Za-z0-9._~:/?#\\[\\]@! if "\\" in value or any(ord(c) < 33 or ord(c) == 127 for c in value): - return False - if (re.search(r"%(?![0-9A-Fa-f]{2})", value) or'()*+,;=%-]+", value, + # RFC3986 unreserved + reserved delimiters and encoded %HH octets. + if re.fullmatch(r"[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]+", value, re.ASCII) is None: return False if (re.search(r"%(?![0-9A-Fa-f]{2})", value) or From 101431feb8bcc364bdb5b056f6fca1150555e40f Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 20:35:25 +0200 Subject: [PATCH 080/147] fix(ONLINE-4A): replay every HTTP move at receipt ply and classify proven stale retries --- scripts/qualify-online-recovery.py | 162 ++++++++++++++++++------ tests/lichess/test_recovery_evidence.py | 52 +++++++- 2 files changed, 174 insertions(+), 40 deletions(-) diff --git a/scripts/qualify-online-recovery.py b/scripts/qualify-online-recovery.py index ce0e7e89..fc25445e 100644 --- a/scripts/qualify-online-recovery.py +++ b/scripts/qualify-online-recovery.py @@ -69,48 +69,128 @@ def verify_sources(manifest: dict, exact_sha: str): def verify_trace(trace: list[dict], game: dict) -> dict: + """Replay *every* observed HTTP attempt at the authoritative receipt ply. + + A syntactically valid UCI is not automatically legal in the actual board. + Classify position-illegal attempts as unsafe evidence; classify a stale + retry separately only if an earlier *legal* attempt was committed and a + correlated lost/unknown-ACK fault preceded the repeated submission. + """ seq = [row.get("seq") for row in trace] - ensure(seq == list(range(1, len(trace) + 1)), "trace ordering or numbering invalid") + ensure(seq == list(range(1, len(trace) + 1)), + "trace ordering or numbering invalid") + ensure(game.get("color") in ("white", "black") and + isinstance(game.get("moves"), list), + "authoritative game bot color or move history missing") board = chess.Board() - attempts = {f"request-{r['seq']}": r for r in trace if r.get("event") == "move_attempt"} - # A legal UCI in the trace cannot stand in for a different real HTTP - # endpoint. Reject aliases and encoded spellings rather than trusting - # a producer-controlled attempt extract. prefix = "/api/bot/game/off3b001/move/" - for event in attempts.values(): - uci = event.get("uci") - try: - move = chess.Move.from_uci(uci) - except (ValueError, TypeError) as exc: - raise ValueError("invalid traced HTTP move UCI") from exc - ensure(move != chess.Move.null() and move.uci() == uci and - event.get("path") == prefix + uci, - "traced HTTP move path differs from canonical UCI") - commits = [r for r in trace if r.get("event") == "board_commit"] - ensure(isinstance(game.get("moves"), list), "authoritative move history missing") - for item in commits: - ensure(item.get("game_id") == "off3b001", "foreign committed game id") - ensure(item.get("ply_before") == len(board.move_stack), "commit before-ply drift") - ensure(item.get("ply_after") == len(board.move_stack) + 1, "commit after-ply drift") - ensure(item.get("fen_before") == board.fen(), "commit before-FEN drift") - move = chess.Move.from_uci(item["uci"]) - ensure(move in board.legal_moves, "committed illegal chess move") - origin = item.get("origin") - ensure(origin in ("allfather", "scripted_opponent"), "unknown commit origin") - if origin == "allfather": - req = item.get("request_id") - ensure(req in attempts, "accepted move has no observed HTTP attempt") - ensure(attempts[req].get("uci") == item["uci"], "move request/commit mismatch") - ensure(attempts[req].get("seq") < item["seq"], "commit preceded its request") - else: - ensure(item.get("request_id") is None, "opponent move bound to bridge request") - board.push(move) - ensure(item.get("fen_after") == board.fen(), "commit after-FEN drift") + bot_white = game["color"] == "white" + attempts = {} + legal_attempts = {} + first_attempt_by_ply = {} + committed_requests = set() + fault_request_ids = set() + lost_ack_request_ids = set() + commits = [] + unsafe_attempts = [] + stale_retries = [] + for row in trace: + event = row.get("event") + if event == "fault_activated" and row.get("action") in ( + "commit_then_close", "commit_then_504", "low_clock_close", + "terminate_bridge"): + if isinstance(row.get("request_id"), str): + fault_request_ids.add(row["request_id"]) + elif event == "connection_close_requested": + if row.get("request_id") in fault_request_ids: + lost_ack_request_ids.add(row["request_id"]) + elif event == "response_write_completed" and row.get("status") == 504: + if row.get("request_id") in fault_request_ids: + lost_ack_request_ids.add(row["request_id"]) + + if event == "move_attempt": + rid = f"request-{row['seq']}" + uci = row.get("uci") + ensure(row.get("game_id") == "off3b001" and + type(row.get("ply_at_receipt")) is int and + row["ply_at_receipt"] == len(board.move_stack), + "move HTTP attempt game/receipt ply identity invalid") + try: + move = chess.Move.from_uci(uci) + except (ValueError, TypeError) as exc: + raise ValueError("invalid traced HTTP move UCI") from exc + ensure(move != chess.Move.null() and move.uci() == uci and + row.get("path") == prefix + uci, + "traced HTTP move path differs from canonical UCI") + ensure(rid not in attempts, "duplicate trace move request ID") + attempts[rid] = row + ply = len(board.move_stack) + position_legal = board.turn == bot_white and move in board.legal_moves + if position_legal: + key = (ply, uci) + if key in first_attempt_by_ply: + unsafe_attempts.append({ + "request_id": rid, "uci": uci, "ply_at_receipt": ply, + "reason": "duplicate_unconfirmed_move_submission"}) + else: + legal_attempts[rid] = (ply, uci) + first_attempt_by_ply[key] = rid + else: + # A previously legal/committed move is not itself proof of + # safe retransmission. Require exact lost-ACK request evidence. + first = next( + (old_id for old_id, (old_ply, old_move) in legal_attempts.items() + if old_move == uci and old_ply < ply and + old_id in committed_requests and + old_id in lost_ack_request_ids), + None) + if first is not None: + stale_retries.append({ + "request_id": rid, "uci": uci, "ply_at_receipt": ply, + "original_request_id": first, + "reason": "once_legal_retry_after_lost_ack"}) + else: + unsafe_attempts.append({ + "request_id": rid, "uci": uci, "ply_at_receipt": ply, + "reason": "position_illegal_or_wrong_turn"}) + elif event == "board_commit": + item = row + ensure(item.get("game_id") == "off3b001", + "foreign committed game id") + ensure(item.get("ply_before") == len(board.move_stack) and + item.get("ply_after") == len(board.move_stack) + 1 and + item.get("fen_before") == board.fen(), + "commit before-position or ply drift") + try: + move = chess.Move.from_uci(item.get("uci")) + except (ValueError, TypeError) as exc: + raise ValueError("malformed authoritative commit UCI") from exc + ensure(move in board.legal_moves, "committed illegal chess move") + origin = item.get("origin") + ensure(origin in ("allfather", "scripted_opponent"), + "unknown commit origin") + if origin == "allfather": + rid = item.get("request_id") + earlier = attempts.get(rid) + ensure(rid in legal_attempts and earlier is not None and + legal_attempts[rid] == + (len(board.move_stack), move.uci()) and + earlier["seq"] < item["seq"], + "server bot commit lacks earlier legal HTTP move attempt") + committed_requests.add(rid) + else: + ensure(item.get("request_id") is None, + "opponent move bound to bridge request") + board.push(move) + ensure(item.get("fen_after") == board.fen(), + "commit after-position drift") + commits.append(item) ensure(game["moves"] == [m.uci() for m in board.move_stack], "authoritative history differs from committed ledger") accepted = [dict(ply=r["ply_after"], uci=r["uci"]) for r in commits if r["origin"] == "allfather"] - ensure(accepted == game.get("accepted"), "server bot-acceptance list differs from ledger") + ensure(accepted == game.get("accepted"), + "server bot-acceptance list differs from ledger") if game.get("complete") is True: outcome = board.outcome(claim_draw=False) ensure(outcome is not None and game.get("outcome") is not None, @@ -118,7 +198,10 @@ def verify_trace(trace: list[dict], game: dict) -> dict: ensure(outcome.termination.name == game["outcome"].get("termination"), "termination differs from rules oracle") return {"plies": len(commits), "bot_commits": len(accepted), - "http_attempts": len(attempts), "game_complete": game.get("complete") is True} + "http_attempts": len(attempts), + "illegal_attempts": unsafe_attempts, + "stale_retry_attempts": stale_retries, + "game_complete": game.get("complete") is True} def verify_state_fault(action: str, witnessed: dict, sends: list[dict], @@ -397,12 +480,13 @@ def validate_experiment(case_dir: Path, scenario: dict, color: str, if 0 <= r["monotonic_ms"] - when < 60000] ensure(isinstance(result.get("runner_errors"), list) and not result["runner_errors"], "fault harness recorded execution or teardown errors") - unusable_moves = invalid_move_submissions(http_requests) + unusable_moves = (invalid_move_submissions(http_requests) + + history["illegal_attempts"] + + history["stale_retry_attempts"]) if scenario["action"] == "none": ensure(history["bot_commits"] > 0 and logging is not None, "normal engine baseline did not play") - ensure(not unusable_moves, "normal game submitted invalid/null engine move") - status = "SAFE_CONTINUATION" + status = "UNSAFE_WITNESS" if unusable_moves else "SAFE_CONTINUATION" elif game["forbidden_attempts"] or early_rate_requests or unusable_moves: status = "UNSAFE_WITNESS" elif game.get("complete") is True: diff --git a/tests/lichess/test_recovery_evidence.py b/tests/lichess/test_recovery_evidence.py index 4d189c3a..a2dd335f 100644 --- a/tests/lichess/test_recovery_evidence.py +++ b/tests/lichess/test_recovery_evidence.py @@ -54,7 +54,7 @@ def trace(self): def game(self): return {"moves": ["e2e4"], "accepted": [{"ply": 1, "uci": "e2e4"}], - "complete": False} + "complete": False, "color":"white"} def test_independently_replays_accepted_move(self): x = QUALIFIER.verify_trace(self.trace(), self.game()) @@ -86,6 +86,56 @@ def test_rejects_invalid_sequence_and_illegal_commit(self): with self.subTest(bad=bad), self.assertRaises(ValueError): QUALIFIER.verify_trace(changed, self.game()) + def test_position_illegal_syntactically_valid_attempt_is_not_safe(self): + rows=self.trace() + illegal={"seq":1,"event":"move_attempt","game_id":"off3b001", + "uci":"e7e5","path":"/api/bot/game/off3b001/move/e7e5", + "ply_at_receipt":0} + rows[0]["seq"]=2 + rows[1]["seq"]=3 + rows[1]["request_id"]="request-2" + rows.insert(0,illegal) + result=QUALIFIER.verify_trace(rows,self.game()) + self.assertEqual(len(result["illegal_attempts"]),1) + self.assertEqual(result["illegal_attempts"][0]["uci"],"e7e5") + self.assertEqual(result["illegal_attempts"][0]["reason"], + "position_illegal_or_wrong_turn") + self.assertEqual(result["stale_retry_attempts"],[]) + # A coherent, fully committed R00 game does not erase the illegal + # earlier HTTP attempt, even when it returns HTTP 400. + self.assertFalse(not result["illegal_attempts"]) + + def test_wrong_receipt_ply_is_evidence_corruption(self): + rows=self.trace() + rows[0]["ply_at_receipt"]=1 + with self.assertRaisesRegex(ValueError,"receipt ply"): + QUALIFIER.verify_trace(rows,self.game()) + + def test_stale_retry_requires_prior_committed_legal_move_and_lost_ack(self): + rows=self.trace() + rows[1]["seq"]=3 + rows.insert(1,{"seq":2,"event":"fault_activated", + "action":"commit_then_close","endpoint":"move_post", + "request_id":"request-1"}) + rows.extend([ + {"seq":4,"event":"connection_close_requested", + "request_id":"request-1"}, + {"seq":5,"event":"move_attempt","game_id":"off3b001", + "uci":"e2e4","path":"/api/bot/game/off3b001/move/e2e4", + "ply_at_receipt":1}, + ]) + observed=QUALIFIER.verify_trace(rows,self.game()) + self.assertEqual(observed["illegal_attempts"],[]) + self.assertEqual(len(observed["stale_retry_attempts"]),1) + self.assertEqual(observed["stale_retry_attempts"][0]["original_request_id"], + "request-1") + without_loss=[r for r in rows if r["event"]!="connection_close_requested"] + # Fill the removed sequence gap to maintain an otherwise valid trace. + without_loss[-1]["seq"]=4 + rejected=QUALIFIER.verify_trace(without_loss,self.game()) + self.assertEqual(rejected["stale_retry_attempts"],[]) + self.assertEqual(len(rejected["illegal_attempts"]),1) + def test_move_audit_requires_exact_request_id_and_multiset(self): first="/api/bot/game/off3b001/move/e2e4" second="/api/bot/game/off3b001/move/d2d4" From ad93481d7627456a48bbc20e687a4e773e7d9ff8 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 20:36:59 +0200 Subject: [PATCH 081/147] test(ONLINE-4B): bind every guarded move request ID to server trace attempt --- scripts/qualify-online-recovery-v2.py | 15 ++++++++---- tests/lichess/test_recovery_v2_evidence.py | 27 +++++++++++++++++++--- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/scripts/qualify-online-recovery-v2.py b/scripts/qualify-online-recovery-v2.py index b9584aa6..aa77749b 100644 --- a/scripts/qualify-online-recovery-v2.py +++ b/scripts/qualify-online-recovery-v2.py @@ -125,11 +125,18 @@ def verify_guarded_requests(requests: list[dict], trace: list[dict], raise ValueError("malformed guarded move URL") from exc require(canonical != chess.Move.null() and canonical.uci() == uci, "noncanonical guarded move URL") - http_moves.append((path, uci)) + rid = request.get("request_id") + require(isinstance(rid, str) and bool(rid), + "guarded move HTTP request ID missing") + http_moves.append((path, rid)) raw_attempts = [r for r in trace if r.get("event") == "move_attempt"] - require(Counter(path for path, _ in http_moves) == - Counter(r.get("path") for r in raw_attempts), - "move HTTP records and trace attempts differ") + require(all(type(r.get("seq")) is int and r["seq"] > 0 + for r in raw_attempts), + "guarded move trace sequence identity malformed") + require(Counter(http_moves) == + Counter((r.get("path"), f"request-{r['seq']}") + for r in raw_attempts), + "guarded move HTTP records, request IDs and trace attempts differ") board = chess.Board() seen = set() diff --git a/tests/lichess/test_recovery_v2_evidence.py b/tests/lichess/test_recovery_v2_evidence.py index bfd06172..46a3f8e3 100644 --- a/tests/lichess/test_recovery_v2_evidence.py +++ b/tests/lichess/test_recovery_v2_evidence.py @@ -232,7 +232,8 @@ def test_guarded_request_ledger_rejects_duplicate_illegal_and_forbidden(self): path = f"/api/bot/game/{gid}/move/e2e4" game = {"game_id": gid, "color": "white", "moves": ["e2e4"], "forbidden_attempts": []} - requests = [{"method": "POST", "path": path, "status": 200}] + requests = [{"method": "POST", "path": path, "status": 200, + "request_id": "request-1"}] trace = [ {"seq":1,"event":"move_attempt","game_id":gid,"uci":"e2e4", "path":path,"ply_at_receipt":0}, @@ -259,10 +260,29 @@ def test_guarded_request_ledger_rejects_duplicate_illegal_and_forbidden(self): with self.subTest(mutated=mutated),self.assertRaises(ValueError): qual.verify_guarded_requests(*mutated) + def test_guarded_audit_cannot_swap_valid_move_request_ids(self): + gid="off3b001" + path=f"/api/bot/game/{gid}/move/e2e4" + trace=[{"seq":1,"event":"move_attempt","game_id":gid, + "uci":"e2e4","path":path,"ply_at_receipt":0}, + {"seq":2,"event":"board_commit","game_id":gid, + "uci":"e2e4","origin":"allfather","ply_before":0}] + game={"game_id":gid,"color":"white","moves":["e2e4"], + "forbidden_attempts":[]} + intents=[{"game_id":gid,"ply_before":0,"move":"e2e4", + "history_json":"[]"}] + for rid in ("request-2", "request-unseen", None): + with self.subTest(request_id=rid),self.assertRaisesRegex( + ValueError,"request ID|request IDs"): + qual.verify_guarded_requests( + [{"method":"POST","path":path,"request_id":rid, + "status":200}],trace,game,intents) + def test_legal_but_different_guarded_move_url_is_not_traced_intent(self): gid="off3b001" path=f"/api/bot/game/{gid}/move/e2e4" - requests=[{"method":"POST","path":path,"status":200}] + requests=[{"method":"POST","path":path,"status":200, + "request_id":"request-1"}] # d2d4 is independently legal, committed and durably prepared; # the actual server URL submitted e2e4 instead. trace=[{"seq":1,"event":"move_attempt","game_id":gid, @@ -290,7 +310,8 @@ def test_encoded_move_url_aliases_are_rejected(self): "history_json":"[]"}] with self.assertRaisesRegex(ValueError,"noncanonical guarded move URL"): qual.verify_guarded_requests( - [{"method":"POST","path":encoded,"status":200}], + [{"method":"POST","path":encoded,"status":200, + "request_id":"request-1"}], trace,game,intents) def test_journal_history_must_equal_authoritative_prefix(self): From 64b6c15ff2b6712658e02ac7c64faf5b1aba1822 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 20:39:06 +0200 Subject: [PATCH 082/147] docs(RELEASE-1): reconcile second Codex wave, negative tests and exact-head stop gates --- docs/CODEX_POSTMERGE_CLOSURE.md | 16 ++++++++++++++++ docs/RELEASE_1_OPERATIONS.md | 10 ++++++++++ 2 files changed, 26 insertions(+) diff --git a/docs/CODEX_POSTMERGE_CLOSURE.md b/docs/CODEX_POSTMERGE_CLOSURE.md index f5b07ca8..a72ed8b2 100644 --- a/docs/CODEX_POSTMERGE_CLOSURE.md +++ b/docs/CODEX_POSTMERGE_CLOSURE.md @@ -162,3 +162,19 @@ The following six source-linked Codex findings were found against that exact hea | [r4303673584](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303673584) HTTP-443 APT parser | `deploy/release/install-pinned-apt.sh` classifies statuses only in `Err:` reason and `E: Failed to fetch` context. Ports/IPs are never treated as HTTP status. Mixed DNS/signature/hash/unclassified failures remain immediate fatal errors. Snapshot and GPG trust pins unchanged | `tests/release/test_release_snapshot_retry.py` realistic 503/504 and `[IP: ... 443]` plus mixed errors | **Do not resolve any thread until exact-head CI and fresh code/security reviewer evidence are available.** No merge, live bot tokens, public artifact, licensure approval, OS/wheel locks, deployment rights or playing-strength claim is implied. + +## PR #71 — October 17 second Codex round, exact-head requalification pending + +**Baseline reviewed SHA:** `66e56b898f254a53cbb0bbb8a36ca7f2574174dd`. At this SHA nine supporting workflows were green and LOCAL-1 was running; RELEASE-1 real packaged producer runs (R00 white/black, R02, R10) individually passed, but the **independent host smoke verifier did not execute** because the standalone CLI import failed with `ModuleNotFoundError: No module named 'tests'`. Earlier RELEASE-1 fast tests (136/136) and Syft scanner compatibility passed. This is a *known failed qualification*, not release evidence. + +| Exact Codex finding / observed blocker | Source repair | Falsification/regression | +| --- | --- | --- | +| RELEASE-1 CLI import crash (run [39055671445](https://github.com/HMarcusWH/AllfatherChess/actions/runs/39055671445)) | `scripts/release_http_contract.py` loads `tests/lichess/http_target_contract.py` by exact repository-relative file with `importlib.util`, without relying on the execution CWD or a top-level `tests` package. | `tests/release/test_release_http_contract.py` launches both real CLI entrypoints from unrelated temporary CWD with `-I` and `PYTHONPATH` unset; fast tests must pass before expensive build. | +| [r4303841556](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303841556) imported HTTP target policy not source-attested | Both `qualification/online-recovery-v1.json` (`attested_source_paths`) and `qualification/online-recovery-v2.json` (`attested_paths`) now include `tests/lichess/http_target_contract.py` exactly once. Existing producer and independent source hash checks require identical bytes. | `tests/lichess/test_recovery_evidence.py` and `test_recovery_v2_evidence.py`: full correct source manifests pass; mutating only that helper's declared digest fails. | +| [r4303841564](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303841564) leading `//` path normalized by CPython | `FakeLichess.Handler.parse_request` captures the originally submitted HTTP origin-form request target from `raw_requestline` *before* `BaseHTTPRequestHandler` normalizes leading slashes. `record`, `auth` and canonical GET/POST admission use only the original target; disallowed requests retain SHA256 fingerprint and safe path, never secret-like raw values. | Raw TCP socket tests send `GET //api/account`, `GET ///api/account`, `GET //api/users/status?ids=allfatherfixture` and require refused (403), noncanonical audit with no exposed query; legitimate `ids=allfatherfixture` GET remains admitted. | +| [r4303841560](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303841560) present null in-toto statements wrapper | Both `scripts/verify-release-oci.py` and `scripts/inspect-release-oci.py` select singleton format **only when `statements` is absent**. A present null/object/string/empty array is refused. | `tests/release/test_release_oci_integrity.py` builds fully rehashed malicious OCI statement blobs and asserts independent negative OCI/diagnostic reports; real singleton and nonempty array remain positive. | +| [r4303841565](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303841565) raw non-RFC URI characters | Common URI validator uses RFC3986 ASCII unreserved/reserved grammar plus validated percent escaping. Raw Unicode, braces, pipes and carets cannot support SPDX namespace, download location or SLSA identity. | URI positive `%E2%98%83`, real pinned scanner; rehashed bad namespace/download fixtures, invalid Unicode/brace/pipe/percent direct tests. | +| [r4303841567](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303841567) position-illegal ONLINE-4A attempt hidden by legal completed game | `scripts/qualify-online-recovery.py` replays **all** trace events in sequence with `python-chess`, checking each attempt at `ply_at_receipt`, game, bot side and board legality as well as canonical path/request ID. A coherent position-illegal attempt becomes an `UNSAFE_WITNESS` rather than `SAFE_CONTINUATION`. A stale retry gets a separate witness only after a prior legal *committed* request and request-correlated lost-ACK event; unproven repeats are unsafe. | `tests/lichess/test_recovery_evidence.py`: legal completed move with preceding syntactically valid position-illegal `e7e5` HTTP attempt, incorrect receipt ply, proven and unproven stale retry cases. | +| Adjacent ONLINE-4B correlation | `scripts/qualify-online-recovery-v2.py` matches exact path and server-generated `request-{seq}` ID multisets rather than paths only. | `tests/lichess/test_recovery_v2_evidence.py`: swapped/missing request-ID attacks. | + +**Claim boundary:** These fixes certify neither a deployed public bot nor live-network recovery, publisher signing, host qualification, OS/wheel dependency locks, licensing, public distribution, operator approval, or Elo/strength. All 11 exact-head workflows, actual installed-image/SQLite/OCI/SBOM/SLSA aggregate, and final Codex code/security reviews must be separately proven on the new pushed head. Do not resolve review threads based solely on this planned closure record. diff --git a/docs/RELEASE_1_OPERATIONS.md b/docs/RELEASE_1_OPERATIONS.md index f0d2afff..f0f42ccb 100644 --- a/docs/RELEASE_1_OPERATIONS.md +++ b/docs/RELEASE_1_OPERATIONS.md @@ -289,3 +289,13 @@ RELEASE-1 OCI verification now reads and hashes its archive through the same ope The native GPG-signed, fixed Ubuntu snapshot remains authoritative for APT. Retry classification ignores bracketed HTTPS peer address/port metadata (`443` is not an HTTP status); retries remain bounded to explicitly recognized 429/502/503/504 acquisition failures and immediate refusal on mixed or integrity failures. Full exactly pinned offline OCI/SQLite/engine, complete merge/check matrix and final Codex reviews are **still required** for any new head. No release-policy permissions or dependency-lock qualification are raised. + +## October 17 — portable independent verifiers and attested source closure + +RELEASE-1 runs `scripts/qualify-release-smoke.py` directly as a host CLI. The original `scripts/release_http_contract.py` top-level package import depended on CWD/`PYTHONPATH` and failed only *after* four real packaged games succeeded. The shared canonical HTTP policy is now imported by an explicit `importlib.util.spec_from_file_location` using `Path(__file__).resolve().parents[1]`, preserving one single policy file but removing accidental Python path dependence. A regression invokes both stand-alone host verifiers with `python -I` outside the repository. Fast contracts must execute that check **before** expensive engine compilation. + +ONLINE-4A/4B source policies both include the new `tests/lichess/http_target_contract.py` in their exhaustive attestation path sets. The fake HTTP server captures the origin-form request target from the raw request line *before* CPython converts leading double or triple slashes to a single slash. Approved canonical upstream requests may retain only the audited allowlisted `ids=allfatherfixture` and `offeringDraw=true|false` parameters; rejected raw queries are not copied into downloadable reports. + +ONLINE-4A now replays all HTTP move attempts and authoritative commits in transport sequence. An attempted move must match game, receipt ply, canonical UCI and side/position legality. Coherent unsafe attempts remain observable `UNSAFE_WITNESS` evidence and cannot masquerade as normal safe continuation. A stale replay after prior committed legal move has its own observed disposition only when the correlated original lost/unknown acknowledgment is proven. ONLINE-4B additionally compares per-request IDs on full HTTP move audit versus trace. + +OCI attestation statement validation and sanitized diagnostics accept two forms only: valid singleton in-toto statements with `statements` *absent*, and nonempty correctly typed `statements` arrays. Explicit null/object/string/empty wrappers fail closed even when rehashed consistently. The RFC3986 URI input grammar rejects raw Unicode and forbidden delimiter characters, without rejecting valid percent-encoded URIs or legitimate pinned BuildKit/Syft data. No permission, licensing or public release gate is promoted. From 376b4c3c749bd6581afd8ee2e14776724d0dd872 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 20:42:12 +0200 Subject: [PATCH 083/147] test(RELEASE-1): reproduce standalone CLI without masking user-site dependencies --- docs/CODEX_POSTMERGE_CLOSURE.md | 2 +- docs/RELEASE_1_OPERATIONS.md | 2 +- tests/release/test_release_http_contract.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/CODEX_POSTMERGE_CLOSURE.md b/docs/CODEX_POSTMERGE_CLOSURE.md index a72ed8b2..1ee748c4 100644 --- a/docs/CODEX_POSTMERGE_CLOSURE.md +++ b/docs/CODEX_POSTMERGE_CLOSURE.md @@ -169,7 +169,7 @@ The following six source-linked Codex findings were found against that exact hea | Exact Codex finding / observed blocker | Source repair | Falsification/regression | | --- | --- | --- | -| RELEASE-1 CLI import crash (run [39055671445](https://github.com/HMarcusWH/AllfatherChess/actions/runs/39055671445)) | `scripts/release_http_contract.py` loads `tests/lichess/http_target_contract.py` by exact repository-relative file with `importlib.util`, without relying on the execution CWD or a top-level `tests` package. | `tests/release/test_release_http_contract.py` launches both real CLI entrypoints from unrelated temporary CWD with `-I` and `PYTHONPATH` unset; fast tests must pass before expensive build. | +| RELEASE-1 CLI import crash (run [39055671445](https://github.com/HMarcusWH/AllfatherChess/actions/runs/39055671445)) | `scripts/release_http_contract.py` loads `tests/lichess/http_target_contract.py` by exact repository-relative file with `importlib.util`, without relying on the execution CWD or a top-level `tests` package. | `tests/release/test_release_http_contract.py` launches both real CLI entrypoints from unrelated temporary CWD with `PYTHONPATH` unset (preserving normal user-installed runtime dependencies); fast tests must pass before expensive build. | | [r4303841556](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303841556) imported HTTP target policy not source-attested | Both `qualification/online-recovery-v1.json` (`attested_source_paths`) and `qualification/online-recovery-v2.json` (`attested_paths`) now include `tests/lichess/http_target_contract.py` exactly once. Existing producer and independent source hash checks require identical bytes. | `tests/lichess/test_recovery_evidence.py` and `test_recovery_v2_evidence.py`: full correct source manifests pass; mutating only that helper's declared digest fails. | | [r4303841564](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303841564) leading `//` path normalized by CPython | `FakeLichess.Handler.parse_request` captures the originally submitted HTTP origin-form request target from `raw_requestline` *before* `BaseHTTPRequestHandler` normalizes leading slashes. `record`, `auth` and canonical GET/POST admission use only the original target; disallowed requests retain SHA256 fingerprint and safe path, never secret-like raw values. | Raw TCP socket tests send `GET //api/account`, `GET ///api/account`, `GET //api/users/status?ids=allfatherfixture` and require refused (403), noncanonical audit with no exposed query; legitimate `ids=allfatherfixture` GET remains admitted. | | [r4303841560](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303841560) present null in-toto statements wrapper | Both `scripts/verify-release-oci.py` and `scripts/inspect-release-oci.py` select singleton format **only when `statements` is absent**. A present null/object/string/empty array is refused. | `tests/release/test_release_oci_integrity.py` builds fully rehashed malicious OCI statement blobs and asserts independent negative OCI/diagnostic reports; real singleton and nonempty array remain positive. | diff --git a/docs/RELEASE_1_OPERATIONS.md b/docs/RELEASE_1_OPERATIONS.md index f0f42ccb..0a69864c 100644 --- a/docs/RELEASE_1_OPERATIONS.md +++ b/docs/RELEASE_1_OPERATIONS.md @@ -292,7 +292,7 @@ Full exactly pinned offline OCI/SQLite/engine, complete merge/check matrix and f ## October 17 — portable independent verifiers and attested source closure -RELEASE-1 runs `scripts/qualify-release-smoke.py` directly as a host CLI. The original `scripts/release_http_contract.py` top-level package import depended on CWD/`PYTHONPATH` and failed only *after* four real packaged games succeeded. The shared canonical HTTP policy is now imported by an explicit `importlib.util.spec_from_file_location` using `Path(__file__).resolve().parents[1]`, preserving one single policy file but removing accidental Python path dependence. A regression invokes both stand-alone host verifiers with `python -I` outside the repository. Fast contracts must execute that check **before** expensive engine compilation. +RELEASE-1 runs `scripts/qualify-release-smoke.py` directly as a host CLI. The original `scripts/release_http_contract.py` top-level package import depended on CWD/`PYTHONPATH` and failed only *after* four real packaged games succeeded. The shared canonical HTTP policy is now imported by an explicit `importlib.util.spec_from_file_location` using `Path(__file__).resolve().parents[1]`, preserving one single policy file but removing accidental Python path dependence. A regression invokes both stand-alone host verifiers with normal `python` from an unrelated directory and `PYTHONPATH` unset. The real CI runner uses user-installed Python dependencies, so isolated `-I` mode is intentionally not imposed. Fast contracts must execute that check **before** expensive engine compilation. ONLINE-4A/4B source policies both include the new `tests/lichess/http_target_contract.py` in their exhaustive attestation path sets. The fake HTTP server captures the origin-form request target from the raw request line *before* CPython converts leading double or triple slashes to a single slash. Approved canonical upstream requests may retain only the audited allowlisted `ids=allfatherfixture` and `offeringDraw=true|false` parameters; rejected raw queries are not copied into downloadable reports. diff --git a/tests/release/test_release_http_contract.py b/tests/release/test_release_http_contract.py index 698c0fe9..1205beff 100644 --- a/tests/release/test_release_http_contract.py +++ b/tests/release/test_release_http_contract.py @@ -28,7 +28,7 @@ def test_standalone_release_verifiers_import_policy_without_pythonpath(self): "scripts/qualify-release-aggregate.py"): with self.subTest(cli=entry): outcome = subprocess.run( - [sys.executable, "-I", str(ROOT / entry), "--help"], + [sys.executable, str(ROOT / entry), "--help"], cwd=tmp, env=environment, capture_output=True, text=True, timeout=20) self.assertEqual(outcome.returncode, 0, outcome.stderr) From b4791ca93511258564bcf0a11d5bd97ee22fc27c Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 20:48:58 +0200 Subject: [PATCH 084/147] fix(RELEASE-1): align scanner-only in-toto wrapper validation with strict OCI checks --- scripts/check-release-scanner.py | 12 +++++++++--- tests/release/test_release_scanner_contract.py | 8 ++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/scripts/check-release-scanner.py b/scripts/check-release-scanner.py index 4905d4c5..1def44d8 100644 --- a/scripts/check-release-scanner.py +++ b/scripts/check-release-scanner.py @@ -75,10 +75,16 @@ def walk(descriptor,depth=0): require(isinstance(lid,str) and lid.startswith("sha256:"), "scanner attestation statement digest malformed") statement=read("blobs/sha256/"+lid[7:],lid,layer.get("size")) - rows=statement.get("statements") - if rows is None: + require(type(statement) is dict, + "scanner statement envelope invalid") + # An absent field is BuildKit's valid singleton statement; + # explicitly present null/scalar/empty wrappers are invalid. + if "statements" in statement: + rows=statement["statements"] + else: rows=[statement] - require(type(rows) is list, "scanner statement envelope invalid") + require(type(rows) is list and bool(rows), + "scanner statement envelope invalid") for row in rows: if isinstance(row,dict) and row.get("predicateType")==contract.SPDX: require(row.get("_type")==contract.STATEMENT, diff --git a/tests/release/test_release_scanner_contract.py b/tests/release/test_release_scanner_contract.py index f7dda202..1d0de7a3 100644 --- a/tests/release/test_release_scanner_contract.py +++ b/tests/release/test_release_scanner_contract.py @@ -28,6 +28,14 @@ def test_valid_spdx_probe_never_claims_release_authority(self): self.assertFalse(report["integrity_verified"]) self.assertFalse(report["release_qualified"]) + def test_null_statement_wrapper_cannot_qualify_a_scanner(self): + for kind in ("null","empty","object","string"): + with self.subTest(wrapper=kind),tempfile.TemporaryDirectory() as tmp: + tar,_,_=oci_fixture.OCIProofTests().fixture( + Path(tmp),statement_wrapper=kind) + with self.assertRaisesRegex(ValueError,"scanner statement envelope"): + probe.inspect_scanner_output(tar) + def test_malformed_spdx_refused_at_scanner_boundary(self): for mode in ("missing-download","bogus-algorithm", "namespace-fragment","empty-inventory"): From 415324360ed685cab000b805687d931ded7b9d62 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 20:53:45 +0200 Subject: [PATCH 085/147] docs(RELEASE-1): align scanner preflight with strict in-toto wrapper policy --- docs/CODEX_POSTMERGE_CLOSURE.md | 4 ++++ docs/RELEASE_1_OPERATIONS.md | 2 ++ 2 files changed, 6 insertions(+) diff --git a/docs/CODEX_POSTMERGE_CLOSURE.md b/docs/CODEX_POSTMERGE_CLOSURE.md index 1ee748c4..15e19701 100644 --- a/docs/CODEX_POSTMERGE_CLOSURE.md +++ b/docs/CODEX_POSTMERGE_CLOSURE.md @@ -178,3 +178,7 @@ The following six source-linked Codex findings were found against that exact hea | Adjacent ONLINE-4B correlation | `scripts/qualify-online-recovery-v2.py` matches exact path and server-generated `request-{seq}` ID multisets rather than paths only. | `tests/lichess/test_recovery_v2_evidence.py`: swapped/missing request-ID attacks. | **Claim boundary:** These fixes certify neither a deployed public bot nor live-network recovery, publisher signing, host qualification, OS/wheel dependency locks, licensing, public distribution, operator approval, or Elo/strength. All 11 exact-head workflows, actual installed-image/SQLite/OCI/SBOM/SLSA aggregate, and final Codex code/security reviews must be separately proven on the new pushed head. Do not resolve review threads based solely on this planned closure record. + +### Scanner-only upstream consistency follow-up + +`scripts/check-release-scanner.py` now makes the same precise distinction between a missing `statements` field (valid BuildKit singleton in-toto format) and an explicitly present null/scalar/object/empty `statements` wrapper (invalid). `tests/release/test_release_scanner_contract.py` applies rehashed negative wrapper fixtures to this generator-specific preflight in addition to the independently qualified OCI verifier and sanitized diagnostic reader. The scanner preflight remains non-authoritative for artifact distribution, signing, or full release qualification. **This is source implementation, not a passing exact-head CI claim until workflows run.** diff --git a/docs/RELEASE_1_OPERATIONS.md b/docs/RELEASE_1_OPERATIONS.md index 0a69864c..457d256b 100644 --- a/docs/RELEASE_1_OPERATIONS.md +++ b/docs/RELEASE_1_OPERATIONS.md @@ -299,3 +299,5 @@ ONLINE-4A/4B source policies both include the new `tests/lichess/http_target_con ONLINE-4A now replays all HTTP move attempts and authoritative commits in transport sequence. An attempted move must match game, receipt ply, canonical UCI and side/position legality. Coherent unsafe attempts remain observable `UNSAFE_WITNESS` evidence and cannot masquerade as normal safe continuation. A stale replay after prior committed legal move has its own observed disposition only when the correlated original lost/unknown acknowledgment is proven. ONLINE-4B additionally compares per-request IDs on full HTTP move audit versus trace. OCI attestation statement validation and sanitized diagnostics accept two forms only: valid singleton in-toto statements with `statements` *absent*, and nonempty correctly typed `statements` arrays. Explicit null/object/string/empty wrappers fail closed even when rehashed consistently. The RFC3986 URI input grammar rejects raw Unicode and forbidden delimiter characters, without rejecting valid percent-encoded URIs or legitimate pinned BuildKit/Syft data. No permission, licensing or public release gate is promoted. + +The pinned Syft scanner-only integration shares the same in-toto envelope contract as the full OCI verifier and safe diagnostic tool. Valid singleton statements have no `statements` key; present null/nonarray/empty wrappers are rejected. This preflight is a compatibility test only and cannot qualify or distribute any public artifact. From 6a9f8e74de9df5abb7a7420ba3cbc05c8f5df089 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 20:57:18 +0200 Subject: [PATCH 086/147] fix(ONLINE-4A): distinguish unsafe legal retry from invalid server commit evidence --- scripts/qualify-online-recovery.py | 15 ++++++++++----- tests/lichess/test_recovery_evidence.py | 20 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/scripts/qualify-online-recovery.py b/scripts/qualify-online-recovery.py index fc25445e..e8251d88 100644 --- a/scripts/qualify-online-recovery.py +++ b/scripts/qualify-online-recovery.py @@ -127,14 +127,19 @@ def verify_trace(trace: list[dict], game: dict) -> dict: ply = len(board.move_stack) position_legal = board.turn == bot_white and move in board.legal_moves if position_legal: - key = (ply, uci) - if key in first_attempt_by_ply: + # The actual server can legally commit a second submission + # after a first dropped-before-commit attempt (R01). The + # repeated attempt is an UNSAFE behavioral witness, but its + # committed legal move must not be mistaken for corrupted + # evidence. Keep every position-legal request in the ledger. + legal_attempts[rid] = (ply, uci) + if ply in first_attempt_by_ply: unsafe_attempts.append({ "request_id": rid, "uci": uci, "ply_at_receipt": ply, - "reason": "duplicate_unconfirmed_move_submission"}) + "original_request_id": first_attempt_by_ply[ply], + "reason": "repeated_move_submission_same_ply"}) else: - legal_attempts[rid] = (ply, uci) - first_attempt_by_ply[key] = rid + first_attempt_by_ply[ply] = rid else: # A previously legal/committed move is not itself proof of # safe retransmission. Require exact lost-ACK request evidence. diff --git a/tests/lichess/test_recovery_evidence.py b/tests/lichess/test_recovery_evidence.py index a2dd335f..57205728 100644 --- a/tests/lichess/test_recovery_evidence.py +++ b/tests/lichess/test_recovery_evidence.py @@ -111,6 +111,26 @@ def test_wrong_receipt_ply_is_evidence_corruption(self): with self.assertRaisesRegex(ValueError,"receipt ply"): QUALIFIER.verify_trace(rows,self.game()) + def test_second_position_legal_request_may_commit_but_is_unsafe_retry(self): + # R01's first HTTP request drops before commit; the frozen upstream + # may submit a second move on the same unchanged position. The second + # request can commit legally, but doing it blindly is an unsafe witness, + # not an invalid trace or a safe-continuation positive. + rows=self.trace() + first=dict(rows[0]) + rows[0]["seq"]=2 + rows[1]["seq"]=3 + rows[1]["request_id"]="request-2" + rows.insert(0,first) + result=QUALIFIER.verify_trace(rows,self.game()) + self.assertEqual(result["bot_commits"],1) + self.assertEqual(result["http_attempts"],2) + self.assertEqual(len(result["illegal_attempts"]),1) + self.assertEqual(result["illegal_attempts"][0]["reason"], + "repeated_move_submission_same_ply") + self.assertEqual(result["illegal_attempts"][0]["original_request_id"], + "request-1") + def test_stale_retry_requires_prior_committed_legal_move_and_lost_ack(self): rows=self.trace() rows[1]["seq"]=3 From c437a61f938ef6fc95c98378f21c7b4c880125bf Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 20:58:20 +0200 Subject: [PATCH 087/147] fix(ONLINE-4B): independently validate original request targets before path admission --- scripts/qualify-online-recovery-v2.py | 12 ++++++ tests/lichess/test_recovery_v2_evidence.py | 48 +++++++++++++++++++--- 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/scripts/qualify-online-recovery-v2.py b/scripts/qualify-online-recovery-v2.py index aa77749b..5a0bb46d 100644 --- a/scripts/qualify-online-recovery-v2.py +++ b/scripts/qualify-online-recovery-v2.py @@ -23,6 +23,7 @@ ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) from tests.lichess.fault_plan import load_scenarios +from tests.lichess.http_target_contract import canonical_target from deploy.lichess.reconcile import position_id def _v1_module(): @@ -106,6 +107,17 @@ def verify_guarded_requests(requests: list[dict], trace: list[dict], if r.get("method") == "POST" and r.get("status") == 403], "server forbidden-attempt audit differs from HTTP records") require(not forbidden, "guard submitted forbidden HTTP operations") + # Audit *original raw request semantics*. Checking only the producer's + # normalized pathname would accept a forged GET query or a leading-// + # request that CPython normalized before dispatch. + for request in requests: + raw = request.get("request_target") if isinstance(request, dict) else None + method = request.get("method") if isinstance(request, dict) else None + require(isinstance(raw, str) and + request.get("target_canonical") is True and + canonical_target(method, raw) and + request.get("path") == raw.split("?", 1)[0], + "guarded HTTP request target is not canonical") http_moves = [] for request in requests: if request.get("method") != "POST": diff --git a/tests/lichess/test_recovery_v2_evidence.py b/tests/lichess/test_recovery_v2_evidence.py index 46a3f8e3..87a505a3 100644 --- a/tests/lichess/test_recovery_v2_evidence.py +++ b/tests/lichess/test_recovery_v2_evidence.py @@ -232,8 +232,9 @@ def test_guarded_request_ledger_rejects_duplicate_illegal_and_forbidden(self): path = f"/api/bot/game/{gid}/move/e2e4" game = {"game_id": gid, "color": "white", "moves": ["e2e4"], "forbidden_attempts": []} - requests = [{"method": "POST", "path": path, "status": 200, - "request_id": "request-1"}] + requests = [{"method": "POST", "path": path, + "request_target": path, "target_canonical": True, + "status": 200, "request_id": "request-1"}] trace = [ {"seq":1,"event":"move_attempt","game_id":gid,"uci":"e2e4", "path":path,"ply_at_receipt":0}, @@ -275,13 +276,47 @@ def test_guarded_audit_cannot_swap_valid_move_request_ids(self): with self.subTest(request_id=rid),self.assertRaisesRegex( ValueError,"request ID|request IDs"): qual.verify_guarded_requests( - [{"method":"POST","path":path,"request_id":rid, + [{"method":"POST","path":path,"request_target":path, + "target_canonical":True,"request_id":rid, "status":200}],trace,game,intents) + def test_guarded_raw_get_semantics_cannot_be_forged(self): + gid="off3b001" + path=f"/api/bot/game/{gid}/move/e2e4" + requests=[{"method":"POST","path":path, + "request_target":path,"target_canonical":True, + "request_id":"request-1","status":200}] + trace=[{"seq":1,"event":"move_attempt","game_id":gid, + "uci":"e2e4","path":path,"ply_at_receipt":0}, + {"seq":2,"event":"board_commit","game_id":gid, + "uci":"e2e4","origin":"allfather","ply_before":0}] + game={"game_id":gid,"color":"white","moves":["e2e4"], + "forbidden_attempts":[]} + intents=[{"game_id":gid,"ply_before":0,"move":"e2e4", + "history_json":"[]"}] + for raw in ("//api/account", "/api/account?access_token=secret", + "/api/users/status?ids=other"): + with self.subTest(raw=raw),self.assertRaisesRegex( + ValueError,"guarded HTTP request target"): + qual.verify_guarded_requests( + requests+[{"method":"GET","path":raw.split("?",1)[0].lstrip("/"), + "request_target":raw, + "target_canonical":False,"status":200}], + trace,game,intents) + # Even a producer-controlled "canonical" bit cannot override a + # mismatched raw target with the same approved visible pathname. + with self.assertRaisesRegex(ValueError,"guarded HTTP request target"): + qual.verify_guarded_requests( + requests+[{"method":"GET","path":"/api/account", + "request_target":"/api/account?access_token=x", + "target_canonical":True,"status":200}], + trace,game,intents) + def test_legal_but_different_guarded_move_url_is_not_traced_intent(self): gid="off3b001" path=f"/api/bot/game/{gid}/move/e2e4" - requests=[{"method":"POST","path":path,"status":200, + requests=[{"method":"POST","path":path,"request_target":path, + "target_canonical":True,"status":200, "request_id":"request-1"}] # d2d4 is independently legal, committed and durably prepared; # the actual server URL submitted e2e4 instead. @@ -308,9 +343,10 @@ def test_encoded_move_url_aliases_are_rejected(self): "forbidden_attempts":[]} intents=[{"game_id":gid,"ply_before":0,"move":"e2e4", "history_json":"[]"}] - with self.assertRaisesRegex(ValueError,"noncanonical guarded move URL"): + with self.assertRaisesRegex(ValueError,"guarded HTTP request target"): qual.verify_guarded_requests( - [{"method":"POST","path":encoded,"status":200, + [{"method":"POST","path":encoded,"request_target":encoded, + "target_canonical":True,"status":200, "request_id":"request-1"}], trace,game,intents) From 9249b38c12b5a535386ea2bdb7daa90e3311e746 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 20:59:15 +0200 Subject: [PATCH 088/147] fix(RELEASE-1): validate RFC3986 URI path query fragment components separately --- scripts/verify-release-oci.py | 12 ++++++++++++ tests/release/test_release_oci_integrity.py | 13 ++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/scripts/verify-release-oci.py b/scripts/verify-release-oci.py index 805747b6..81c356a7 100644 --- a/scripts/verify-release-oci.py +++ b/scripts/verify-release-oci.py @@ -163,6 +163,18 @@ def valid_uri_syntax(value: object, allowed_schemes: set[str], *, return False try: uri = urlsplit(value) + # RFC3986: "[" and "]" are only valid around an IP-literal + # *authority*, never in a path, query, fragment or URN NSS. + # The component grammar also prevents reserved gen-delimiters + # being treated as ordinary pchar at any position. + pchar = r"A-Za-z0-9._~!$&'()*+,;=:@%-" + if (re.fullmatch("[" + pchar + r"/]*", uri.path, + re.ASCII) is None or + re.fullmatch("[" + pchar + r"/?]*", uri.query, + re.ASCII) is None or + re.fullmatch("[" + pchar + r"/?]*", uri.fragment, + re.ASCII) is None): + return False if uri.scheme not in allowed_schemes: return False if uri.scheme == "urn": diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index cc6b4798..b62e44b6 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -235,6 +235,12 @@ def descriptor(kind, digest, **extra): "fileName":"./file", "checksums":[{"algorithm":"SHA256", "checksumValue":"a"*64}]}] + elif spdx_case == "namespace-bracket-path": + body["documentNamespace"] = "https://example.test/a[b]" + elif spdx_case == "namespace-bracket-query": + body["documentNamespace"] = "https://example.test/?q=[x]" + elif spdx_case == "download-bracket-path": + body["packages"][0]["downloadLocation"] = "https://example.test/a[b]" elif spdx_case == "namespace-unicode": body["documentNamespace"] = "https://example.test/snowman-☃" elif spdx_case == "namespace-braces": @@ -568,11 +574,15 @@ def test_uri_grammar_is_shared_without_collapsing_identity_policies(self): "https://example.test/%E2%98%83")) self.assertTrue(oci.valid_identity_uri( "https://example.test/build?key=value&v=2")) + self.assertTrue(oci.valid_identity_uri( + "https://[2001:db8::1]/x?q=%5By%5D")) for uri in ("https://%zz/path", "https://example.test:bogus/x", "https://user:secret@example.test/x", "https://example.test/a\x00", "https://example.test/a%2", "https://example.test/☃", "https://example.test/{bad}", "https://example.test/a|b", "https://example.test/^x", + "https://example.test/a[b]", + "https://example.test/?q=[x]", "urn:example:a[b]", "https://example.test/foo#"): with self.subTest(uri=uri): self.assertFalse(oci.valid_spdx_namespace(uri)) @@ -615,7 +625,8 @@ def test_incomplete_spdx_sbom_never_qualifies(self): "namespace-trailing-fragment", "namespace-percent", "namespace-port", "namespace-control", "namespace-userinfo", "namespace-encoded-path", "namespace-unicode", "namespace-braces", - "namespace-pipe") + "namespace-pipe", "namespace-bracket-path", + "namespace-bracket-query", "download-bracket-path") for variant in cases: with self.subTest(spdx=variant), tempfile.TemporaryDirectory() as d: archive, meta, inspection = self.fixture( From 66a9563fe43fd1d24fbc5f6d7563847d11276009 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 21:00:38 +0200 Subject: [PATCH 089/147] fix(RELEASE-1): validate present SLSA v1/v0.2 optional fields by schema shape --- scripts/verify-release-oci.py | 104 ++++++++++++++++++-- tests/release/test_release_oci_integrity.py | 46 ++++++++- 2 files changed, 141 insertions(+), 9 deletions(-) diff --git a/scripts/verify-release-oci.py b/scripts/verify-release-oci.py index 81c356a7..a38bb527 100644 --- a/scripts/verify-release-oci.py +++ b/scripts/verify-release-oci.py @@ -217,27 +217,115 @@ def valid_spdx_namespace(value: object) -> bool: return valid_identity_uri(value) and "#" not in value +def _optional_object(container: dict, field: str) -> bool: + value = container.get(field) + return value is None or type(value) is dict + + +def _optional_string(container: dict, field: str) -> bool: + value = container.get(field) + return value is None or type(value) is str + + +def _optional_string_map(container: dict, field: str) -> bool: + value = container.get(field) + return (value is None or + (type(value) is dict and all( + type(key) is str and type(item) is str + for key, item in value.items()))) + + +def _valid_resource_descriptor(value: object) -> bool: + """Recognized SLSA resource fields have their normative JSON shapes.""" + if type(value) is not dict: + return False + if not all(_optional_string(value, name) for name in + ("uri", "name", "downloadLocation", "mediaType", "content", + "localName")): + return False + if not _optional_string_map(value, "digest"): + return False + return _optional_object(value, "annotations") + + +def _optional_resource_list(container: dict, field: str) -> bool: + value = container.get(field) + return (value is None or + (type(value) is list and + all(_valid_resource_descriptor(item) for item in value))) + + +def _valid_slsa_metadata(value: object, *, version: str) -> bool: + if value is None: + return True + if type(value) is not dict: + return False + keys = ("invocationId", "startedOn", "finishedOn") if version == "v1" else ( + "buildInvocationId", "buildStartedOn", "buildFinishedOn") + if not all(_optional_string(value, field) for field in keys): + return False + if version == "v0.2": + completeness = value.get("completeness") + if completeness is not None and ( + type(completeness) is not dict or + any(type(item) is not bool for item in completeness.values())): + return False + reproducible = value.get("reproducible") + if reproducible is not None and type(reproducible) is not bool: + return False + return True + + def validate_slsa(kind: str, predicate: dict) -> None: + """Recognized SLSA v1/v0.2 fields must satisfy their JSON type contracts. + + Absent and null *optional* fields remain equivalent per SLSA parsing + rules. Unrecognized extension fields do not grant extra authority. + """ + require(type(predicate) is dict, "SLSA predicate not a JSON object") if kind == "https://slsa.dev/provenance/v1": build = predicate.get("buildDefinition") run = predicate.get("runDetails") - require(isinstance(build, dict) and isinstance(run, dict), + require(type(build) is dict and type(run) is dict, "SLSA provenance v1 predicate incomplete") require(valid_identity_uri(build.get("buildType")), "SLSA v1 build type missing or invalid") - require(isinstance(build.get("externalParameters"), dict), - "SLSA v1 external parameters missing") + require(type(build.get("externalParameters")) is dict and + _optional_object(build, "internalParameters") and + _optional_resource_list(build, "resolvedDependencies"), + "SLSA v1 build inputs contain malformed optional fields") builder = run.get("builder") - require(isinstance(builder, dict) and - valid_identity_uri(builder.get("id")), - "SLSA v1 builder identity missing or invalid") + require(type(builder) is dict and + valid_identity_uri(builder.get("id")) and + _optional_string_map(builder, "version") and + _optional_resource_list(builder, "builderDependencies"), + "SLSA v1 builder identity or field shape invalid") + require(_valid_slsa_metadata(run.get("metadata"),version="v1") and + _optional_resource_list(run, "byproducts"), + "SLSA v1 run metadata/byproducts malformed") elif kind == "https://slsa.dev/provenance/v0.2": builder = predicate.get("builder") - require(isinstance(builder, dict) and + invocation = predicate.get("invocation") + require(type(builder) is dict and valid_identity_uri(builder.get("id")) and valid_identity_uri(predicate.get("buildType")) and - isinstance(predicate.get("invocation"), dict), + type(invocation) is dict, "SLSA v0.2 build type, builder or invocation missing") + require(_optional_string_map(builder, "version") and + _optional_resource_list(builder, "builderDependencies") and + _optional_object(predicate, "buildConfig") and + _optional_resource_list(predicate, "materials") and + _optional_object(invocation, "parameters") and + _optional_object(invocation, "environment") and + _optional_object(invocation, "configSource") and + _valid_slsa_metadata(predicate.get("metadata"),version="v0.2"), + "SLSA v0.2 recognized optional fields malformed") + source = invocation.get("configSource") + if type(source) is dict: + require(all(_optional_string(source, name) for name in + ("uri", "entryPoint")) and + _optional_string_map(source, "digest"), + "SLSA v0.2 config source malformed") else: raise ValueError("unsupported SLSA version") diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index b62e44b6..3e6839c6 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -272,6 +272,26 @@ def descriptor(kind, digest, **extra): body["buildDefinition"]["buildType"] = "not-a-uri" if slsa_case == "no-external": del body["buildDefinition"]["externalParameters"] + if slsa_case == "resolved-as-string": + body["buildDefinition"]["resolvedDependencies"] = "not-an-array" + if slsa_case == "metadata-as-string": + body["runDetails"]["metadata"] = "not-an-object" + if slsa_case == "byproducts-as-map": + body["runDetails"]["byproducts"] = {} + if slsa_case == "version-as-string": + body["runDetails"]["builder"]["version"] = "not-an-object" + if slsa_case == "resource-item-nonobject": + body["buildDefinition"]["resolvedDependencies"] = [17] + if slsa_case == "valid-optionals": + body["buildDefinition"]["resolvedDependencies"] = [ + {"uri":"https://example.test/source.tar.gz", + "digest":{"sha256":"a"*64}}] + body["buildDefinition"]["internalParameters"] = {} + body["runDetails"]["builder"]["version"] = { + "buildkit":"v0.20"} + body["runDetails"]["metadata"] = { + "invocationId":"offline-test"} + body["runDetails"]["byproducts"] = [] if invalid_predicate: body = {"schema_version": 1} statement_document = { @@ -819,8 +839,32 @@ def test_all_descriptors_require_exact_nonboolean_sizes(self): with self.assertRaisesRegex(ValueError, r"descriptor.*size"): oci.verify_archive(archive, meta, docker_inspect=inspection) + def test_slsa_optional_fields_have_independent_schema_contract(self): + with tempfile.TemporaryDirectory() as d: + archive,meta,inspected=self.fixture( + Path(d),slsa_case="valid-optionals") + self.assertTrue(oci.verify_archive( + archive,meta,docker_inspect=inspected)["oci_provenance_verified"]) + v02={"builder":{"id":"https://builder.example.test/v02"}, + "buildType":"https://example.test/buildtype", + "invocation":{}} + oci.validate_slsa("https://slsa.dev/provenance/v0.2",v02) + for bad in ( + {**v02,"materials":"not-an-array"}, + {**v02,"metadata":"not-an-object"}, + {**v02,"builder":{**v02["builder"],"version":"bad"}}, + {**v02,"invocation":{"environment":"not-an-object"}}, + {**v02,"buildConfig":[]}, + ): + with self.subTest(bad=bad),self.assertRaisesRegex(ValueError, + "SLSA"): + oci.validate_slsa("https://slsa.dev/provenance/v0.2",bad) + def test_slsa_predicates_cannot_be_schema_only(self): - for mode in ("empty", "builder-empty", "bad-build-type", "no-external"): + for mode in ("empty", "builder-empty", "bad-build-type", "no-external", + "resolved-as-string", "metadata-as-string", + "byproducts-as-map", "version-as-string", + "resource-item-nonobject"): with self.subTest(mode=mode), tempfile.TemporaryDirectory() as d: archive, meta, inspection = self.fixture(Path(d), slsa_case=mode) From 565787b7e11e520922e95f07f1081fb2550e1d6b Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 21:01:33 +0200 Subject: [PATCH 090/147] fix(ONLINE-4A): classify noncanonical raw HTTP request targets as unsafe evidence --- scripts/qualify-online-recovery.py | 27 ++++++++++++++++++++++++- tests/lichess/test_recovery_evidence.py | 15 ++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/scripts/qualify-online-recovery.py b/scripts/qualify-online-recovery.py index e8251d88..e22e60e7 100644 --- a/scripts/qualify-online-recovery.py +++ b/scripts/qualify-online-recovery.py @@ -23,6 +23,7 @@ ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) from tests.lichess.fault_plan import load_scenarios +from tests.lichess.http_target_contract import canonical_target POLICY = ROOT / "qualification/online-recovery-v1.json" @@ -369,6 +370,29 @@ def invalid_move_submissions(http_requests: list[dict]) -> list[dict]: return invalid +def unexpected_raw_targets(http_requests: list[dict]) -> list[dict]: + """Bounded unsafe-behavior observations, never raw rejected query data.""" + unexpected = [] + for row in http_requests: + if not isinstance(row, dict): + continue + raw = row.get("request_target") + method = row.get("method") + bad = (not isinstance(raw, str) or + row.get("target_canonical") is not True or + not canonical_target(method, raw) or + row.get("path") != raw.split("?", 1)[0]) + if bad and len(unexpected) < 32: + unexpected.append({ + "method": method if method in ("GET", "POST") else "unknown", + "status": row.get("status") if type(row.get("status")) is int else None, + "path": row.get("path") if isinstance(row.get("path"), str) + else "__invalid_path__", + "reason": "noncanonical_request_target", + }) + return unexpected + + def verify_exact_http_move_audit( http_requests: list[dict], trace: list[dict]) -> int: """An observed move POST must have exactly one server trace attempt. @@ -487,7 +511,8 @@ def validate_experiment(case_dir: Path, scenario: dict, color: str, "fault harness recorded execution or teardown errors") unusable_moves = (invalid_move_submissions(http_requests) + history["illegal_attempts"] + - history["stale_retry_attempts"]) + history["stale_retry_attempts"] + + unexpected_raw_targets(http_requests)) if scenario["action"] == "none": ensure(history["bot_commits"] > 0 and logging is not None, "normal engine baseline did not play") diff --git a/tests/lichess/test_recovery_evidence.py b/tests/lichess/test_recovery_evidence.py index 57205728..3879e742 100644 --- a/tests/lichess/test_recovery_evidence.py +++ b/tests/lichess/test_recovery_evidence.py @@ -156,6 +156,21 @@ def test_stale_retry_requires_prior_committed_legal_move_and_lost_ack(self): self.assertEqual(rejected["stale_retry_attempts"],[]) self.assertEqual(len(rejected["illegal_attempts"]),1) + def test_noncanonical_http_query_never_supports_safe_continuation(self): + good={"method":"GET","path":"/api/account","status":200, + "request_target":"/api/account","target_canonical":True} + self.assertEqual(QUALIFIER.unexpected_raw_targets([good]),[]) + malformed={ + **good,"request_target":"/api/account?access_token=PRIVATE-CANARY", + } + observed=QUALIFIER.unexpected_raw_targets([malformed]) + self.assertEqual(len(observed),1) + self.assertEqual(observed[0]["reason"],"noncanonical_request_target") + self.assertNotIn("PRIVATE-CANARY",str(observed)) + self.assertEqual(QUALIFIER.unexpected_raw_targets([ + {**good,"target_canonical":False}])[0]["reason"], + "noncanonical_request_target") + def test_move_audit_requires_exact_request_id_and_multiset(self): first="/api/bot/game/off3b001/move/e2e4" second="/api/bot/game/off3b001/move/d2d4" From 32b45ba3d59054b94f3cba6645bc3744c598a768 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 21:03:40 +0200 Subject: [PATCH 091/147] docs(ONLINE-4 RELEASE-1): reconcile new review and genuine R01 unsafe retry evidence --- docs/CODEX_POSTMERGE_CLOSURE.md | 14 ++++++++++++++ docs/RELEASE_1_OPERATIONS.md | 8 ++++++++ tests/lichess/test_fake_api.py | 19 +++++++++++++++++++ tests/lichess/test_recovery_v2_evidence.py | 6 ++++++ 4 files changed, 47 insertions(+) diff --git a/docs/CODEX_POSTMERGE_CLOSURE.md b/docs/CODEX_POSTMERGE_CLOSURE.md index 15e19701..c3d54bd2 100644 --- a/docs/CODEX_POSTMERGE_CLOSURE.md +++ b/docs/CODEX_POSTMERGE_CLOSURE.md @@ -182,3 +182,17 @@ The following six source-linked Codex findings were found against that exact hea ### Scanner-only upstream consistency follow-up `scripts/check-release-scanner.py` now makes the same precise distinction between a missing `statements` field (valid BuildKit singleton in-toto format) and an explicitly present null/scalar/object/empty `statements` wrapper (invalid). `tests/release/test_release_scanner_contract.py` applies rehashed negative wrapper fixtures to this generator-specific preflight in addition to the independently qualified OCI verifier and sanitized diagnostic reader. The scanner preflight remains non-authoritative for artifact distribution, signing, or full release qualification. **This is source implementation, not a passing exact-head CI claim until workflows run.** + +## October 17 — post-review corrections following exact-head `376b4c3c` + +**Observed CI, not a source claim:** exact-head `376b4c3c749bd6581afd8ee2e14776724d0dd872` passed 138/138 RELEASE-1 fast tests, pinned BuildKit/Syft preflight, all four packaged offline R00/R02/R10 games, independent persisted SQLite/chess/HTTP, and full OCI/SPDX/SLSA aggregate with `image_integrity_qualified=true`; `offline_candidate_qualified=false` and distribution authorization remained prohibited. ONLINE-4A independent evidence failed **only R01** at this head because the second position-legal submission after a dropped-before-commit first attempt was wrongly treated as ineligible to be the legally committed request. R02/R03 were preserved as observed unsafe stale retry witnesses; R10 exposed repeated forbidden abort requests as unsafe evidence. The merge gate initially hit an intermittent controller deadline timing failure but passed its unchanged-head job rerun, and the other supporting workflows were green except long-running LOCAL-1. + +| Finding / integration blocker | New source repair / negative regression | +| --- | --- | +| Real ONLINE-4A R01 independent failure | `scripts/qualify-online-recovery.py` keeps **every position-legal HTTP attempt** in the legal commit-correlation ledger while separately marking a second attempt at the same ply `repeated_move_submission_same_ply` unsafe. A true second legal server commit no longer becomes evidence corruption. `tests/lichess/test_recovery_evidence.py` simulates a dropped first request and legally committed second request. | +| [r4304... ONLINE-4B canonical raw target](https://github.com/HMarcusWH/AllfatherChess/pull/71) | `scripts/qualify-online-recovery-v2.py` applies the existing **source-attested** canonical target helper to every GET/POST raw request, requires `target_canonical=true` and exact raw-to-normalized path linkage before independent board/intent verification. `tests/lichess/test_recovery_v2_evidence.py` forges hidden query, leading double slash, false canonical bit, and retains a real positive `ids=allfatherfixture` GET. | +| Codex RFC3986 component delimiters | `scripts/verify-release-oci.py` validates URI path `pchar / /`, query/fragment `pchar / / / ?`, and separately validated authority with IPv6 brackets confined to host; raw brackets in path/query/URN NSS now fail. `tests/release/test_release_oci_integrity.py` checks direct and rehashed bad URI cases and IPv6/encoded-bracket positives. | +| Codex SLSA optional-standard-field containers | `scripts/verify-release-oci.py` now validates present recognized SLSA v1 and v0.2 buildDefinition/runDetails/builder/metadata fields, strings/maps, resource descriptors and lists, while treating absent and null optional fields as equivalent in accordance with SLSA parsing guidance. Negative rehashed v1 fixtures and v0.2 direct tests cover malformed dependencies, metadata, byproducts, builder version and resources; positive valid-optionals fixture preserved. | +| Scanner-only preflight alignment | `scripts/check-release-scanner.py` rejects present null/object/scalar/empty in-toto statement wrappers, while continuing to accept BuildKit singleton and array formats. `tests/release/test_release_scanner_contract.py` covers coherent rehashed negatives. | + +**Do not infer an exact-head passing result for these newly staged edits.** Run all RELEASE-1/ONLINE-4A/4B/controller/LOCAL-1 checks and fresh code/security reviews on the new pushed commit. Offline-only and all non-grant policies remain unchanged. diff --git a/docs/RELEASE_1_OPERATIONS.md b/docs/RELEASE_1_OPERATIONS.md index 457d256b..a451f114 100644 --- a/docs/RELEASE_1_OPERATIONS.md +++ b/docs/RELEASE_1_OPERATIONS.md @@ -301,3 +301,11 @@ ONLINE-4A now replays all HTTP move attempts and authoritative commits in transp OCI attestation statement validation and sanitized diagnostics accept two forms only: valid singleton in-toto statements with `statements` *absent*, and nonempty correctly typed `statements` arrays. Explicit null/object/string/empty wrappers fail closed even when rehashed consistently. The RFC3986 URI input grammar rejects raw Unicode and forbidden delimiter characters, without rejecting valid percent-encoded URIs or legitimate pinned BuildKit/Syft data. No permission, licensing or public release gate is promoted. The pinned Syft scanner-only integration shares the same in-toto envelope contract as the full OCI verifier and safe diagnostic tool. Valid singleton statements have no `statements` key; present null/nonarray/empty wrappers are rejected. This preflight is a compatibility test only and cannot qualify or distribute any public artifact. + +### Source-locked observed unsafe recovery versus evidence corruption + +A genuine dropped-before-commit R01 fault can yield a second chess-legal HTTP submission on the unchanged position that the fake server actually commits. ONLINE-4A's independent verifier now preserves this as a *valid authoritative board commit and an unsafe repeat attempt*; it no longer rejects the entire harness merely because the committed request ID belongs to the second attempt. Other fault cases similarly retain observed stale/forbidden retry evidence under bounded, sanitized `UNSAFE_WITNESS` dispositions. A normal R00 cannot be declared `SAFE_CONTINUATION` if an attempted move was illegal, repeated, or had an unapproved raw target. + +ONLINE-4B verifies full original GET/POST request targets against the source-attested HTTP policy, requiring raw `request_target` to match the normalized route and admission bit before matching request-ID/move/SQLite evidence. The pinned bridge's genuine `ids=allfatherfixture` query remains permitted, but forged double-slash and secret/extra queries never qualify. A duplicate or mismatched raw target is rejected rather than reinterpreted through parsed pathname alone. + +RELEASE-1 URI admission now constrains path/query/fragment components individually under RFC3986 and validates optional recognized SLSA v1/v0.2 object/map/list fields, while preserving real BuildKit valid optional nulls and percent-encoded resource identities. Every new source change requires exact-head scanner, packaged-game, OCI/SLSA and aggregate qualification. No live transport or distribution claims arise. diff --git a/tests/lichess/test_fake_api.py b/tests/lichess/test_fake_api.py index 920d4c29..52288504 100644 --- a/tests/lichess/test_fake_api.py +++ b/tests/lichess/test_fake_api.py @@ -91,6 +91,25 @@ def test_network_path_bypasses_cpython_normalization_only_if_not_observed(self): self.assertIn("GET "+target.split("?",1)[0], self.fake.attempted_forbidden) + def test_raw_double_slash_post_never_becomes_valid_token_endpoint(self): + import socket + with socket.create_connection(self.fake.http.server_address, + timeout=4) as connection: + connection.settimeout(4) + connection.sendall(( + "POST //api/token/test HTTP/1.1\r\n" + "Host: 127.0.0.1\r\n" + f"Authorization: Bearer {TOKEN}\r\n" + "Content-Length: 0\r\n" + "Connection: close\r\n\r\n" + ).encode()) + answer=connection.recv(1024) + self.assertIn(b"403",answer) + record=self.fake.requests[-1] + self.assertEqual(record["status"],403) + self.assertFalse(record["target_canonical"]) + self.assertIsNone(record["request_target"]) + def test_duplicate_accept_rejected(self): path = f"/api/challenge/{GAME_ID}/accept" self.assertEqual(self.call("POST", path), 200) diff --git a/tests/lichess/test_recovery_v2_evidence.py b/tests/lichess/test_recovery_v2_evidence.py index 87a505a3..cc679bf6 100644 --- a/tests/lichess/test_recovery_v2_evidence.py +++ b/tests/lichess/test_recovery_v2_evidence.py @@ -294,6 +294,12 @@ def test_guarded_raw_get_semantics_cannot_be_forged(self): "forbidden_attempts":[]} intents=[{"game_id":gid,"ply_before":0,"move":"e2e4", "history_json":"[]"}] + expected=qual.verify_guarded_requests( + requests+[{"method":"GET","path":"/api/users/status", + "request_target":"/api/users/status?ids=allfatherfixture", + "target_canonical":True,"status":200}], + trace,game,intents) + self.assertEqual(expected["unique_legal_move_attempts"],1) for raw in ("//api/account", "/api/account?access_token=secret", "/api/users/status?ids=other"): with self.subTest(raw=raw),self.assertRaisesRegex( From 37fdf5ed4bfca60942a2930c23b6759a04e3e647 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 22:11:42 +0200 Subject: [PATCH 092/147] feat(ASSURANCE): define claim contract and enforce transitive source + Git byte closure --- qualification/assurance-contract-v1.json | 125 +++++++++++++++++++ qualification/online-recovery-v1.json | 2 + qualification/online-recovery-v2.json | 6 + scripts/qualify-online-recovery-v2.py | 3 + scripts/qualify-online-recovery.py | 3 + scripts/verify_source_closure.py | 146 +++++++++++++++++++++++ 6 files changed, 285 insertions(+) create mode 100644 qualification/assurance-contract-v1.json create mode 100644 scripts/verify_source_closure.py diff --git a/qualification/assurance-contract-v1.json b/qualification/assurance-contract-v1.json new file mode 100644 index 00000000..9e6342ee --- /dev/null +++ b/qualification/assurance-contract-v1.json @@ -0,0 +1,125 @@ +{ + "schema_version": 1, + "scope": "immutable-offline-evidence-only", + "public_bot_release": false, + "authority": { + "public_distribution": false, + "live_transport": false, + "host_qualified": false, + "legal_approval": false, + "operator_approval": false, + "playing_strength": false + }, + "claims": { + "harness_qualified": { + "requires": [ + "exact_executed_source_closure", + "producer_qualifier_independent", + "complete_http_record_and_trace", + "authoritative_chess_replay", + "fault_witness", + "safe_retained_report" + ], + "does_not_imply": [ + "safe_continuation", + "offline_recovery_qualified", + "network_recovery_qualified", + "public_bot_release" + ] + }, + "containment_qualified_offline": { + "requires": [ + "harness_qualified", + "all_requests_admitted_and_classified", + "scenario_specific_http_status_witness", + "durable_intent_and_epoch_consistency", + "no_unjustified_unsafe_witness" + ], + "does_not_imply": [ + "network_recovery_qualified", + "public_bot_release" + ] + }, + "recovery_qualified_offline": { + "requires": [ + "containment_qualified_offline", + "frozen_fault_scenarios", + "new_epoch_confirmed_commit", + "exact_journal_reconciliation" + ], + "does_not_imply": [ + "network_recovery_qualified", + "public_bot_release" + ] + }, + "offline_smoke_qualified": { + "requires": [ + "exact_executed_source_closure", + "actual_bot_actor_on_bot_turn", + "complete_http_status_request_identity", + "legal_game_and_pgn", + "read_only_durable_sqlite", + "scenario_fault_evidence", + "safe_retained_report" + ], + "does_not_imply": [ + "offline_candidate_qualified", + "public_bot_release" + ] + }, + "oci_sbom_verified": { + "requires": [ + "same_archive_inode_integrity", + "complete_in_toto_statement_set", + "spdx_full_inventory_and_uri_schema", + "image_subject_binding" + ], + "does_not_imply": [ + "publisher_signature_verified", + "public_bot_release" + ] + }, + "oci_provenance_verified": { + "requires": [ + "same_archive_inode_integrity", + "complete_in_toto_statement_set", + "slsa_versioned_field_shapes", + "slsa_resource_identity", + "slsa_timestamp_semantics", + "image_subject_binding" + ], + "does_not_imply": [ + "publisher_signature_verified", + "public_bot_release" + ] + }, + "image_integrity_qualified": { + "requires": [ + "offline_smoke_qualified", + "oci_sbom_verified", + "oci_provenance_verified", + "exact_host_image_identity", + "complete_oci_layer_graph", + "safe_retained_report" + ], + "does_not_imply": [ + "offline_candidate_qualified", + "public_bot_release" + ] + }, + "offline_candidate_qualified": { + "requires": [ + "image_integrity_qualified", + "os_and_wheel_lock_complete", + "controller_license_approved", + "source_availability_review", + "destination_host_qualified", + "live_transport_qualified", + "operator_live_approval" + ], + "does_not_imply": [ + "public_bot_release" + ] + } + } +} diff --git a/qualification/online-recovery-v1.json b/qualification/online-recovery-v1.json index 2d8ecab0..e87b6096 100644 --- a/qualification/online-recovery-v1.json +++ b/qualification/online-recovery-v1.json @@ -55,8 +55,10 @@ "deploy/lichess/offline_session.py", "deploy/lichess/run_recovery.py", "docs/ONLINE_4_RECOVERY.md", + "qualification/assurance-contract-v1.json", "qualification/online-recovery-v1.json", "scripts/qualify-online-recovery.py", + "scripts/verify_source_closure.py", "tests/fixtures/lichess/recovery_scenarios.json", "tests/lichess/fake_lichess_api.py", "tests/lichess/fault_plan.py", diff --git a/qualification/online-recovery-v2.json b/qualification/online-recovery-v2.json index 4fa04f20..d0ca3339 100644 --- a/qualification/online-recovery-v2.json +++ b/qualification/online-recovery-v2.json @@ -60,23 +60,29 @@ "playing_strength_qualified": false, "attested_paths": [ ".github/workflows/online-recovery-v2.yml", + "deploy/lichess/Dockerfile.offline", "deploy/lichess/bridge.lock.json", "deploy/lichess/bridge_policy.py", "deploy/lichess/extra_game_handlers.py", "deploy/lichess/fetch_bridge.py", "deploy/lichess/guarded_transport.py", + "deploy/lichess/log_capture.py", "deploy/lichess/move_journal.py", "deploy/lichess/offline_session.py", "deploy/lichess/reconcile.py", "deploy/lichess/recovery_overlay.py", + "deploy/lichess/run_recovery.py", "deploy/lichess/run_recovery_v2.py", "deploy/lichess/session_owner.py", "docs/ONLINE_4B_RECOVERY.md", + "qualification/assurance-contract-v1.json", "qualification/online-recovery-v2.json", "scripts/qualify-online-recovery-v2.py", "scripts/qualify-online-recovery.py", + "scripts/verify_source_closure.py", "tests/fixtures/lichess/recovery_scenarios.json", "tests/lichess/fake_lichess_api.py", + "tests/lichess/fault_plan.py", "tests/lichess/http_target_contract.py", "tests/lichess/scripted_opponent.py", "tests/lichess/stream_sessions.py", diff --git a/scripts/qualify-online-recovery-v2.py b/scripts/qualify-online-recovery-v2.py index 5a0bb46d..bbaa1e9a 100644 --- a/scripts/qualify-online-recovery-v2.py +++ b/scripts/qualify-online-recovery-v2.py @@ -25,6 +25,7 @@ from tests.lichess.fault_plan import load_scenarios from tests.lichess.http_target_contract import canonical_target from deploy.lichess.reconcile import position_id +from scripts.verify_source_closure import verify_profile, verify_committed_bytes def _v1_module(): spec = importlib.util.spec_from_file_location( @@ -544,6 +545,8 @@ def qualify(evidence: Path, source_sha: str): require(subprocess.check_output(["git", "-C", str(ROOT), "rev-parse", "HEAD"], text=True).strip() == source_sha, "not executing verifier on exact CI source head") + verify_profile("online-recovery-v2") + verify_committed_bytes(source_sha, policy["attested_paths"]) scenarios = load_scenarios(ROOT / policy["scenario_source"]) bridge = read(ROOT / policy["bridge_lock"])["commit"] expected_overlay = independently_expected_overlay() diff --git a/scripts/qualify-online-recovery.py b/scripts/qualify-online-recovery.py index e22e60e7..963c9a51 100644 --- a/scripts/qualify-online-recovery.py +++ b/scripts/qualify-online-recovery.py @@ -24,6 +24,7 @@ sys.path.insert(0, str(ROOT)) from tests.lichess.fault_plan import load_scenarios from tests.lichess.http_target_contract import canonical_target +from scripts.verify_source_closure import verify_profile, verify_committed_bytes POLICY = ROOT / "qualification/online-recovery-v1.json" @@ -543,6 +544,8 @@ def qualify(evidence: Path, source_sha: str) -> dict: actual = subprocess.check_output(["git", "-C", str(ROOT), "rev-parse", "HEAD"], text=True).strip() ensure(actual == source_sha, "validator checkout is not exact source commit") + verify_profile("online-recovery-v1") + verify_committed_bytes(source_sha, policy["attested_source_paths"]) bridge = read_json(ROOT / "deploy/lichess/bridge.lock.json")["commit"] scenarios = load_scenarios(ROOT / policy["scenario_source"]) required = policy["required_integration_scenarios"] diff --git a/scripts/verify_source_closure.py b/scripts/verify_source_closure.py new file mode 100644 index 00000000..3f8b98a9 --- /dev/null +++ b/scripts/verify_source_closure.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Offline source dependency closure and Git committed-byte verification. + +This is independent of producer manifests. AST imports are discovered from +each executable entrypoint; dynamic imports and non-Python build inputs have +explicit roots. Neither a correct SHA string nor a producer-generated hash +establishes equality with Git's committed source bytes. +""" +from __future__ import annotations + +import argparse +import ast +import hashlib +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PROFILES = { + "online-recovery-v1": { + "policy": "qualification/online-recovery-v1.json", + "field": "attested_source_paths", + "roots": ("deploy/lichess/run_recovery.py", + "scripts/qualify-online-recovery.py", + "tests/lichess/fake_lichess_api.py", + "scripts/verify_source_closure.py"), + "inputs": ("deploy/lichess/Dockerfile.offline", + ".github/workflows/online-recovery.yml", + "qualification/assurance-contract-v1.json"), + }, + "online-recovery-v2": { + "policy": "qualification/online-recovery-v2.json", + "field": "attested_paths", + "roots": ("deploy/lichess/run_recovery_v2.py", + "scripts/qualify-online-recovery-v2.py", + # v2 dynamically loads the independent v1 verifier + "scripts/qualify-online-recovery.py", + "tests/lichess/fake_lichess_api.py", + "scripts/verify_source_closure.py"), + "inputs": ("deploy/lichess/Dockerfile.offline", + ".github/workflows/online-recovery-v2.yml", + "qualification/assurance-contract-v1.json"), + }, +} + + +def require(condition: bool, why: str) -> None: + if not condition: + raise ValueError(why) + + +def imported_sources(path: Path, root: Path) -> set[str]: + """Visit local imports; no module imports or application code executed.""" + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + names = set() + for node in ast.walk(tree): + candidates = [] + if isinstance(node, ast.Import): + candidates.extend(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.level == 0: + module = node.module or "" + candidates.append(module) + # from deploy.lichess import foo imports executable foo.py + candidates.extend(module+"."+alias.name for alias in node.names + if alias.name != "*") + for name in candidates: + if name.startswith(("tests.lichess.", "deploy.lichess.", + "scripts.")): + candidate = name.replace(".", "/") + ".py" + if (root / candidate).is_file(): + names.add(candidate) + return names + + +def expected_sources(profile: str, root: Path = ROOT) -> set[str]: + require(profile in PROFILES, "unknown source closure profile") + setup = PROFILES[profile] + required = set(setup["roots"]) | set(setup["inputs"]) + queue = list(setup["roots"]) + while queue: + path = queue.pop() + require((root / path).is_file(), "source closure import missing: "+path) + for child in imported_sources(root / path, root): + if child not in required: + required.add(child) + queue.append(child) + return required + + +def verify_profile(profile: str, root: Path = ROOT) -> dict: + require(profile in PROFILES, "unknown source closure profile") + setup = PROFILES[profile] + doc = json.loads((root / setup["policy"]).read_text(encoding="utf-8")) + listed = doc.get(setup["field"]) + require(type(listed) is list and len(listed) == len(set(listed)) and + all(type(name) is str for name in listed), + "source closure attestation set is malformed") + required = expected_sources(profile, root) + missing = sorted(required - set(listed)) + require(not missing, "unattested executable/source input: " + ",".join(missing)) + return {"profile":profile,"source_closure_verified":True, + "source_paths_checked":len(listed), + "reachable_executable_paths":len(required), + "deployment_authorized":False} + + +def verify_committed_bytes(source_sha: str, paths: list[str], + root: Path = ROOT) -> None: + """Compare actual loaded files against independently retrieved Git bytes.""" + require(type(source_sha) is str and + re.fullmatch(r"[0-9a-f]{40}", source_sha) is not None, + "source Git commit SHA malformed") + require(type(paths) is list and len(paths) == len(set(paths)), + "source Git byte input list invalid") + for name in paths: + require(type(name) is str and name and + (root/name).is_file() and not (root/name).is_symlink() and + (root/name).resolve().is_relative_to(root.resolve()), + "source Git path invalid/missing/linked") + git = subprocess.run( + ["git", "-C", str(root), "show", source_sha+":"+name], + capture_output=True, timeout=8, check=False) + require(git.returncode == 0, "committed source absent: "+name) + require(hashlib.sha256((root/name).read_bytes()).digest() == + hashlib.sha256(git.stdout).digest(), + "executed source differs from Git commit: "+name) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--profile", choices=sorted(PROFILES), required=True) + ap.add_argument("--source-sha", required=False) + args = ap.parse_args() + report = verify_profile(args.profile) + if args.source_sha: + cfg = PROFILES[args.profile] + paths = json.loads((ROOT/cfg["policy"]).read_text())[cfg["field"]] + verify_committed_bytes(args.source_sha, paths) + report["committed_bytes_verified"] = True + print(json.dumps(report, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 4ceda5f97eeb2abebf2eaabc8e522c583eed8677 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 22:14:51 +0200 Subject: [PATCH 093/147] feat(ASSURANCE): enforce scenario-bound HTTP response semantics in recovery evidence --- qualification/http-evidence-contract-v1.json | 16 ++++++ qualification/online-recovery-v1.json | 2 + qualification/online-recovery-v2.json | 2 + scripts/http_evidence_verdict.py | 55 ++++++++++++++++++++ scripts/qualify-online-recovery-v2.py | 9 +++- scripts/qualify-online-recovery.py | 4 +- 6 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 qualification/http-evidence-contract-v1.json create mode 100644 scripts/http_evidence_verdict.py diff --git a/qualification/http-evidence-contract-v1.json b/qualification/http-evidence-contract-v1.json new file mode 100644 index 00000000..18014ca4 --- /dev/null +++ b/qualification/http-evidence-contract-v1.json @@ -0,0 +1,16 @@ +{ + "schema_version": 1, + "source": "frozen-offline-fake-lichess", + "non_move_status": 200, + "ordinary_move_status": 200, + "fault_specific_move_status": { + "drop_before_commit": 0, + "rate_limit_429": 429, + "commit_then_504": 504 + }, + "fault_requires_exact_trace_request_id": true, + "non_grants": { + "live_transport": false, + "public_bot_release": false + } +} diff --git a/qualification/online-recovery-v1.json b/qualification/online-recovery-v1.json index e87b6096..4739d6d3 100644 --- a/qualification/online-recovery-v1.json +++ b/qualification/online-recovery-v1.json @@ -56,7 +56,9 @@ "deploy/lichess/run_recovery.py", "docs/ONLINE_4_RECOVERY.md", "qualification/assurance-contract-v1.json", + "qualification/http-evidence-contract-v1.json", "qualification/online-recovery-v1.json", + "scripts/http_evidence_verdict.py", "scripts/qualify-online-recovery.py", "scripts/verify_source_closure.py", "tests/fixtures/lichess/recovery_scenarios.json", diff --git a/qualification/online-recovery-v2.json b/qualification/online-recovery-v2.json index d0ca3339..9621f660 100644 --- a/qualification/online-recovery-v2.json +++ b/qualification/online-recovery-v2.json @@ -76,7 +76,9 @@ "deploy/lichess/session_owner.py", "docs/ONLINE_4B_RECOVERY.md", "qualification/assurance-contract-v1.json", + "qualification/http-evidence-contract-v1.json", "qualification/online-recovery-v2.json", + "scripts/http_evidence_verdict.py", "scripts/qualify-online-recovery-v2.py", "scripts/qualify-online-recovery.py", "scripts/verify_source_closure.py", diff --git a/scripts/http_evidence_verdict.py b/scripts/http_evidence_verdict.py new file mode 100644 index 00000000..31617d53 --- /dev/null +++ b/scripts/http_evidence_verdict.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Independent status adjudication of an *observed*, canonical HTTP request. + +An injected fault status is legitimate only on the exact request ID selected +by the frozen source trace. This validator never assumes syntactically valid +GET/POST equals successful or safe operation. +""" +from __future__ import annotations +import json +from pathlib import Path + +ROOT=Path(__file__).resolve().parents[1] +POLICY=ROOT/"qualification/http-evidence-contract-v1.json" +MOVE_PREFIX="/api/bot/game/off3b001/move/" + + +def approved_status(request: object, trace: list[dict], + action: str="none") -> bool: + if not isinstance(request,dict) or type(request.get("status")) is not int: + return False + policy=json.loads(POLICY.read_text(encoding="utf-8")) + if policy.get("schema_version") != 1 or policy.get("public_bot_release") is True: + return False + method,path,status=request.get("method"),request.get("path"),request["status"] + if method not in ("GET","POST") or type(path) is not str: + return False + if method == "GET" or not path.startswith(MOVE_PREFIX): + return status == policy["non_move_status"] + if status == policy["ordinary_move_status"]: + return True + target=policy["fault_specific_move_status"].get(action) + if target is None or status != target: + return False + request_id=request.get("request_id") + return (type(request_id) is str and bool(request_id) and + sum(1 for event in trace if type(event) is dict and + event.get("event")=="fault_activated" and + event.get("action")==action and + event.get("endpoint")=="move_post" and + event.get("request_id")==request_id)==1) + + +def unexpected_statuses(requests: list[dict], trace: list[dict], + action: str) -> list[dict]: + """Return safe bounded labels; no raw requests, URL or query strings.""" + result=[] + for row in requests: + if not approved_status(row,trace,action): + result.append({ + "method":row.get("method") if row.get("method") in ("GET","POST") + else "unknown", + "status":row.get("status") if type(row.get("status")) is int else None, + "reason":"unapproved_http_status", + }) + return result[:32] diff --git a/scripts/qualify-online-recovery-v2.py b/scripts/qualify-online-recovery-v2.py index bbaa1e9a..ea6add4a 100644 --- a/scripts/qualify-online-recovery-v2.py +++ b/scripts/qualify-online-recovery-v2.py @@ -26,6 +26,7 @@ from tests.lichess.http_target_contract import canonical_target from deploy.lichess.reconcile import position_id from scripts.verify_source_closure import verify_profile, verify_committed_bytes +from scripts.http_evidence_verdict import approved_status def _v1_module(): spec = importlib.util.spec_from_file_location( @@ -90,7 +91,8 @@ def verify_overlay_identity(manifest: dict, expected: dict) -> None: def verify_guarded_requests(requests: list[dict], trace: list[dict], - game: dict, intents: list[dict]) -> dict: + game: dict, intents: list[dict], *, + scenario_action: str = "none") -> dict: """Bind every HTTP move attempt to a unique durable intent and legal board.""" gid = game.get("game_id") moves = game.get("moves") @@ -119,6 +121,8 @@ def verify_guarded_requests(requests: list[dict], trace: list[dict], canonical_target(method, raw) and request.get("path") == raw.split("?", 1)[0], "guarded HTTP request target is not canonical") + require(approved_status(request, trace, scenario_action), + "guarded HTTP request has unqualified status or fault witness") http_moves = [] for request in requests: if request.get("method") != "POST": @@ -458,7 +462,8 @@ def verify_one(folder: Path, *, scenario: dict, color: str, invalid = v1.invalid_move_submissions(requests) require(not invalid, "guarded transport sent malformed or null UCI move") http_witness = verify_guarded_requests(requests, trace, game, - result["journal"]["intents"]) + result["journal"]["intents"], + scenario_action=scenario["action"]) runner_errors = result.get("runner_errors") require(isinstance(runner_errors, list) and not runner_errors, "harness startup/cleanup failed: " + str(runner_errors)) diff --git a/scripts/qualify-online-recovery.py b/scripts/qualify-online-recovery.py index 963c9a51..79b9566e 100644 --- a/scripts/qualify-online-recovery.py +++ b/scripts/qualify-online-recovery.py @@ -25,6 +25,7 @@ from tests.lichess.fault_plan import load_scenarios from tests.lichess.http_target_contract import canonical_target from scripts.verify_source_closure import verify_profile, verify_committed_bytes +from scripts.http_evidence_verdict import unexpected_statuses POLICY = ROOT / "qualification/online-recovery-v1.json" @@ -513,7 +514,8 @@ def validate_experiment(case_dir: Path, scenario: dict, color: str, unusable_moves = (invalid_move_submissions(http_requests) + history["illegal_attempts"] + history["stale_retry_attempts"] + - unexpected_raw_targets(http_requests)) + unexpected_raw_targets(http_requests) + + unexpected_statuses(http_requests,trace,scenario["action"])) if scenario["action"] == "none": ensure(history["bot_commits"] > 0 and logging is not None, "normal engine baseline did not play") From 2f98cb082b439082e915017f960ddaa7275f8c83 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 22:15:50 +0200 Subject: [PATCH 094/147] fix(ASSURANCE): bind committed chess actor to turn and reject unsupported HTTP outcomes --- scripts/qualify-release-smoke.py | 7 ++++-- tests/lichess/test_recovery_evidence.py | 16 +++++++++++++ tests/lichess/test_recovery_v2_evidence.py | 24 ++++++++++++++++++++ tests/release/test_release_smoke_evidence.py | 14 ++++++++++++ 4 files changed, 59 insertions(+), 2 deletions(-) diff --git a/scripts/qualify-release-smoke.py b/scripts/qualify-release-smoke.py index e28806b3..f6d1e398 100644 --- a/scripts/qualify-release-smoke.py +++ b/scripts/qualify-release-smoke.py @@ -145,8 +145,11 @@ def verify_smoke(doc: dict, key: str) -> dict: require(move in board.legal_moves, "illegal authoritative board commit") origin = row.get("origin") - require(origin in ("allfather", "scripted_opponent"), - "unknown authoritative move origin") + expected_origin = ("allfather" if + board.turn == (color == "white") else + "scripted_opponent") + require(origin == expected_origin, + "committed move actor differs from authoritative side to move") if origin == "allfather": rid = row.get("request_id") prior = attempt_index.get(rid) diff --git a/tests/lichess/test_recovery_evidence.py b/tests/lichess/test_recovery_evidence.py index 3879e742..7ed3d4a1 100644 --- a/tests/lichess/test_recovery_evidence.py +++ b/tests/lichess/test_recovery_evidence.py @@ -156,6 +156,22 @@ def test_stale_retry_requires_prior_committed_legal_move_and_lost_ack(self): self.assertEqual(rejected["stale_retry_attempts"],[]) self.assertEqual(len(rejected["illegal_attempts"]),1) + def test_offline_fault_http_statuses_require_correlated_witness(self): + from scripts.http_evidence_verdict import ( + approved_status, unexpected_statuses) + move="/api/bot/game/off3b001/move/e2e4" + sample={"method":"POST","path":move,"request_id":"request-1", + "status":429} + trace=[{"event":"fault_activated","action":"rate_limit_429", + "endpoint":"move_post","request_id":"request-1"}] + self.assertTrue(approved_status(sample,trace,"rate_limit_429")) + self.assertFalse(approved_status(sample,[],"rate_limit_429")) + self.assertFalse(approved_status(sample,trace,"none")) + self.assertEqual(unexpected_statuses([ + {"method":"GET","path":"/api/account","status":401}, + {"method":"POST","path":"/api/token/test","status":500}, + ],[],"none")[0]["reason"],"unapproved_http_status") + def test_noncanonical_http_query_never_supports_safe_continuation(self): good={"method":"GET","path":"/api/account","status":200, "request_target":"/api/account","target_canonical":True} diff --git a/tests/lichess/test_recovery_v2_evidence.py b/tests/lichess/test_recovery_v2_evidence.py index cc679bf6..cacb7e3a 100644 --- a/tests/lichess/test_recovery_v2_evidence.py +++ b/tests/lichess/test_recovery_v2_evidence.py @@ -261,6 +261,30 @@ def test_guarded_request_ledger_rejects_duplicate_illegal_and_forbidden(self): with self.subTest(mutated=mutated),self.assertRaises(ValueError): qual.verify_guarded_requests(*mutated) + def test_guarded_failed_nonmove_endpoint_never_qualifies(self): + gid="off3b001" + path=f"/api/bot/game/{gid}/move/e2e4" + base=[{"method":"POST","path":path,"request_target":path, + "target_canonical":True,"request_id":"request-1","status":200}] + trace=[{"seq":1,"event":"move_attempt","game_id":gid, + "uci":"e2e4","path":path,"ply_at_receipt":0}, + {"seq":2,"event":"board_commit","game_id":gid, + "uci":"e2e4","origin":"allfather","ply_before":0}] + game={"game_id":gid,"color":"white","moves":["e2e4"], + "forbidden_attempts":[]} + intents=[{"game_id":gid,"ply_before":0,"move":"e2e4", + "history_json":"[]"}] + for method,target,status in (("GET","/api/account",401), + ("GET","/api/account/playing",503), + ("POST","/api/token/test",500), + ("POST",f"/api/challenge/{gid}/accept",409)): + with self.subTest(target=target,status=status),self.assertRaisesRegex( + ValueError,"unqualified status"): + qual.verify_guarded_requests( + base+[{"method":method,"path":target, + "request_target":target,"target_canonical":True, + "status":status}],trace,game,intents) + def test_guarded_audit_cannot_swap_valid_move_request_ids(self): gid="off3b001" path=f"/api/bot/game/{gid}/move/e2e4" diff --git a/tests/release/test_release_smoke_evidence.py b/tests/release/test_release_smoke_evidence.py index 6bf839e3..dc956da4 100644 --- a/tests/release/test_release_smoke_evidence.py +++ b/tests/release/test_release_smoke_evidence.py @@ -169,6 +169,20 @@ def test_all_four_realistic_cases_have_valid_independent_witnesses(self): self.assertTrue(result["independent_qualified"]) self.assertFalse(result["public_bot_release"]) + def test_completed_game_cannot_forge_scripted_opponent_as_bot(self): + for case in ("r00-white", "r00-black", "r02-white"): + with self.subTest(case=case): + doc=fixture(case) + for item in doc["transport_trace"]: + if item.get("event") == "board_commit": + item["origin"]="scripted_opponent" + item["request_id"]=None + doc["game"]["accepted"]=[] + seal(doc) + with self.assertRaisesRegex( + ValueError, "committed move actor differs"): + qual.verify_smoke(doc, case) + def test_get_allowlist_matches_fake_api_without_wildcard(self): doc = fixture("r00-white") for endpoint in sorted(qual.http_contract.ALLOWED_GETS): From b334a07843b4d6f6d04ad72a7d893b0e40a0e615 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 22:17:41 +0200 Subject: [PATCH 095/147] fix(ASSURANCE): enforce in-toto statement, ResourceDescriptor, time and IPvFuture semantics --- scripts/check-release-scanner.py | 10 +- scripts/verify-release-oci.py | 105 +++++++++++++++++- tests/release/test_release_oci_integrity.py | 75 ++++++++++++- .../release/test_release_scanner_contract.py | 19 ++++ 4 files changed, 200 insertions(+), 9 deletions(-) diff --git a/scripts/check-release-scanner.py b/scripts/check-release-scanner.py index 1def44d8..978bd8c0 100644 --- a/scripts/check-release-scanner.py +++ b/scripts/check-release-scanner.py @@ -86,9 +86,13 @@ def walk(descriptor,depth=0): require(type(rows) is list and bool(rows), "scanner statement envelope invalid") for row in rows: - if isinstance(row,dict) and row.get("predicateType")==contract.SPDX: - require(row.get("_type")==contract.STATEMENT, - "scanner in-toto statement invalid") + require(type(row) is dict and + row.get("_type")==contract.STATEMENT and + type(row.get("predicateType")) is str and + type(row.get("subject")) is list and + bool(row["subject"]), + "scanner in-toto statement element invalid") + if row.get("predicateType")==contract.SPDX: pred=row.get("predicate") require(type(pred) is dict,"scanner SPDX predicate malformed") contract.validate_spdx_document(pred) diff --git a/scripts/verify-release-oci.py b/scripts/verify-release-oci.py index a38bb527..6bf7ed22 100644 --- a/scripts/verify-release-oci.py +++ b/scripts/verify-release-oci.py @@ -6,6 +6,9 @@ """ from __future__ import annotations import argparse +import base64 +import binascii +from datetime import datetime import os import stat import hashlib @@ -192,10 +195,21 @@ def valid_uri_syntax(value: object, allowed_schemes: set[str], *, return False host = uri.hostname if ":" in host: + if host.lower().startswith("v"): + # RFC3986 IPvFuture: v HEXDIG+ '.' 1*(unreserved/subdelims/:) + if re.fullmatch( + r"v[0-9A-Fa-f]+\.[A-Za-z0-9._~! if ":" in host: try: ipaddress.IPv6Address(host) except ValueError: - return False + return False'()*+,;=:-]+", + host,re.IGNORECASE) is None: + return False + else: + try: + ipaddress.IPv6Address(host) + except ValueError: + return False else: labels = host.split(".") if (len(host) > 253 or not all(re.fullmatch( @@ -235,17 +249,87 @@ def _optional_string_map(container: dict, field: str) -> bool: for key, item in value.items()))) +def _resource_uri(value: object) -> bool: + """External resource identifiers are URI, not arbitrary strings. + + A conservative set of schemes includes git+https and OCI resource URIs; + general SLSA builder identities remain governed by valid_identity_uri. + """ + if type(value) is not str or not value: + return False + schemes={"http","https","git+https","git+http","git+ssh", + "git","ssh","ftp","ftps","oci","docker","urn"} + return valid_uri_syntax(value,schemes,allow_git_ssh_user=True) + + +def _valid_digest_set(value: object) -> bool: + if type(value) is not dict or not value: + return False + sizes={"sha1":40,"sha256":64,"sha384":96,"sha512":128} + for algorithm,raw in value.items(): + if (type(algorithm) is not str or + re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]{0,31}",algorithm) is None or + type(raw) is not str or + re.fullmatch(r"[0-9a-fA-F]+",raw) is None): + return False + expected=sizes.get(algorithm.lower()) + if expected is not None: + if len(raw)!=expected: + return False + elif not (16 <= len(raw) <= 128 and len(raw)%2==0): + return False + return True + + def _valid_resource_descriptor(value: object) -> bool: - """Recognized SLSA resource fields have their normative JSON shapes.""" + """Typed in-toto ResourceDescriptor with at least one meaningful identity. + + The normative minimum is one of uri/digest/content. We also validate + nonselected identity fields if present; combining one good URI with an + empty digest or malformed base64 cannot bypass the contract. + """ if type(value) is not dict: return False if not all(_optional_string(value, name) for name in ("uri", "name", "downloadLocation", "mediaType", "content", "localName")): return False - if not _optional_string_map(value, "digest"): + uri=value.get("uri") + digest=value.get("digest") + content=value.get("content") + has_uri=type(uri) is str and _resource_uri(uri) + has_digest=_valid_digest_set(digest) + has_content=type(content) is str + if "uri" in value and uri is not None and not has_uri: + return False + if "digest" in value and digest is not None and not has_digest: + return False + if has_content: + try: + base64.b64decode(content,validate=True) + except (ValueError,binascii.Error): + return False + if "content" in value and content is not None and not has_content: + return False + location=value.get("downloadLocation") + if location not in (None,"") and not _resource_uri(location): return False - return _optional_object(value, "annotations") + return bool(has_uri or has_digest or has_content) and _optional_object( + value,"annotations") + + +def _rfc3339_timestamp(value: object) -> bool: + if value in (None,""): + return True + if type(value) is not str or re.fullmatch( + r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}" + r"(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})",value) is None: + return False + try: + observed=datetime.fromisoformat(value.replace("Z","+00:00")) + except ValueError: + return False + return observed.tzinfo is not None and observed.utcoffset() is not None def _optional_resource_list(container: dict, field: str) -> bool: @@ -262,8 +346,19 @@ def _valid_slsa_metadata(value: object, *, version: str) -> bool: return False keys = ("invocationId", "startedOn", "finishedOn") if version == "v1" else ( "buildInvocationId", "buildStartedOn", "buildFinishedOn") - if not all(_optional_string(value, field) for field in keys): + if not all((_rfc3339_timestamp(value.get(field)) + if field in ("startedOn","finishedOn", + "buildStartedOn","buildFinishedOn") + else _optional_string(value,field)) + for field in keys): return False + first=value.get("startedOn" if version=="v1" else "buildStartedOn") + last=value.get("finishedOn" if version=="v1" else "buildFinishedOn") + if first not in (None,"") and last not in (None,""): + start=datetime.fromisoformat(first.replace("Z","+00:00")) + finish=datetime.fromisoformat(last.replace("Z","+00:00")) + if finish < start: + return False if version == "v0.2": completeness = value.get("completeness") if completeness is not None and ( diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index 3e6839c6..ee946baf 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -282,6 +282,27 @@ def descriptor(kind, digest, **extra): body["runDetails"]["builder"]["version"] = "not-an-object" if slsa_case == "resource-item-nonobject": body["buildDefinition"]["resolvedDependencies"] = [17] + if slsa_case == "resource-empty": + body["buildDefinition"]["resolvedDependencies"] = [{}] + if slsa_case == "resource-name-only": + body["runDetails"]["byproducts"] = [{"name":"not-identity"}] + if slsa_case == "resource-bad-digest": + body["buildDefinition"]["resolvedDependencies"] = [ + {"digest":{"sha256":"not-hex"}}] + if slsa_case == "resource-empty-digest": + body["buildDefinition"]["resolvedDependencies"] = [ + {"digest":{}}] + if slsa_case == "resource-bad-content": + body["buildDefinition"]["resolvedDependencies"] = [ + {"content":"not+base64!"}] + if slsa_case == "metadata-bad-start": + body["runDetails"]["metadata"] = {"startedOn":"not-a-time"} + if slsa_case == "metadata-bad-finish": + body["runDetails"]["metadata"] = {"finishedOn":"garbage"} + if slsa_case == "metadata-reversed": + body["runDetails"]["metadata"] = { + "startedOn":"2026-10-18T00:00:00Z", + "finishedOn":"2026-10-17T00:00:00Z"} if slsa_case == "valid-optionals": body["buildDefinition"]["resolvedDependencies"] = [ {"uri":"https://example.test/source.tar.gz", @@ -839,6 +860,54 @@ def test_all_descriptors_require_exact_nonboolean_sizes(self): with self.assertRaisesRegex(ValueError, r"descriptor.*size"): oci.verify_archive(archive, meta, docker_inspect=inspection) + def test_resource_descriptors_require_meaningful_identity(self): + good=( + {"uri":"https://example.test/file"}, + {"digest":{"sha256":"a"*64}}, + {"content":"e30="}, + {"uri":"git+https://example.test/code.git@abc123", + "digest":{"sha1":"b"*40}}, + ) + bad=( + {},{"name":"name-is-not-an-identity"},{"uri":""}, + {"digest":{}},{"digest":{"sha256":"garbage"}}, + {"content":"not+base64!"},{"uri":"https://example.test/x", + "digest":{}}, + {"uri":"https://example.test/x","content":"*"}, + ) + for row in good: + with self.subTest(valid=row): + self.assertTrue(oci._valid_resource_descriptor(row)) + for row in bad: + with self.subTest(invalid=row): + self.assertFalse(oci._valid_resource_descriptor(row)) + + def test_metadata_timestamps_require_rfc3339_and_monotonic_order(self): + good={"startedOn":"2026-10-17T08:00:00Z", + "finishedOn":"2026-10-17T08:00:01+00:00"} + self.assertTrue(oci._valid_slsa_metadata(good,version="v1")) + self.assertTrue(oci._valid_slsa_metadata( + {"startedOn":None,"finishedOn":""},version="v1")) + for bad in ( + {"startedOn":"not-a-time"}, + {"finishedOn":"2026-15-17T18:00:00Z"}, + {"startedOn":"2026-10-17T19:00:00Z", + "finishedOn":"2026-10-17T18:00:00Z"}, + {"startedOn":"2026-10-17T08:00:00"}, + ): + with self.subTest(bad=bad): + self.assertFalse(oci._valid_slsa_metadata(bad,version="v1")) + + def test_ipvfuture_authority_is_valid_but_brackets_in_paths_are_not(self): + self.assertTrue(oci.valid_identity_uri( + "https://[v1.a:b]/resource")) + self.assertTrue(oci.valid_identity_uri( + "https://[2001:db8::1]/resource")) + self.assertFalse(oci.valid_identity_uri( + "https://example.test/a[b]")) + self.assertFalse(oci.valid_identity_uri( + "https://[v1.bad!]/a[b]")) + def test_slsa_optional_fields_have_independent_schema_contract(self): with tempfile.TemporaryDirectory() as d: archive,meta,inspected=self.fixture( @@ -864,7 +933,11 @@ def test_slsa_predicates_cannot_be_schema_only(self): for mode in ("empty", "builder-empty", "bad-build-type", "no-external", "resolved-as-string", "metadata-as-string", "byproducts-as-map", "version-as-string", - "resource-item-nonobject"): + "resource-item-nonobject", "resource-empty", + "resource-name-only","resource-bad-digest", + "resource-empty-digest","resource-bad-content", + "metadata-bad-start","metadata-bad-finish", + "metadata-reversed"): with self.subTest(mode=mode), tempfile.TemporaryDirectory() as d: archive, meta, inspection = self.fixture(Path(d), slsa_case=mode) diff --git a/tests/release/test_release_scanner_contract.py b/tests/release/test_release_scanner_contract.py index 1d0de7a3..f6dd8007 100644 --- a/tests/release/test_release_scanner_contract.py +++ b/tests/release/test_release_scanner_contract.py @@ -36,6 +36,25 @@ def test_null_statement_wrapper_cannot_qualify_a_scanner(self): with self.assertRaisesRegex(ValueError,"scanner statement envelope"): probe.inspect_scanner_output(tar) + def test_mixed_nonobject_statement_element_must_reject(self): + # Pinned real scanner must not claim compatible if an otherwise + # valid SPDX statement array includes an extra null/scalar item. + with tempfile.TemporaryDirectory() as tmp: + path=Path(tmp) + tar,_,_=oci_fixture.OCIProofTests().fixture( + path,statement_wrapper="array") + # A structurally valid positive array is accepted. + self.assertEqual(probe.inspect_scanner_output(tar)[ + "spdx_document_count"],1) + # Direct structural check in the same admission condition: every + # element must be a supported in-toto statement object. + fake=[None,{"_type":oci_fixture.oci.STATEMENT, + "predicateType":oci_fixture.oci.SPDX, + "subject":[{"digest":{"sha256":"a"*64}}]}] + self.assertFalse(all(type(x) is dict and + x.get("_type")==oci_fixture.oci.STATEMENT + for x in fake)) + def test_malformed_spdx_refused_at_scanner_boundary(self): for mode in ("missing-download","bogus-algorithm", "namespace-fragment","empty-inventory"): From 697dcf01becdb7e9b410833765fd9c76ab1d76da Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 22:19:30 +0200 Subject: [PATCH 096/147] feat(ASSURANCE): centralize bounded safe serialization for retained qualification reports --- qualification/online-recovery-v1.json | 2 + qualification/online-recovery-v2.json | 2 + .../retained-qualification-report-v1.json | 15 ++++ scripts/qualify-online-recovery-v2.py | 2 + scripts/qualify-online-recovery.py | 10 ++- scripts/qualify-release-smoke.py | 3 +- scripts/sanitize_qualification_evidence.py | 86 +++++++++++++++++++ 7 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 qualification/schemas/retained-qualification-report-v1.json create mode 100644 scripts/sanitize_qualification_evidence.py diff --git a/qualification/online-recovery-v1.json b/qualification/online-recovery-v1.json index 4739d6d3..13c7f9e6 100644 --- a/qualification/online-recovery-v1.json +++ b/qualification/online-recovery-v1.json @@ -58,8 +58,10 @@ "qualification/assurance-contract-v1.json", "qualification/http-evidence-contract-v1.json", "qualification/online-recovery-v1.json", + "qualification/schemas/retained-qualification-report-v1.json", "scripts/http_evidence_verdict.py", "scripts/qualify-online-recovery.py", + "scripts/sanitize_qualification_evidence.py", "scripts/verify_source_closure.py", "tests/fixtures/lichess/recovery_scenarios.json", "tests/lichess/fake_lichess_api.py", diff --git a/qualification/online-recovery-v2.json b/qualification/online-recovery-v2.json index 9621f660..723190bd 100644 --- a/qualification/online-recovery-v2.json +++ b/qualification/online-recovery-v2.json @@ -78,9 +78,11 @@ "qualification/assurance-contract-v1.json", "qualification/http-evidence-contract-v1.json", "qualification/online-recovery-v2.json", + "qualification/schemas/retained-qualification-report-v1.json", "scripts/http_evidence_verdict.py", "scripts/qualify-online-recovery-v2.py", "scripts/qualify-online-recovery.py", + "scripts/sanitize_qualification_evidence.py", "scripts/verify_source_closure.py", "tests/fixtures/lichess/recovery_scenarios.json", "tests/lichess/fake_lichess_api.py", diff --git a/qualification/schemas/retained-qualification-report-v1.json b/qualification/schemas/retained-qualification-report-v1.json new file mode 100644 index 00000000..2644caf8 --- /dev/null +++ b/qualification/schemas/retained-qualification-report-v1.json @@ -0,0 +1,15 @@ +{ + "schema_version": 1, + "deny_by_default_unknown_strings": true, + "allow_numeric_and_boolean": true, + "fields": { + "path": "validated-canonical-path-no-query", + "request_target": "never-retained", + "uci": "canonical-uci", + "request_id": "synthetic-request-id", + "reason": "enumeration-only", + "error": "fixed-redaction", + "unknown_strings": "redacted" + }, + "public_authority_granted": false +} diff --git a/scripts/qualify-online-recovery-v2.py b/scripts/qualify-online-recovery-v2.py index ea6add4a..2084c113 100644 --- a/scripts/qualify-online-recovery-v2.py +++ b/scripts/qualify-online-recovery-v2.py @@ -27,6 +27,7 @@ from deploy.lichess.reconcile import position_id from scripts.verify_source_closure import verify_profile, verify_committed_bytes from scripts.http_evidence_verdict import approved_status +from scripts.sanitize_qualification_evidence import sanitize_report def _v1_module(): spec = importlib.util.spec_from_file_location( @@ -607,6 +608,7 @@ def main(): "qualification_failures": [{"error": str(exc)}], "network_recovery_qualified": False, "public_bot_release": False, "release_blocked": True} + report=sanitize_report(report) args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n") print(json.dumps({k:v for k,v in report.items() if k!="scenario_results"},sort_keys=True)) diff --git a/scripts/qualify-online-recovery.py b/scripts/qualify-online-recovery.py index 79b9566e..58a3224d 100644 --- a/scripts/qualify-online-recovery.py +++ b/scripts/qualify-online-recovery.py @@ -26,6 +26,7 @@ from tests.lichess.http_target_contract import canonical_target from scripts.verify_source_closure import verify_profile, verify_committed_bytes from scripts.http_evidence_verdict import unexpected_statuses +from scripts.sanitize_qualification_evidence import sanitize_report, safe_path POLICY = ROOT / "qualification/online-recovery-v1.json" @@ -368,7 +369,10 @@ def invalid_move_submissions(http_requests: list[dict]) -> list[dict]: except ValueError: move = None if move is None or move == chess.Move.null(): - invalid.append({"path": path, "uci": uci, "status": request.get("status")}) + invalid.append({"path": safe_path(path), + "uci": uci if type(uci) is str and + len(uci) <= 5 else "__redacted__", + "status": request.get("status")}) return invalid @@ -388,8 +392,7 @@ def unexpected_raw_targets(http_requests: list[dict]) -> list[dict]: unexpected.append({ "method": method if method in ("GET", "POST") else "unknown", "status": row.get("status") if type(row.get("status")) is int else None, - "path": row.get("path") if isinstance(row.get("path"), str) - else "__invalid_path__", + "path": safe_path(row.get("path")), "reason": "noncanonical_request_target", }) return unexpected @@ -606,6 +609,7 @@ def main() -> int: "public_bot_release": False, "release_blocked": True, } + report=sanitize_report(report) args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(json.dumps(report, sort_keys=True, indent=2, allow_nan=False) + "\n") print(json.dumps({k: v for k, v in report.items() if k != "scenario_results"}, diff --git a/scripts/qualify-release-smoke.py b/scripts/qualify-release-smoke.py index f6d1e398..d5f4f46b 100644 --- a/scripts/qualify-release-smoke.py +++ b/scripts/qualify-release-smoke.py @@ -20,6 +20,7 @@ from urllib.parse import unquote import chess +from scripts.sanitize_qualification_evidence import sanitize_report CASES = {"r00-white": ("R00", "white"), "r00-black": ("R00", "black"), @@ -559,7 +560,7 @@ def main() -> int: parser.add_argument("--smoke-dir", type=Path, required=True) parser.add_argument("--output", type=Path, required=True) args = parser.parse_args() - report = qualify_directory(args.smoke_dir) + report = sanitize_report(qualify_directory(args.smoke_dir)) args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") diff --git a/scripts/sanitize_qualification_evidence.py b/scripts/sanitize_qualification_evidence.py new file mode 100644 index 00000000..65d4f636 --- /dev/null +++ b/scripts/sanitize_qualification_evidence.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Single bounded output boundary for offline qualification evidence. + +All producer-controlled strings are untrusted, including values received +under apparently safe keys. Never emit arbitrary producer request targets, +exception descriptions, free-form filenames or values into CI artifacts. +""" +from __future__ import annotations +import json +import math +import re + +SAFE_ENUM_FIELDS=frozenset({ + "status","reason","action","event","endpoint","observation","origin", + "scenario","color","behavior","release_kind","termination","mode", + "phase","kind","cause","http_method","method","run_id","run","name", + "stop_reason","scope","type","event_id","generation", +}) +HASH_FIELDS=frozenset({ + "source_commit","bridge_commit","source_sha","sha256","policy_sha256", + "scenario_source_sha256","sanitized_sha256","manifest_sha256", + "position_id","owner_epoch","epoch","request_target_sha256", +}) +SAFE_KEYS=re.compile(r"[a-z][A-Za-z0-9_]{0,79}\Z") +SAFE_ENUM=re.compile(r"[A-Za-z][A-Za-z0-9_.:-]{0,95}\Z") +SAFE_MOVE=re.compile(r"[a-h][1-8][a-h][1-8][qrbn]?\Z") +SAFE_ID=re.compile(r"[A-Fa-f0-9]{16,128}\Z") +SAFE_PATH=re.compile(r"/[A-Za-z0-9_./-]{0,220}\Z") +SAFE_REQUEST=re.compile(r"(?:request|stream)-\d{1,12}\Z") +MAX_DEPTH=22 +MAX_SEQUENCE=2000 + + +def safe_path(value: object) -> str: + """Conservatively strip query/fragment, or substitute a fixed sentinel.""" + if type(value) is not str: + return "__invalid_path__" + path=value.split("?",1)[0].split("#",1)[0] + return path if SAFE_PATH.fullmatch(path) else "__invalid_path__" + + +def safe_string(field: str, value: str) -> str | None: + if field=="request_target": + return None + if field=="path": + return safe_path(value) + if field in ("uci","move"): + return value if SAFE_MOVE.fullmatch(value) else "__redacted__" + if field in ("request_id","original_request_id"): + return value if SAFE_REQUEST.fullmatch(value) else "__redacted__" + if field in HASH_FIELDS or field.endswith("_sha256"): + return value if SAFE_ID.fullmatch(value) else "__redacted__" + if field in ("error","errors","runner_errors","message","details", + "stdout","stderr","url","uri","query","raw","raw_path"): + return "__redacted__" + if field in SAFE_ENUM_FIELDS: + return value if SAFE_ENUM.fullmatch(value) else "__redacted__" + # Strict default deny: otherwise any new producer-controlled field can + # leak a secret without a separate output-safety review. + return "__redacted__" + + +def sanitize_report(value: object, *, field: str = "", depth: int = 0): + if depth>MAX_DEPTH: + return "__redacted__" + if value is None or type(value) is bool or type(value) is int: + return value + if type(value) is float: + return value if math.isfinite(value) else None + if type(value) is str: + return safe_string(field,value) + if type(value) in (tuple,list): + return [sanitize_report(row,field=field,depth=depth+1) + for row in value[:MAX_SEQUENCE]] + if type(value) is dict: + report={} + for key,item in list(value.items())[:MAX_SEQUENCE]: + if type(key) is str and SAFE_KEYS.fullmatch(key): + report[key]=sanitize_report(item,field=key,depth=depth+1) + return report + return "__redacted__" + + +def serialize_report(value: object) -> str: + return json.dumps(sanitize_report(value),sort_keys=True, + indent=2,allow_nan=False)+"\n" From def505436ea2bd7d28cee65f65e4064a4fdbf103 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 22:21:26 +0200 Subject: [PATCH 097/147] fix(ASSURANCE): close safe serializer dependencies and test nested rejected evidence --- qualification/online-recovery-v2.json | 1 + scripts/build-release-candidate.py | 3 +++ scripts/qualify-release-smoke.py | 12 ++++++++- scripts/verify_source_closure.py | 3 ++- tests/lichess/test_recovery_evidence.py | 25 +++++++++++++++++++ tests/release/test_release_http_contract.py | 9 +++++++ tests/release/test_release_oci_integrity.py | 2 ++ .../release/test_release_scanner_contract.py | 14 +++++------ 8 files changed, 59 insertions(+), 10 deletions(-) diff --git a/qualification/online-recovery-v2.json b/qualification/online-recovery-v2.json index 723190bd..14d3a2b8 100644 --- a/qualification/online-recovery-v2.json +++ b/qualification/online-recovery-v2.json @@ -77,6 +77,7 @@ "docs/ONLINE_4B_RECOVERY.md", "qualification/assurance-contract-v1.json", "qualification/http-evidence-contract-v1.json", + "qualification/online-recovery-v1.json", "qualification/online-recovery-v2.json", "qualification/schemas/retained-qualification-report-v1.json", "scripts/http_evidence_verdict.py", diff --git a/scripts/build-release-candidate.py b/scripts/build-release-candidate.py index a0abcfe8..dc6d5075 100644 --- a/scripts/build-release-candidate.py +++ b/scripts/build-release-candidate.py @@ -51,6 +51,9 @@ "scripts/build-release-candidate.py", "scripts/qualify-release-smoke.py", "scripts/release_http_contract.py", + "scripts/sanitize_qualification_evidence.py", + "qualification/schemas/retained-qualification-report-v1.json", + "qualification/assurance-contract-v1.json", "scripts/inspect-release-oci.py", "scripts/check-release-snapshot.py", "scripts/check-release-scanner.py", *TOOL_FILES) diff --git a/scripts/qualify-release-smoke.py b/scripts/qualify-release-smoke.py index d5f4f46b..8f745f38 100644 --- a/scripts/qualify-release-smoke.py +++ b/scripts/qualify-release-smoke.py @@ -20,7 +20,17 @@ from urllib.parse import unquote import chess -from scripts.sanitize_qualification_evidence import sanitize_report +# This is invoked as a standalone host-side script; do not rely on the repo +# root or its optional package path being on sys.path. +import importlib.util as _safe_importlib +_safe_spec = _safe_importlib.spec_from_file_location( + "release1_sanitized_report", + Path(__file__).with_name("sanitize_qualification_evidence.py")) +if _safe_spec is None or _safe_spec.loader is None: + raise ValueError("missing safe release report serializer") +_safe_module = _safe_importlib.module_from_spec(_safe_spec) +_safe_spec.loader.exec_module(_safe_module) +sanitize_report = _safe_module.sanitize_report CASES = {"r00-white": ("R00", "white"), "r00-black": ("R00", "black"), diff --git a/scripts/verify_source_closure.py b/scripts/verify_source_closure.py index 3f8b98a9..e3169829 100644 --- a/scripts/verify_source_closure.py +++ b/scripts/verify_source_closure.py @@ -40,7 +40,8 @@ "scripts/verify_source_closure.py"), "inputs": ("deploy/lichess/Dockerfile.offline", ".github/workflows/online-recovery-v2.yml", - "qualification/assurance-contract-v1.json"), + "qualification/assurance-contract-v1.json", + "qualification/online-recovery-v1.json"), }, } diff --git a/tests/lichess/test_recovery_evidence.py b/tests/lichess/test_recovery_evidence.py index 7ed3d4a1..f22e954b 100644 --- a/tests/lichess/test_recovery_evidence.py +++ b/tests/lichess/test_recovery_evidence.py @@ -172,6 +172,31 @@ def test_offline_fault_http_statuses_require_correlated_witness(self): {"method":"POST","path":"/api/token/test","status":500}, ],[],"none")[0]["reason"],"unapproved_http_status") + def test_retained_report_serializer_recursively_removes_canaries(self): + from scripts.sanitize_qualification_evidence import sanitize_report + sample={ + "harness_qualified":False, + "unsafe_witnesses":[{ + "behavior":"UNSAFE_WITNESS", + "early_rate_limit_requests":[ + {"path":"/api/account?access_token=PRIVATE-CANARY", + "request_target":"//private?PRIVATE-CANARY", + "reason":"bad","status":403}], + "unusable_move_submissions":[{ + "path":"/api/account?access_token=PRIVATE-CANARY", + "uci":"PRIVATE-CANARY", "status":400}], + "runner_errors":["PRIVATE-CANARY"], + }], + "harness_failures":[{"error":"PRIVATE-CANARY"}], + "malicious":{"exception":"PRIVATE-CANARY"}, + } + result=sanitize_report(sample) + data=json.dumps(result) + self.assertNotIn("PRIVATE-CANARY",data) + self.assertIn("/api/account",data) + self.assertTrue(result["unsafe_witnesses"][0]["behavior"]=="UNSAFE_WITNESS") + self.assertFalse(result["harness_qualified"]) + def test_noncanonical_http_query_never_supports_safe_continuation(self): good={"method":"GET","path":"/api/account","status":200, "request_target":"/api/account","target_canonical":True} diff --git a/tests/release/test_release_http_contract.py b/tests/release/test_release_http_contract.py index 1205beff..7049d654 100644 --- a/tests/release/test_release_http_contract.py +++ b/tests/release/test_release_http_contract.py @@ -20,6 +20,15 @@ def load(path, ident): class SharedHTTPAdmissionTests(unittest.TestCase): + def test_safe_release_report_module_loads_without_tests_package(self): + import importlib.util as ilu + path=ROOT/"scripts/sanitize_qualification_evidence.py" + spec=ilu.spec_from_file_location("safe_report_test",path) + instance=ilu.module_from_spec(spec) + spec.loader.exec_module(instance) + self.assertEqual(instance.sanitize_report({ + "path":"/api/account?secret=CANARY"})["path"],"/api/account") + def test_standalone_release_verifiers_import_policy_without_pythonpath(self): # Identical invocation mode to the real OCI release workflow. with tempfile.TemporaryDirectory() as tmp: diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index ee946baf..773b2bd1 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -325,6 +325,8 @@ def descriptor(kind, digest, **extra): if predicate == "https://spdx.dev/Document": if statement_wrapper == "array": statement_document = {"statements": [statement_document]} + elif statement_wrapper == "array-null": + statement_document = {"statements":[None,statement_document]} elif statement_wrapper in ("null", "object", "empty", "string"): statement_document["statements"] = { "null": None, "object": {}, "empty": [], diff --git a/tests/release/test_release_scanner_contract.py b/tests/release/test_release_scanner_contract.py index f6dd8007..544fcebe 100644 --- a/tests/release/test_release_scanner_contract.py +++ b/tests/release/test_release_scanner_contract.py @@ -46,14 +46,12 @@ def test_mixed_nonobject_statement_element_must_reject(self): # A structurally valid positive array is accepted. self.assertEqual(probe.inspect_scanner_output(tar)[ "spdx_document_count"],1) - # Direct structural check in the same admission condition: every - # element must be a supported in-toto statement object. - fake=[None,{"_type":oci_fixture.oci.STATEMENT, - "predicateType":oci_fixture.oci.SPDX, - "subject":[{"digest":{"sha256":"a"*64}}]}] - self.assertFalse(all(type(x) is dict and - x.get("_type")==oci_fixture.oci.STATEMENT - for x in fake)) + with tempfile.TemporaryDirectory() as tmp: + tar,_,_=oci_fixture.OCIProofTests().fixture( + Path(tmp),statement_wrapper="array-null") + with self.assertRaisesRegex(ValueError, + "scanner in-toto statement element"): + probe.inspect_scanner_output(tar) def test_malformed_spdx_refused_at_scanner_boundary(self): for mode in ("missing-download","bogus-algorithm", From 75a44ce992d960a669ee9ca292b3475dc04c8563 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 22:23:02 +0200 Subject: [PATCH 098/147] feat(ASSURANCE): enforce claim dependency implications and prevent false candidate promotion --- qualification/online-recovery-v1.json | 1 + qualification/online-recovery-v2.json | 1 + scripts/build-release-candidate.py | 1 + scripts/qualify-online-recovery-v2.py | 2 + scripts/qualify-online-recovery.py | 2 + scripts/qualify-release-aggregate.py | 12 ++- scripts/qualify-release-smoke.py | 9 ++ scripts/verify_assurance_contract.py | 132 ++++++++++++++++++++++++++ 8 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 scripts/verify_assurance_contract.py diff --git a/qualification/online-recovery-v1.json b/qualification/online-recovery-v1.json index 13c7f9e6..62a492e0 100644 --- a/qualification/online-recovery-v1.json +++ b/qualification/online-recovery-v1.json @@ -62,6 +62,7 @@ "scripts/http_evidence_verdict.py", "scripts/qualify-online-recovery.py", "scripts/sanitize_qualification_evidence.py", + "scripts/verify_assurance_contract.py", "scripts/verify_source_closure.py", "tests/fixtures/lichess/recovery_scenarios.json", "tests/lichess/fake_lichess_api.py", diff --git a/qualification/online-recovery-v2.json b/qualification/online-recovery-v2.json index 14d3a2b8..cdcbb8f0 100644 --- a/qualification/online-recovery-v2.json +++ b/qualification/online-recovery-v2.json @@ -84,6 +84,7 @@ "scripts/qualify-online-recovery-v2.py", "scripts/qualify-online-recovery.py", "scripts/sanitize_qualification_evidence.py", + "scripts/verify_assurance_contract.py", "scripts/verify_source_closure.py", "tests/fixtures/lichess/recovery_scenarios.json", "tests/lichess/fake_lichess_api.py", diff --git a/scripts/build-release-candidate.py b/scripts/build-release-candidate.py index dc6d5075..a603e388 100644 --- a/scripts/build-release-candidate.py +++ b/scripts/build-release-candidate.py @@ -52,6 +52,7 @@ "scripts/qualify-release-smoke.py", "scripts/release_http_contract.py", "scripts/sanitize_qualification_evidence.py", + "scripts/verify_assurance_contract.py", "qualification/schemas/retained-qualification-report-v1.json", "qualification/assurance-contract-v1.json", "scripts/inspect-release-oci.py", diff --git a/scripts/qualify-online-recovery-v2.py b/scripts/qualify-online-recovery-v2.py index 2084c113..d035abef 100644 --- a/scripts/qualify-online-recovery-v2.py +++ b/scripts/qualify-online-recovery-v2.py @@ -28,6 +28,7 @@ from scripts.verify_source_closure import verify_profile, verify_committed_bytes from scripts.http_evidence_verdict import approved_status from scripts.sanitize_qualification_evidence import sanitize_report +from scripts.verify_assurance_contract import verify_report def _v1_module(): spec = importlib.util.spec_from_file_location( @@ -609,6 +610,7 @@ def main(): "network_recovery_qualified": False, "public_bot_release": False, "release_blocked": True} report=sanitize_report(report) + verify_report(report,"online4b") args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n") print(json.dumps({k:v for k,v in report.items() if k!="scenario_results"},sort_keys=True)) diff --git a/scripts/qualify-online-recovery.py b/scripts/qualify-online-recovery.py index 58a3224d..210c6a39 100644 --- a/scripts/qualify-online-recovery.py +++ b/scripts/qualify-online-recovery.py @@ -27,6 +27,7 @@ from scripts.verify_source_closure import verify_profile, verify_committed_bytes from scripts.http_evidence_verdict import unexpected_statuses from scripts.sanitize_qualification_evidence import sanitize_report, safe_path +from scripts.verify_assurance_contract import verify_report POLICY = ROOT / "qualification/online-recovery-v1.json" @@ -610,6 +611,7 @@ def main() -> int: "release_blocked": True, } report=sanitize_report(report) + verify_report(report,"online4a") args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(json.dumps(report, sort_keys=True, indent=2, allow_nan=False) + "\n") print(json.dumps({k: v for k, v in report.items() if k != "scenario_results"}, diff --git a/scripts/qualify-release-aggregate.py b/scripts/qualify-release-aggregate.py index b3ce8383..78d3f919 100644 --- a/scripts/qualify-release-aggregate.py +++ b/scripts/qualify-release-aggregate.py @@ -191,7 +191,9 @@ def qualify(*, stage:Path, oci:Path, oci_archive:Path, host:Path, smoke_dir:Path "smoke":tests,"image_integrity_qualified":True, "frozen_dependency_lock_qualified":all(checked.values()), "dependency_lock_checks":checked, - "offline_candidate_qualified":all(checked.values()), + # Source and image integrity do not grant host/legal/live authority. + # This deliberately remains false even if dependency pins are filled. + "offline_candidate_qualified":False, "license_approved":False,"destination_host_qualified":False, "network_recovery_qualified":False, "artifact_distribution_authorized":False, @@ -202,6 +204,14 @@ def qualify(*, stage:Path, oci:Path, oci_archive:Path, host:Path, smoke_dir:Path "destination_host_qualification","live_transport_qualification", "operator_live_play_approval"], } + spec = importlib.util.spec_from_file_location( + "release1_aggregate_claims", + ROOT/"scripts/verify_assurance_contract.py") + require(spec is not None and spec.loader is not None, + "missing RELEASE-1 independent claim contract") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + module.verify_report(source_contents,"release_aggregate") return source_contents diff --git a/scripts/qualify-release-smoke.py b/scripts/qualify-release-smoke.py index 8f745f38..f5e4bd38 100644 --- a/scripts/qualify-release-smoke.py +++ b/scripts/qualify-release-smoke.py @@ -31,6 +31,14 @@ _safe_module = _safe_importlib.module_from_spec(_safe_spec) _safe_spec.loader.exec_module(_safe_module) sanitize_report = _safe_module.sanitize_report +_claim_spec = _safe_importlib.spec_from_file_location( + "release1_assurance_claims", + Path(__file__).with_name("verify_assurance_contract.py")) +if _claim_spec is None or _claim_spec.loader is None: + raise ValueError("missing independent release assurance check") +_claim_module = _safe_importlib.module_from_spec(_claim_spec) +_claim_spec.loader.exec_module(_claim_module) +verify_claims = _claim_module.verify_report CASES = {"r00-white": ("R00", "white"), "r00-black": ("R00", "black"), @@ -571,6 +579,7 @@ def main() -> int: parser.add_argument("--output", type=Path, required=True) args = parser.parse_args() report = sanitize_report(qualify_directory(args.smoke_dir)) + verify_claims(report, "release_smoke") args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") diff --git a/scripts/verify_assurance_contract.py b/scripts/verify_assurance_contract.py new file mode 100644 index 00000000..c62e4cc4 --- /dev/null +++ b/scripts/verify_assurance_contract.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Independently reconstruct necessary boolean implications in offline verdicts. + +A green bit is NOT authorization. The JSON contract enumerates prerequisites; +these verifications reject internally inconsistent positive claims after all +individual source, game, journal and OCI validators have run. +""" +from __future__ import annotations +import argparse +import json +from pathlib import Path + +ROOT=Path(__file__).resolve().parents[1] +POLICY=ROOT/"qualification/assurance-contract-v1.json" + + +def require(ok: bool, reason: str) -> None: + if not ok: + raise ValueError("assurance claim contract: "+reason) + + +def validate_contract() -> dict: + contract=json.loads(POLICY.read_text(encoding="utf-8")) + require(contract.get("schema_version")==1 and + contract.get("public_bot_release") is False and + contract.get("authority",{}).get("public_distribution") is False, + "policy permits public authority") + for claim in ("harness_qualified","containment_qualified_offline", + "recovery_qualified_offline","offline_smoke_qualified", + "oci_sbom_verified","oci_provenance_verified", + "image_integrity_qualified","offline_candidate_qualified"): + item=contract.get("claims",{}).get(claim) + require(type(item) is dict and type(item.get("requires")) is list + and bool(item["requires"]), "missing dependencies for "+claim) + return contract + + +def verify_report(report: dict, kind: str) -> dict: + validate_contract() + require(type(report) is dict and + report.get("public_bot_release") is False and + report.get("network_recovery_qualified") is not True and + report.get("artifact_distribution_authorized") is not True, + "release authority was raised") + if kind=="online4a": + valid=report.get("harness_qualified") + rows=report.get("scenario_results") + failures=report.get("harness_failures") + if valid: + require(type(rows) is list and type(failures) is list and not failures and + report.get("required_run_count")==len(rows) and + report.get("validated_run_count")==len(rows) and + all(type(r) is dict and r.get("harness_integrity") is True + for r in rows), "ONLINE-4A harness lacks full evidence") + require(report.get("recovery_behavior_qualified") is False, + "unsafe unguarded bridge was called recovered") + return {"claim":"harness_qualified","consistent":True} + if kind=="online4b": + rows=report.get("scenario_results") + failures=report.get("qualification_failures") + harness=report.get("harness_qualified") is True + if harness: + require(type(rows) is list and type(failures) is list and not failures and + len(rows)==report.get("required_run_count") and + len(rows)==report.get("validated_run_count") and + all(type(r) is dict and r.get("harness_valid") is True + for r in rows), "ONLINE-4B harness lacks full evidence") + if report.get("containment_qualified_offline") is True: + require(harness and all(r.get("status") in ( + "NORMAL_COMPLETION","SAFE_CONTINUATION","RECOVERED", + "SAFE_SUSPENSION") for r in rows), + "ONLINE-4B containment has unsafe or unverified outcome") + if report.get("recovery_qualified_offline") is True: + require(report.get("containment_qualified_offline") is True, + "offline recovery without containment") + needed=("R02","R03","R08","R13") + require(all(any(r.get("scenario")==name and + r.get("status")=="RECOVERED" and + r.get("recovered") is True for r in rows) + for name in needed), "missing fresh recovery witness") + return {"claim":"recovery_qualified_offline","consistent":True} + if kind=="release_smoke": + if report.get("offline_smoke_qualified") is True: + cases=report.get("verified_cases") + require(type(cases) is dict and set(cases)=={ + "r00-white","r00-black","r02-white","r10-white"} and + report.get("failures")==[] and + all(type(row) is dict and + row.get("independent_qualified") is True and + row.get("persisted_sqlite_verified") is True + for row in cases.values()), + "RELEASE-1 smoke lacks independent chess/SQLite evidence") + return {"claim":"offline_smoke_qualified","consistent":True} + if kind=="release_aggregate": + require(report.get("release_blocked") is True and + report.get("release_promotion_authorized") is False and + report.get("license_approved") is False and + report.get("destination_host_qualified") is False, + "RELEASE-1 no-publication boundary changed") + if report.get("image_integrity_qualified") is True: + smoke=report.get("smoke") + require(type(smoke) is dict and set(smoke)=={ + "r00-white","r00-black","r02-white","r10-white"} and + all(type(row) is dict and row.get("passed") is True and + type(row.get("independent_evidence")) is dict and + row["independent_evidence"].get("independent_qualified") is True + for row in smoke.values()), + "image integrity lacks verified packaged games") + if report.get("offline_candidate_qualified") is True: + require(not report.get("blockers") and + report.get("frozen_dependency_lock_qualified") is True and + report.get("license_approved") is True and + report.get("destination_host_qualified") is True and + report.get("network_recovery_qualified") is True, + "candidate green without authorization prerequisites") + return {"claim":"image_integrity_qualified","consistent":True} + raise ValueError("unknown assurance verdict kind") + + +def main() -> int: + ap=argparse.ArgumentParser() + ap.add_argument("--kind",required=True,choices=( + "online4a","online4b","release_smoke","release_aggregate")) + ap.add_argument("--report",type=Path,required=True) + args=ap.parse_args() + verdict=verify_report(json.loads(args.report.read_text()),args.kind) + print(json.dumps(verdict,sort_keys=True)) + return 0 + + +if __name__=="__main__": + raise SystemExit(main()) From e6822d638f700fee0b059d0f6e2304d59235f48a Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 22:24:39 +0200 Subject: [PATCH 099/147] test(ASSURANCE): falsify source closure, report semantics and release grants --- docs/CODEX_POSTMERGE_CLOSURE.md | 13 ++ docs/RELEASE_1_OPERATIONS.md | 6 + qualification/online-recovery-v1.json | 2 + qualification/online-recovery-v2.json | 2 + .../slsa-build-metadata-v1.contract.json | 15 ++ .../slsa-resource-descriptor-v1.contract.json | 14 ++ scripts/build-release-candidate.py | 2 + tests/release/test_assurance_contract.py | 132 ++++++++++++++++++ 8 files changed, 186 insertions(+) create mode 100644 qualification/schemas/slsa-build-metadata-v1.contract.json create mode 100644 qualification/schemas/slsa-resource-descriptor-v1.contract.json create mode 100644 tests/release/test_assurance_contract.py diff --git a/docs/CODEX_POSTMERGE_CLOSURE.md b/docs/CODEX_POSTMERGE_CLOSURE.md index c3d54bd2..ab81f639 100644 --- a/docs/CODEX_POSTMERGE_CLOSURE.md +++ b/docs/CODEX_POSTMERGE_CLOSURE.md @@ -196,3 +196,16 @@ The following six source-linked Codex findings were found against that exact hea | Scanner-only preflight alignment | `scripts/check-release-scanner.py` rejects present null/object/scalar/empty in-toto statement wrappers, while continuing to accept BuildKit singleton and array formats. `tests/release/test_release_scanner_contract.py` covers coherent rehashed negatives. | **Do not infer an exact-head passing result for these newly staged edits.** Run all RELEASE-1/ONLINE-4A/4B/controller/LOCAL-1 checks and fresh code/security reviews on the new pushed commit. Offline-only and all non-grant policies remain unchanged. + +## Bottom-up / top-down assurance consolidation (October 17, PR #71) + +This repair pass addresses the eight actionable Codex P2s from review of `32b45ba3` and broadens the contract beyond those comments. + +- **Trust roots:** `qualification/assurance-contract-v1.json` enumerates positive claims and required evidence; `scripts/verify_source_closure.py` uses Python AST imports rooted at executed producer and verifier entrypoints, including inherited v1 dependencies, build inputs and explicit dynamic imports. Both ONLINE-4 source policies now cover `fault_plan.py`, `log_capture.py`, `run_recovery.py`, and `Dockerfile.offline`. Before verdict formation, source files are independently compared against actual `git show exact_sha:path` bytes, not just producer-generated hash entries. +- **Transport and behavioral identity:** `qualification/http-evidence-contract-v1.json` and `scripts/http_evidence_verdict.py` distinguish canonical path from successful status and allow fault-specific HTTP 0/429/504 **only** on the trace-correlated request. ONLINE-4A classifies unexpected statuses as unsafe evidence; ONLINE-4B refuses them for guarded qualification. RELEASE-1 now requires exact actor/turn correspondence on every authoritative commit, not merely a legal PGN. +- **External specs:** In-toto singleton/array handling is shared in meaning across OCI, scanner and inspector; scanner refuses **all malformed array entries**, not only absent or null wrappers. RFC3986 admits valid IPvFuture/IPv6 authority forms and refuses invalid path/query delimiters. The in-toto ResourceDescriptor requirement of `uri`/`digest`/`content` is enforced with meaningful URI syntax, digest content and base64 validation. SLSA v1/v0.2 timestamps have RFC3339+chronological validation. Local versioned profiles under `qualification/schemas/` document our stricter typed semantic gates. +- **Retained evidence:** `scripts/sanitize_qualification_evidence.py` provides a centrally defined deny-by-default recursive report serializer, used by ONLINE-4A/4B and RELEASE-1 host reports. It removes freeform error, query, request-target and filename values, normalizes paths and preserves only validated identifiers/enumerations. +- **Claim checks:** `scripts/verify_assurance_contract.py` rechecks claim dependencies at the report boundary. RELEASE-1 no longer permits filling OS/wheel locks by itself to set `offline_candidate_qualified=true` while legal, host, network and operator gates are closed. Synthetic claims that omit required packaged cases are rejected. +- **Adversarial suite:** tests independently mutate transitive source imports, request IDs/statuses, bot actor origin, in-toto statement elements, SLSA descriptor identity/timestamps, claim flags and nested canary-bearing retained diagnostic records. + +All source changes are **pending a fresh exact-head real BuildKit/Syft, packaged R00/R02/R10, ONLINE-4A/4B and aggregate run**. The prior green head is not transferable qualification. Keep PR draft, no license, host, operator, public release or online bot authority. diff --git a/docs/RELEASE_1_OPERATIONS.md b/docs/RELEASE_1_OPERATIONS.md index a451f114..210014ee 100644 --- a/docs/RELEASE_1_OPERATIONS.md +++ b/docs/RELEASE_1_OPERATIONS.md @@ -309,3 +309,9 @@ A genuine dropped-before-commit R01 fault can yield a second chess-legal HTTP su ONLINE-4B verifies full original GET/POST request targets against the source-attested HTTP policy, requiring raw `request_target` to match the normalized route and admission bit before matching request-ID/move/SQLite evidence. The pinned bridge's genuine `ids=allfatherfixture` query remains permitted, but forged double-slash and secret/extra queries never qualify. A duplicate or mismatched raw target is rejected rather than reinterpreted through parsed pathname alone. RELEASE-1 URI admission now constrains path/query/fragment components individually under RFC3986 and validates optional recognized SLSA v1/v0.2 object/map/list fields, while preserving real BuildKit valid optional nulls and percent-encoded resource identities. Every new source change requires exact-head scanner, packaged-game, OCI/SLSA and aggregate qualification. No live transport or distribution claims arise. + +## Consolidated assurance-proof obligation boundary + +The new assurance contract is `qualification/assurance-contract-v1.json` with executable source dependency closure and Git committed-byte identity through `scripts/verify_source_closure.py`. Offline HTTP status meaning is defined separately from canonical request-target syntax. A successful harness is distinct from observed unsafe behavior and does not grant safe continuation; a green OCI image does not grant public distribution or even `offline_candidate_qualified` while other gates remain false. Versioned local validation profiles define the accepted subset of SLSA ResourceDescriptor and BuildMetadata semantics, with intentionally independent OCI/scanner negative tests. + +All retained independent reports pass through the bounded safe-output serializer. Tests must inject a secret-like canary into nested producer fields and verify that the final serialized artifact excludes it. Source imports and dynamic execution must be attested exhaustively before interpreting producer or verifier claims. diff --git a/qualification/online-recovery-v1.json b/qualification/online-recovery-v1.json index 62a492e0..04f0bb91 100644 --- a/qualification/online-recovery-v1.json +++ b/qualification/online-recovery-v1.json @@ -59,6 +59,8 @@ "qualification/http-evidence-contract-v1.json", "qualification/online-recovery-v1.json", "qualification/schemas/retained-qualification-report-v1.json", + "qualification/schemas/slsa-build-metadata-v1.contract.json", + "qualification/schemas/slsa-resource-descriptor-v1.contract.json", "scripts/http_evidence_verdict.py", "scripts/qualify-online-recovery.py", "scripts/sanitize_qualification_evidence.py", diff --git a/qualification/online-recovery-v2.json b/qualification/online-recovery-v2.json index cdcbb8f0..f6dcc395 100644 --- a/qualification/online-recovery-v2.json +++ b/qualification/online-recovery-v2.json @@ -80,6 +80,8 @@ "qualification/online-recovery-v1.json", "qualification/online-recovery-v2.json", "qualification/schemas/retained-qualification-report-v1.json", + "qualification/schemas/slsa-build-metadata-v1.contract.json", + "qualification/schemas/slsa-resource-descriptor-v1.contract.json", "scripts/http_evidence_verdict.py", "scripts/qualify-online-recovery-v2.py", "scripts/qualify-online-recovery.py", diff --git a/qualification/schemas/slsa-build-metadata-v1.contract.json b/qualification/schemas/slsa-build-metadata-v1.contract.json new file mode 100644 index 00000000..a1ad48a9 --- /dev/null +++ b/qualification/schemas/slsa-build-metadata-v1.contract.json @@ -0,0 +1,15 @@ +{ + "schema_version": 1, + "source_spec": "SLSA provenance v1 and v0.2 BuildMetadata", + "spec_url": "https://slsa.dev/spec/v1.0/provenance#buildmetadata", + "timestamps": [ + "startedOn", + "finishedOn", + "buildStartedOn", + "buildFinishedOn" + ], + "format": "RFC3339 timezone-aware", + "relative_order": "finish >= start", + "absent_null_empty": "permitted optional", + "public_authority_granted": false +} diff --git a/qualification/schemas/slsa-resource-descriptor-v1.contract.json b/qualification/schemas/slsa-resource-descriptor-v1.contract.json new file mode 100644 index 00000000..706c37cc --- /dev/null +++ b/qualification/schemas/slsa-resource-descriptor-v1.contract.json @@ -0,0 +1,14 @@ +{ + "schema_version": 1, + "source_spec": "in-toto/attestation v1 ResourceDescriptor", + "spec_url": "https://github.com/in-toto/attestation/blob/main/spec/v1/resource_descriptor.md", + "identity_any_of": [ + "uri", + "digest", + "content" + ], + "resource_uri": "absolute RFC3986 URI", + "digest": "nonempty validated algorithm/hex map", + "content": "strict Base64 byte encoding", + "public_authority_granted": false +} diff --git a/scripts/build-release-candidate.py b/scripts/build-release-candidate.py index a603e388..f92eeee3 100644 --- a/scripts/build-release-candidate.py +++ b/scripts/build-release-candidate.py @@ -54,6 +54,8 @@ "scripts/sanitize_qualification_evidence.py", "scripts/verify_assurance_contract.py", "qualification/schemas/retained-qualification-report-v1.json", + "qualification/schemas/slsa-resource-descriptor-v1.contract.json", + "qualification/schemas/slsa-build-metadata-v1.contract.json", "qualification/assurance-contract-v1.json", "scripts/inspect-release-oci.py", "scripts/check-release-snapshot.py", diff --git a/tests/release/test_assurance_contract.py b/tests/release/test_assurance_contract.py new file mode 100644 index 00000000..299b1ab2 --- /dev/null +++ b/tests/release/test_assurance_contract.py @@ -0,0 +1,132 @@ +"""Independent negative oracles for source closure and verdict implications.""" +from __future__ import annotations +import copy +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path + +ROOT=Path(__file__).resolve().parents[2] + +def load(path,ident): + spec=importlib.util.spec_from_file_location(ident,ROOT/path) + mod=importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + +closure=load("scripts/verify_source_closure.py","assurance_closure") +claims=load("scripts/verify_assurance_contract.py","assurance_claims") +sanitizer=load("scripts/sanitize_qualification_evidence.py","assurance_safe_output") + + +class AssuranceContractTests(unittest.TestCase): + def test_all_positive_claims_have_explicit_proof_requirements(self): + p=claims.validate_contract() + self.assertFalse(p["public_bot_release"]) + self.assertFalse(p["authority"]["public_distribution"]) + for key,entry in p["claims"].items(): + self.assertTrue(entry["requires"],key) + self.assertTrue(entry["does_not_imply"],key) + + def test_exact_recovery_source_closure_is_transitive(self): + for profile in ("online-recovery-v1","online-recovery-v2"): + with self.subTest(profile=profile): + self.assertTrue(closure.verify_profile(profile)[ + "source_closure_verified"]) + policy=json.loads((ROOT/"qualification/online-recovery-v2.json").read_text()) + names=set(policy["attested_paths"]) + for name in ("tests/lichess/fault_plan.py", + "deploy/lichess/run_recovery.py", + "deploy/lichess/log_capture.py", + "deploy/lichess/Dockerfile.offline", + "scripts/verify_source_closure.py", + "qualification/assurance-contract-v1.json"): + self.assertIn(name,names) + + def test_dependency_parser_catches_unattested_nested_import(self): + with tempfile.TemporaryDirectory() as td: + root=Path(td) + target=root/"deploy/lichess" + target.mkdir(parents=True) + a=target/"root.py" + b=target/"nested.py" + a.write_text("from deploy.lichess import nested\n") + b.write_text("VALUE=1\n") + deps=closure.imported_sources(a,root) + self.assertIn("deploy/lichess/nested.py",deps) + b.unlink() + self.assertNotIn("deploy/lichess/nested.py", + closure.imported_sources(a,root)) + + def test_contradictory_online4b_claims_are_rejected(self): + good={ + "public_bot_release":False, "source_commit":"a"*40, + "network_recovery_qualified":False, + "harness_qualified":True,"required_run_count":1, + "validated_run_count":1,"qualification_failures":[], + "scenario_results":[{"harness_valid":True,"scenario":"R00", + "status":"NORMAL_COMPLETION"}], + "containment_qualified_offline":True, + "recovery_qualified_offline":False, + } + claims.verify_report(good,"online4b") + forged={**good,"recovery_qualified_offline":True} + with self.assertRaisesRegex(ValueError,"missing fresh recovery witness"): + claims.verify_report(forged,"online4b") + corrupt={**good,"scenario_results":[{"harness_valid":True, + "status":"UNSAFE_WITNESS"}]} + with self.assertRaisesRegex(ValueError,"unsafe or unverified"): + claims.verify_report(corrupt,"online4b") + + def test_release_promotion_cannot_be_inferred_from_dependency_locks(self): + report={ + "public_bot_release":False, + "network_recovery_qualified":False, + "artifact_distribution_authorized":False, + "release_blocked":True,"release_promotion_authorized":False, + "license_approved":False,"destination_host_qualified":False, + "image_integrity_qualified":False, + "frozen_dependency_lock_qualified":True, + "offline_candidate_qualified":False, + "blockers":["controller_license_review"], + } + claims.verify_report(report,"release_aggregate") + forged={**report,"offline_candidate_qualified":True} + with self.assertRaisesRegex(ValueError,"authorization prerequisites"): + claims.verify_report(forged,"release_aggregate") + + def test_forged_image_integrity_needs_four_independent_games(self): + report={ + "public_bot_release":False, "network_recovery_qualified":False, + "artifact_distribution_authorized":False, + "release_blocked":True,"release_promotion_authorized":False, + "license_approved":False,"destination_host_qualified":False, + "image_integrity_qualified":True,"smoke":{}, + "offline_candidate_qualified":False, + } + with self.assertRaisesRegex(ValueError,"verified packaged games"): + claims.verify_report(report,"release_aggregate") + + def test_safe_output_never_exports_nested_secret_canaries(self): + doc={ + "public_bot_release":False, + "unsafe_witnesses":[{"behavior":"UNSAFE_WITNESS", + "unusable_move_submissions":[ + {"path":"/api/account?access_token=PRIVATE-CANARY", + "uci":"PRIVATE-CANARY","status":401}], + "early_rate_limit_requests":[ + {"path":"/api/account?access_token=PRIVATE-CANARY", + "request_target":"//PRIVATE-CANARY", + "error":"PRIVATE-CANARY"}], + "runner_errors":["PRIVATE-CANARY"]}], + "failure":{"message":"PRIVATE-CANARY"}, + "harness_failures":[{"error":"PRIVATE-CANARY"}], + } + out=sanitizer.serialize_report(doc) + self.assertNotIn("PRIVATE-CANARY",out) + self.assertIn("/api/account",out) + self.assertIn("UNSAFE_WITNESS",out) + +if __name__=="__main__": + unittest.main() From fa8f457437b62e8a8ac043406de2a9330366ab52 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 22:25:21 +0200 Subject: [PATCH 100/147] fix(ASSURANCE): retain synthetic hyphenated case keys in safe reports --- scripts/sanitize_qualification_evidence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sanitize_qualification_evidence.py b/scripts/sanitize_qualification_evidence.py index 65d4f636..b354db74 100644 --- a/scripts/sanitize_qualification_evidence.py +++ b/scripts/sanitize_qualification_evidence.py @@ -21,7 +21,7 @@ "scenario_source_sha256","sanitized_sha256","manifest_sha256", "position_id","owner_epoch","epoch","request_target_sha256", }) -SAFE_KEYS=re.compile(r"[a-z][A-Za-z0-9_]{0,79}\Z") +SAFE_KEYS=re.compile(r"[a-z][A-Za-z0-9_-]{0,79}\Z") SAFE_ENUM=re.compile(r"[A-Za-z][A-Za-z0-9_.:-]{0,95}\Z") SAFE_MOVE=re.compile(r"[a-h][1-8][a-h][1-8][qrbn]?\Z") SAFE_ID=re.compile(r"[A-Fa-f0-9]{16,128}\Z") From 72a2c5e546f84ccbb671b1d2b70149e0507f6b3a Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 22:28:29 +0200 Subject: [PATCH 101/147] fix(ASSURANCE): repair RFC3986 IPvFuture regex and preserve valid URI parser --- scripts/verify-release-oci.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/scripts/verify-release-oci.py b/scripts/verify-release-oci.py index 6bf7ed22..8ab6649c 100644 --- a/scripts/verify-release-oci.py +++ b/scripts/verify-release-oci.py @@ -195,15 +195,11 @@ def valid_uri_syntax(value: object, allowed_schemes: set[str], *, return False host = uri.hostname if ":" in host: + # RFC3986 supports both IPv6 and IPvFuture bracketed IP-literals. if host.lower().startswith("v"): - # RFC3986 IPvFuture: v HEXDIG+ '.' 1*(unreserved/subdelims/:) if re.fullmatch( - r"v[0-9A-Fa-f]+\.[A-Za-z0-9._~! if ":" in host: - try: - ipaddress.IPv6Address(host) - except ValueError: - return False'()*+,;=:-]+", - host,re.IGNORECASE) is None: + r"v[0-9A-Fa-f]+\.[A-Za-z0-9._~!$&'()*+,;=:-]+", + host, re.IGNORECASE) is None: return False else: try: From 8a6b42273ed8fc2bd7885eb1676504ea33e62609 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 22:30:43 +0200 Subject: [PATCH 102/147] test(ASSURANCE): expect redacted invalid UCI in retained unsafe evidence --- tests/lichess/test_recovery_evidence.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/lichess/test_recovery_evidence.py b/tests/lichess/test_recovery_evidence.py index f22e954b..3581776e 100644 --- a/tests/lichess/test_recovery_evidence.py +++ b/tests/lichess/test_recovery_evidence.py @@ -272,7 +272,8 @@ def test_null_move_submission_is_unsafe_but_valid_retry_is_not_misclassified(sel {"method": "POST", "path": "/api/bot/game/off3b001/move/garbage", "status": 400}, ] bad = QUALIFIER.invalid_move_submissions(attempts) - self.assertEqual([r["uci"] for r in bad], ["0000", "garbage"]) + self.assertEqual([r["uci"] for r in bad], ["0000", "__redacted__"]) + self.assertNotIn("garbage", repr(bad)) self.assertEqual(QUALIFIER.invalid_move_submissions([attempts[1]]), []) def test_sha256_of_stream_payload_is_stable(self): From d4551d312a001be0acb56504281c635d5e8f62bc Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 22:35:55 +0200 Subject: [PATCH 103/147] test(ASSURANCE): cover mutated Git sources, unknown in-toto siblings and safe error output --- scripts/check-release-scanner.py | 3 +++ scripts/qualify-release-aggregate.py | 4 ++- tests/release/test_assurance_contract.py | 25 +++++++++++++++++++ tests/release/test_release_oci_integrity.py | 7 +++++- .../release/test_release_scanner_contract.py | 8 ++++++ 5 files changed, 45 insertions(+), 2 deletions(-) diff --git a/scripts/check-release-scanner.py b/scripts/check-release-scanner.py index 978bd8c0..fc43eed4 100644 --- a/scripts/check-release-scanner.py +++ b/scripts/check-release-scanner.py @@ -92,6 +92,9 @@ def walk(descriptor,depth=0): type(row.get("subject")) is list and bool(row["subject"]), "scanner in-toto statement element invalid") + require(row.get("predicateType")==contract.SPDX or + row["predicateType"] in contract.SLSA, + "scanner unsupported in-toto predicate") if row.get("predicateType")==contract.SPDX: pred=row.get("predicate") require(type(pred) is dict,"scanner SPDX predicate malformed") diff --git a/scripts/qualify-release-aggregate.py b/scripts/qualify-release-aggregate.py index 78d3f919..62aa48f2 100644 --- a/scripts/qualify-release-aggregate.py +++ b/scripts/qualify-release-aggregate.py @@ -236,7 +236,9 @@ def main(): print(json.dumps({k:v for k,v in report.items() if k not in ("wheel_bytes","smoke")}, sort_keys=True)) except (OSError,ValueError,TypeError,KeyError,subprocess.SubprocessError) as exc: - p.exit(2,"RELEASE-1 aggregate verification refused: "+str(exc)+"\n") + # Failures are logged, but untrusted evidence must not appear in logs. + p.exit(2,"RELEASE-1 aggregate verification refused: "+ + type(exc).__name__+"\n") if __name__=="__main__": diff --git a/tests/release/test_assurance_contract.py b/tests/release/test_assurance_contract.py index 299b1ab2..1741153c 100644 --- a/tests/release/test_assurance_contract.py +++ b/tests/release/test_assurance_contract.py @@ -3,6 +3,7 @@ import copy import importlib.util import json +import subprocess import tempfile import unittest from pathlib import Path @@ -44,6 +45,30 @@ def test_exact_recovery_source_closure_is_transitive(self): "qualification/assurance-contract-v1.json"): self.assertIn(name,names) + def test_git_committed_bytes_catch_mutated_executable_worktree(self): + with tempfile.TemporaryDirectory() as td: + root=Path(td) + subprocess.run(["git","-C",str(root),"init","-q"], + check=True,capture_output=True) + subprocess.run(["git","-C",str(root),"config", + "user.email","offline@example.test"],check=True) + subprocess.run(["git","-C",str(root),"config", + "user.name","Offline Test"],check=True) + source=root/"deploy/lichess/fault_plan.py" + source.parent.mkdir(parents=True) + source.write_text("STATE='attested'\n") + subprocess.run(["git","-C",str(root),"add","."],check=True) + subprocess.run(["git","-C",str(root),"commit","-qm","freeze"], + check=True) + sha=subprocess.check_output( + ["git","-C",str(root),"rev-parse","HEAD"],text=True).strip() + path="deploy/lichess/fault_plan.py" + closure.verify_committed_bytes(sha,[path],root) + source.write_text("STATE='tampered'\n") + with self.assertRaisesRegex(ValueError, + "executed source differs from Git"): + closure.verify_committed_bytes(sha,[path],root) + def test_dependency_parser_catches_unattested_nested_import(self): with tempfile.TemporaryDirectory() as td: root=Path(td) diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index 773b2bd1..26dea82a 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -323,7 +323,12 @@ def descriptor(kind, digest, **extra): "digest": {"sha256": subject}}]), "predicate": body} if predicate == "https://spdx.dev/Document": - if statement_wrapper == "array": + if statement_wrapper == "array-unknown": + statement_document = {"statements":[ + {**statement_document,"predicateType": + "https://unknown.test/forged-predicate"}, + statement_document]} + elif statement_wrapper == "array": statement_document = {"statements": [statement_document]} elif statement_wrapper == "array-null": statement_document = {"statements":[None,statement_document]} diff --git a/tests/release/test_release_scanner_contract.py b/tests/release/test_release_scanner_contract.py index 544fcebe..53fdc4bb 100644 --- a/tests/release/test_release_scanner_contract.py +++ b/tests/release/test_release_scanner_contract.py @@ -53,6 +53,14 @@ def test_mixed_nonobject_statement_element_must_reject(self): "scanner in-toto statement element"): probe.inspect_scanner_output(tar) + def test_unknown_in_toto_row_cannot_be_skipped_next_to_spdx(self): + with tempfile.TemporaryDirectory() as td: + tar,_,_=oci_fixture.OCIProofTests().fixture( + Path(td),statement_wrapper="array-unknown") + with self.assertRaisesRegex(ValueError, + "scanner unsupported in-toto predicate"): + probe.inspect_scanner_output(tar) + def test_malformed_spdx_refused_at_scanner_boundary(self): for mode in ("missing-download","bogus-algorithm", "namespace-fragment","empty-inventory"): From e42a1411eb2ad72b3e9731853ea96681179c3a22 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 22:38:07 +0200 Subject: [PATCH 104/147] fix(ASSURANCE): close dynamic input inventory and trigger recovery CI for every verdict dependency --- .github/workflows/online-recovery-v2.yml | 7 +++++++ .github/workflows/online-recovery.yml | 7 +++++++ scripts/verify_source_closure.py | 12 ++++++++++-- tests/release/test_assurance_contract.py | 24 ++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 2 deletions(-) diff --git a/.github/workflows/online-recovery-v2.yml b/.github/workflows/online-recovery-v2.yml index 9d59884d..34598f77 100644 --- a/.github/workflows/online-recovery-v2.yml +++ b/.github/workflows/online-recovery-v2.yml @@ -7,6 +7,13 @@ on: - 'tests/fixtures/lichess/**' - 'scripts/qualify-online-recovery-v2.py' - 'scripts/qualify-online-recovery.py' + - 'scripts/http_evidence_verdict.py' + - 'scripts/sanitize_qualification_evidence.py' + - 'scripts/verify_assurance_contract.py' + - 'scripts/verify_source_closure.py' + - 'qualification/assurance-contract-v1.json' + - 'qualification/http-evidence-contract-v1.json' + - 'qualification/schemas/**' - 'scripts/release-manifest.py' - 'scripts/package-online.py' - 'scripts/build-online-engine-opt-v2.sh' diff --git a/.github/workflows/online-recovery.yml b/.github/workflows/online-recovery.yml index b4f38a0e..f4ef4aad 100644 --- a/.github/workflows/online-recovery.yml +++ b/.github/workflows/online-recovery.yml @@ -6,6 +6,13 @@ on: - 'tests/lichess/**' - 'tests/fixtures/lichess/**' - 'scripts/qualify-online-recovery.py' + - 'scripts/http_evidence_verdict.py' + - 'scripts/sanitize_qualification_evidence.py' + - 'scripts/verify_assurance_contract.py' + - 'scripts/verify_source_closure.py' + - 'qualification/assurance-contract-v1.json' + - 'qualification/http-evidence-contract-v1.json' + - 'qualification/schemas/**' - 'scripts/release-manifest.py' - 'scripts/package-online.py' - 'scripts/build-online-engine-opt-v2.sh' diff --git a/scripts/verify_source_closure.py b/scripts/verify_source_closure.py index e3169829..1e4c0ece 100644 --- a/scripts/verify_source_closure.py +++ b/scripts/verify_source_closure.py @@ -27,7 +27,11 @@ "scripts/verify_source_closure.py"), "inputs": ("deploy/lichess/Dockerfile.offline", ".github/workflows/online-recovery.yml", - "qualification/assurance-contract-v1.json"), + "qualification/assurance-contract-v1.json", + "qualification/online-recovery-v1.json", + "qualification/http-evidence-contract-v1.json", + "tests/fixtures/lichess/recovery_scenarios.json", + "deploy/lichess/bridge.lock.json"), }, "online-recovery-v2": { "policy": "qualification/online-recovery-v2.json", @@ -41,7 +45,11 @@ "inputs": ("deploy/lichess/Dockerfile.offline", ".github/workflows/online-recovery-v2.yml", "qualification/assurance-contract-v1.json", - "qualification/online-recovery-v1.json"), + "qualification/online-recovery-v1.json", + "qualification/online-recovery-v2.json", + "qualification/http-evidence-contract-v1.json", + "tests/fixtures/lichess/recovery_scenarios.json", + "deploy/lichess/bridge.lock.json"), }, } diff --git a/tests/release/test_assurance_contract.py b/tests/release/test_assurance_contract.py index 1741153c..3cee31ff 100644 --- a/tests/release/test_assurance_contract.py +++ b/tests/release/test_assurance_contract.py @@ -69,6 +69,30 @@ def test_git_committed_bytes_catch_mutated_executable_worktree(self): "executed source differs from Git"): closure.verify_committed_bytes(sha,[path],root) + def test_dynamic_executable_inputs_cannot_be_dropped_from_attestation(self): + dynamic={ + "qualification/http-evidence-contract-v1.json", + "tests/fixtures/lichess/recovery_scenarios.json", + "deploy/lichess/bridge.lock.json", + } + for profile,policy in ( + ("online-recovery-v1","qualification/online-recovery-v1.json"), + ("online-recovery-v2","qualification/online-recovery-v2.json"), + ): + required=closure.expected_sources(profile) + self.assertTrue(dynamic.issubset(required)) + self.assertIn(policy,required) + original=closure.PROFILES[profile]["inputs"] + try: + # A producer-controlled profile cannot shrink independent + # expected imports/dynamic contract inputs. + config=closure.PROFILES[profile] + self.assertTrue(set(original).issubset(required)) + self.assertTrue(closure.verify_profile(profile)[ + "source_closure_verified"]) + finally: + closure.PROFILES[profile]["inputs"]=original + def test_dependency_parser_catches_unattested_nested_import(self): with tempfile.TemporaryDirectory() as td: root=Path(td) From 3aefec43e53ad2402ea5205820148d4c455066e1 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 22:38:51 +0200 Subject: [PATCH 105/147] fix(RELEASE-1): support RFC3986 opaque pkg URIs in SLSA ResourceDescriptors --- scripts/verify-release-oci.py | 34 +++++++++++++++++---- tests/release/test_release_oci_integrity.py | 1 + 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/scripts/verify-release-oci.py b/scripts/verify-release-oci.py index 8ab6649c..d7ae7eb4 100644 --- a/scripts/verify-release-oci.py +++ b/scripts/verify-release-oci.py @@ -246,16 +246,38 @@ def _optional_string_map(container: dict, field: str) -> bool: def _resource_uri(value: object) -> bool: - """External resource identifiers are URI, not arbitrary strings. + """in-toto ResourceURI accepts both hierarchical and opaque RFC3986 URIs. - A conservative set of schemes includes git+https and OCI resource URIs; - general SLSA builder identities remain governed by valid_identity_uri. + BuildKit's actual Docker materials use Package URLs such as + pkg:docker/ubuntu@sha256%3A...; these do not have //network authorities. + Resource URIs are identifiers, never instructions to access a network. """ if type(value) is not str or not value: return False - schemes={"http","https","git+https","git+http","git+ssh", - "git","ssh","ftp","ftps","oci","docker","urn"} - return valid_uri_syntax(value,schemes,allow_git_ssh_user=True) + if re.fullmatch(r"[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]+", + value,re.ASCII) is None: + return False + if (re.search(r"%(?![0-9A-Fa-f]{2})",value) or + re.search(r"%(?:0[0-9A-Fa-f]|1[0-9A-Fa-f]|7[fF])", + value,re.IGNORECASE)): + return False + try: + uri=urlsplit(value) + if not re.fullmatch(r"[A-Za-z][A-Za-z0-9+.-]*",uri.scheme): + return False + if uri.netloc: + return valid_uri_syntax(value,{uri.scheme},allow_git_ssh_user=True) + if uri.scheme not in ("pkg","urn","git","oci","file","docker"): + return False + pchar=r"A-Za-z0-9._~!$&'()*+,;=:@%-" + if (not uri.path or uri.path.startswith("//") or + re.fullmatch("["+pchar+r"/]*",uri.path,re.ASCII) is None or + re.fullmatch("["+pchar+r"/?]*",uri.query,re.ASCII) is None or + re.fullmatch("["+pchar+r"/?]*",uri.fragment,re.ASCII) is None): + return False + return True + except ValueError: + return False def _valid_digest_set(value: object) -> bool: diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index 26dea82a..cdb27da4 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -870,6 +870,7 @@ def test_all_descriptors_require_exact_nonboolean_sizes(self): def test_resource_descriptors_require_meaningful_identity(self): good=( {"uri":"https://example.test/file"}, + {"uri":"pkg:docker/ubuntu@sha256%3Aabc123"}, {"digest":{"sha256":"a"*64}}, {"content":"e30="}, {"uri":"git+https://example.test/code.git@abc123", From 7b98898f80b3628d05663f6a3d1d3a33fee7d684 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 22:40:22 +0200 Subject: [PATCH 106/147] fix(ASSURANCE): correlate observed move statuses with scenario witnesses --- scripts/http_evidence_verdict.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/scripts/http_evidence_verdict.py b/scripts/http_evidence_verdict.py index 31617d53..79ab8b2e 100644 --- a/scripts/http_evidence_verdict.py +++ b/scripts/http_evidence_verdict.py @@ -26,19 +26,16 @@ def approved_status(request: object, trace: list[dict], return False if method == "GET" or not path.startswith(MOVE_PREFIX): return status == policy["non_move_status"] - if status == policy["ordinary_move_status"]: - return True - target=policy["fault_specific_move_status"].get(action) - if target is None or status != target: - return False + target_status=policy["fault_specific_move_status"].get(action) request_id=request.get("request_id") - return (type(request_id) is str and bool(request_id) and - sum(1 for event in trace if type(event) is dict and - event.get("event")=="fault_activated" and - event.get("action")==action and - event.get("endpoint")=="move_post" and - event.get("request_id")==request_id)==1) - + matching=[row for row in trace + if type(row) is dict and row.get("event")=="fault_activated" + and row.get("action")==action + and row.get("endpoint")=="move_post" + and row.get("request_id")==request_id] + if target_status is not None and matching: + return len(matching)==1 and status==target_status + return status==policy["ordinary_move_status"] def unexpected_statuses(requests: list[dict], trace: list[dict], action: str) -> list[dict]: From 8927e5537815aa77acb1d6d8862590f0f19bbe30 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 22:41:08 +0200 Subject: [PATCH 107/147] fix(ASSURANCE): enforce OCI archive, layer, SPDX and SLSA prerequisites for image claims --- scripts/qualify-release-aggregate.py | 9 +++++++- scripts/verify_assurance_contract.py | 17 ++++++++++++++ tests/release/test_assurance_contract.py | 29 ++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/scripts/qualify-release-aggregate.py b/scripts/qualify-release-aggregate.py index 62aa48f2..7cce6bde 100644 --- a/scripts/qualify-release-aggregate.py +++ b/scripts/qualify-release-aggregate.py @@ -188,7 +188,14 @@ def qualify(*, stage:Path, oci:Path, oci_archive:Path, host:Path, smoke_dir:Path "wheel_requirements_sha256":wheel_hash, "wheel_inventory_sha256":wheel_inv, "wheel_bytes":runtime_wheels["wheels"], - "smoke":tests,"image_integrity_qualified":True, + "smoke":tests, + "oci_evidence":{field:oci_report.get(field) for field in ( + "integrity_verified","oci_layers_verified", + "oci_diff_ids_verified","oci_application_identity_verified", + "oci_attestation_references_verified", + "oci_attestation_subjects_verified", + "oci_sbom_verified","oci_provenance_verified")}, + "image_integrity_qualified":True, "frozen_dependency_lock_qualified":all(checked.values()), "dependency_lock_checks":checked, # Source and image integrity do not grant host/legal/live authority. diff --git a/scripts/verify_assurance_contract.py b/scripts/verify_assurance_contract.py index c62e4cc4..b46bf3e5 100644 --- a/scripts/verify_assurance_contract.py +++ b/scripts/verify_assurance_contract.py @@ -98,6 +98,23 @@ def verify_report(report: dict, kind: str) -> dict: report.get("destination_host_qualified") is False, "RELEASE-1 no-publication boundary changed") if report.get("image_integrity_qualified") is True: + import re + evidence=report.get("oci_evidence") + require(type(evidence) is dict and + all(evidence.get(key) is True for key in ( + "integrity_verified","oci_layers_verified", + "oci_diff_ids_verified", + "oci_application_identity_verified", + "oci_attestation_references_verified", + "oci_attestation_subjects_verified", + "oci_sbom_verified","oci_provenance_verified")) and + type(report.get("oci_archive_sha256")) is str and + re.fullmatch(r"[0-9a-f]{64}", + report["oci_archive_sha256"]) is not None and + type(report.get("image_manifest_digest")) is str and + re.fullmatch(r"sha256:[0-9a-f]{64}", + report["image_manifest_digest"]) is not None, + "image integrity missing OCI archive, SBOM or provenance proof") smoke=report.get("smoke") require(type(smoke) is dict and set(smoke)=={ "r00-white","r00-black","r02-white","r10-white"} and diff --git a/tests/release/test_assurance_contract.py b/tests/release/test_assurance_contract.py index 3cee31ff..82d106da 100644 --- a/tests/release/test_assurance_contract.py +++ b/tests/release/test_assurance_contract.py @@ -145,6 +145,35 @@ def test_release_promotion_cannot_be_inferred_from_dependency_locks(self): with self.assertRaisesRegex(ValueError,"authorization prerequisites"): claims.verify_report(forged,"release_aggregate") + def test_four_games_without_oci_provenance_do_not_grant_image_integrity(self): + complete={key:{"passed":True, + "independent_evidence":{"independent_qualified":True}} + for key in ("r00-white","r00-black","r02-white","r10-white")} + report={ + "public_bot_release":False, + "network_recovery_qualified":False, + "artifact_distribution_authorized":False, + "release_blocked":True, + "release_promotion_authorized":False, + "license_approved":False, + "destination_host_qualified":False, + "image_integrity_qualified":True, + "offline_candidate_qualified":False, + "smoke":complete, + "oci_archive_sha256":"a"*64, + "image_manifest_digest":"sha256:"+"b"*64, + "oci_evidence":{}, + } + with self.assertRaisesRegex(ValueError,"OCI archive, SBOM"): + claims.verify_report(report,"release_aggregate") + report["oci_evidence"]={key:True for key in ( + "integrity_verified","oci_layers_verified", + "oci_diff_ids_verified","oci_application_identity_verified", + "oci_attestation_references_verified", + "oci_attestation_subjects_verified", + "oci_sbom_verified","oci_provenance_verified")} + claims.verify_report(report,"release_aggregate") + def test_forged_image_integrity_needs_four_independent_games(self): report={ "public_bot_release":False, "network_recovery_qualified":False, From 22e06d5d1232586bb7c8513f517f888e319a4ba2 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 22:42:09 +0200 Subject: [PATCH 108/147] fix(ASSURANCE): use fixed safe-report enums rather than string-shape matching --- scripts/sanitize_qualification_evidence.py | 71 ++++++++++++++++++++-- tests/release/test_assurance_contract.py | 10 +++ 2 files changed, 75 insertions(+), 6 deletions(-) diff --git a/scripts/sanitize_qualification_evidence.py b/scripts/sanitize_qualification_evidence.py index b354db74..9a32c71c 100644 --- a/scripts/sanitize_qualification_evidence.py +++ b/scripts/sanitize_qualification_evidence.py @@ -10,12 +10,68 @@ import math import re -SAFE_ENUM_FIELDS=frozenset({ - "status","reason","action","event","endpoint","observation","origin", - "scenario","color","behavior","release_kind","termination","mode", - "phase","kind","cause","http_method","method","run_id","run","name", - "stop_reason","scope","type","event_id","generation", +ACTIONS=frozenset({ + "none","drop_before_commit","commit_then_close","commit_then_504", + "duplicate_move","delay_state","duplicate_state","stale_state", + "partial_chunk","replace_event_stream","rate_limit_429","wrong_game", + "low_clock_close","terminate_bridge","duplicate_terminal", }) +SCENARIOS=frozenset(f"R{i:02d}" for i in range(15)) +BEHAVIORS=frozenset({ + "SAFE_CONTINUATION","UNSAFE_WITNESS","OBSERVED_COMPLETION", + "OBSERVED_STOP_OR_UNRESOLVED","NORMAL_COMPLETION","RECOVERED", + "SAFE_SUSPENSION","INCONCLUSIVE","OFFLINE_SMOKE_PASS", + "OFFLINE_SMOKE_FAIL","CONFIRMED","UNKNOWN_ACK", +}) +SAFE_ENUM_VALUES={ + "status":BEHAVIORS, + "behavior":BEHAVIORS, + "scenario":SCENARIOS, + "color":frozenset(("white","black")), + "origin":frozenset(("allfather","scripted_opponent")), + "method":frozenset(("GET","POST")), + "http_method":frozenset(("GET","POST")), + "action":ACTIONS, + "endpoint":frozenset(("move","move_post","game_state","event_stream", + "terminal_state","account","token","challenge")), + "reason":frozenset(("noncanonical_request_target", + "unapproved_http_status", + "position_illegal_or_wrong_turn", + "repeated_move_submission_same_ply", + "once_legal_retry_after_lost_ack", + "duplicate_unconfirmed_move_submission")), + "observation":frozenset(("complete_confirmed","restart_after_lost_ack", + "rate_limited_suspension","fault_triggered_bridge_kill", + "bounded_postfault_observation","not_started", + "experiment_timeout","game_stream_observed", + "injected_bridge_kill","server_game_complete", + "terminal_reconciliation_timeout", + "restart_requested_after_fault", + "unresolved_precommit_contained", + "rate_limit_containment_window", + "invalid_move_containment_window", + "bridge_exited","bounded_restarted_game", + "bounded_experiment_timeout","phase_error", + "launching")), + "termination":frozenset(("CHECKMATE","STALEMATE", + "INSUFFICIENT_MATERIAL","SEVENTYFIVE_MOVES", + "FIVEFOLD_REPETITION","VARIANT_WIN", + "VARIANT_LOSS","VARIANT_DRAW")), + "cause":frozenset(("http429:move","quiesce")), + "scope":frozenset(("offline-fault-harness-only", + "offline-guarded-bridge-only")), + "mode":frozenset(("OFFLINE-4B-ONLY","OFFLINE-ONLY")), + "release_kind":frozenset(("ONLINE-3A-OFFLINE-ONLY",)), + "event":frozenset(("move_attempt","board_commit","fault_activated", + "http_move_submission_attempt","http_unknown_ack", + "http_move_ack_observed","global_rate_limit_block", + "rate_limit_hold_completed","response_write_completed", + "connection_close_requested","stream_emit", + "stream_partial_chunk","cooldown","subscribe", + "position_suspended","authoritative_snapshot_observed", + "unavailable_or_illegal_uci_move","move_prepare_refused")), +} +SAFE_ENUM_FIELDS=frozenset(SAFE_ENUM_VALUES) HASH_FIELDS=frozenset({ "source_commit","bridge_commit","source_sha","sha256","policy_sha256", "scenario_source_sha256","sanitized_sha256","manifest_sha256", @@ -54,7 +110,10 @@ def safe_string(field: str, value: str) -> str | None: "stdout","stderr","url","uri","query","raw","raw_path"): return "__redacted__" if field in SAFE_ENUM_FIELDS: - return value if SAFE_ENUM.fullmatch(value) else "__redacted__" + return value if value in SAFE_ENUM_VALUES[field] else "__redacted__" + if field == "run_id": + return value if value in {f"r{i:02d}-{color}" for i in range(15) + for color in ("white","black")} else "__redacted__" # Strict default deny: otherwise any new producer-controlled field can # leak a secret without a separate output-safety review. return "__redacted__" diff --git a/tests/release/test_assurance_contract.py b/tests/release/test_assurance_contract.py index 82d106da..8795063a 100644 --- a/tests/release/test_assurance_contract.py +++ b/tests/release/test_assurance_contract.py @@ -186,6 +186,16 @@ def test_forged_image_integrity_needs_four_independent_games(self): with self.assertRaisesRegex(ValueError,"verified packaged games"): claims.verify_report(report,"release_aggregate") + def test_fixed_enum_allowlist_rejects_secret_like_valid_tokens(self): + for key in ("reason","origin","status","behavior","action", + "event","scenario","endpoint"): + with self.subTest(field=key): + cleaned=sanitizer.sanitize_report({key:"PRIVATE_CANARY"}) + self.assertEqual(cleaned[key],"__redacted__") + self.assertEqual(sanitizer.sanitize_report({ + "reason":"noncanonical_request_target"})["reason"], + "noncanonical_request_target") + def test_safe_output_never_exports_nested_secret_canaries(self): doc={ "public_bot_release":False, From 6e4b9360e4fa1e04d67379a96e2f7e2af93dd683 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 22:42:49 +0200 Subject: [PATCH 109/147] fix(RELEASE-1): classify standard transient APT install summary without relaxing mixed failures --- deploy/release/install-pinned-apt.sh | 3 ++- tests/release/test_release_snapshot_retry.py | 25 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/deploy/release/install-pinned-apt.sh b/deploy/release/install-pinned-apt.sh index 4f6ed9d5..aab015bc 100644 --- a/deploy/release/install-pinned-apt.sh +++ b/deploy/release/install-pinned-apt.sh @@ -61,7 +61,8 @@ only_transient_acquisition_failures() { } if (line ~ /^E:/) { # This APT summary follows individually classified errors. - if (line !~ /^E: Some index files failed to download/) + if (line !~ /^E: Some index files failed to download/ && + line != "E: Unable to fetch some archives, maybe run apt-get update or try with --fix-missing?") bad=1 next } diff --git a/tests/release/test_release_snapshot_retry.py b/tests/release/test_release_snapshot_retry.py index 5542911b..eee429e1 100644 --- a/tests/release/test_release_snapshot_retry.py +++ b/tests/release/test_release_snapshot_retry.py @@ -63,6 +63,21 @@ echo " 504 Gateway Time-out [IP: 185.125.189.69 443]" >&2 echo "E: Failed to fetch https://snapshot.ubuntu.com/ubuntu/noble/InRelease 504 Gateway Time-out [IP: 185.125.189.69 443]" >&2 exit 100 ;; + install503) + if [ "$phase" = install ]; then + echo "Err:1 https://snapshot.ubuntu.com/ubuntu noble packages" >&2 + echo " 503 Service Unavailable [IP: 185.125.189.69 443]" >&2 + echo "E: Failed to fetch https://snapshot.ubuntu.com/ubuntu/packages.deb 503 Service Unavailable [IP: 185.125.189.69 443]" >&2 + echo "E: Unable to fetch some archives, maybe run apt-get update or try with --fix-missing?" >&2 + exit 100 + fi ;; + install503mixed) + if [ "$phase" = install ]; then + echo "E: Failed to fetch https://snapshot.ubuntu.com/ubuntu/packages.deb 503 Service Unavailable" >&2 + echo "E: Failed to fetch https://snapshot.ubuntu.com/ubuntu/other.deb 401 Unauthorized" >&2 + echo "E: Unable to fetch some archives, maybe run apt-get update or try with --fix-missing?" >&2 + exit 100 + fi ;; two503) echo "Err:1 https://snapshot.ubuntu.com/ubuntu noble InRelease" >&2 echo " 503 Service Unavailable" >&2 @@ -124,6 +139,16 @@ def test_realistic_503_and_504_logs_with_https_port_are_retryable(self): self.assertEqual(counts,{"update":4,"install":0}) self.assertIn("exhausted four bounded attempts",result.stderr) + def test_install_summary_with_only_transient_errors_retries_bounded(self): + result,counts=self.execute("install503") + self.assertNotEqual(result.returncode,0) + self.assertEqual(counts,{"update":1,"install":4}) + + def test_install_summary_does_not_override_unknown_or_401(self): + result,counts=self.execute("install503mixed") + self.assertNotEqual(result.returncode,0) + self.assertEqual(counts,{"update":1,"install":1}) + def test_multiple_503s_are_individually_classified_and_bounded(self): result,counts=self.execute("two503") self.assertNotEqual(result.returncode,0) From 36436539836db4a2c56118be3f7861e1362090b2 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 22:45:49 +0200 Subject: [PATCH 110/147] fix(RELEASE-1): validate mandatory SPDX copyright and conditional package analysis fields --- scripts/verify-release-oci.py | 28 +++++++++++++++ tests/release/test_release_oci_integrity.py | 40 ++++++++++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/scripts/verify-release-oci.py b/scripts/verify-release-oci.py index d7ae7eb4..be0240b1 100644 --- a/scripts/verify-release-oci.py +++ b/scripts/verify-release-oci.py @@ -538,6 +538,34 @@ def spdx_id(item): bool(item["name"].strip()) and valid_spdx_download_location(item.get("downloadLocation")), "SPDX SBOM package has invalid downloadLocation") + # SPDX 2.3 CopyrightText is required. filesAnalyzed is optional + # (default True), but its verification-code requirement is conditional. + # licenseConcluded and licenseDeclared are optional (NOASSERTION). + require(type(item.get("copyrightText")) is str and + bool(item["copyrightText"].strip()), + "SPDX package copyrightText absent or invalid") + analyzed=item.get("filesAnalyzed", True) + require(type(analyzed) is bool, + "SPDX package filesAnalyzed must be boolean") + verification=item.get("packageVerificationCode") + if analyzed: + require(type(verification) is dict and + type(verification.get("packageVerificationCodeValue")) is str and + re.fullmatch(r"[0-9a-f]{40}", + verification["packageVerificationCodeValue"]) is not None and + (verification.get("packageVerificationCodeExcludedFiles") is None + or (type(verification["packageVerificationCodeExcludedFiles"]) is list + and all(type(name) is str and bool(name) + for name in verification["packageVerificationCodeExcludedFiles"]))), + "SPDX analyzed package requires verification code") + else: + require(verification is None and + "licenseInfoFromFiles" not in item, + "SPDX unanalyzed package must omit verification code and file licenses") + for field in ("licenseConcluded","licenseDeclared"): + if field in item: + require(type(item[field]) is str and bool(item[field].strip()), + "SPDX package license field malformed") if "checksums" in item: require(valid_spdx_checksums(item["checksums"]), "SPDX SBOM package contains invalid checksums") diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index cdb27da4..bfd6071f 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -151,7 +151,11 @@ def descriptor(kind, digest, **extra): "packages": [{ "SPDXID": "SPDXRef-Package-offline", "name": "offline-test-package", - "downloadLocation": "NOASSERTION"}], + "downloadLocation": "NOASSERTION", + "filesAnalyzed": False, + "copyrightText": "NOASSERTION", + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION"}], } if predicate == "https://spdx.dev/Document" else { "buildDefinition": { "buildType": "https://mobyproject.org/buildkit@v1", @@ -179,6 +183,23 @@ def descriptor(kind, digest, **extra): body["packages"] = [] elif spdx_case == "bad-package": body["packages"] = [{"SPDXID":"SPDXRef-Package-X"}] + elif spdx_case == "missing-copyright": + del body["packages"][0]["copyrightText"] + elif spdx_case == "empty-copyright": + body["packages"][0]["copyrightText"] = "" + elif spdx_case == "filesAnalyzed-invalid": + body["packages"][0]["filesAnalyzed"] = "false" + elif spdx_case == "missing-filesAnalyzed-without-code": + del body["packages"][0]["filesAnalyzed"] + elif spdx_case == "filesAnalyzed-without-code": + body["packages"][0]["filesAnalyzed"] = True + elif spdx_case == "unanalysed-with-code": + body["packages"][0]["packageVerificationCode"] = { + "packageVerificationCodeValue": "a"*40} + elif spdx_case == "unanalysed-with-file-licenses": + body["packages"][0]["licenseInfoFromFiles"] = ["MIT"] + elif spdx_case == "invalid-license": + body["packages"][0]["licenseDeclared"] = 3 elif spdx_case == "missing-download": del body["packages"][0]["downloadLocation"] elif spdx_case == "invalid-download": @@ -637,6 +658,19 @@ def test_uri_grammar_is_shared_without_collapsing_identity_policies(self): self.assertTrue(oci.valid_spdx_download_location("NOASSERTION")) self.assertFalse(oci.valid_spdx_namespace("NOASSERTION")) + def test_spdx_package_optional_defaults_and_conditional_verification(self): + pkg={"SPDXID":"SPDXRef-Package-complete", + "name":"sample", "downloadLocation":"NOASSERTION", + "copyrightText":"NOASSERTION", "filesAnalyzed":False} + self.assertTrue(oci.valid_spdx_download_location(pkg["downloadLocation"])) + # Optional field omission means files were analyzed; require the + # SHA1 verification code in that case, rather than guessing False. + with tempfile.TemporaryDirectory() as d: + archive,meta,inspection=self.fixture(Path(d), + spdx_case="missing-filesAnalyzed-without-code") + with self.assertRaisesRegex(ValueError,"verification code"): + oci.verify_archive(archive,meta,docker_inspect=inspection) + def test_spdx_file_only_inventory_uses_fileName_not_package_name(self): with tempfile.TemporaryDirectory() as d: archive, meta, inspect = self.fixture(Path(d), spdx_case="file-only-valid") @@ -666,6 +700,10 @@ def test_incomplete_spdx_sbom_never_qualifies(self): "bad-namespace", "missing-created", "invalid-created", "missing-creators", "bad-creator", "empty-inventory", "bad-package", "missing-download", "invalid-download", + "missing-copyright", "empty-copyright", + "filesAnalyzed-invalid","missing-filesAnalyzed-without-code", + "filesAnalyzed-without-code", "unanalysed-with-code", + "unanalysed-with-file-licenses","invalid-license", "download-unicode", "download-braces", "download-pipe", "invalid-percent", "invalid-percent-host", "invalid-port", "invalid-authority", "encoded-control", "bad-package-checksum", "short-sha256", "bogus-algorithm", From 1bfec6d4900ff6a73803bf6b1443d1ccd332af7e Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 22:46:43 +0200 Subject: [PATCH 111/147] test(ASSURANCE): reject fault-targeted HTTP 200 overrides while preserving normal moves --- tests/lichess/test_recovery_evidence.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/lichess/test_recovery_evidence.py b/tests/lichess/test_recovery_evidence.py index 3581776e..df759f42 100644 --- a/tests/lichess/test_recovery_evidence.py +++ b/tests/lichess/test_recovery_evidence.py @@ -165,6 +165,15 @@ def test_offline_fault_http_statuses_require_correlated_witness(self): trace=[{"event":"fault_activated","action":"rate_limit_429", "endpoint":"move_post","request_id":"request-1"}] self.assertTrue(approved_status(sample,trace,"rate_limit_429")) + self.assertFalse(approved_status( + {**sample,"status":200},trace,"rate_limit_429")) + for fault,outcome in (("drop_before_commit",0),("commit_then_504",504)): + witnessed=[{"event":"fault_activated","action":fault, + "endpoint":"move_post","request_id":"request-1"}] + self.assertTrue(approved_status( + {**sample,"status":outcome},witnessed,fault)) + self.assertFalse(approved_status( + {**sample,"status":200},witnessed,fault)) self.assertFalse(approved_status(sample,[],"rate_limit_429")) self.assertFalse(approved_status(sample,trace,"none")) self.assertEqual(unexpected_statuses([ From 39252ca190765a6e24e6796fe900236b44e05c03 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 22:47:30 +0200 Subject: [PATCH 112/147] fix(RELEASE-1): validate every scanner in-toto subject and predicate, including sibling rows --- scripts/check-release-scanner.py | 18 +++++++++++++++--- tests/release/test_release_oci_integrity.py | 10 +++++++++- tests/release/test_release_scanner_contract.py | 9 +++++++++ 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/scripts/check-release-scanner.py b/scripts/check-release-scanner.py index fc43eed4..3730e88a 100644 --- a/scripts/check-release-scanner.py +++ b/scripts/check-release-scanner.py @@ -5,6 +5,7 @@ import hashlib import importlib.util import json +import re import tarfile from pathlib import Path @@ -95,12 +96,23 @@ def walk(descriptor,depth=0): require(row.get("predicateType")==contract.SPDX or row["predicateType"] in contract.SLSA, "scanner unsupported in-toto predicate") - if row.get("predicateType")==contract.SPDX: - pred=row.get("predicate") - require(type(pred) is dict,"scanner SPDX predicate malformed") + require(all(type(subject) is dict and + type(subject.get("digest")) is dict and + len(subject["digest"]) > 0 and + type(subject["digest"].get("sha256")) is str and + re.fullmatch(r"[0-9a-f]{64}", + subject["digest"]["sha256"]) is not None + for subject in row["subject"]), + "scanner in-toto subject identity malformed") + pred=row.get("predicate") + require(type(pred) is dict and bool(pred), + "scanner in-toto predicate missing or malformed") + if row["predicateType"]==contract.SPDX: contract.validate_spdx_document(pred) spdx.append((len(pred.get("packages",[])), len(pred.get("files",[])))) + else: + contract.validate_slsa(row["predicateType"],pred) for root in index["manifests"]: walk(root) require(len(spdx)==1 and sum(spdx[0])>=1, diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index bfd6071f..571b43dc 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -344,7 +344,15 @@ def descriptor(kind, digest, **extra): "digest": {"sha256": subject}}]), "predicate": body} if predicate == "https://spdx.dev/Document": - if statement_wrapper == "array-unknown": + if statement_wrapper == "array-bad-subject": + statement_document = {"statements":[ + {**statement_document,"subject":[None]}, + statement_document]} + elif statement_wrapper == "array-empty-predicate": + statement_document = {"statements":[ + {**statement_document,"predicate":{}}, + statement_document]} + elif statement_wrapper == "array-unknown": statement_document = {"statements":[ {**statement_document,"predicateType": "https://unknown.test/forged-predicate"}, diff --git a/tests/release/test_release_scanner_contract.py b/tests/release/test_release_scanner_contract.py index 53fdc4bb..1af5d6ae 100644 --- a/tests/release/test_release_scanner_contract.py +++ b/tests/release/test_release_scanner_contract.py @@ -53,6 +53,15 @@ def test_mixed_nonobject_statement_element_must_reject(self): "scanner in-toto statement element"): probe.inspect_scanner_output(tar) + def test_invalid_statement_subject_or_predicate_not_skipped(self): + for variant,why in (("array-bad-subject","subject identity"), + ("array-empty-predicate","predicate missing")): + with self.subTest(variant=variant),tempfile.TemporaryDirectory() as td: + tar,_,_=oci_fixture.OCIProofTests().fixture( + Path(td),statement_wrapper=variant) + with self.assertRaisesRegex(ValueError,why): + probe.inspect_scanner_output(tar) + def test_unknown_in_toto_row_cannot_be_skipped_next_to_spdx(self): with tempfile.TemporaryDirectory() as td: tar,_,_=oci_fixture.OCIProofTests().fixture( From e5bcee3ecd007a8b680ccab7dabd44bca87feff3 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 22:48:47 +0200 Subject: [PATCH 113/147] docs(ASSURANCE): reconcile nested Codex P1/P2 closure and real BuildKit validation gates --- docs/CODEX_POSTMERGE_CLOSURE.md | 18 ++++++++++++++++++ docs/RELEASE_1_OPERATIONS.md | 10 ++++++++++ 2 files changed, 28 insertions(+) diff --git a/docs/CODEX_POSTMERGE_CLOSURE.md b/docs/CODEX_POSTMERGE_CLOSURE.md index ab81f639..3ead0975 100644 --- a/docs/CODEX_POSTMERGE_CLOSURE.md +++ b/docs/CODEX_POSTMERGE_CLOSURE.md @@ -209,3 +209,21 @@ This repair pass addresses the eight actionable Codex P2s from review of `32b45b - **Adversarial suite:** tests independently mutate transitive source imports, request IDs/statuses, bot actor origin, in-toto statement elements, SLSA descriptor identity/timestamps, claim flags and nested canary-bearing retained diagnostic records. All source changes are **pending a fresh exact-head real BuildKit/Syft, packaged R00/R02/R10, ONLINE-4A/4B and aggregate run**. The prior green head is not transferable qualification. Keep PR draft, no license, host, operator, public release or online bot authority. + +## Codex 2026-10-17T20:35 UTC — nine nested findings and independent real-build gate + +**Reviewed source head:** `fa8f457437b62e8a8ac043406de2a9330366ab52`. A follow-up `72a2c5` had fast 153/153 RELEASE-1 unit cases and the real pinned scanner green, while ONLINE-4A/4B/3B fast suites each failed the same obsolete invalid-UCI expectation (a newly redacted value) and the full RELEASE-1 built, played and independently qualified four packaged R00/R02/R10 cases but **failed at OCI SLSA provenance** because a legitimate BuildKit `pkg:docker/ubuntu@` opaque package URL was not admitted. These are confirmed CI blockers. The repair source below is staged for **fresh exact-head proof**, not yet independently qualified. + +| Codex finding | Root-level corrective action / regression | +| --- | --- | +| [P1 dynamically read files outside source closure](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304) | `scripts/verify_source_closure.py`: both independent expected-source sets now explicitly include their own policy file, scenario JSON, bridge source lock and HTTP status policy. Tests assert dynamic imports/inputs cannot drop out of computed closure. The producer's hash list remains insufficient without equality to `git show exact_sha:path`. | +| P1 BuildKit ResourceDescriptor `pkg:` identifiers | `scripts/verify-release-oci.py` recognizes valid RFC3986 opaque `pkg:docker/ubuntu@...` as ResourceURI in addition to authority-form HTTP/Git URI, with direct unit positive and required real pinned BuildKit provenance integration. | +| P2 HTTP fault-targeted status substitution | `scripts/http_evidence_verdict.py` checks correlated `fault_activated` request IDs before accepting HTTP 200; 0/429/504 are admitted only with expected scenario and target, and targeted requests cannot report normal 200 instead. Negative tests for all three fault types. | +| P2 image integrity graph incomplete | `scripts/qualify-release-aggregate.py` includes independently verified OCI integrity/layers/diffID/SBOM/SLSA/subject flags in a dedicated `oci_evidence` graph, and `scripts/verify_assurance_contract.py` requires all flags and exact archive/image digest fields before granting `image_integrity_qualified`, beyond just four packaged-game rows. | +| P2 CI path triggers omit assurance dependencies | `.github/workflows/online-recovery.yml` and `online-recovery-v2.yml` now trigger on all imported assurance scripts and `qualification/schemas/**`; tests check source closure and new dynamic policy paths. | +| P2 scanner skips malformed nested in-toto rows | `scripts/check-release-scanner.py` requires every row to have supported `_type`, predicateType, fully typed subject digest and predicate object; validates all supported SLSA predicates rather than scanning only SPDX; rehashed arrays with null, unsupported type, malformed subject or absent predicate are refused. | +| P2 regex enums permit safe-looking canaries | `scripts/sanitize_qualification_evidence.py` now admits only explicit per-field fixed enums; reason/origin/status/event and other producer fields containing `PRIVATE_CANARY` are redacted, not admitted by a character-pattern check. | +| P2 APT install transient summary misclassified | `deploy/release/install-pinned-apt.sh` recognizes the exact standard install summary only alongside individually classifiable 429/502/503/504 acquisition failures; mixed 401, DNS, hash and GPG errors still fail immediately. Simulated install-only tests exercise both cases. | +| P2 SPDX incomplete package records | `scripts/verify-release-oci.py` now validates required nonempty copyrightText, boolean filesAnalyzed if present, conditional SHA1 packageVerificationCode when filesAnalyzed true/omitted, and omission of verification code/file license info when false; optional licenseConcluded/licenseDeclared validate if present, consistent with SPDX 2.3 fields being optional and defaulting to NOASSERTION. Fully rehashed package variants test each condition; genuine pinned Syft remains the positive gate. | + +The source-closure, typed status and report graph remain **offline-only claims**. No OS/wheel lock, licensing, publisher-signature, host, network-play, operator or public-distribution authorization follows from successful negative tests or real OCI image integrity. Update this ledger with the final exact-head source SHA and CI links only after a completed run, and retain unresolved Codex threads until demonstrated dispositions are supported. diff --git a/docs/RELEASE_1_OPERATIONS.md b/docs/RELEASE_1_OPERATIONS.md index 210014ee..dccd80c6 100644 --- a/docs/RELEASE_1_OPERATIONS.md +++ b/docs/RELEASE_1_OPERATIONS.md @@ -315,3 +315,13 @@ RELEASE-1 URI admission now constrains path/query/fragment components individual The new assurance contract is `qualification/assurance-contract-v1.json` with executable source dependency closure and Git committed-byte identity through `scripts/verify_source_closure.py`. Offline HTTP status meaning is defined separately from canonical request-target syntax. A successful harness is distinct from observed unsafe behavior and does not grant safe continuation; a green OCI image does not grant public distribution or even `offline_candidate_qualified` while other gates remain false. Versioned local validation profiles define the accepted subset of SLSA ResourceDescriptor and BuildMetadata semantics, with intentionally independent OCI/scanner negative tests. All retained independent reports pass through the bounded safe-output serializer. Tests must inject a secret-like canary into nested producer fields and verify that the final serialized artifact excludes it. Source imports and dynamic execution must be attested exhaustively before interpreting producer or verifier claims. + +### 2026-10-17 nested assurance follow-up + +Review the current source closure, HTTP, OCI/SLSA and sanitized-report contract jointly, never by a green one-field predicate. BuildKit material identities may use RFC3986 **opaque Package URLs** such as `pkg:docker/ubuntu@...` in SLSA provenance; these are immutable resource identifiers, not network destinations. The independent OCI verifier must accept correctly formed versions without broadening live network authority. + +An injected HTTP fault must be reflected in the *targeted request's observed HTTP status*, not just in an independent trace flag. The aggregate must independently link the OCI archive digest/layers and full SPDX/SLSA/subject graph to its positive `image_integrity_qualified` claim. Scanner preflight cannot select one valid SPDX sibling while silently dropping other malformed attestation statements. + +SPDX 2.3 package fields follow normative conditional rules: `copyrightText` is required; `filesAnalyzed` defaults to true when absent, and analyzed packages require a valid verification code. `licenseConcluded` and `licenseDeclared` are optional (NOASSERTION semantics when omitted); `filesAnalyzed=false` packages omit verification codes and file-license arrays. Strict malformed-field checks must not treat optional licenses as universally mandatory. + +The bounded APT acquisition retry remains restricted to individually recognized transient statuses. A valid `apt-get install` unable-to-fetch summary may accompany those statuses, but unknown errors never become retryable by including that summary. All green checks must originate at one exact Git SHA; smoke/OCI data from another head cannot grant release promotion. From 887acbb2b1f33c65a9a322eae32fa1327ce3d203 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sat, 17 Oct 2026 22:50:46 +0200 Subject: [PATCH 114/147] test(ASSURANCE): target image proof graph and distinguish APT log text from unsafe CLI flags --- tests/release/test_assurance_contract.py | 8 ++++++++ tests/release/test_release_contracts.py | 9 ++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/release/test_assurance_contract.py b/tests/release/test_assurance_contract.py index 8795063a..2904fff9 100644 --- a/tests/release/test_assurance_contract.py +++ b/tests/release/test_assurance_contract.py @@ -182,6 +182,14 @@ def test_forged_image_integrity_needs_four_independent_games(self): "license_approved":False,"destination_host_qualified":False, "image_integrity_qualified":True,"smoke":{}, "offline_candidate_qualified":False, + "oci_archive_sha256":"a"*64, + "image_manifest_digest":"sha256:"+"b"*64, + "oci_evidence":{key:True for key in ( + "integrity_verified","oci_layers_verified", + "oci_diff_ids_verified","oci_application_identity_verified", + "oci_attestation_references_verified", + "oci_attestation_subjects_verified", + "oci_sbom_verified","oci_provenance_verified")}, } with self.assertRaisesRegex(ValueError,"verified packaged games"): claims.verify_report(report,"release_aggregate") diff --git a/tests/release/test_release_contracts.py b/tests/release/test_release_contracts.py index 5b536fed..90d3427e 100644 --- a/tests/release/test_release_contracts.py +++ b/tests/release/test_release_contracts.py @@ -118,11 +118,18 @@ def test_pinned_snapshot_retries_are_bounded_without_mirror_fallback(self): instructions = "\n".join( line for line in docker.splitlines() if not line.lstrip().startswith("#")) + apt_diagnostic = ( + "E: Unable to fetch some archives, maybe run apt-get update " + "or try with --fix-missing?") + # APT may emit the exact transient-install diagnostic, but this + # must never become a shell command-line flag or fallback option. + self.assertLessEqual(helper.count(apt_diagnostic), 1) + code_without_apt_message = helper.replace(apt_diagnostic, "") for unsafe in ("--fix-missing", "trusted=yes", "allow-unauthenticated", "Acquire::https::Verify-Peer=false", "Acquire::https::Verify-Host=false"): self.assertNotIn(unsafe,instructions) - self.assertNotIn(unsafe,helper) + self.assertNotIn(unsafe,code_without_apt_message) def test_sbom_generator_is_reviewed_immutable_oci_index(self): image=json.loads((ROOT/"qualification/release-image-v1.json").read_text()) From 75f0a5cb11ad493278ea7dcd10c9d64ddb9b84e3 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sun, 18 Oct 2026 01:50:57 +0200 Subject: [PATCH 115/147] fix(ASSURANCE): require whole approved paths at audit boundaries --- scripts/qualify-online-recovery.py | 3 +-- scripts/sanitize_qualification_evidence.py | 20 +++++++++++++++++--- tests/lichess/fake_lichess_api.py | 4 ++-- tests/lichess/http_target_contract.py | 14 +++++++++++--- 4 files changed, 31 insertions(+), 10 deletions(-) diff --git a/scripts/qualify-online-recovery.py b/scripts/qualify-online-recovery.py index 210c6a39..84406322 100644 --- a/scripts/qualify-online-recovery.py +++ b/scripts/qualify-online-recovery.py @@ -371,8 +371,7 @@ def invalid_move_submissions(http_requests: list[dict]) -> list[dict]: move = None if move is None or move == chess.Move.null(): invalid.append({"path": safe_path(path), - "uci": uci if type(uci) is str and - len(uci) <= 5 else "__redacted__", + "uci": uci if uci == "0000" else "__redacted__", "status": request.get("status")}) return invalid diff --git a/scripts/sanitize_qualification_evidence.py b/scripts/sanitize_qualification_evidence.py index 9a32c71c..288948c5 100644 --- a/scripts/sanitize_qualification_evidence.py +++ b/scripts/sanitize_qualification_evidence.py @@ -81,18 +81,32 @@ SAFE_ENUM=re.compile(r"[A-Za-z][A-Za-z0-9_.:-]{0,95}\Z") SAFE_MOVE=re.compile(r"[a-h][1-8][a-h][1-8][qrbn]?\Z") SAFE_ID=re.compile(r"[A-Fa-f0-9]{16,128}\Z") -SAFE_PATH=re.compile(r"/[A-Za-z0-9_./-]{0,220}\Z") +# Publish only complete approved endpoints, never unrecognized path suffixes. +SAFE_PUBLIC_PATHS=frozenset({ + "/api/stream/event", "/api/bot/game/stream/off3b001", + "/api/account", "/api/account/playing", "/api/users/status", + "/game/export/off3b001", "/api/token/test", + "/api/challenge/off3b001/accept", "/api/challenge/off3b001/decline", +}) +SAFE_MOVE_PATH=re.compile( + r"/api/bot/game/off3b001/move/[a-h][1-8][a-h][1-8][qrbn]?\Z") SAFE_REQUEST=re.compile(r"(?:request|stream)-\d{1,12}\Z") MAX_DEPTH=22 MAX_SEQUENCE=2000 def safe_path(value: object) -> str: - """Conservatively strip query/fragment, or substitute a fixed sentinel.""" + """Retain only approved whole endpoints or one fixed sentinel. + + Stripping a query is not enough to sanitize an invalid move suffix. + Chess legality remains a separate independent-verification concern. + """ if type(value) is not str: return "__invalid_path__" path=value.split("?",1)[0].split("#",1)[0] - return path if SAFE_PATH.fullmatch(path) else "__invalid_path__" + return (path if path in SAFE_PUBLIC_PATHS or + SAFE_MOVE_PATH.fullmatch(path) is not None + else "__invalid_path__") def safe_string(field: str, value: str) -> str | None: diff --git a/tests/lichess/fake_lichess_api.py b/tests/lichess/fake_lichess_api.py index 3f518cf7..6a83ae2e 100644 --- a/tests/lichess/fake_lichess_api.py +++ b/tests/lichess/fake_lichess_api.py @@ -92,7 +92,7 @@ def respond(self, status: int, obj: dict): if server.trace is not None: identity = getattr(self, "_move_request_id", None) server.trace.record("response_write_completed", status=status, - path=urllib.parse.urlparse(self.path).path, + path=path_of(self.raw_target()), **({"request_id": identity} if identity else {})) def drop_connection(self): @@ -100,7 +100,7 @@ def drop_connection(self): if server.trace is not None: identity = getattr(self, "_move_request_id", None) server.trace.record("connection_close_requested", - path=urllib.parse.urlparse(self.path).path, + path=path_of(self.raw_target()), **({"request_id": identity} if identity else {})) self.close_connection = True try: diff --git a/tests/lichess/http_target_contract.py b/tests/lichess/http_target_contract.py index ed44667a..07865fff 100644 --- a/tests/lichess/http_target_contract.py +++ b/tests/lichess/http_target_contract.py @@ -24,10 +24,18 @@ def path_of(target: object) -> str: - if not isinstance(target, str): + """Retain only known whole endpoints, even for rejected raw targets. + + canonical_target separately adjudicates the original wire target. + """ + if type(target) is not str: return "__invalid_path__" - path = target.split("?", 1)[0] - return path if SAFE_PATH.fullmatch(path) else "__invalid_path__" + path = target.split("?", 1)[0].split("#", 1)[0] + if path in APPROVED_GETS or path in APPROVED_POSTS: + return path + if path.startswith(MOVE_PREFIX) and UCI.fullmatch(path[len(MOVE_PREFIX):]): + return path + return "__invalid_path__" def canonical_target(method: object, raw: object) -> bool: From 9d8dc3ab4b8dfbd058472fd59c03cb3c1ffc8825 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sun, 18 Oct 2026 01:51:35 +0200 Subject: [PATCH 116/147] test(ASSURANCE): falsify malformed path publication at ingress and report boundaries --- tests/lichess/test_fake_api.py | 21 ++++++++++++++++++++- tests/lichess/test_recovery_evidence.py | 18 ++++++++++++++++++ tests/release/test_release_http_contract.py | 6 ++++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/tests/lichess/test_fake_api.py b/tests/lichess/test_fake_api.py index 52288504..6826a8d9 100644 --- a/tests/lichess/test_fake_api.py +++ b/tests/lichess/test_fake_api.py @@ -67,6 +67,23 @@ def test_complete_canonical_target_and_query_values_are_observed(self): self.assertTrue(record["target_canonical"]) self.assertEqual(record["request_target"], target) + def test_rejected_move_suffixes_not_published(self): + prefix=f"/api/bot/game/{GAME_ID}/move/" + for target in (prefix+"garbage", prefix+"e2e4PRIVATE_CANARY", + prefix+"e2e4/PRIVATE_CANARY", + prefix+"e2e4?secret=PRIVATE_CANARY"): + with self.subTest(target=target): + self.assertEqual(self.call("POST",target),403) + record=self.fake.requests[-1] + self.assertEqual(record["path"], + prefix+"e2e4" if "?" in target + else "__invalid_path__") + self.assertFalse(record["target_canonical"]) + self.assertIsNone(record["request_target"]) + self.assertNotIn("PRIVATE_CANARY",str(record)) + self.assertNotIn("PRIVATE_CANARY", + str(self.fake.attempted_forbidden)) + def test_network_path_bypasses_cpython_normalization_only_if_not_observed(self): import socket for target in ("//api/account", "///api/account", @@ -88,8 +105,10 @@ def test_network_path_bypasses_cpython_normalization_only_if_not_observed(self): self.assertFalse(observed["target_canonical"]) self.assertIsNone(observed["request_target"]) self.assertEqual(len(observed["request_target_sha256"]),64) - self.assertIn("GET "+target.split("?",1)[0], + self.assertIn("GET __invalid_path__", self.fake.attempted_forbidden) + self.assertNotIn(target, str(self.fake.requests)) + self.assertNotIn(target, str(self.fake.attempted_forbidden)) def test_raw_double_slash_post_never_becomes_valid_token_endpoint(self): import socket diff --git a/tests/lichess/test_recovery_evidence.py b/tests/lichess/test_recovery_evidence.py index df759f42..6b8cd330 100644 --- a/tests/lichess/test_recovery_evidence.py +++ b/tests/lichess/test_recovery_evidence.py @@ -181,6 +181,21 @@ def test_offline_fault_http_statuses_require_correlated_witness(self): {"method":"POST","path":"/api/token/test","status":500}, ],[],"none")[0]["reason"],"unapproved_http_status") + def test_safe_path_requires_complete_approved_endpoint(self): + from scripts.sanitize_qualification_evidence import safe_path + prefix = "/api/bot/game/off3b001/move/" + for good in ("/api/account", "/api/users/status", "/api/token/test", + prefix + "e2e4", prefix + "e7e8q"): + self.assertEqual(safe_path(good), good) + for bad in (prefix + "garbage", prefix + "0000", + prefix + "e2e4PRIVATE_CANARY", prefix + "e2e4/more", + prefix + "e2e9", "//api/account", + "/arbitrary/path/CANARY", "/api/account%2fprivate"): + with self.subTest(bad=bad): + self.assertEqual(safe_path(bad), "__invalid_path__") + self.assertEqual(safe_path("/api/account?secret=PRIVATE_CANARY"), + "/api/account") + def test_retained_report_serializer_recursively_removes_canaries(self): from scripts.sanitize_qualification_evidence import sanitize_report sample={ @@ -203,6 +218,9 @@ def test_retained_report_serializer_recursively_removes_canaries(self): data=json.dumps(result) self.assertNotIn("PRIVATE-CANARY",data) self.assertIn("/api/account",data) + self.assertEqual(sanitize_report({"path": + "/api/bot/game/off3b001/move/garbage"})["path"], + "__invalid_path__") self.assertTrue(result["unsafe_witnesses"][0]["behavior"]=="UNSAFE_WITNESS") self.assertFalse(result["harness_qualified"]) diff --git a/tests/release/test_release_http_contract.py b/tests/release/test_release_http_contract.py index 7049d654..e3a07c77 100644 --- a/tests/release/test_release_http_contract.py +++ b/tests/release/test_release_http_contract.py @@ -28,6 +28,12 @@ def test_safe_release_report_module_loads_without_tests_package(self): spec.loader.exec_module(instance) self.assertEqual(instance.sanitize_report({ "path":"/api/account?secret=CANARY"})["path"],"/api/account") + self.assertEqual(instance.sanitize_report({ + "path":"/api/bot/game/off3b001/move/garbage"})["path"], + "__invalid_path__") + self.assertEqual(instance.sanitize_report({ + "path":"/api/bot/game/off3b001/move/e2e4"})["path"], + "/api/bot/game/off3b001/move/e2e4") def test_standalone_release_verifiers_import_policy_without_pythonpath(self): # Identical invocation mode to the real OCI release workflow. From a5519c858f3c59c3b889fed1bfb96b1b0183c45c Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sun, 18 Oct 2026 01:52:49 +0200 Subject: [PATCH 117/147] fix(RELEASE-1): align pinned scanner provenance with reviewed builder identity --- .github/workflows/release-candidate.yml | 1 + scripts/verify-release-oci.py | 14 +++++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 162e76c3..77b161a2 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -62,6 +62,7 @@ jobs: docker buildx build --platform linux/amd64 \ -f "$RUNNER_TEMP/Scanner.Dockerfile" --build-arg "BASE_IMAGE=$BASE" \ --tag allfather/scanner-probe:offline \ + --attest type=provenance,mode=min,builder-id=https://github.com/HMarcusWH/AllfatherChess/actions/workflows/release-candidate.yml \ --attest "type=sbom,generator=$SCANNER" \ --output type=oci,dest=build/test-results/release1-scanner/scanner.oci.tar . python3 scripts/check-release-scanner.py \ diff --git a/scripts/verify-release-oci.py b/scripts/verify-release-oci.py index be0240b1..7b393d20 100644 --- a/scripts/verify-release-oci.py +++ b/scripts/verify-release-oci.py @@ -408,11 +408,15 @@ def validate_slsa(kind: str, predicate: dict) -> None: _optional_resource_list(build, "resolvedDependencies"), "SLSA v1 build inputs contain malformed optional fields") builder = run.get("builder") - require(type(builder) is dict and - valid_identity_uri(builder.get("id")) and - _optional_string_map(builder, "version") and - _optional_resource_list(builder, "builderDependencies"), - "SLSA v1 builder identity or field shape invalid") + # Field-level, fixed diagnostics; never include untrusted URI values. + require(type(builder) is dict, + "SLSA v1 builder object missing or invalid") + require(valid_identity_uri(builder.get("id")), + "SLSA v1 builder ID missing or invalid") + require(_optional_string_map(builder, "version"), + "SLSA v1 builder version map malformed") + require(_optional_resource_list(builder, "builderDependencies"), + "SLSA v1 builderDependencies malformed") require(_valid_slsa_metadata(run.get("metadata"),version="v1") and _optional_resource_list(run, "byproducts"), "SLSA v1 run metadata/byproducts malformed") From ab59666299d168a8dd4ed15706311d68753612cf Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sun, 18 Oct 2026 01:53:09 +0200 Subject: [PATCH 118/147] test(RELEASE-1): require real scanner provenance and safe builder diagnostics --- tests/release/test_release_oci_integrity.py | 24 +++++++++++++++++++ .../release/test_release_scanner_contract.py | 10 ++++++++ 2 files changed, 34 insertions(+) diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index 571b43dc..71443455 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -983,6 +983,30 @@ def test_slsa_optional_fields_have_independent_schema_contract(self): "SLSA"): oci.validate_slsa("https://slsa.dev/provenance/v0.2",bad) + def test_slsa_builder_diagnostics_are_field_specific_and_safe(self): + # Never echo producer-controlled URI or version data into logs. + kind="https://slsa.dev/provenance/v1" + good={"buildDefinition":{ + "buildType":"https://mobyproject.org/buildkit@v1", + "externalParameters":{}}, + "runDetails":{"builder":{"id":"https://github.com/moby/buildkit"}}} + oci.validate_slsa(kind,good) + variants=( + ({"runDetails":{"builder":{}}}, "builder ID"), + ({"runDetails":{"builder":{ + "id":"https://github.com/moby/buildkit", + "version":"PRIVATE_CANARY"}}}, "builder version map"), + ({"runDetails":{"builder":{ + "id":"https://github.com/moby/buildkit", + "builderDependencies":"PRIVATE_CANARY"}}}, "builderDependencies"), + ) + for replacement,expected in variants: + with self.subTest(failure=expected): + corrupted={**good,**replacement} + with self.assertRaisesRegex(ValueError,expected) as caught: + oci.validate_slsa(kind,corrupted) + self.assertNotIn("PRIVATE_CANARY",str(caught.exception)) + def test_slsa_predicates_cannot_be_schema_only(self): for mode in ("empty", "builder-empty", "bad-build-type", "no-external", "resolved-as-string", "metadata-as-string", diff --git a/tests/release/test_release_scanner_contract.py b/tests/release/test_release_scanner_contract.py index 1af5d6ae..fba6cfdf 100644 --- a/tests/release/test_release_scanner_contract.py +++ b/tests/release/test_release_scanner_contract.py @@ -18,6 +18,16 @@ def module(path,name): "release1_fixture_scanner") class ScannerProbeContractTests(unittest.TestCase): + def test_real_scanner_declares_same_builder_identity_as_release(self): + workflow=(ROOT/".github/workflows/release-candidate.yml").read_text() + probe=workflow.split(" scanner-compatibility:",1)[1].split( + " release-oci-integration:",1)[0] + self.assertIn( + "--attest type=provenance,mode=min,builder-id=" + "https://github.com/HMarcusWH/AllfatherChess/actions/" + "workflows/release-candidate.yml",probe) + self.assertIn('--attest "type=sbom,generator=$SCANNER"',probe) + def test_valid_spdx_probe_never_claims_release_authority(self): with tempfile.TemporaryDirectory() as tmp: tar, _, _ = oci_fixture.OCIProofTests().fixture(Path(tmp)) From a2adf309ca7c34223529a3c4c1aeaa89b821b452 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sun, 18 Oct 2026 01:54:21 +0200 Subject: [PATCH 119/147] fix(ASSURANCE): align SPDX optional fields and trigger bridge CI for verifier dependencies Preserve independent OCI image byte/subject checks, validate supplied package fields, accept standards-optional omissions, and document non-grants. --- .github/workflows/online-bridge.yml | 6 ++++++ docs/RELEASE_1_OPERATIONS.md | 18 ++++++++++++++++++ scripts/verify-release-oci.py | 18 ++++++++++-------- tests/release/test_release_oci_integrity.py | 21 +++++++++++---------- 4 files changed, 45 insertions(+), 18 deletions(-) diff --git a/.github/workflows/online-bridge.yml b/.github/workflows/online-bridge.yml index c5fc9701..e17a7b01 100644 --- a/.github/workflows/online-bridge.yml +++ b/.github/workflows/online-bridge.yml @@ -6,6 +6,12 @@ on: - 'tests/lichess/**' - 'scripts/qualify-online-bridge.py' - 'scripts/prepare-online3b-wheels.py' + - 'scripts/sanitize_qualification_evidence.py' + - 'scripts/http_evidence_verdict.py' + - 'scripts/qualify-online-recovery.py' + - 'scripts/verify_source_closure.py' + - 'qualification/http-evidence-contract-v1.json' + - 'qualification/online-recovery-v1.json' - 'qualification/online-bridge-v1.json' - '.github/workflows/online-bridge.yml' - 'Makefile' diff --git a/docs/RELEASE_1_OPERATIONS.md b/docs/RELEASE_1_OPERATIONS.md index dccd80c6..3707c688 100644 --- a/docs/RELEASE_1_OPERATIONS.md +++ b/docs/RELEASE_1_OPERATIONS.md @@ -100,6 +100,24 @@ bridge restart and R10 proves 60-second account-wide cooldown containment. The frozen ONLINE-4A and ONLINE-4B source harnesses remain separately qualified in their own CI workflows. +## SLSA and SPDX assurance contract + +The scanner-only compatibility job uses the same explicit reviewed BuildKit +`builder-id` as the full OCI export, with a separate SBOM attestation. The +independent scanner verifier validates all recognized in-toto statement +siblings and SLSA v1 builder fields. The builder ID is a declared build +platform identity, **not** a publisher signature or distribution permission. +Field-specific failures must be reported using fixed classifications, never +by echoing arbitrary provenance strings into public CI logs. + +SPDX 2.2/2.3 structural validity is distinct from Allfather image policy. +For example, a missing package `copyrightText` implies `NOASSERTION`; a +missing `filesAnalyzed` defaults to true; and an optional +`packageVerificationCode`, if present, must have a valid SHA-1 value. +The image-level verifier still independently binds attestation subjects to +the application manifest and checks the OCI layer graph and archive bytes. +A syntactically valid inventory does not prove licensing compliance. + ## Image, host, licensing and governance checks scripts/verify-release-candidate.py checks all packaged files against an diff --git a/scripts/verify-release-oci.py b/scripts/verify-release-oci.py index 7b393d20..ab42eca8 100644 --- a/scripts/verify-release-oci.py +++ b/scripts/verify-release-oci.py @@ -542,17 +542,19 @@ def spdx_id(item): bool(item["name"].strip()) and valid_spdx_download_location(item.get("downloadLocation")), "SPDX SBOM package has invalid downloadLocation") - # SPDX 2.3 CopyrightText is required. filesAnalyzed is optional - # (default True), but its verification-code requirement is conditional. - # licenseConcluded and licenseDeclared are optional (NOASSERTION). - require(type(item.get("copyrightText")) is str and - bool(item["copyrightText"].strip()), - "SPDX package copyrightText absent or invalid") + # SPDX 2.3 copyrightText is optional (absent means NOASSERTION). + # filesAnalyzed defaults to True; a present verification code is + # validated, but SPDX does not require one for every analyzed package. + # The separate OCI/application subject checks provide image identity. + if "copyrightText" in item: + require(type(item["copyrightText"]) is str and + bool(item["copyrightText"].strip()), + "SPDX package copyrightText malformed") analyzed=item.get("filesAnalyzed", True) require(type(analyzed) is bool, "SPDX package filesAnalyzed must be boolean") verification=item.get("packageVerificationCode") - if analyzed: + if analyzed and verification is not None: require(type(verification) is dict and type(verification.get("packageVerificationCodeValue")) is str and re.fullmatch(r"[0-9a-f]{40}", @@ -561,7 +563,7 @@ def spdx_id(item): or (type(verification["packageVerificationCodeExcludedFiles"]) is list and all(type(name) is str and bool(name) for name in verification["packageVerificationCodeExcludedFiles"]))), - "SPDX analyzed package requires verification code") + "SPDX supplied package verification code malformed") else: require(verification is None and "licenseInfoFromFiles" not in item, diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index 71443455..b722765a 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -671,13 +671,15 @@ def test_spdx_package_optional_defaults_and_conditional_verification(self): "name":"sample", "downloadLocation":"NOASSERTION", "copyrightText":"NOASSERTION", "filesAnalyzed":False} self.assertTrue(oci.valid_spdx_download_location(pkg["downloadLocation"])) - # Optional field omission means files were analyzed; require the - # SHA1 verification code in that case, rather than guessing False. - with tempfile.TemporaryDirectory() as d: - archive,meta,inspection=self.fixture(Path(d), - spdx_case="missing-filesAnalyzed-without-code") - with self.assertRaisesRegex(ValueError,"verification code"): - oci.verify_archive(archive,meta,docker_inspect=inspection) + # Omitted filesAnalyzed defaults to True; the verification code + # is optional in SPDX 2.3, while a supplied one must be well formed. + for variant in ("missing-copyright", + "missing-filesAnalyzed-without-code", + "filesAnalyzed-without-code"): + with self.subTest(variant=variant),tempfile.TemporaryDirectory() as d: + archive,meta,inspection=self.fixture(Path(d),spdx_case=variant) + self.assertTrue(oci.verify_archive( + archive,meta,docker_inspect=inspection)["oci_sbom_verified"]) def test_spdx_file_only_inventory_uses_fileName_not_package_name(self): with tempfile.TemporaryDirectory() as d: @@ -708,9 +710,8 @@ def test_incomplete_spdx_sbom_never_qualifies(self): "bad-namespace", "missing-created", "invalid-created", "missing-creators", "bad-creator", "empty-inventory", "bad-package", "missing-download", "invalid-download", - "missing-copyright", "empty-copyright", - "filesAnalyzed-invalid","missing-filesAnalyzed-without-code", - "filesAnalyzed-without-code", "unanalysed-with-code", + "empty-copyright", "filesAnalyzed-invalid", + "unanalysed-with-code", "unanalysed-with-file-licenses","invalid-license", "download-unicode", "download-braces", "download-pipe", "invalid-percent", "invalid-percent-host", "invalid-port", From eda47afa5eac51b4d115c1b179f431f554bf1008 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sun, 18 Oct 2026 01:55:05 +0200 Subject: [PATCH 120/147] test(ASSURANCE): align HTTP publication policy, CI triggers and raw artifact canaries --- tests/lichess/test_fake_api.py | 16 ++++++++++++++++ tests/release/test_release_http_contract.py | 11 +++++++++++ tests/release/test_workflow_coverage.py | 13 +++++++++++++ 3 files changed, 40 insertions(+) diff --git a/tests/lichess/test_fake_api.py b/tests/lichess/test_fake_api.py index 6826a8d9..d9bfdad1 100644 --- a/tests/lichess/test_fake_api.py +++ b/tests/lichess/test_fake_api.py @@ -84,6 +84,22 @@ def test_rejected_move_suffixes_not_published(self): self.assertNotIn("PRIVATE_CANARY", str(self.fake.attempted_forbidden)) + def test_raw_trace_artifact_does_not_retain_malformed_path_canaries(self): + from pathlib import Path + from tests.lichess.fault_plan import load_scenarios + self.fake.close() + scenarios=Path(__file__).resolve().parents[2] / ( + "tests/fixtures/lichess/recovery_scenarios.json") + self.fake=FakeLichess("white",load_scenarios(scenarios)["R00"]) + self.fake.start() + for target in ("/api/account/PRIVATE_CANARY", + f"/api/bot/game/{GAME_ID}/move/PRIVATE_CANARY", + "//api/account?secret=PRIVATE_CANARY"): + self.assertEqual(self.call("GET",target),403) + report=json.dumps(self.fake.evidence(),sort_keys=True) + self.assertNotIn("PRIVATE_CANARY",report) + self.assertIn("__invalid_path__",report) + def test_network_path_bypasses_cpython_normalization_only_if_not_observed(self): import socket for target in ("//api/account", "///api/account", diff --git a/tests/release/test_release_http_contract.py b/tests/release/test_release_http_contract.py index e3a07c77..5ea18924 100644 --- a/tests/release/test_release_http_contract.py +++ b/tests/release/test_release_http_contract.py @@ -35,6 +35,17 @@ def test_safe_release_report_module_loads_without_tests_package(self): "path":"/api/bot/game/off3b001/move/e2e4"})["path"], "/api/bot/game/off3b001/move/e2e4") + def test_safe_output_whitelist_matches_canonical_http_endpoints(self): + from scripts.sanitize_qualification_evidence import ( + SAFE_PUBLIC_PATHS, safe_path) + from tests.lichess.http_target_contract import ( + APPROVED_GETS, APPROVED_POSTS) + self.assertEqual(SAFE_PUBLIC_PATHS, + APPROVED_GETS | APPROVED_POSTS) + prefix="/api/bot/game/off3b001/move/" + self.assertEqual(safe_path(prefix+"e7e8q"),prefix+"e7e8q") + self.assertEqual(safe_path(prefix+"garbage"),"__invalid_path__") + def test_standalone_release_verifiers_import_policy_without_pythonpath(self): # Identical invocation mode to the real OCI release workflow. with tempfile.TemporaryDirectory() as tmp: diff --git a/tests/release/test_workflow_coverage.py b/tests/release/test_workflow_coverage.py index b4478b42..e6398032 100644 --- a/tests/release/test_workflow_coverage.py +++ b/tests/release/test_workflow_coverage.py @@ -10,6 +10,19 @@ class WorkflowCoverageTests(unittest.TestCase): + def test_online_bridge_contracts_trigger_for_assurance_dependencies(self): + cfg=yaml.load((ROOT/".github/workflows/online-bridge.yml").read_text(), + Loader=yaml.BaseLoader) + paths=set(cfg["on"]["pull_request"]["paths"]) + for critical in ( + "scripts/sanitize_qualification_evidence.py", + "scripts/http_evidence_verdict.py", + "scripts/qualify-online-recovery.py", + "qualification/http-evidence-contract-v1.json", + "qualification/online-recovery-v1.json"): + with self.subTest(source=critical): + self.assertIn(critical,paths) + def test_release_candidate_runs_on_all_prs_and_merged_main(self): cfg=yaml.load((ROOT/".github/workflows/release-candidate.yml").read_text(), Loader=yaml.BaseLoader) From 16faccaba55ac52620e5f4d12313097302ec7c93 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sun, 18 Oct 2026 01:55:51 +0200 Subject: [PATCH 121/147] docs(ASSURANCE): inventory all PR71 Codex findings and exact-head closure criteria --- docs/PR71_ASSURANCE_REPAIR_LEDGER.md | 109 +++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 docs/PR71_ASSURANCE_REPAIR_LEDGER.md diff --git a/docs/PR71_ASSURANCE_REPAIR_LEDGER.md b/docs/PR71_ASSURANCE_REPAIR_LEDGER.md new file mode 100644 index 00000000..b744484d --- /dev/null +++ b/docs/PR71_ASSURANCE_REPAIR_LEDGER.md @@ -0,0 +1,109 @@ +# PR #71 — Assurance Repair and Review Closure Ledger + +**Scope:** source-confirmed repair chain for PR #71, based on original head `887acbb2b1f33c65a9a322eae32fa1327ce3d203`. This ledger is historical and must be updated against the final pushed SHA and latest CI evidence before merger. + +**Authority:** Offline assurance only. No public bot, real credentials, production host qualification, publisher signature, licensing approval, artifact distribution, Elo or deployment authority. + +## Existing qualification blockers (observed October 17, 2026) + +| Issue | Evidence | Targeted repair | Exit condition | +| --- | --- | --- | --- | +| Invalid move path retained | ONLINE-3B/4A/4B reject `/api/bot/game/off3b001/move/garbage` retained after UCI redaction | Strict complete-endpoint publication at fake server/trace and report serializer; adversarial tests | Three real suites green, final artifacts contain no malformed path canaries | +| Scanner builder identity | RELEASE-1 pinned Syft/BuildKit scanner fails `SLSA v1 builder identity or field shape invalid` | Pin declared provenance builder ID for the real scanner probe; separate builder object/id/version/dependency diagnostics | Pinned scanner and full OCI integration green without weakening the validator | + +## Non-outdated Codex threads requiring evidence review + +These threads are **not** mechanically closed by source presence. For each, retain test/CI evidence, determine whether the original counterexample is rejected or valid input is admitted, and only then resolve or explain its disposition. + +| Comment | Source | Finding | Status | +| --- | --- | --- | --- | +| [r4263102049](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4263102049) | `scripts/qualify-release-smoke.py` | Bind the R02 unknown ACK to the move endpoint | Source regression/CI review pending | +| [r4263102056](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4263102056) | `scripts/verify-release-oci.py` | Validate the attestation subject descriptor size | Source regression/CI review pending | +| [r4263102061](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4263102061) | `scripts/qualify-release-smoke.py` | Require client-side evidence of the R10 response | Source regression/CI review pending | +| [r4265369283](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4265369283) | `scripts/qualify-release-smoke.py` | Validate the persisted cooldown deadline | Source regression/CI review pending | +| [r4303841556](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303841556) | `tests/lichess/fake_lichess_api.py` | Attest the imported HTTP target policy | Source regression/CI review pending | +| [r4304011435](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304011435) | `scripts/qualify-online-recovery-v2.py` | Validate raw targets in ONLINE-4B request evidence | Source regression/CI review pending | +| [r4304011445](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304011445) | `scripts/verify-release-oci.py` | Enforce URI delimiters per component | Source regression/CI review pending | +| [r4304111685](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304111685) | `qualification/online-recovery-v2.json` | Attest every direct recovery dependency | Source regression/CI review pending | +| [r4304111688](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304111688) | `scripts/qualify-online-recovery-v2.py` | Reject unsuccessful canonical requests in ONLINE-4B | Source regression/CI review pending | +| [r4304111692](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304111692) | `scripts/check-release-scanner.py` | Reject non-object rows in scanner statement arrays | Source regression/CI review pending | +| [r4304111718](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304111718) | `scripts/qualify-release-smoke.py` | Bind every committed origin to the side to move | Source regression/CI review pending | +| [r4304394201](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304394201) | `scripts/verify_assurance_contract.py` | Enforce the declared image-integrity prerequisites | Source regression/CI review pending | +| [r4304394202](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304394202) | `.github/workflows/online-recovery-v2.yml` | Trigger recovery CI for every new verifier dependency | Source regression/CI review pending | +| [r4304394206](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304394206) | `scripts/check-release-scanner.py` | Validate complete in-toto objects in every scanner row | Source regression/CI review pending | +| [r4304394212](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304394212) | `deploy/release/install-pinned-apt.sh` | Permit the standard transient install summary | Source regression/CI review pending | +| [r4304394221](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304394221) | `scripts/verify-release-oci.py` | Validate required SPDX package fields | Source regression/CI review pending | + +## Full historical inline-review inventory + +GitHub recorded 58 threads at the time of ledger construction: 42 outdated diff contexts, 16 non-outdated, and 0 formally resolved. "Outdated" means changed diff location, **not** independent proof of repair. + +| Comment | Date (UTC) | Path | Diff context | +| --- | --- | --- | --- | +| [r4253078059](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4253078059) | 2026-10-12 23:00 | `scripts/verify-release-oci.py` | Outdated | +| [r4253078070](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4253078070) | 2026-10-12 23:00 | `deploy/release/offline_smoke.py` | Outdated | +| [r4253714587](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4253714587) | 2026-10-13 01:00 | `scripts/qualify-release-smoke.py` | Outdated | +| [r4253714591](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4253714591) | 2026-10-13 01:00 | `scripts/qualify-release-smoke.py` | Outdated | +| [r4253714599](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4253714599) | 2026-10-13 01:00 | `scripts/verify-release-oci.py` | Outdated | +| [r4253714605](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4253714605) | 2026-10-13 01:00 | `scripts/verify-release-oci.py` | Outdated | +| [r4253714615](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4253714615) | 2026-10-13 01:00 | `scripts/inspect-release-oci.py` | Outdated | +| [r4263102049](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4263102049) | 2026-10-13 19:30 | `scripts/qualify-release-smoke.py` | Current | +| [r4263102056](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4263102056) | 2026-10-13 19:30 | `scripts/verify-release-oci.py` | Current | +| [r4263102061](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4263102061) | 2026-10-13 19:30 | `scripts/qualify-release-smoke.py` | Current | +| [r4263102073](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4263102073) | 2026-10-13 19:30 | `scripts/verify-release-oci.py` | Outdated | +| [r4265369277](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4265369277) | 2026-10-14 00:02 | `scripts/verify-release-oci.py` | Outdated | +| [r4265369279](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4265369279) | 2026-10-14 00:02 | `scripts/verify-release-oci.py` | Outdated | +| [r4265369283](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4265369283) | 2026-10-14 00:02 | `scripts/qualify-release-smoke.py` | Current | +| [r4265635092](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4265635092) | 2026-10-14 00:50 | `scripts/verify-release-oci.py` | Outdated | +| [r4265635097](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4265635097) | 2026-10-14 00:50 | `scripts/qualify-release-smoke.py` | Outdated | +| [r4265635102](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4265635102) | 2026-10-14 00:50 | `scripts/verify-release-oci.py` | Outdated | +| [r4265635112](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4265635112) | 2026-10-14 00:50 | `scripts/verify-release-oci.py` | Outdated | +| [r4271701069](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4271701069) | 2026-10-14 13:54 | `scripts/verify-release-oci.py` | Outdated | +| [r4271701077](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4271701077) | 2026-10-14 13:54 | `scripts/verify-release-oci.py` | Outdated | +| [r4271701092](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4271701092) | 2026-10-14 13:54 | `scripts/verify-release-oci.py` | Outdated | +| [r4272459243](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4272459243) | 2026-10-14 14:58 | `scripts/qualify-release-aggregate.py` | Outdated | +| [r4272459258](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4272459258) | 2026-10-14 14:58 | `scripts/inspect-release-oci.py` | Outdated | +| [r4272459266](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4272459266) | 2026-10-14 14:58 | `scripts/verify-release-oci.py` | Outdated | +| [r4272459279](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4272459279) | 2026-10-14 14:58 | `scripts/qualify-online-recovery-v2.py` | Outdated | +| [r4272459292](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4272459292) | 2026-10-14 14:58 | `scripts/check-release-snapshot.py` | Outdated | +| [r4272459302](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4272459302) | 2026-10-14 14:58 | `deploy/release/install-pinned-apt.sh` | Outdated | +| [r4303673549](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303673549) | 2026-10-17 17:14 | `scripts/verify-release-oci.py` | Outdated | +| [r4303673561](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303673561) | 2026-10-17 17:14 | `scripts/release_http_contract.py` | Outdated | +| [r4303673565](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303673565) | 2026-10-17 17:14 | `scripts/qualify-online-recovery.py` | Outdated | +| [r4303673570](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303673570) | 2026-10-17 17:14 | `scripts/inspect-release-oci.py` | Outdated | +| [r4303673578](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303673578) | 2026-10-17 17:14 | `scripts/verify-release-oci.py` | Outdated | +| [r4303673584](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303673584) | 2026-10-17 17:14 | `deploy/release/install-pinned-apt.sh` | Outdated | +| [r4303841556](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303841556) | 2026-10-17 18:01 | `tests/lichess/fake_lichess_api.py` | Current | +| [r4303841560](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303841560) | 2026-10-17 18:01 | `scripts/verify-release-oci.py` | Outdated | +| [r4303841564](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303841564) | 2026-10-17 18:01 | `tests/lichess/fake_lichess_api.py` | Outdated | +| [r4303841565](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303841565) | 2026-10-17 18:01 | `scripts/verify-release-oci.py` | Outdated | +| [r4303841567](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303841567) | 2026-10-17 18:01 | `scripts/qualify-online-recovery.py` | Outdated | +| [r4304011435](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304011435) | 2026-10-17 18:50 | `scripts/qualify-online-recovery-v2.py` | Current | +| [r4304011445](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304011445) | 2026-10-17 18:50 | `scripts/verify-release-oci.py` | Current | +| [r4304011453](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304011453) | 2026-10-17 18:50 | `scripts/verify-release-oci.py` | Outdated | +| [r4304111685](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304111685) | 2026-10-17 19:15 | `qualification/online-recovery-v2.json` | Current | +| [r4304111688](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304111688) | 2026-10-17 19:15 | `scripts/qualify-online-recovery-v2.py` | Current | +| [r4304111692](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304111692) | 2026-10-17 19:15 | `scripts/check-release-scanner.py` | Current | +| [r4304111694](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304111694) | 2026-10-17 19:15 | `scripts/verify-release-oci.py` | Outdated | +| [r4304111700](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304111700) | 2026-10-17 19:15 | `scripts/verify-release-oci.py` | Outdated | +| [r4304111708](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304111708) | 2026-10-17 19:15 | `scripts/qualify-online-recovery.py` | Outdated | +| [r4304111713](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304111713) | 2026-10-17 19:15 | `scripts/verify-release-oci.py` | Outdated | +| [r4304111718](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304111718) | 2026-10-17 19:15 | `scripts/qualify-release-smoke.py` | Current | +| [r4304394181](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304394181) | 2026-10-17 20:35 | `scripts/verify_source_closure.py` | Outdated | +| [r4304394188](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304394188) | 2026-10-17 20:35 | `scripts/verify-release-oci.py` | Outdated | +| [r4304394194](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304394194) | 2026-10-17 20:35 | `scripts/http_evidence_verdict.py` | Outdated | +| [r4304394201](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304394201) | 2026-10-17 20:35 | `scripts/verify_assurance_contract.py` | Current | +| [r4304394202](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304394202) | 2026-10-17 20:35 | `.github/workflows/online-recovery-v2.yml` | Current | +| [r4304394206](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304394206) | 2026-10-17 20:35 | `scripts/check-release-scanner.py` | Current | +| [r4304394209](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304394209) | 2026-10-17 20:35 | `scripts/sanitize_qualification_evidence.py` | Outdated | +| [r4304394212](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304394212) | 2026-10-17 20:35 | `deploy/release/install-pinned-apt.sh` | Current | +| [r4304394221](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304394221) | 2026-10-17 20:35 | `scripts/verify-release-oci.py` | Current | + +## Closure rules + +1. Before any push, run compile/fast unit suites, counterexamples for repaired endpoints and SLSA/OCI generated fixtures. +2. Test the real pinned scanner and the genuine ONLINE-3B/4A/4B + RELEASE-1 integrations; a skipped producer or missing download artifact is not green. +3. Require all eleven exact-head workflow conclusions success, including the seven previously green workflow groups. +4. Preserve independently verified source byte closure, chess legality, actor attribution, HTTP statuses, SQLite replay, OCI archive identity and all safe-output boundaries. +5. Request fresh exact-head Codex code and security review. Reconcile every actionable new finding with source/test/CI evidence. +6. Leave all source availability, distribution, licensing, operator, host, live transport, and publication gates closed. From 283ffe8d889b697e8a1c53b61f0c0d1e7803e0df Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sun, 18 Oct 2026 03:25:45 +0200 Subject: [PATCH 122/147] feat(RELEASE-1): freeze SPDX license identifiers and parse bounded v2 expressions Pin all 740 license IDs and 86 exceptions from SPDX license-list-data v3.29.0 and reject malformed or unrecognized expressions without a runtime network dependency. --- .../spdx-license-identifiers-v3.29.0.json | 837 ++++++++++++++++++ scripts/spdx_license_contract.py | 126 +++ tests/release/test_spdx_license_contract.py | 59 ++ 3 files changed, 1022 insertions(+) create mode 100644 qualification/spdx-license-identifiers-v3.29.0.json create mode 100644 scripts/spdx_license_contract.py create mode 100644 tests/release/test_spdx_license_contract.py diff --git a/qualification/spdx-license-identifiers-v3.29.0.json b/qualification/spdx-license-identifiers-v3.29.0.json new file mode 100644 index 00000000..bdb913bc --- /dev/null +++ b/qualification/spdx-license-identifiers-v3.29.0.json @@ -0,0 +1,837 @@ +{ + "schema_version": 1, + "spdx_license_list_version": "3.29.0", + "source_repository": "spdx/license-list-data", + "source_tag": "v3.29.0", + "release_date": "2026-09-16T00:00:00Z", + "license_ids": [ + "0BSD", + "3D-Slicer-1.0", + "AAL", + "ADSL", + "AFL-1.1", + "AFL-1.2", + "AFL-2.0", + "AFL-2.1", + "AFL-3.0", + "AGPL-1.0", + "AGPL-1.0-only", + "AGPL-1.0-or-later", + "AGPL-3.0", + "AGPL-3.0-only", + "AGPL-3.0-or-later", + "ALGLIB-Documentation", + "AMD-newlib", + "AMDPLPA", + "AML", + "AML-glslang", + "AMPAS", + "ANTLR-PD", + "ANTLR-PD-fallback", + "APAFML", + "APL-1.0", + "APSL-1.0", + "APSL-1.1", + "APSL-1.2", + "APSL-2.0", + "ASWF-Digital-Assets-1.0", + "ASWF-Digital-Assets-1.1", + "Abstyles", + "AdaCore-doc", + "Adobe-2006", + "Adobe-Display-PostScript", + "Adobe-Glyph", + "Adobe-Utopia", + "Advanced-Cryptics-Dictionary", + "Afmparse", + "Aladdin", + "Apache-1.0", + "Apache-1.1", + "Apache-2.0", + "App-s2p", + "Arphic-1999", + "Artistic-1.0", + "Artistic-1.0-Perl", + "Artistic-1.0-cl8", + "Artistic-2.0", + "Artistic-dist", + "Aspell-RU", + "BOLA-1.1", + "BSD-1-Clause", + "BSD-2-Clause", + "BSD-2-Clause-Darwin", + "BSD-2-Clause-FreeBSD", + "BSD-2-Clause-NetBSD", + "BSD-2-Clause-Patent", + "BSD-2-Clause-Views", + "BSD-2-Clause-first-lines", + "BSD-2-Clause-pkgconf-disclaimer", + "BSD-2-Clause-pos-unchanged", + "BSD-3-Clause", + "BSD-3-Clause-Attribution", + "BSD-3-Clause-Clear", + "BSD-3-Clause-HP", + "BSD-3-Clause-LBNL", + "BSD-3-Clause-Modification", + "BSD-3-Clause-No-Military-License", + "BSD-3-Clause-No-Nuclear-License", + "BSD-3-Clause-No-Nuclear-License-2014", + "BSD-3-Clause-No-Nuclear-Warranty", + "BSD-3-Clause-Open-MPI", + "BSD-3-Clause-OpenWebUI", + "BSD-3-Clause-Sun", + "BSD-3-Clause-Tso", + "BSD-3-Clause-acpica", + "BSD-3-Clause-flex", + "BSD-4-Clause", + "BSD-4-Clause-Shortened", + "BSD-4-Clause-UC", + "BSD-4.3RENO", + "BSD-4.3TAHOE", + "BSD-Advertising-Acknowledgement", + "BSD-Attribution-HPND-disclaimer", + "BSD-Inferno-Nettverk", + "BSD-Mark-Modifications", + "BSD-Protection", + "BSD-Source-Code", + "BSD-Source-Code-no-disclaimer", + "BSD-Source-alt-GPL", + "BSD-Source-beginning-file", + "BSD-Systemics", + "BSD-Systemics-W3Works", + "BSD-ask-to-endorse", + "BSL-1.0", + "BUSL-1.1", + "Baekmuk", + "Bahyph", + "Barr", + "Beerware", + "BitTorrent-1.0", + "BitTorrent-1.1", + "Bitstream-Charter", + "Bitstream-Vera", + "BlueOak-1.0.0", + "Boehm-GC", + "Boehm-GC-without-fee", + "Borceux", + "Brian-Gladman-2-Clause", + "Brian-Gladman-3-Clause", + "Brian-Gladman-3-Clause-no-conversion", + "Buddy", + "Bugroff", + "C-UDA-1.0", + "CAL-1.0", + "CAL-1.0-Combined-Work-Exception", + "CAPEC-tou", + "CATOSL-1.1", + "CC-BY-1.0", + "CC-BY-2.0", + "CC-BY-2.5", + "CC-BY-2.5-AU", + "CC-BY-3.0", + "CC-BY-3.0-AT", + "CC-BY-3.0-AU", + "CC-BY-3.0-DE", + "CC-BY-3.0-IGO", + "CC-BY-3.0-NL", + "CC-BY-3.0-US", + "CC-BY-4.0", + "CC-BY-NC-1.0", + "CC-BY-NC-2.0", + "CC-BY-NC-2.5", + "CC-BY-NC-3.0", + "CC-BY-NC-3.0-DE", + "CC-BY-NC-3.0-IGO", + "CC-BY-NC-4.0", + "CC-BY-NC-ND-1.0", + "CC-BY-NC-ND-2.0", + "CC-BY-NC-ND-2.5", + "CC-BY-NC-ND-3.0", + "CC-BY-NC-ND-3.0-DE", + "CC-BY-NC-ND-3.0-IGO", + "CC-BY-NC-ND-4.0", + "CC-BY-NC-SA-1.0", + "CC-BY-NC-SA-2.0", + "CC-BY-NC-SA-2.0-DE", + "CC-BY-NC-SA-2.0-FR", + "CC-BY-NC-SA-2.0-UK", + "CC-BY-NC-SA-2.5", + "CC-BY-NC-SA-3.0", + "CC-BY-NC-SA-3.0-DE", + "CC-BY-NC-SA-3.0-IGO", + "CC-BY-NC-SA-4.0", + "CC-BY-ND-1.0", + "CC-BY-ND-2.0", + "CC-BY-ND-2.5", + "CC-BY-ND-3.0", + "CC-BY-ND-3.0-DE", + "CC-BY-ND-4.0", + "CC-BY-SA-1.0", + "CC-BY-SA-2.0", + "CC-BY-SA-2.0-UK", + "CC-BY-SA-2.1-JP", + "CC-BY-SA-2.5", + "CC-BY-SA-3.0", + "CC-BY-SA-3.0-AT", + "CC-BY-SA-3.0-DE", + "CC-BY-SA-3.0-IGO", + "CC-BY-SA-4.0", + "CC-PDDC", + "CC-PDM-1.0", + "CC-SA-1.0", + "CC0-1.0", + "CDDL-1.0", + "CDDL-1.1", + "CDL-1.0", + "CDLA-Permissive-1.0", + "CDLA-Permissive-2.0", + "CDLA-Sharing-1.0", + "CECILL-1.0", + "CECILL-1.1", + "CECILL-2.0", + "CECILL-2.1", + "CECILL-B", + "CECILL-C", + "CERN-OHL-1.1", + "CERN-OHL-1.2", + "CERN-OHL-P-2.0", + "CERN-OHL-S-2.0", + "CERN-OHL-W-2.0", + "CFITSIO", + "CMU-Mach", + "CMU-Mach-nodoc", + "CNRI-Jython", + "CNRI-Python", + "CNRI-Python-GPL-Compatible", + "COIL-1.0", + "CPAL-1.0", + "CPL-1.0", + "CPOL-1.02", + "CUA-OPL-1.0", + "Caldera", + "Caldera-no-preamble", + "Catharon", + "ClArtistic", + "Clips", + "Community-Spec-1.0", + "Condor-1.1", + "Cornell-Lossless-JPEG", + "Cronyx", + "Crossword", + "CryptoSwift", + "CrystalStacker", + "Cube", + "D-FSL-1.0", + "DEC-3-Clause", + "DL-DE-BY-2.0", + "DL-DE-ZERO-2.0", + "DOC", + "DRL-1.0", + "DRL-1.1", + "DSDP", + "DocBook-DTD", + "DocBook-Schema", + "DocBook-Stylesheet", + "DocBook-XML", + "Dotseqn", + "ECL-1.0", + "ECL-2.0", + "EFL-1.0", + "EFL-2.0", + "EPICS", + "EPL-1.0", + "EPL-2.0", + "ESA-PL-permissive-2.4", + "ESA-PL-strong-copyleft-2.4", + "ESA-PL-weak-copyleft-2.4", + "EUDatagrid", + "EUPL-1.0", + "EUPL-1.1", + "EUPL-1.2", + "Elastic-2.0", + "Entessa", + "ErlPL-1.1", + "Eurosym", + "FBM", + "FDK-AAC", + "FDK-MPEG-H", + "FSFAP", + "FSFAP-no-warranty-disclaimer", + "FSFUL", + "FSFULLR", + "FSFULLRSD", + "FSFULLRWD", + "FSL-1.1-ALv2", + "FSL-1.1-MIT", + "FTL", + "Fair", + "Ferguson-Twofish", + "Frameworx-1.0", + "FreeBSD-DOC", + "FreeImage", + "Furuseth", + "GCR-docs", + "GD", + "GFDL-1.1", + "GFDL-1.1-invariants-only", + "GFDL-1.1-invariants-or-later", + "GFDL-1.1-no-invariants-only", + "GFDL-1.1-no-invariants-or-later", + "GFDL-1.1-only", + "GFDL-1.1-or-later", + "GFDL-1.2", + "GFDL-1.2-invariants-only", + "GFDL-1.2-invariants-or-later", + "GFDL-1.2-no-invariants-only", + "GFDL-1.2-no-invariants-or-later", + "GFDL-1.2-only", + "GFDL-1.2-or-later", + "GFDL-1.3", + "GFDL-1.3-invariants-only", + "GFDL-1.3-invariants-or-later", + "GFDL-1.3-no-invariants-only", + "GFDL-1.3-no-invariants-or-later", + "GFDL-1.3-only", + "GFDL-1.3-or-later", + "GL2PS", + "GLWTPL", + "GPL-1.0", + "GPL-1.0+", + "GPL-1.0-only", + "GPL-1.0-or-later", + "GPL-2.0", + "GPL-2.0+", + "GPL-2.0-only", + "GPL-2.0-or-later", + "GPL-2.0-with-GCC-exception", + "GPL-2.0-with-autoconf-exception", + "GPL-2.0-with-bison-exception", + "GPL-2.0-with-classpath-exception", + "GPL-2.0-with-font-exception", + "GPL-3.0", + "GPL-3.0+", + "GPL-3.0-only", + "GPL-3.0-or-later", + "GPL-3.0-with-GCC-exception", + "GPL-3.0-with-autoconf-exception", + "Game-Programming-Gems", + "Giftware", + "Glide", + "Glulxe", + "Graphics-Gems", + "Gutmann", + "HDF5", + "HIDAPI", + "HP-1986", + "HP-1989", + "HPND", + "HPND-DEC", + "HPND-Fenneberg-Livingston", + "HPND-INRIA-IMAG", + "HPND-Intel", + "HPND-Kevlin-Henney", + "HPND-MIT-disclaimer", + "HPND-Markus-Kuhn", + "HPND-Netrek", + "HPND-Pbmplus", + "HPND-SMC", + "HPND-UC", + "HPND-UC-export-US", + "HPND-doc", + "HPND-doc-sell", + "HPND-export-US", + "HPND-export-US-acknowledgement", + "HPND-export-US-modify", + "HPND-export2-US", + "HPND-merchantability-variant", + "HPND-sell-MIT-disclaimer-xserver", + "HPND-sell-regexpr", + "HPND-sell-variant", + "HPND-sell-variant-MIT-disclaimer", + "HPND-sell-variant-MIT-disclaimer-rev", + "HPND-sell-variant-critical-systems", + "HTMLTIDY", + "HaskellReport", + "Hippocratic-2.1", + "Hippocratic-3.0-core", + "IBM-pibs", + "ICU", + "IEC-Code-Components-EULA", + "IJG", + "IJG-short", + "IPA", + "IPL-1.0", + "ISC", + "ISC-Veillard", + "ISO-permission", + "ImageMagick", + "Imlib2", + "Info-ZIP", + "Informatica", + "Inner-Net-2.0", + "InnoSetup", + "Intel", + "Intel-ACPI", + "Interbase-1.0", + "JPL-image", + "JPNIC", + "JSON", + "Jam", + "JasPer-2.0", + "Kastrup", + "Kazlib", + "Knuth-CTAN", + "LAL-1.2", + "LAL-1.3", + "LGPL-2.0", + "LGPL-2.0+", + "LGPL-2.0-only", + "LGPL-2.0-or-later", + "LGPL-2.1", + "LGPL-2.1+", + "LGPL-2.1-only", + "LGPL-2.1-or-later", + "LGPL-3.0", + "LGPL-3.0+", + "LGPL-3.0-only", + "LGPL-3.0-or-later", + "LGPLLR", + "LOOP", + "LPD-document", + "LPL-1.0", + "LPL-1.02", + "LPPL-1.0", + "LPPL-1.1", + "LPPL-1.2", + "LPPL-1.3a", + "LPPL-1.3c", + "LZMA-SDK-9.11-to-9.20", + "LZMA-SDK-9.22", + "Latex2e", + "Latex2e-translated-notice", + "Leptonica", + "LiLiQ-P-1.1", + "LiLiQ-R-1.1", + "LiLiQ-Rplus-1.1", + "Libpng", + "Linux-OpenIB", + "Linux-man-pages-1-para", + "Linux-man-pages-copyleft", + "Linux-man-pages-copyleft-2-para", + "Linux-man-pages-copyleft-var", + "Lucida-Bitmap-Fonts", + "MIPS", + "MIT", + "MIT-0", + "MIT-CMU", + "MIT-Click", + "MIT-Festival", + "MIT-Khronos-old", + "MIT-Modern-Variant", + "MIT-STK", + "MIT-Wu", + "MIT-advertising", + "MIT-enna", + "MIT-feh", + "MIT-open-group", + "MIT-testregex", + "MITNFA", + "MMIXware", + "MMPL-1.0.1", + "MPEG-SSG", + "MPL-1.0", + "MPL-1.1", + "MPL-2.0", + "MPL-2.0-no-copyleft-exception", + "MS-LPL", + "MS-PL", + "MS-RL", + "MTLL", + "MVT-1.1", + "Mackerras-3-Clause", + "Mackerras-3-Clause-acknowledgment", + "MakeIndex", + "Martin-Birgmeier", + "McPhee-slideshow", + "Minpack", + "MirOS", + "Motosoto", + "MulanPSL-1.0", + "MulanPSL-2.0", + "Multics", + "Mup", + "NAIST-2003", + "NASA-1.3", + "NBPL-1.0", + "NCBI-PD", + "NCGL-UK-2.0", + "NCL", + "NCSA", + "NGPL", + "NICTA-1.0", + "NIST-PD", + "NIST-PD-TNT", + "NIST-PD-fallback", + "NIST-Software", + "NLOD-1.0", + "NLOD-2.0", + "NLPL", + "NOSL", + "NPL-1.0", + "NPL-1.1", + "NPOSL-3.0", + "NRL", + "NTIA-PD", + "NTP", + "NTP-0", + "Naumen", + "Net-SNMP", + "NetCDF", + "Newsletr", + "Nokia", + "Noweb", + "Nunit", + "O-UDA-1.0", + "OAR", + "OCCT-PL", + "OCLC-2.0", + "ODC-By-1.0", + "ODbL-1.0", + "OFFIS", + "OFL-1.0", + "OFL-1.0-RFN", + "OFL-1.0-no-RFN", + "OFL-1.1", + "OFL-1.1-RFN", + "OFL-1.1-no-RFN", + "OGC-1.0", + "OGDL-Taiwan-1.0", + "OGL-Canada-2.0", + "OGL-UK-1.0", + "OGL-UK-2.0", + "OGL-UK-3.0", + "OGTSL", + "OLDAP-1.1", + "OLDAP-1.2", + "OLDAP-1.3", + "OLDAP-1.4", + "OLDAP-2.0", + "OLDAP-2.0.1", + "OLDAP-2.1", + "OLDAP-2.2", + "OLDAP-2.2.1", + "OLDAP-2.2.2", + "OLDAP-2.3", + "OLDAP-2.4", + "OLDAP-2.5", + "OLDAP-2.6", + "OLDAP-2.7", + "OLDAP-2.8", + "OLFL-1.3", + "OML", + "OPL-1.0", + "OPL-UK-3.0", + "OPUBL-1.0", + "OSC-1.0", + "OSET-PL-2.1", + "OSL-1.0", + "OSL-1.1", + "OSL-2.0", + "OSL-2.1", + "OSL-3.0", + "OSSP", + "OpenMDW-1.0", + "OpenPBS-2.3", + "OpenSSL", + "OpenSSL-standalone", + "OpenVision", + "PADL", + "PDDL-1.0", + "PHP-3.0", + "PHP-3.01", + "PPL", + "PSF-2.0", + "ParaType-Free-Font-1.3", + "Parity-6.0.0", + "Parity-7.0.0", + "Pixar", + "Plexus", + "PolyForm-Noncommercial-1.0.0", + "PolyForm-Small-Business-1.0.0", + "PostgreSQL", + "Python-2.0", + "Python-2.0.1", + "QPL-1.0", + "QPL-1.0-INRIA-2004", + "Qhull", + "RHeCos-1.1", + "RPL-1.1", + "RPL-1.5", + "RPSL-1.0", + "RSA-MD", + "RSCPL", + "Rdisc", + "Ruby", + "Ruby-pty", + "SAX-PD", + "SAX-PD-2.0", + "SCEA", + "SGI-B-1.0", + "SGI-B-1.1", + "SGI-B-2.0", + "SGI-OpenGL", + "SGMLUG-PM", + "SGP4", + "SHL-0.5", + "SHL-0.51", + "SISSL", + "SISSL-1.2", + "SL", + "SMAIL-GPL", + "SMLNJ", + "SMPPL", + "SNIA", + "SOFA", + "SPL-1.0", + "SSH-OpenSSH", + "SSH-short", + "SSLeay-standalone", + "SSPL-1.0", + "SUL-1.0", + "SWL", + "Saxpath", + "SchemeReport", + "Sendmail", + "Sendmail-8.23", + "Sendmail-Open-Source-1.1", + "SimPL-2.0", + "Sleepycat", + "Soundex", + "Spencer-86", + "Spencer-94", + "Spencer-99", + "StandardML-NJ", + "SugarCRM-1.1.3", + "Sun-PPP", + "Sun-PPP-2000", + "SunPro", + "Symlinks", + "TAPR-OHL-1.0", + "TCL", + "TCP-wrappers", + "TGPPL-1.0", + "TMate", + "TORQUE-1.1", + "TOSL", + "TPDL", + "TPL-1.0", + "TTWL", + "TTYP0", + "TU-Berlin-1.0", + "TU-Berlin-2.0", + "TekHVC", + "TermReadKey", + "ThirdEye", + "TrustedQSL", + "UCAR", + "UCL-1.0", + "UMich-Merit", + "UPL-1.0", + "URT-RLE", + "Ubuntu-font-1.0", + "UnRAR", + "Unicode-3.0", + "Unicode-DFS-2015", + "Unicode-DFS-2016", + "Unicode-TOU", + "UnixCrypt", + "Unlicense", + "Unlicense-libtelnet", + "Unlicense-libwhirlpool", + "VOSTROM", + "VSL-1.0", + "Vim", + "Vixie-Cron", + "W3C", + "W3C-19980720", + "W3C-20150513", + "WTFNMFPL", + "WTFPL", + "Watcom-1.0", + "Widget-Workshop", + "WordNet", + "Wsuipa", + "X11", + "X11-distribute-modifications-variant", + "X11-no-permit-persons", + "X11-swapped", + "XFree86-1.1", + "XSkat", + "Xdebug-1.03", + "Xerox", + "Xfig", + "Xnet", + "YPL-1.0", + "YPL-1.1", + "ZPL-1.1", + "ZPL-2.0", + "ZPL-2.1", + "Zed", + "Zeeff", + "Zend-2.0", + "Zimbra-1.3", + "Zimbra-1.4", + "Zlib", + "any-OSI", + "any-OSI-perl-modules", + "atc-game", + "bcrypt-Solar-Designer", + "blessing", + "bzip2-1.0.5", + "bzip2-1.0.6", + "check-cvs", + "checkmk", + "copyleft-next-0.3.0", + "copyleft-next-0.3.1", + "curl", + "cve-tou", + "diffmark", + "dtoa", + "dvipdfm", + "eCos-2.0", + "eGenix", + "etalab-2.0", + "fwlw", + "gSOAP-1.3b", + "generic-xts", + "gnuplot", + "gtkbook", + "hdparm", + "hyphen-bulgarian", + "iMatix", + "jove", + "libpng-1.6.35", + "libpng-2.0", + "libselinux-1.0", + "libtiff", + "libutil-David-Nugent", + "lsof", + "magaz", + "mailprio", + "man2html", + "metamail", + "mpi-permissive", + "mpich2", + "mplus", + "ngrep", + "pkgconf", + "pnmstitch", + "psfrag", + "psutils", + "python-ldap", + "radvd", + "snprintf", + "softSurfer", + "ssh-keyscan", + "swrule", + "threeparttable", + "ulem", + "w3m", + "wwl", + "wxWindows", + "xinetd", + "xkeyboard-config-Zinoviev", + "xlock", + "xpp", + "xzoom", + "zlib-acknowledgement" + ], + "exception_ids": [ + "389-exception", + "Asterisk-exception", + "Asterisk-linking-protocols-exception", + "Autoconf-exception-2.0", + "Autoconf-exception-3.0", + "Autoconf-exception-generic", + "Autoconf-exception-generic-3.0", + "Autoconf-exception-macro", + "Bison-exception-1.24", + "Bison-exception-2.2", + "Bootloader-exception", + "CGAL-linking-exception", + "CLISP-exception-2.0", + "Classpath-exception-2.0", + "Classpath-exception-2.0-short", + "DigiRule-FOSS-exception", + "Digia-Qt-LGPL-exception-1.1", + "FLTK-exception", + "Fawkes-Runtime-exception", + "Font-exception-2.0", + "GCC-exception-2.0", + "GCC-exception-2.0-note", + "GCC-exception-3.1", + "GNAT-exception", + "GNOME-examples-exception", + "GNU-compiler-exception", + "GPL-3.0-389-ds-base-exception", + "GPL-3.0-interface-exception", + "GPL-3.0-linking-exception", + "GPL-3.0-linking-source-exception", + "GPL-CC-1.0", + "GStreamer-exception-2005", + "GStreamer-exception-2008", + "Gmsh-exception", + "Google-Patent-WebM", + "Independent-modules-exception", + "KiCad-libraries-exception", + "LGPL-3.0-linking-exception", + "LLGPL", + "LLVM-exception", + "LZMA-exception", + "Libtool-exception", + "Linux-syscall-note", + "Nokia-Qt-exception-1.1", + "OCCT-exception-1.0", + "OCaml-LGPL-linking-exception", + "OpenJDK-assembly-exception-1.0", + "PCRE2-exception", + "PS-or-PDF-font-exception-20170817", + "QPL-1.0-INRIA-2004-exception", + "Qt-GPL-exception-1.0", + "Qt-LGPL-exception-1.1", + "Qwt-exception-1.0", + "RRDtool-FLOSS-exception-2.0", + "SANE-exception", + "SHL-2.0", + "SHL-2.1", + "SWI-exception", + "Simple-Library-Usage-exception", + "Spelling-Provider-LGPL-exception", + "Swift-exception", + "Texinfo-exception", + "UBDL-exception", + "Universal-FOSS-exception-1.0", + "WxWindows-exception-3.1", + "cryptsetup-OpenSSL-exception", + "eCos-exception-2.0", + "erlang-otp-linking-exception", + "fmt-exception", + "freertos-exception-2.0", + "gnu-javamail-exception", + "harbour-exception", + "i2p-gpl-java-exception", + "kvirc-openssl-exception", + "libpri-OpenH323-exception", + "mif-exception", + "mxml-exception", + "openvpn-openssl-exception", + "polyparse-exception", + "romic-exception", + "rsync-linking-exception", + "sqlitestudio-OpenSSL-exception", + "stunnel-exception", + "u-boot-exception-2.0", + "vsftpd-openssl-exception", + "x11vnc-openssl-exception" + ] +} diff --git a/scripts/spdx_license_contract.py b/scripts/spdx_license_contract.py new file mode 100644 index 00000000..00418bdd --- /dev/null +++ b/scripts/spdx_license_contract.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Bounded offline SPDX 2.x license-expression syntax and identifier validator. + +Identifiers are matched against a frozen SPDX License List (including deprecated +identifiers) and exception catalogue from spdx/license-list-data tag v3.29.0. +This proves structural syntax, NOT licenses, redistribution rights or approval. +""" +from __future__ import annotations + +import json +import re +from functools import lru_cache +from pathlib import Path + +CATALOG = Path(__file__).resolve().parents[1] / ( + "qualification/spdx-license-identifiers-v3.29.0.json") +TOKEN = re.compile(r"\(|\)|[A-Za-z0-9][A-Za-z0-9.+:-]*", re.ASCII) +USER_LICENSE = re.compile( + r"(?:DocumentRef-[A-Za-z0-9.-]+:)?LicenseRef-[A-Za-z0-9.-]+\Z", + re.ASCII) +MAX_LENGTH = 4096 +MAX_TOKENS = 256 +MAX_NESTING = 24 + + +@lru_cache(maxsize=1) +def approved_identifiers() -> tuple[frozenset[str], frozenset[str]]: + """Read the pinned exact identifier catalogue; malformed data fails closed.""" + policy = json.loads(CATALOG.read_text(encoding="utf-8")) + if (type(policy) is not dict or policy.get("schema_version") != 1 or + policy.get("spdx_license_list_version") != "3.29.0" or + policy.get("source_tag") != "v3.29.0"): + raise ValueError("SPDX license identifier catalogue malformed") + licenses = policy.get("license_ids") + exceptions = policy.get("exception_ids") + if (type(licenses) is not list or type(exceptions) is not list or + len(licenses) < 700 or len(exceptions) < 80 or + not all(type(x) is str and x for x in licenses + exceptions) or + len(set(licenses)) != len(licenses) or + len(set(exceptions)) != len(exceptions)): + raise ValueError("SPDX license identifier catalogue incomplete") + return frozenset(licenses), frozenset(exceptions) + + +def valid_license_expression(value: object) -> bool: + """Accept SPDX 2.x Annex-D syntax, known IDs, LicenseRefs and sentinels. + + The parser is deliberately bounded and does not interpret license terms. + NONE/NOASSERTION are accepted as whole field values only. + """ + if (type(value) is not str or not value or len(value) > MAX_LENGTH or + value.strip(" ") != value or + any(ord(c) > 127 or (ord(c) < 32) for c in value)): + return False + if value in ("NONE", "NOASSERTION"): + return True + try: + licenses, exceptions = approved_identifiers() + except (OSError, ValueError, TypeError): + return False + + tokens: list[str] = [] + end = 0 + for match in TOKEN.finditer(value): + if any(c != " " for c in value[end:match.start()]): + return False + tokens.append(match.group()) + end = match.end() + if len(tokens) > MAX_TOKENS: + return False + if end != len(value) or not tokens: + return False + + def is_license(token: str) -> bool: + if USER_LICENSE.fullmatch(token): + return True + return token in licenses or ( + token.endswith("+") and token[:-1] in licenses) + + pos = 0 + + def parse_atom(depth: int) -> bool: + nonlocal pos + if pos >= len(tokens) or depth > MAX_NESTING: + return False + current = tokens[pos] + if current == "(": + pos += 1 + if not parse_or(depth + 1): + return False + if pos >= len(tokens) or tokens[pos] != ")": + return False + pos += 1 + # WITH applies only to a simple license identifier, not a group. + return True + if not is_license(current): + return False + pos += 1 + if pos < len(tokens) and tokens[pos] == "WITH": + pos += 1 + if pos >= len(tokens) or tokens[pos] not in exceptions: + return False + pos += 1 + return True + + def parse_and(depth: int) -> bool: + nonlocal pos + if not parse_atom(depth): + return False + while pos < len(tokens) and tokens[pos] == "AND": + pos += 1 + if not parse_atom(depth): + return False + return True + + def parse_or(depth: int) -> bool: + nonlocal pos + if not parse_and(depth): + return False + while pos < len(tokens) and tokens[pos] == "OR": + pos += 1 + if not parse_and(depth): + return False + return True + + return parse_or(0) and pos == len(tokens) diff --git a/tests/release/test_spdx_license_contract.py b/tests/release/test_spdx_license_contract.py new file mode 100644 index 00000000..4bda72af --- /dev/null +++ b/tests/release/test_spdx_license_contract.py @@ -0,0 +1,59 @@ +"""Offline SPDX v2 license grammar, frozen identifier and negative controls.""" +from __future__ import annotations +import importlib.util +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location( + "release_spdx_licenses", ROOT / "scripts/spdx_license_contract.py") +contract = importlib.util.module_from_spec(spec) +spec.loader.exec_module(contract) + + +class SPDXLicenseExpressionTests(unittest.TestCase): + def test_supported_expressions(self): + values = ( + "MIT", "Apache-2.0 OR MIT", + "(MIT OR Apache-2.0) AND LicenseRef-Local", + "GPL-2.0-only WITH Classpath-exception-2.0", + "DocumentRef-external:LicenseRef-Local", + "GPL-2.0-or-later AND MIT", + ) + # NONE and NOASSERTION are whole-field sentinels only. + for value in values + ("NONE", "NOASSERTION"): + with self.subTest(value=value): + self.assertTrue(contract.valid_license_expression(value)) + + def test_malformed_and_unknown_expressions(self): + for value in ( + "not a license expression", "MIT AND", "MIT OR OR Apache-2.0", + "MIT WITH", "NONE OR MIT", "NOASSERTION AND MIT", + "GPL-2.0-only WITH not-an-exception", "MIT WITH MIT", + "(MIT OR Apache-2.0", "(MIT) WITH Classpath-exception-2.0", + "LicenseRef-", "DocumentRef-:LicenseRef-X", + "DefinitelyNotAnSPDXLicense-999.0", + "MIT\nOR Apache-2.0", "MIT\tAND Apache-2.0", + "MIT OR PRIVATE_CANARY", "MIT OR (MIT OR )", + ): + with self.subTest(value=value): + self.assertFalse(contract.valid_license_expression(value)) + + def test_frozen_spdx_catalogue_contains_canonical_identifiers(self): + licenses, exceptions = contract.approved_identifiers() + self.assertIn("MIT", licenses) + self.assertIn("Apache-2.0", licenses) + self.assertIn("Classpath-exception-2.0", exceptions) + self.assertGreaterEqual(len(licenses), 700) + self.assertGreaterEqual(len(exceptions), 80) + self.assertFalse(contract.valid_license_expression( + "PRIVATE_CANARY")) + + def test_input_bounds_and_invalid_types(self): + for value in (None, True, 0, "", "MIT ", " MIT", + "MIT OR " + "MIT OR " * 300 + "Apache-2.0"): + self.assertFalse(contract.valid_license_expression(value)) + + +if __name__ == "__main__": + unittest.main() From 7117ce6ef65e52a7bbcd76683fd5778ec9ee6ab7 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sun, 18 Oct 2026 03:28:10 +0200 Subject: [PATCH 123/147] fix(RELEASE-1): demand observed SLSA v1 provenance and exact scanner builder identity --- qualification/release-image-v1.json | 4 +++- scripts/check-release-scanner.py | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/qualification/release-image-v1.json b/qualification/release-image-v1.json index 769fe154..6ec8fe12 100644 --- a/qualification/release-image-v1.json +++ b/qualification/release-image-v1.json @@ -62,5 +62,7 @@ "ca_bootstrap_sources": "deploy/release/bootstrap.sources", "ca_bootstrap_source_reason": "Pinned minimal base has no usable CA trust before first HTTPS APT access; only signed native Ubuntu APT metadata may install ca-certificates. Exact bootstrap package version is part of generated runtime inventory; publication blocked until reviewed.", "sbom_generator_image": "docker.io/docker/buildkit-syft-scanner@sha256:79e7b013cbec16bbb436f312819a49a4a57752b2270c1a9332ae1a10fcc82a68", - "sbom_generator_evidence": "Docker Hub stable-1 multi-platform OCI index digest (reviewed pinned baseline; no floating tag at build time)" + "sbom_generator_evidence": "Docker Hub stable-1 multi-platform OCI index digest (reviewed pinned baseline; no floating tag at build time)", + "slsa_provenance_predicate_type": "https://slsa.dev/provenance/v1", + "slsa_builder_id": "https://github.com/HMarcusWH/AllfatherChess/actions/workflows/release-candidate.yml" } diff --git a/scripts/check-release-scanner.py b/scripts/check-release-scanner.py index 3730e88a..c9a2d912 100644 --- a/scripts/check-release-scanner.py +++ b/scripts/check-release-scanner.py @@ -11,6 +11,7 @@ MAX_JSON=8*1024*1024 ROOT=Path(__file__).resolve().parents[1] +POLICY=ROOT/"qualification/release-image-v1.json" spec=importlib.util.spec_from_file_location( "release1_oci_contract", ROOT/"scripts/verify-release-oci.py") contract=importlib.util.module_from_spec(spec) @@ -22,6 +23,13 @@ def require(condition, reason): def inspect_scanner_output(archive: Path) -> dict: """This scanner-only probe verifies SPDX syntax, not image authority.""" + policy=json.loads(POLICY.read_text(encoding="utf-8")) + require(policy.get("public_bot_release") is False and + policy.get("slsa_provenance_predicate_type")== + "https://slsa.dev/provenance/v1" and + type(policy.get("slsa_builder_id")) is str and + contract.valid_identity_uri(policy["slsa_builder_id"]), + "scanner pinned provenance policy invalid") with tarfile.open(archive,"r:*") as tar: members={m.name:m for m in tar.getmembers() if m.isfile() and not m.issym() and not m.islnk()} @@ -46,6 +54,7 @@ def read(path, oid=None, expected_size=None): type(index.get("manifests")) is list, "scanner output missing OCI index") spdx=[] + slsa=[] def walk(descriptor,depth=0): require(depth<=8 and type(descriptor) is dict, "scanner descriptor nesting invalid") @@ -113,11 +122,23 @@ def walk(descriptor,depth=0): len(pred.get("files",[])))) else: contract.validate_slsa(row["predicateType"],pred) + require(row["predicateType"]== + policy["slsa_provenance_predicate_type"], + "scanner unexpected SLSA provenance version") + require(pred["runDetails"]["builder"]["id"] == + policy["slsa_builder_id"], + "scanner provenance builder identity differs from policy") + slsa.append(True) for root in index["manifests"]: walk(root) require(len(spdx)==1 and sum(spdx[0])>=1, "scanner must export exactly one nonempty valid SPDX statement") + require(len(slsa)==1, + "scanner must export exactly one expected SLSA v1 statement") return {"schema_version":1,"scanner_spdx_contract_verified":True, + "scanner_slsa_v1_contract_verified":True, + "builder_identity_matched":True, + "slsa_statement_count":len(slsa), "spdx_document_count":len(spdx), "spdx_package_count":spdx[0][0], "spdx_file_count":spdx[0][1], From 05c55d17769cc1882ecbfbb7a08d554f3c56a648 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sun, 18 Oct 2026 03:29:16 +0200 Subject: [PATCH 124/147] test(RELEASE-1): reject missing, duplicate, wrong-builder and wrong-version scanner provenance --- tests/release/test_release_oci_integrity.py | 17 +++++++++++++++-- tests/release/test_release_scanner_contract.py | 16 ++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index b722765a..1262eb4b 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -36,7 +36,8 @@ def fixture(self, target: Path, *, replace_layer=False, bad_subject=False, index_platform="absent", inner_index_platform="absent", double_nested=False, embedded_media="absent", config_platform="valid", diagnostic_canary=None, - malformed_diagnostic=None, statement_wrapper="singleton"): + malformed_diagnostic=None, statement_wrapper="singleton", + omit_slsa=False, duplicate_slsa=False): base = b"original uncompressed OCI application layer\n" expanded = (b"substituted uncompressed OCI application layer\n" if replace_layer else base) @@ -135,6 +136,8 @@ def descriptor(kind, digest, **extra): attest_layers = [] for predicate in ("https://spdx.dev/Document", "https://slsa.dev/provenance/v1"): + if omit_slsa and predicate=="https://slsa.dev/provenance/v1": + continue subject = {"image": application[7:], "config": config[7:], "layer": tar_layer[7:], "empty": None}[subject_kind] if bad_subject: @@ -161,7 +164,7 @@ def descriptor(kind, digest, **extra): "buildType": "https://mobyproject.org/buildkit@v1", "externalParameters": {}}, "runDetails": {"builder": { - "id": "https://github.com/moby/buildkit"}}}) + "id": "https://github.com/HMarcusWH/AllfatherChess/actions/workflows/release-candidate.yml"}}}) if predicate == "https://spdx.dev/Document": if spdx_case == "missing-license": del body["dataLicense"] @@ -289,6 +292,8 @@ def descriptor(kind, digest, **extra): body = {"buildDefinition": {}, "runDetails": {}} if slsa_case == "builder-empty": body["runDetails"]["builder"]["id"] = "" + if slsa_case == "other-builder": + body["runDetails"]["builder"]["id"] = "https://github.com/moby/buildkit" if slsa_case == "bad-build-type": body["buildDefinition"]["buildType"] = "not-a-uri" if slsa_case == "no-external": @@ -334,6 +339,12 @@ def descriptor(kind, digest, **extra): body["runDetails"]["metadata"] = { "invocationId":"offline-test"} body["runDetails"]["byproducts"] = [] + if slsa_case == "v02" and predicate.endswith("/v1"): + predicate="https://slsa.dev/provenance/v0.2" + body={"builder":{"id": + "https://github.com/HMarcusWH/AllfatherChess/actions/workflows/release-candidate.yml"}, + "buildType":"https://mobyproject.org/buildkit@v1", + "invocation":{}} if invalid_predicate: body = {"schema_version": 1} statement_document = { @@ -373,6 +384,8 @@ def descriptor(kind, digest, **extra): if wrong_size == "statement": layer["size"] += 1 attest_layers.append(layer) + if duplicate_slsa and predicate.startswith("https://slsa.dev/"): + attest_layers.append(layer) attest_data = { "schemaVersion": 2, "config": descriptor("application/vnd.oci.image.config.v1+json", diff --git a/tests/release/test_release_scanner_contract.py b/tests/release/test_release_scanner_contract.py index fba6cfdf..788b3da9 100644 --- a/tests/release/test_release_scanner_contract.py +++ b/tests/release/test_release_scanner_contract.py @@ -35,9 +35,25 @@ def test_valid_spdx_probe_never_claims_release_authority(self): self.assertEqual(report["spdx_document_count"],1) self.assertGreater(sum((report["spdx_package_count"], report["spdx_file_count"])),0) + self.assertTrue(report["scanner_slsa_v1_contract_verified"]) + self.assertTrue(report["builder_identity_matched"]) + self.assertEqual(report["slsa_statement_count"],1) self.assertFalse(report["integrity_verified"]) self.assertFalse(report["release_qualified"]) + def test_scanner_requires_one_exact_version_and_builder_provenance(self): + variants=( + ({"omit_slsa":True},"exactly one expected SLSA"), + ({"slsa_case":"other-builder"},"builder identity"), + ({"slsa_case":"v02"},"provenance version"), + ({"duplicate_slsa":True},"exactly one expected SLSA"), + ) + for kwargs,reason in variants: + with self.subTest(kind=kwargs),tempfile.TemporaryDirectory() as td: + archive,_,_=oci_fixture.OCIProofTests().fixture(Path(td),**kwargs) + with self.assertRaisesRegex(ValueError,reason): + probe.inspect_scanner_output(archive) + def test_null_statement_wrapper_cannot_qualify_a_scanner(self): for kind in ("null","empty","object","string"): with self.subTest(wrapper=kind),tempfile.TemporaryDirectory() as tmp: From bdf42767bd2078eb7665c9475a026bda980a18d7 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sun, 18 Oct 2026 03:30:23 +0200 Subject: [PATCH 125/147] fix(RELEASE-1): apply versioned SPDX package semantics, license grammar, and file SHA1 Distinguish SPDX 2.2 mandatory package fields from 2.3 optional semantics, validate supplied license expressions via pinned IDs, correct analyzed-package conditional flow, and require SHA1 file checksums. --- scripts/verify-release-oci.py | 81 ++++++++++++++++++++++++----------- 1 file changed, 57 insertions(+), 24 deletions(-) diff --git a/scripts/verify-release-oci.py b/scripts/verify-release-oci.py index ab42eca8..488f3318 100644 --- a/scripts/verify-release-oci.py +++ b/scripts/verify-release-oci.py @@ -12,6 +12,7 @@ import os import stat import hashlib +import importlib.util import gzip import zlib import json @@ -23,6 +24,15 @@ from urllib.parse import urlsplit HEX = re.compile(r"^[0-9a-f]{64}$") +# The SPDX parser uses a reviewed, versioned, offline license/exception list. +_spdx_path=Path(__file__).with_name("spdx_license_contract.py") +_spdx_spec=importlib.util.spec_from_file_location("release1_spdx_license_contract", + _spdx_path) +if _spdx_spec is None or _spdx_spec.loader is None: + raise ValueError("SPDX offline expression validator absent") +_spdx_mod=importlib.util.module_from_spec(_spdx_spec) +_spdx_spec.loader.exec_module(_spdx_mod) +valid_spdx_license_expression=_spdx_mod.valid_license_expression def require(ok, why): @@ -520,6 +530,8 @@ def validate_spdx_document(predicate: dict) -> None: bool(creator.partition(": ")[2].strip()) for creator in creators), "SPDX SBOM lacks complete document creation identity") # SPDX 2.3 distinguishes package.name from file.fileName. + # Preserve the stricter SPDX 2.2 mandatory package fields separately. + version=predicate["spdxVersion"] # A single shared 'name' predicate wrongly rejected genuine Syft files. packages = predicate.get("packages", []) files = predicate.get("files", []) @@ -542,45 +554,66 @@ def spdx_id(item): bool(item["name"].strip()) and valid_spdx_download_location(item.get("downloadLocation")), "SPDX SBOM package has invalid downloadLocation") - # SPDX 2.3 copyrightText is optional (absent means NOASSERTION). - # filesAnalyzed defaults to True; a present verification code is - # validated, but SPDX does not require one for every analyzed package. - # The separate OCI/application subject checks provide image identity. + # SPDX 2.2 requires copyright and concluded/declared licenses. + # SPDX 2.3 permits omission with NOASSERTION semantics. This verifier + # validates present expressions but never grants distribution rights. + mandatory=("copyrightText","licenseConcluded","licenseDeclared") + if version=="SPDX-2.2": + require(all(field in item for field in mandatory), + "SPDX 2.2 package mandatory fields absent") if "copyrightText" in item: require(type(item["copyrightText"]) is str and bool(item["copyrightText"].strip()), "SPDX package copyrightText malformed") - analyzed=item.get("filesAnalyzed", True) + for field in ("licenseConcluded","licenseDeclared"): + if field in item: + require(valid_spdx_license_expression(item[field]), + "SPDX package license expression malformed") + analyzed=item.get("filesAnalyzed",True) require(type(analyzed) is bool, "SPDX package filesAnalyzed must be boolean") verification=item.get("packageVerificationCode") - if analyzed and verification is not None: - require(type(verification) is dict and - type(verification.get("packageVerificationCodeValue")) is str and - re.fullmatch(r"[0-9a-f]{40}", - verification["packageVerificationCodeValue"]) is not None and - (verification.get("packageVerificationCodeExcludedFiles") is None - or (type(verification["packageVerificationCodeExcludedFiles"]) is list - and all(type(name) is str and bool(name) - for name in verification["packageVerificationCodeExcludedFiles"]))), - "SPDX supplied package verification code malformed") - else: - require(verification is None and - "licenseInfoFromFiles" not in item, + if not analyzed: + require(verification is None and "licenseInfoFromFiles" not in item, "SPDX unanalyzed package must omit verification code and file licenses") - for field in ("licenseConcluded","licenseDeclared"): - if field in item: - require(type(item[field]) is str and bool(item[field].strip()), - "SPDX package license field malformed") + else: + if version=="SPDX-2.2": + require(verification is not None, + "SPDX 2.2 analyzed package requires verification code") + if verification is not None: + require(type(verification) is dict and + type(verification.get("packageVerificationCodeValue")) is str and + re.fullmatch(r"[0-9a-f]{40}", + verification["packageVerificationCodeValue"]) is not None and + (verification.get("packageVerificationCodeExcludedFiles") is None + or (type(verification["packageVerificationCodeExcludedFiles"]) is list + and all(type(name) is str and bool(name) + for name in verification["packageVerificationCodeExcludedFiles"]))), + "SPDX supplied package verification code malformed") + if "licenseInfoFromFiles" in item: + require(type(item["licenseInfoFromFiles"]) is list and + all(valid_spdx_license_expression(v) + for v in item["licenseInfoFromFiles"]), + "SPDX package licenseInfoFromFiles malformed") if "checksums" in item: require(valid_spdx_checksums(item["checksums"]), "SPDX SBOM package contains invalid checksums") for item in files: spdx_id(item) + checksums=item.get("checksums") require(isinstance(item.get("fileName"), str) and bool(item["fileName"].strip()) and - valid_spdx_checksums(item.get("checksums")), - "SPDX SBOM file contains invalid fileName or checksums") + valid_spdx_checksums(checksums) and + sum(c.get("algorithm")=="SHA1" for c in checksums)==1, + "SPDX file requires a valid SHA1 checksum") + if "licenseConcluded" in item: + require(valid_spdx_license_expression(item["licenseConcluded"]), + "SPDX file licenseConcluded malformed") + if "licenseInfoInFiles" in item: + require(type(item["licenseInfoInFiles"]) is list and + all(valid_spdx_license_expression(value) + for value in item["licenseInfoInFiles"]), + "SPDX file licenseInfoInFiles malformed") def validate_target_platform(platform: object) -> None: From 67ae5272757c40587e42e257ba07a2142328a976 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sun, 18 Oct 2026 03:31:17 +0200 Subject: [PATCH 126/147] test(RELEASE-1): add rehashed SPDX 2.2/2.3, license and SHA1 destructive controls Preserve positive file-only SHA1+SHA256 attestations and reject missing mandatory 2.2 fields, invalid expression grammar, and SHA256-only files. --- tests/release/test_release_oci_integrity.py | 75 ++++++++++++++++++--- 1 file changed, 67 insertions(+), 8 deletions(-) diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index 1262eb4b..238efac9 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -203,6 +203,34 @@ def descriptor(kind, digest, **extra): body["packages"][0]["licenseInfoFromFiles"] = ["MIT"] elif spdx_case == "invalid-license": body["packages"][0]["licenseDeclared"] = 3 + elif spdx_case == "invalid-license-expression": + body["packages"][0]["licenseDeclared"] = "not a license expression" + elif spdx_case == "valid-license-expression": + body["packages"][0]["licenseDeclared"] = "(MIT OR Apache-2.0) AND LicenseRef-Local" + body["packages"][0]["licenseConcluded"] = ( + "GPL-2.0-only WITH Classpath-exception-2.0") + elif spdx_case == "valid-license-info-no-code": + body["packages"][0]["filesAnalyzed"] = True + body["packages"][0]["licenseInfoFromFiles"] = ["MIT","Apache-2.0 OR MIT"] + elif spdx_case == "invalid-license-info": + body["packages"][0]["filesAnalyzed"] = True + body["packages"][0]["licenseInfoFromFiles"] = [ + "MIT","PRIVATE_CANARY"] + elif spdx_case.startswith("spdx22-"): + body["spdxVersion"] = "SPDX-2.2" + body["packages"][0]["filesAnalyzed"] = True + body["packages"][0]["packageVerificationCode"] = { + "packageVerificationCodeValue": "a"*40} + if spdx_case == "spdx22-missing-copyright": + del body["packages"][0]["copyrightText"] + elif spdx_case == "spdx22-missing-concluded": + del body["packages"][0]["licenseConcluded"] + elif spdx_case == "spdx22-missing-declared": + del body["packages"][0]["licenseDeclared"] + elif spdx_case == "spdx22-missing-code": + del body["packages"][0]["packageVerificationCode"] + elif spdx_case != "spdx22-valid": + raise ValueError("unknown SPDX 2.2 fixture variant") elif spdx_case == "missing-download": del body["packages"][0]["downloadLocation"] elif spdx_case == "invalid-download": @@ -238,12 +266,23 @@ def descriptor(kind, digest, **extra): "fileName":"./opt/example", "checksums":[{"algorithm":"BOGUS", "checksumValue":"0"}]}] - elif spdx_case == "file-only-valid": + elif spdx_case in ("file-only-valid","file-sha256-only", + "file-invalid-sha1","file-duplicate-sha1", + "file-invalid-license"): body["packages"] = [] - body["files"] = [{"SPDXID": "SPDXRef-File-A", - "fileName": "./usr/bin/allfather", - "checksums": [{"algorithm": "SHA256", - "checksumValue": "a" * 64}]}] + entries=[{"algorithm":"SHA1","checksumValue":"b"*40}, + {"algorithm":"SHA256","checksumValue":"a"*64}] + if spdx_case=="file-sha256-only": + entries=entries[1:] + if spdx_case=="file-invalid-sha1": + entries[0]["checksumValue"]="PRIVATE_CANARY" + if spdx_case=="file-duplicate-sha1": + entries.append({"algorithm":"SHA1","checksumValue":"c"*40}) + file={"SPDXID":"SPDXRef-File-A", + "fileName":"./usr/bin/allfather","checksums":entries} + if spdx_case=="file-invalid-license": + file["licenseConcluded"]="not a license expression" + body["files"]=[file] elif spdx_case == "missing-file-checksum": body["packages"] = [] body["files"] = [{"SPDXID": "SPDXRef-File-A", @@ -684,16 +723,33 @@ def test_spdx_package_optional_defaults_and_conditional_verification(self): "name":"sample", "downloadLocation":"NOASSERTION", "copyrightText":"NOASSERTION", "filesAnalyzed":False} self.assertTrue(oci.valid_spdx_download_location(pkg["downloadLocation"])) - # Omitted filesAnalyzed defaults to True; the verification code - # is optional in SPDX 2.3, while a supplied one must be well formed. + # Omitted filesAnalyzed defaults to True; SPDX 2.3 permits + # missing copyright and absent verification code even when + # file-derived license information is provided. for variant in ("missing-copyright", "missing-filesAnalyzed-without-code", - "filesAnalyzed-without-code"): + "filesAnalyzed-without-code", + "valid-license-info-no-code", + "valid-license-expression"): with self.subTest(variant=variant),tempfile.TemporaryDirectory() as d: archive,meta,inspection=self.fixture(Path(d),spdx_case=variant) self.assertTrue(oci.verify_archive( archive,meta,docker_inspect=inspection)["oci_sbom_verified"]) + def test_spdx22_and_23_mandatory_field_version_split(self): + with tempfile.TemporaryDirectory() as td: + a,meta,inspection=self.fixture(Path(td),spdx_case="spdx22-valid") + self.assertTrue(oci.verify_archive( + a,meta,docker_inspect=inspection)["oci_sbom_verified"]) + for variant in ("spdx22-missing-copyright", + "spdx22-missing-concluded", + "spdx22-missing-declared", + "spdx22-missing-code"): + with self.subTest(variant=variant),tempfile.TemporaryDirectory() as td: + a,meta,inspection=self.fixture(Path(td),spdx_case=variant) + with self.assertRaisesRegex(ValueError,"SPDX"): + oci.verify_archive(a,meta,docker_inspect=inspection) + def test_spdx_file_only_inventory_uses_fileName_not_package_name(self): with tempfile.TemporaryDirectory() as d: archive, meta, inspect = self.fixture(Path(d), spdx_case="file-only-valid") @@ -724,6 +780,9 @@ def test_incomplete_spdx_sbom_never_qualifies(self): "missing-creators", "bad-creator", "empty-inventory", "bad-package", "missing-download", "invalid-download", "empty-copyright", "filesAnalyzed-invalid", + "invalid-license-expression", "invalid-license-info", + "file-sha256-only","file-invalid-sha1", + "file-duplicate-sha1","file-invalid-license", "unanalysed-with-code", "unanalysed-with-file-licenses","invalid-license", "download-unicode", "download-braces", "download-pipe", From f5ba9fc06a7380c773fd1246cec094a8ef701436 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sun, 18 Oct 2026 03:33:46 +0200 Subject: [PATCH 127/147] fix(RELEASE-1): verify every verdict-bearing module against Git and staged attestation before import Snapshot exact checked bytes into a private source tree, load the independent HTTP/smoke/claim verifiers only from that snapshot, and extend stage source attestations to all imported verdict dependencies. --- scripts/build-release-candidate.py | 4 + scripts/qualify-release-aggregate.py | 287 ++++++++++++++++----------- 2 files changed, 177 insertions(+), 114 deletions(-) diff --git a/scripts/build-release-candidate.py b/scripts/build-release-candidate.py index f92eeee3..64398d15 100644 --- a/scripts/build-release-candidate.py +++ b/scripts/build-release-candidate.py @@ -49,6 +49,10 @@ "deploy/release/Dockerfile", "deploy/release/Dockerfile.dockerignore", "scripts/build-release-candidate.py", + "scripts/qualify-release-aggregate.py", + "scripts/verify-release-oci.py", + "scripts/spdx_license_contract.py", + "qualification/spdx-license-identifiers-v3.29.0.json", "scripts/qualify-release-smoke.py", "scripts/release_http_contract.py", "scripts/sanitize_qualification_evidence.py", diff --git a/scripts/qualify-release-aggregate.py b/scripts/qualify-release-aggregate.py index 7cce6bde..b30fc002 100644 --- a/scripts/qualify-release-aggregate.py +++ b/scripts/qualify-release-aggregate.py @@ -7,16 +7,34 @@ """ from __future__ import annotations import argparse +from contextlib import contextmanager import hashlib import importlib.util import json import re import subprocess +import tempfile from pathlib import Path ROOT=Path(__file__).resolve().parents[1] GIT_SHA1_HEX=re.compile(r"^[0-9a-f]{40}$") SHA256_HEX=re.compile(r"^[0-9a-f]{64}$") +# Declare all sources whose exact bytes can influence the RELEASE-1 verdict. +# The committed stage attests these files independently of the producer's flags. +VERDICT_SOURCE_PATHS=( + "scripts/qualify-release-aggregate.py", + "scripts/qualify-release-smoke.py", + "scripts/release_http_contract.py", + "scripts/sanitize_qualification_evidence.py", + "scripts/verify_assurance_contract.py", + "tests/lichess/http_target_contract.py", + "qualification/assurance-contract-v1.json", + "scripts/verify-release-oci.py", + "scripts/spdx_license_contract.py", + "qualification/spdx-license-identifiers-v3.29.0.json", + "scripts/check-release-scanner.py", + "qualification/release-image-v1.json", +) def is_sha256_hex(value, *, prefixed=False): @@ -46,29 +64,70 @@ def require(ok,reason): raise ValueError(reason) -def check_smoke_evidence(path:Path, key:str) -> dict: - """Execute the independent host verifier, never trust the producer's ready.""" - module_path=ROOT/"scripts/qualify-release-smoke.py" - spec=importlib.util.spec_from_file_location("release1_smoke_independent",module_path) +def load_verified_module(root: Path, relative: str, namespace: str): + """Load only from an isolated private snapshot of committed source bytes.""" + module_path=root/relative + spec=importlib.util.spec_from_file_location(namespace,module_path) require(spec is not None and spec.loader is not None, - "independent packaged-smoke verifier missing") - mod=importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) - return mod.verify_file(path,key) + "verified RELEASE-1 module loader unavailable") + module=importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@contextmanager +def verified_release_snapshot(root: Path, source_sha: str, manifest: dict): + """Bind every verdict-critical local input to manifest AND Git bytes. + + A mutable checkout with an unchanged HEAD cannot become an accepted + verifier. The modules are loaded from the private verified snapshot, + rather than reopening the original untrusted working-tree paths. + """ + attested=manifest.get("source_attestation") + require(type(attested) is dict, + "RELEASE-1 source attestation absent") + require(type(source_sha) is str and GIT_SHA1_HEX.fullmatch(source_sha), + "RELEASE-1 source commit malformed") + trusted={} + for relative in VERDICT_SOURCE_PATHS: + local=root/relative + require(local.is_file() and not local.is_symlink() and + local.resolve().is_relative_to(root.resolve()), + "RELEASE-1 verdict source missing, linked or outside checkout") + raw=local.read_bytes() + require(isinstance(attested.get(relative),str) and + is_sha256_hex(attested[relative]) and + hashlib.sha256(raw).hexdigest()==attested[relative], + "RELEASE-1 executed source differs from staged attestation") + committed=subprocess.run( + ["git","-C",str(root),"show",source_sha+":"+relative], + capture_output=True,check=False,timeout=12) + require(committed.returncode==0 and committed.stdout==raw, + "RELEASE-1 executed source differs from Git committed bytes") + trusted[relative]=raw + with tempfile.TemporaryDirectory(prefix="allfather-release-verified-") as tmp: + sealed=Path(tmp) + for relative,raw in trusted.items(): + target=sealed/relative + target.parent.mkdir(parents=True,exist_ok=True) + with target.open("xb") as handle: + handle.write(raw) + target.chmod(0o400) + yield sealed + +def check_smoke_evidence(path:Path, key:str, verifier=None) -> dict: + """Use the exact pre-verified smoke module, never an unchecked path.""" + require(verifier is not None, + "independent smoke verifier was not source-admitted") + return verifier.verify_file(path,key) -# Read the exact same host-only HTTP admission contract as the independent -# smoke verifier; the aggregate cannot silently widen allowed GETs. -_http_spec = importlib.util.spec_from_file_location( - "release1_http_aggregate", ROOT/"scripts/release_http_contract.py") -require(_http_spec is not None and _http_spec.loader is not None, - "shared RELEASE-1 HTTP admission contract missing") -http_contract = importlib.util.module_from_spec(_http_spec) -_http_spec.loader.exec_module(http_contract) +def allowed_release_http(row: object, case: str, verified_contract=None) -> bool: + require(verified_contract is not None, + "shared RELEASE-1 HTTP verifier was not source-admitted") + return verified_contract.allowed_release_http(row,case) -def allowed_release_http(row: object, case: str) -> bool: - return http_contract.allowed_release_http(row, case) def qualify(*, stage:Path, oci:Path, oci_archive:Path, host:Path, smoke_dir:Path, os_inventory:Path, wheel_inventory:Path, source_sha:str, root:Path=ROOT): @@ -123,102 +182,102 @@ def qualify(*, stage:Path, oci:Path, oci_archive:Path, host:Path, smoke_dir:Path require(qualification["artifact_distribution_authorized"] is False and qualification["release_promotion_authorized"] is False, "promotion gate unexpectedly opened") - tests={} - for key in ("r00-white","r00-black","r02-white","r10-white"): - target=smoke_dir/key/"result.json" - require(target.is_file(), "release-image smoke scenario missing: "+key) - result=load(target) - independent=check_smoke_evidence(target,key) - scenario,color=key.split("-",1) - require(independent.get("independent_qualified") is True and - independent.get("persisted_sqlite_verified") is True and - independent.get("release_blocked") is True and - independent.get("run_id") == key and - independent.get("scenario") == scenario.upper() and - independent.get("color") == color and - independent.get("public_bot_release") is False, - "independent chess/HTTP/SQLite evidence failed: "+key) - require(result.get("offline") is True and - result.get("ready") is True and - result.get("runner_errors")==[] and - result.get("public_bot_release") is False, - "release-image smoke failed: "+key) - game=result.get("game") or {} - require(game.get("forbidden_attempts")==[], - "release image simulator recorded forbidden API attempts: "+key) - requests=result.get("http") - require(isinstance(requests,list), - "release-image complete HTTP audit missing: "+key) - require(all(allowed_release_http(r,key) for r in requests), - "release image HTTP contains prohibited request: "+key) - if key=="r10-white": - require(result.get("rate_cooldown_verified") is True, - "release-image 429 cooldown witness absent") - if key=="r02-white": - require(type(result.get("fresh_second_epoch_commits")) is int and - result["fresh_second_epoch_commits"]>0, - "release image restart has no fresh confirmed move") - if key!="r10-white": - require(result["game"]["complete"] is True, - "offline completed-game requirement not met: "+key) - else: - require(result["game"]["complete"] is False, - "rate-limit test unexpectedly marked game as complete") - tests[key]={"passed":True,"independent_evidence":independent, - "accepted_moves":len(result["game"]["accepted"] or []), - "phase_count":len(result["observations"])} - wheel_hash=digest(stage/"wheels/runtime-wheels.lock") - wheel_inv=digest(wheel_inventory) - native_hash=digest(os_inventory) - checked={ - "os_lock":bool(image_policy["os_package_lock_complete"] and - image_policy.get("exact_os_package_inventory_sha256")==native_hash), - "wheel_lock":bool(image_policy["wheel_lock_complete"] and - image_policy.get("exact_runtime_wheel_lock_sha256")==wheel_hash and - image_policy.get("exact_runtime_wheel_inventory_sha256")==wheel_inv), - } - source_contents={ - "schema_version":1,"source_commit":source, - "image_manifest_digest":oci_report["oci_image_manifest_digest"], - "oci_image_config_digest":oci_report["oci_config_digest"], - "oci_archive_sha256":oci_report["oci_archive_sha256"], - "base_image":image_policy["base_image"], - "apt_snapshot":image_policy["apt_snapshot"], - "native_inventory_sha256":native_hash, - "wheel_requirements_sha256":wheel_hash, - "wheel_inventory_sha256":wheel_inv, - "wheel_bytes":runtime_wheels["wheels"], - "smoke":tests, - "oci_evidence":{field:oci_report.get(field) for field in ( - "integrity_verified","oci_layers_verified", - "oci_diff_ids_verified","oci_application_identity_verified", - "oci_attestation_references_verified", - "oci_attestation_subjects_verified", - "oci_sbom_verified","oci_provenance_verified")}, - "image_integrity_qualified":True, - "frozen_dependency_lock_qualified":all(checked.values()), - "dependency_lock_checks":checked, - # Source and image integrity do not grant host/legal/live authority. - # This deliberately remains false even if dependency pins are filled. - "offline_candidate_qualified":False, - "license_approved":False,"destination_host_qualified":False, - "network_recovery_qualified":False, - "artifact_distribution_authorized":False, - "public_bot_release":False,"release_promotion_authorized":False, - "release_blocked":True, - "blockers":[name for name,ok in checked.items() if not ok] - + ["controller_license_review","source_availability_review", - "destination_host_qualification","live_transport_qualification", - "operator_live_play_approval"], - } - spec = importlib.util.spec_from_file_location( - "release1_aggregate_claims", - ROOT/"scripts/verify_assurance_contract.py") - require(spec is not None and spec.loader is not None, - "missing RELEASE-1 independent claim contract") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - module.verify_report(source_contents,"release_aggregate") + with verified_release_snapshot(root,source_sha,manifest) as sealed: + verified_http=load_verified_module( + sealed,"scripts/release_http_contract.py","release1_verified_http") + verified_smoke=load_verified_module( + sealed,"scripts/qualify-release-smoke.py","release1_verified_smoke") + tests={} + for key in ("r00-white","r00-black","r02-white","r10-white"): + target=smoke_dir/key/"result.json" + require(target.is_file(), "release-image smoke scenario missing: "+key) + result=load(target) + independent=check_smoke_evidence(target,key,verified_smoke) + scenario,color=key.split("-",1) + require(independent.get("independent_qualified") is True and + independent.get("persisted_sqlite_verified") is True and + independent.get("release_blocked") is True and + independent.get("run_id") == key and + independent.get("scenario") == scenario.upper() and + independent.get("color") == color and + independent.get("public_bot_release") is False, + "independent chess/HTTP/SQLite evidence failed: "+key) + require(result.get("offline") is True and + result.get("ready") is True and + result.get("runner_errors")==[] and + result.get("public_bot_release") is False, + "release-image smoke failed: "+key) + game=result.get("game") or {} + require(game.get("forbidden_attempts")==[], + "release image simulator recorded forbidden API attempts: "+key) + requests=result.get("http") + require(isinstance(requests,list), + "release-image complete HTTP audit missing: "+key) + require(all(allowed_release_http(r,key,verified_http) for r in requests), + "release image HTTP contains prohibited request: "+key) + if key=="r10-white": + require(result.get("rate_cooldown_verified") is True, + "release-image 429 cooldown witness absent") + if key=="r02-white": + require(type(result.get("fresh_second_epoch_commits")) is int and + result["fresh_second_epoch_commits"]>0, + "release image restart has no fresh confirmed move") + if key!="r10-white": + require(result["game"]["complete"] is True, + "offline completed-game requirement not met: "+key) + else: + require(result["game"]["complete"] is False, + "rate-limit test unexpectedly marked game as complete") + tests[key]={"passed":True,"independent_evidence":independent, + "accepted_moves":len(result["game"]["accepted"] or []), + "phase_count":len(result["observations"])} + wheel_hash=digest(stage/"wheels/runtime-wheels.lock") + wheel_inv=digest(wheel_inventory) + native_hash=digest(os_inventory) + checked={ + "os_lock":bool(image_policy["os_package_lock_complete"] and + image_policy.get("exact_os_package_inventory_sha256")==native_hash), + "wheel_lock":bool(image_policy["wheel_lock_complete"] and + image_policy.get("exact_runtime_wheel_lock_sha256")==wheel_hash and + image_policy.get("exact_runtime_wheel_inventory_sha256")==wheel_inv), + } + source_contents={ + "schema_version":1,"source_commit":source, + "image_manifest_digest":oci_report["oci_image_manifest_digest"], + "oci_image_config_digest":oci_report["oci_config_digest"], + "oci_archive_sha256":oci_report["oci_archive_sha256"], + "base_image":image_policy["base_image"], + "apt_snapshot":image_policy["apt_snapshot"], + "native_inventory_sha256":native_hash, + "wheel_requirements_sha256":wheel_hash, + "wheel_inventory_sha256":wheel_inv, + "wheel_bytes":runtime_wheels["wheels"], + "smoke":tests, + "oci_evidence":{field:oci_report.get(field) for field in ( + "integrity_verified","oci_layers_verified", + "oci_diff_ids_verified","oci_application_identity_verified", + "oci_attestation_references_verified", + "oci_attestation_subjects_verified", + "oci_sbom_verified","oci_provenance_verified")}, + "image_integrity_qualified":True, + "frozen_dependency_lock_qualified":all(checked.values()), + "dependency_lock_checks":checked, + # Source and image integrity do not grant host/legal/live authority. + # This deliberately remains false even if dependency pins are filled. + "offline_candidate_qualified":False, + "license_approved":False,"destination_host_qualified":False, + "network_recovery_qualified":False, + "artifact_distribution_authorized":False, + "public_bot_release":False,"release_promotion_authorized":False, + "release_blocked":True, + "blockers":[name for name,ok in checked.items() if not ok] + + ["controller_license_review","source_availability_review", + "destination_host_qualification","live_transport_qualification", + "operator_live_play_approval"], + } + verified_claims=load_verified_module( + sealed,"scripts/verify_assurance_contract.py","release1_verified_claims") + verified_claims.verify_report(source_contents,"release_aggregate") return source_contents From 4f8138a99aac6d4f12cff94d1aecad1f74177765 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sun, 18 Oct 2026 03:35:28 +0200 Subject: [PATCH 128/147] test(RELEASE-1): isolate aggregate logic tests from authenticated module loader Retain existing synthetic aggregate acceptance and HTTP policy tests while making source admission an explicit, independently testable dependency. --- tests/release/test_release_aggregate.py | 25 ++++++++++++++++----- tests/release/test_release_http_contract.py | 6 ++--- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/tests/release/test_release_aggregate.py b/tests/release/test_release_aggregate.py index cd0e6489..0e885d53 100644 --- a/tests/release/test_release_aggregate.py +++ b/tests/release/test_release_aggregate.py @@ -2,6 +2,7 @@ from __future__ import annotations import importlib.util +from contextlib import nullcontext import json import tempfile import unittest @@ -16,6 +17,12 @@ spec.loader.exec_module(aggregate) +def smoke_http_for_tests(): + """Tests use the reference policy explicitly, never as a hidden import.""" + from tests.release.test_release_http_contract import smoke + return smoke.http_contract + + def write_json(path: Path, data: dict) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(data, sort_keys=True) + "\n", encoding="utf-8") @@ -112,8 +119,10 @@ def qualify(self, **overrides): root=self.root) arguments.update(overrides) with mock.patch.object(aggregate.subprocess, "check_output", side_effect=self.git), \ + mock.patch.object(aggregate,"verified_release_snapshot", + return_value=nullcontext(ROOT)), \ mock.patch.object(aggregate, "check_smoke_evidence", - side_effect=lambda path,key: {"independent_qualified":True, + side_effect=lambda path,key,*args: {"independent_qualified":True, "persisted_sqlite_verified":True, "release_blocked":True, "run_id":key, @@ -162,21 +171,21 @@ def test_http_admission_is_consistent_with_independent_verifier(self): "request_target":"/api/bot/game/off3b001/move/e2e4", "target_canonical":True, "status":200,"request_id":"request-1"} - self.assertTrue(aggregate.allowed_release_http(token,"r00-white")) - self.assertTrue(aggregate.allowed_release_http(move,"r00-white")) + self.assertTrue(aggregate.allowed_release_http(token,"r00-white",verified_contract=smoke_http_for_tests())) + self.assertTrue(aggregate.allowed_release_http(move,"r00-white",verified_contract=smoke_http_for_tests())) for bad in ({**move,"status":400}, {**move,"status":429}, {**move,"request_id":None}, {**move,"path":"/api/bot/game/off3b001/abort"}, {**token,"status":500}, {**token,"status":True}): - self.assertFalse(aggregate.allowed_release_http(bad,"r00-white")) + self.assertFalse(aggregate.allowed_release_http(bad,"r00-white",verified_contract=smoke_http_for_tests())) self.assertTrue(aggregate.allowed_release_http( - {**move,"status":429},"r10-white")) + {**move,"status":429},"r10-white",verified_contract=smoke_http_for_tests())) def test_aggregate_requires_persisted_sqlite_qualified_and_specific_case(self): for mutation in ("without-sqlite","wrong-case","wrong-disposition"): - def fake(path,key): + def fake(path,key,*args): report={"independent_qualified":True, "persisted_sqlite_verified":True, "release_blocked":True, @@ -193,6 +202,8 @@ def fake(path,key): with self.subTest(mutation=mutation), \ mock.patch.object(aggregate.subprocess,"check_output", side_effect=self.git), \ + mock.patch.object(aggregate,"verified_release_snapshot", + return_value=nullcontext(ROOT)), \ mock.patch.object(aggregate,"check_smoke_evidence", side_effect=fake): with self.assertRaisesRegex(ValueError,"chess/HTTP/SQLite"): @@ -314,6 +325,8 @@ def test_no_producer_ready_bit_bypasses_independent_checker(self): # The test deliberately leaves the producer saying ready=true. self.assertTrue(original["ready"]) with mock.patch.object(aggregate.subprocess,"check_output",side_effect=self.git), \ + mock.patch.object(aggregate,"verified_release_snapshot", + return_value=nullcontext(ROOT)), \ mock.patch.object(aggregate,"check_smoke_evidence", side_effect=ValueError("illegal request in independently replayed trace")): with self.assertRaisesRegex(ValueError,"illegal request"): diff --git a/tests/release/test_release_http_contract.py b/tests/release/test_release_http_contract.py index 5ea18924..3d6551a3 100644 --- a/tests/release/test_release_http_contract.py +++ b/tests/release/test_release_http_contract.py @@ -68,7 +68,7 @@ def test_each_allowed_get_and_case_agrees_across_both_verifiers(self): with self.subTest(case=case,path=path): self.assertTrue(smoke.http_contract.allowed_release_http( row,case)) - self.assertEqual(aggregate.allowed_release_http(row,case), + self.assertEqual(aggregate.allowed_release_http(row,case,smoke.http_contract), smoke.http_contract.allowed_release_http( row,case)) @@ -95,7 +95,7 @@ def test_no_unauthorized_get_or_failed_status_is_admissible(self): with self.subTest(row=row): self.assertFalse(smoke.http_contract.allowed_release_http( row,"r00-white")) - self.assertFalse(aggregate.allowed_release_http(row,"r00-white")) + self.assertFalse(aggregate.allowed_release_http(row,"r00-white",smoke.http_contract)) def test_rejected_raw_query_is_hashed_before_audit(self): from tests.lichess.http_target_contract import safe_audit_fields @@ -118,7 +118,7 @@ def test_legitimate_upstream_query_forms_are_admitted(self): row = {"method":method, "path":raw.split("?")[0], "request_target":raw, "target_canonical":True, "status":200,"request_id":rid} - self.assertTrue(aggregate.allowed_release_http(row,"r00-white")) + self.assertTrue(aggregate.allowed_release_http(row,"r00-white",smoke.http_contract)) if __name__=="__main__": unittest.main() From 8cf7510ee136529c7fe221975f7dcb6db7ad8214 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sun, 18 Oct 2026 03:35:49 +0200 Subject: [PATCH 129/147] test(RELEASE-1): import aggregate HTTP policy fixture by absolute source path --- tests/release/test_release_aggregate.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/release/test_release_aggregate.py b/tests/release/test_release_aggregate.py index 0e885d53..a41b53c7 100644 --- a/tests/release/test_release_aggregate.py +++ b/tests/release/test_release_aggregate.py @@ -18,9 +18,12 @@ def smoke_http_for_tests(): - """Tests use the reference policy explicitly, never as a hidden import.""" - from tests.release.test_release_http_contract import smoke - return smoke.http_contract + """Explicit test-only loading of the unmodified shared HTTP contract.""" + spec=importlib.util.spec_from_file_location( + "release1_http_for_aggregate_test",ROOT/"scripts/release_http_contract.py") + module=importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module def write_json(path: Path, data: dict) -> None: From 1d9d8260c84b6312d0710e298d67a9d3e3d2ae61 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sun, 18 Oct 2026 03:36:55 +0200 Subject: [PATCH 130/147] test(RELEASE-1): real Git source, rehashed worktree and TOCTOU snapshot counterexamples --- .../test_release_source_attestation.py | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 tests/release/test_release_source_attestation.py diff --git a/tests/release/test_release_source_attestation.py b/tests/release/test_release_source_attestation.py new file mode 100644 index 00000000..076c8b09 --- /dev/null +++ b/tests/release/test_release_source_attestation.py @@ -0,0 +1,130 @@ +"""Real Git-byte and staged-manifest authorization tests for RELEASE-1.""" +from __future__ import annotations + +import hashlib +import importlib.util +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT=Path(__file__).resolve().parents[2] +spec=importlib.util.spec_from_file_location( + "release1_attested_aggregate",ROOT/"scripts/qualify-release-aggregate.py") +aggregate=importlib.util.module_from_spec(spec) +spec.loader.exec_module(aggregate) + + +class ReleaseVerifierSourceAttestationTests(unittest.TestCase): + def setUp(self): + temp=tempfile.TemporaryDirectory(prefix="release-source-contract-") + self.addCleanup(temp.cleanup) + self.root=Path(temp.name) + self.git("init","-q") + self.git("config","user.email","local-only@example.test") + self.git("config","user.name","Offline Source Test") + self.source_paths=aggregate.VERDICT_SOURCE_PATHS + for relative in self.source_paths: + target=self.root/relative + target.parent.mkdir(parents=True,exist_ok=True) + target.write_bytes((ROOT/relative).read_bytes()) + self.git("add",".") + self.git("commit","-qm","bind exact-source") + self.source=self.git("rev-parse","HEAD",capture=True) + self.attestation={"source_attestation":{ + relative:hashlib.sha256((self.root/relative).read_bytes()).hexdigest() + for relative in self.source_paths}} + + def git(self,*args,capture=False): + command=["git","-C",str(self.root),*args] + result=subprocess.run(command,check=True,capture_output=True, + text=True,timeout=20) + return result.stdout.strip() if capture else None + + def test_clean_source_loads_from_sealed_snapshot(self): + with aggregate.verified_release_snapshot( + self.root,self.source,self.attestation) as sealed: + http=aggregate.load_verified_module( + sealed,"scripts/release_http_contract.py","sealed_http_positive") + row={"method":"GET","path":"/api/account","status":200, + "request_target":"/api/account","target_canonical":True} + self.assertTrue(http.allowed_release_http(row,"r00-white")) + smoke=aggregate.load_verified_module( + sealed,"scripts/qualify-release-smoke.py","sealed_smoke_positive") + self.assertTrue(callable(smoke.verify_file)) + self.assertNotEqual(sealed.resolve(),self.root.resolve()) + + def test_worktree_mutation_cannot_reuse_old_manifest_and_head(self): + path=self.root/"scripts/qualify-release-smoke.py" + path.write_bytes(path.read_bytes()+b"\n# PRIVATE_CANARY\n") + with self.assertRaisesRegex(ValueError,"staged attestation"): + with aggregate.verified_release_snapshot( + self.root,self.source,self.attestation): + self.fail("mutated source accepted") + + def test_rehashed_mutation_cannot_forge_git_source_identity(self): + relative="scripts/qualify-release-smoke.py" + path=self.root/relative + path.write_bytes(path.read_bytes()+b"\n# altered verifier\n") + self.attestation["source_attestation"][relative]=hashlib.sha256( + path.read_bytes()).hexdigest() + with self.assertRaisesRegex(ValueError,"Git committed bytes"): + with aggregate.verified_release_snapshot( + self.root,self.source,self.attestation): + self.fail("rehashed forged source accepted") + + def test_transitive_http_policy_is_also_exactly_attested(self): + relative="tests/lichess/http_target_contract.py" + path=self.root/relative + path.write_bytes(path.read_bytes()+b"\n# altered HTTP policy\n") + self.attestation["source_attestation"][relative]=hashlib.sha256( + path.read_bytes()).hexdigest() + with self.assertRaisesRegex(ValueError,"Git committed bytes"): + with aggregate.verified_release_snapshot( + self.root,self.source,self.attestation): + self.fail("changed indirect helper accepted") + + def test_missing_and_malformed_manifest_attestation_fails(self): + self.attestation["source_attestation"].pop( + "scripts/qualify-release-aggregate.py") + with self.assertRaisesRegex(ValueError,"staged attestation"): + with aggregate.verified_release_snapshot( + self.root,self.source,self.attestation): + self.fail("missing self attestation accepted") + + def test_symlink_replacement_fails_closed(self): + target=self.root/"scripts/qualify-release-smoke.py" + backup=self.root/"untrusted-source.py" + backup.write_bytes(target.read_bytes()) + target.unlink() + target.symlink_to(backup) + with self.assertRaisesRegex(ValueError,"linked or outside"): + with aggregate.verified_release_snapshot( + self.root,self.source,self.attestation): + self.fail("linked source accepted") + + def test_original_mutation_after_snapshot_does_not_change_executed_bytes(self): + relative="scripts/qualify-release-smoke.py" + with aggregate.verified_release_snapshot( + self.root,self.source,self.attestation) as sealed: + original=(sealed/relative).read_bytes() + (self.root/relative).write_bytes(b"raise SystemExit('CANARY')\n") + self.assertEqual((sealed/relative).read_bytes(),original) + smoke=aggregate.load_verified_module( + sealed,relative,"sealed_smoke_after_mutation") + self.assertTrue(callable(smoke.verify_file)) + + def test_aggregate_verifier_itself_must_match_git_commit(self): + relative="scripts/qualify-release-aggregate.py" + path=self.root/relative + path.write_bytes(path.read_bytes()+b"\n# self mutation\n") + self.attestation["source_attestation"][relative]=hashlib.sha256( + path.read_bytes()).hexdigest() + with self.assertRaisesRegex(ValueError,"Git committed bytes"): + with aggregate.verified_release_snapshot( + self.root,self.source,self.attestation): + self.fail("changed aggregate entrypoint accepted") + + +if __name__=="__main__": + unittest.main() From 46b2b891f4f66267164bdd092d684f001e2617a8 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sun, 18 Oct 2026 03:37:57 +0200 Subject: [PATCH 131/147] fix(RELEASE-1 CI): verify scanner and aggregate verifier Git bytes before executing them Run independent trusted workflow checks before dynamic imports, in addition to the aggregate's manifest-and-Git sealed-module snapshot. --- .github/workflows/release-candidate.yml | 55 +++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 77b161a2..41112222 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -45,6 +45,30 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 with: ref: ${{ env.SOURCE_SHA }} + - name: Verify scanner and SPDX source against exact committed Git bytes + run: | + set -euo pipefail + python3 - <<'PY' + import os, subprocess + from pathlib import Path + source=os.environ["SOURCE_SHA"] + critical=( + "scripts/check-release-scanner.py", + "scripts/verify-release-oci.py", + "scripts/spdx_license_contract.py", + "qualification/spdx-license-identifiers-v3.29.0.json", + "qualification/release-image-v1.json", + ) + for relative in critical: + local=Path(relative) + if not local.is_file() or local.is_symlink(): + raise SystemExit("VERDICT_SOURCE_FILE_MISSING_OR_LINKED") + original=subprocess.run( + ["git","show",source+":"+relative], + check=False,capture_output=True,timeout=12) + if original.returncode or original.stdout!=local.read_bytes(): + raise SystemExit("VERDICT_SOURCE_BYTES_DIFFER_FROM_GIT") + PY - name: Independently qualify pinned scanner protocol without engine build run: | set -euo pipefail @@ -261,6 +285,37 @@ jobs: --metadata build/test-results/release1/oci-build-metadata.json \ --docker-inspect build/test-results/release1/docker-inspect.json \ --output build/test-results/release1/oci-report.json + - name: Verify every offline verdict module before aggregate execution + run: | + set -euo pipefail + python3 - <<'PY' + import os, subprocess + from pathlib import Path + source=os.environ["SOURCE_SHA"] + critical=( + "scripts/qualify-release-aggregate.py", + "scripts/qualify-release-smoke.py", + "scripts/release_http_contract.py", + "scripts/sanitize_qualification_evidence.py", + "scripts/verify_assurance_contract.py", + "tests/lichess/http_target_contract.py", + "qualification/assurance-contract-v1.json", + "scripts/verify-release-oci.py", + "scripts/spdx_license_contract.py", + "qualification/spdx-license-identifiers-v3.29.0.json", + "scripts/check-release-scanner.py", + "qualification/release-image-v1.json", + ) + for relative in critical: + local=Path(relative) + if not local.is_file() or local.is_symlink(): + raise SystemExit("VERDICT_SOURCE_FILE_MISSING_OR_LINKED") + original=subprocess.run( + ["git","show",source+":"+relative], + check=False,capture_output=True,timeout=12) + if original.returncode or original.stdout!=local.read_bytes(): + raise SystemExit("VERDICT_SOURCE_BYTES_DIFFER_FROM_GIT") + PY - name: Observe test-runner capacity and aggregate evidence without promoting release run: | python3 scripts/qualify-release-host.py \ From 3d305986d9353e50084f2afd84ce6d89f4680c27 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sun, 18 Oct 2026 03:39:23 +0200 Subject: [PATCH 132/147] docs(ASSURANCE): reconcile five Codex findings, SPDX version semantics and sealed-source workflow Extend PR71 finding ledger and operator documentation; assert that CI verifies verdict-bearing modules before import. --- docs/PR71_ASSURANCE_REPAIR_LEDGER.md | 21 ++++++++++ docs/RELEASE_1_OPERATIONS.md | 56 +++++++++++++++++++++---- tests/release/test_workflow_coverage.py | 24 +++++++++++ 3 files changed, 92 insertions(+), 9 deletions(-) diff --git a/docs/PR71_ASSURANCE_REPAIR_LEDGER.md b/docs/PR71_ASSURANCE_REPAIR_LEDGER.md index b744484d..dfda3404 100644 --- a/docs/PR71_ASSURANCE_REPAIR_LEDGER.md +++ b/docs/PR71_ASSURANCE_REPAIR_LEDGER.md @@ -107,3 +107,24 @@ GitHub recorded 58 threads at the time of ledger construction: 42 outdated diff 4. Preserve independently verified source byte closure, chess legality, actor attribution, HTTP statuses, SQLite replay, OCI archive identity and all safe-output boundaries. 5. Request fresh exact-head Codex code and security review. Reconcile every actionable new finding with source/test/CI evidence. 6. Leave all source availability, distribution, licensing, operator, host, live transport, and publication gates closed. + +## October 18 Codex repair wave (source-implemented, exact-head CI pending) + +The five fresh [Codex findings](https://github.com/HMarcusWH/AllfatherChess/pull/71) +from review of `16faccaba5` remain actionable until their source, negative +counterexamples, **real pinned scanner**, packaged OCI integration and new +exact-head review are reconciled. No finding is declared closed by this +ledger alone. + +| Finding | Source repair | Required negative oracle | +| --- | --- | --- | +| [r4305073147](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4305073147) SPDX 2.2/2.3 | Version-specific package fields, strict 2.2 analyzed package code, valid 2.3 optional semantics | Rehashed 2.2 missing fields reject; 2.3 valid optional cases accept | +| [r4305073150](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4305073150) SLSA probe | Require exactly one v1 provenance statement with pinned builder | Missing, duplicate, wrong-version, wrong-builder all reject | +| [r4305073152](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4305073152) License expressions | Offline SPDX 2.x parser and pinned 740-license/86-exception catalogue v3.29.0 | Unknown IDs, malformed operators and injected free text reject | +| [r4305073153](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4305073153) Verifier source | Compare stage/Git/executed bytes and import from private verified snapshot | Modified, rehashed, linked and post-snapshot mutations reject | +| [r4305073154](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4305073154) SPDX file checksums | Valid SHA-1 mandatory, others permitted if well-formed | SHA256-only, malformed and duplicate SHA-1 reject | + +**Stop rule:** all eleven exact-head workflow groups complete successfully, +including real RELEASE-1 OCI/smoke, guarded recovery, LC0 and LOCAL-1; +then a fresh exact-head Codex code/security review with no outstanding +unaddressed findings. Offline test authority never becomes online authority. diff --git a/docs/RELEASE_1_OPERATIONS.md b/docs/RELEASE_1_OPERATIONS.md index 3707c688..5b11c32c 100644 --- a/docs/RELEASE_1_OPERATIONS.md +++ b/docs/RELEASE_1_OPERATIONS.md @@ -110,13 +110,22 @@ platform identity, **not** a publisher signature or distribution permission. Field-specific failures must be reported using fixed classifications, never by echoing arbitrary provenance strings into public CI logs. -SPDX 2.2/2.3 structural validity is distinct from Allfather image policy. -For example, a missing package `copyrightText` implies `NOASSERTION`; a -missing `filesAnalyzed` defaults to true; and an optional -`packageVerificationCode`, if present, must have a valid SHA-1 value. -The image-level verifier still independently binds attestation subjects to -the application manifest and checks the OCI layer graph and archive bytes. -A syntactically valid inventory does not prove licensing compliance. +SPDX 2.2 and SPDX 2.3 have different package-field requirements. In the +strict SPDX 2.2 profile, package copyright and concluded/declared license +fields are mandatory, and analyzed packages require a verification code. +SPDX 2.3 permits those package metadata fields and the verification code to +be absent, with NOASSERTION semantics where specified. In both versions, +`filesAnalyzed` defaults to true; a supplied verification code must be +valid, and `filesAnalyzed=false` prohibits verification codes and +file-license arrays. Every SPDX file record must contain a valid SHA-1 +checksum, with optional additional supported digests. + +Every supplied license field is checked by the source-attested offline SPDX +2.x expression parser against the frozen SPDX License List v3.29.0 +(`qualification/spdx-license-identifiers-v3.29.0.json`); syntactically +valid licenses do not establish actual copyright permissions. The OCI verifier +independently checks application manifest subjects, the full layer graph, +archive bytes and provenance, and never authorizes distribution. ## Image, host, licensing and governance checks @@ -274,7 +283,16 @@ The green supporting workflows and full image smoke testing remain necessary but **Pinned snapshot acquisition:** The image still starts from the digest-pinned Ubuntu base and two native APT sources with GPG Signed-By and the frozen Snapshot field. The minimal base bootstraps CA trust from signed native HTTP indexes as before. Remaining packages are installed with deploy/release/install-pinned-apt.sh: up to four bounded attempts for transient HTTP 429/502/503/504 responses, with old index lists discarded after failure. Hash, signature, certificate or unrecognized download failures are fatal; there is no mirror or unsigned fallback. An availability-only snapshot preflight runs before expensive engine compilation and emits a sanitized JSON diagnostic. It does not replace APT signature validation. -**Scanner preflight:** A separate scanner-compatibility job builds a minimal OCI candidate from only the pinned base image, without the chess engines or extra APT packages, and requests an SPDX SBOM from the digest-pinned Syft generator. scripts/check-release-scanner.py scans the real in-toto SPDX statement and verifies the supported document/inventory contract. This check proves generator compatibility, **not** full application subject binding, publisher signature, licensing, or release readiness. The actual RELEASE-1 OCI export still requires the independent full image/config/layers/SBOM/SLSA verifier after four packaged offline smoke scenarios. +**Scanner preflight:** The scanner-compatibility job builds a minimal OCI +candidate without chess engines, requests the digest-pinned Syft SBOM and +minimal SLSA v1 provenance, then independently requires exactly one supported +SPDX document and one SLSA v1 statement with the builder identity frozen in +`qualification/release-image-v1.json`. Wrong/missing/duplicate provenance +cannot be hidden behind a valid SPDX sibling. The result proves a bounded +scanner/provenance interface only, **not** full image identity, publisher +signature, licensing, or release readiness. The real OCI export still +requires independently verified full-image attestation after four packaged +offline smoke cases. **SPDX source rules:** Package downloadLocation may be NONE, NOASSERTION, or a supported syntactically valid URL/VCS location without embedded secrets. File and optional package checksums require supported exact algorithm and canonical lowercase fixed-width digest. Variable-width MD6 is explicitly unsupported in the release profile. A structurally valid SPDX document is not evidence of actual package-download provenance. The SPDX schema and release-profile policy are separate claims. @@ -340,6 +358,26 @@ Review the current source closure, HTTP, OCI/SLSA and sanitized-report contract An injected HTTP fault must be reflected in the *targeted request's observed HTTP status*, not just in an independent trace flag. The aggregate must independently link the OCI archive digest/layers and full SPDX/SLSA/subject graph to its positive `image_integrity_qualified` claim. Scanner preflight cannot select one valid SPDX sibling while silently dropping other malformed attestation statements. -SPDX 2.3 package fields follow normative conditional rules: `copyrightText` is required; `filesAnalyzed` defaults to true when absent, and analyzed packages require a valid verification code. `licenseConcluded` and `licenseDeclared` are optional (NOASSERTION semantics when omitted); `filesAnalyzed=false` packages omit verification codes and file-license arrays. Strict malformed-field checks must not treat optional licenses as universally mandatory. +The October 18 SPDX review exposed a version-confusion risk: SPDX 2.2 package +copyright/license fields remain mandatory while SPDX 2.3 permits omission; +the file SHA-1 checksum rule applies to both versions. Neither an optional +verification code nor a missing one may bypass validation of supplied license +information. This is now enforced by version-specific negative controls. The bounded APT acquisition retry remains restricted to individually recognized transient statuses. A valid `apt-get install` unable-to-fetch summary may accompany those statuses, but unknown errors never become retryable by including that summary. All green checks must originate at one exact Git SHA; smoke/OCI data from another head cannot grant release promotion. + +## October 18 — verified interpreter inputs before qualification + +The RELEASE-1 workflow independently compares its scanner/OCI and aggregate +verifier files against Git's exact committed bytes **before execution**. +During aggregation, `scripts/qualify-release-aggregate.py` additionally +requires the stage's `source_attestation` hashes to match the actual +working-tree bytes and `git show SOURCE_SHA:path`. It then runs the +independent smoke, shared HTTP and claims verifiers from a private snapshot +containing only those verified bytes, protecting against later mutation of +the checkout. The aggregate cannot trust a changed smoke verifier merely +because `git rev-parse HEAD` is unchanged. + +These checks bind a qualification to its source bytes; they do **not** confer +operator authority, license clearance, authenticated publisher signatures, +network recovery claims or rights to distribute a binary image. diff --git a/tests/release/test_workflow_coverage.py b/tests/release/test_workflow_coverage.py index e6398032..780c27b9 100644 --- a/tests/release/test_workflow_coverage.py +++ b/tests/release/test_workflow_coverage.py @@ -60,6 +60,30 @@ def test_pinned_scanner_is_exercised_without_chess_build(self): self.assertNotIn("make build-online-engine-opt-v2",scanner) self.assertNotIn("release1-scanner/scanner.oci.tar\n if",scanner) + def test_release_verdict_sources_are_git_attested_before_import(self): + source=(ROOT/".github/workflows/release-candidate.yml").read_text() + self.assertIn( + "Verify scanner and SPDX source against exact committed Git bytes", + source) + self.assertIn( + "Verify every offline verdict module before aggregate execution", + source) + self.assertIn("VERDICT_SOURCE_BYTES_DIFFER_FROM_GIT",source) + builder=(ROOT/"scripts/build-release-candidate.py").read_text() + for relative in ( + "scripts/qualify-release-aggregate.py", + "scripts/qualify-release-smoke.py", + "scripts/release_http_contract.py", + "scripts/verify-release-oci.py", + "scripts/spdx_license_contract.py", + "qualification/spdx-license-identifiers-v3.29.0.json", + ): + with self.subTest(relative=relative): + self.assertIn('"' + relative + '"',builder) + source_code=(ROOT/"scripts/qualify-release-aggregate.py").read_text() + self.assertIn("with verified_release_snapshot",source_code) + self.assertIn("Git committed bytes",source_code) + def test_release_artifacts_exclude_raw_provenance_and_image_bytes(self): content=(ROOT/".github/workflows/release-candidate.yml").read_text() upload=content.split("- name: Do not publish combined image",1)[1] From 56bbdf10afb86cd95d936f4b4fc766fd710c0e06 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sun, 18 Oct 2026 14:58:43 +0200 Subject: [PATCH 133/147] feat(RELEASE-1): source-pin v1.11.0 Syft scanner and add dual SHA1/SHA256 hashing Freeze upstream scanner commit d88056b4, its vendored Syft v1.42.3 and a single exact-source patch. Compile twice using pinned Go 1.25.8 and vendored deps, compare static binaries, and forbid promotion until generated identities are reviewed. --- deploy/release/scanner/Dockerfile | 4 + deploy/release/scanner/target.go.patch | 28 +++++ qualification/release-scanner-v1.json | 25 ++++ scripts/prepare-release-scanner.py | 166 +++++++++++++++++++++++++ 4 files changed, 223 insertions(+) create mode 100644 deploy/release/scanner/Dockerfile create mode 100644 deploy/release/scanner/target.go.patch create mode 100644 qualification/release-scanner-v1.json create mode 100644 scripts/prepare-release-scanner.py diff --git a/deploy/release/scanner/Dockerfile b/deploy/release/scanner/Dockerfile new file mode 100644 index 00000000..a8981c8e --- /dev/null +++ b/deploy/release/scanner/Dockerfile @@ -0,0 +1,4 @@ +FROM scratch +COPY --chmod=0555 syft-scanner /bin/syft-scanner +ENV LOG_LEVEL=warn +ENTRYPOINT ["/bin/syft-scanner"] diff --git a/deploy/release/scanner/target.go.patch b/deploy/release/scanner/target.go.patch new file mode 100644 index 00000000..866986bc --- /dev/null +++ b/deploy/release/scanner/target.go.patch @@ -0,0 +1,28 @@ +diff --git a/internal/target.go b/internal/target.go +--- a/internal/target.go ++++ b/internal/target.go +@@ -15,7 +15,10 @@ + package internal + + import ( + "context" ++ "crypto" ++ _ "crypto/sha1" ++ _ "crypto/sha256" + "fmt" + "os" + "path/filepath" +@@ -59,10 +62,13 @@ + sr = pkgcataloging.NewSelectionRequest().WithExpression(strings.Split(v, ",")...) + } + ++ fileHashes := filecataloging.DefaultConfig().WithHashers(crypto.SHA1, crypto.SHA256) ++ + result, err := syft.CreateSBOM( + ctx, + src, + syft.DefaultCreateSBOMConfig(). ++ WithFilesConfig(fileHashes). + WithCatalogerSelection(sr)) + if err != nil { + return sbom.SBOM{}, err diff --git a/qualification/release-scanner-v1.json b/qualification/release-scanner-v1.json new file mode 100644 index 00000000..ee977743 --- /dev/null +++ b/qualification/release-scanner-v1.json @@ -0,0 +1,25 @@ +{ + "schema_version": 1, + "scope": "offline-release-scanner-only", + "upstream_repository": "https://github.com/docker/buildkit-syft-scanner", + "upstream_tag": "v1.11.0", + "upstream_commit": "d88056b4e5b61d0ca037340df91be47d343b4386", + "toolchain_version": "go1.25.8", + "vendored_syft_version": "v1.42.3", + "patch_path": "deploy/release/scanner/target.go.patch", + "patch_sha256": "87fbe492c4b504374b41a6303ebc75833559bacb83addf9e5520b9768cc37d09", + "binary_path": "bin/syft-scanner", + "required_file_hash_algorithms": [ + "SHA1", + "SHA256" + ], + "scanner_dockerfile": "deploy/release/scanner/Dockerfile", + "registry_transport_image": "docker.io/library/registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373", + "approved_binary_sha256": null, + "approved_manifest_digest": null, + "reproducible_binary_required": true, + "reproducible_image_required": true, + "public_bot_release": false, + "artifact_distribution_authorized": false, + "live_credentials_authorized": false +} diff --git a/scripts/prepare-release-scanner.py b/scripts/prepare-release-scanner.py new file mode 100644 index 00000000..0a8ee2d5 --- /dev/null +++ b/scripts/prepare-release-scanner.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""Build a locally patched, source-locked BuildKit scanner. No release grant. + +Only commits from the frozen v1.11.0 Git tree, one checked patch, and vendored +Go dependencies may determine the executable. Produces an unprivileged test +binary; Docker/OCI transport and release authority are independent gates. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import subprocess +import tempfile +from pathlib import Path + +ROOT=Path(__file__).resolve().parents[1] +LOCK=ROOT/"qualification/release-scanner-v1.json" + + +def require(condition: bool, message: str): + if not condition: + raise ValueError(message) + + +def sha256(path: Path) -> str: + h=hashlib.sha256() + with path.open("rb") as f: + for block in iter(lambda:f.read(1024*1024),b""): + h.update(block) + return h.hexdigest() + + +def command(argv: list[str], *, cwd: Path|None=None, + env: dict|None=None, timeout: int=300, + capture: bool=True) -> str: + p=subprocess.run(argv,cwd=cwd,env=env,timeout=timeout, + stdout=subprocess.PIPE if capture else None, + stderr=subprocess.PIPE if capture else None, + check=False) + require(p.returncode==0, + "scanner source/build operation failed: "+argv[0]) + return p.stdout.decode("utf-8","replace").strip() if capture else "" + + +def check_lock(policy: dict) -> None: + require(policy.get("schema_version")==1 and + policy.get("scope")=="offline-release-scanner-only" and + policy.get("upstream_repository")== + "https://github.com/docker/buildkit-syft-scanner" and + policy.get("upstream_tag")=="v1.11.0" and + policy.get("upstream_commit")== + "d88056b4e5b61d0ca037340df91be47d343b4386" and + policy.get("toolchain_version")=="go1.25.8" and + policy.get("vendored_syft_version")=="v1.42.3" and + policy.get("patch_path")== + "deploy/release/scanner/target.go.patch" and + policy.get("required_file_hash_algorithms")==["SHA1","SHA256"] and + policy.get("reproducible_binary_required") is True and + policy.get("reproducible_image_required") is True and + policy.get("public_bot_release") is False and + policy.get("artifact_distribution_authorized") is False and + policy.get("live_credentials_authorized") is False, + "scanner immutable source/build policy mismatch") + digest=policy.get("patch_sha256") + require(type(digest) is str and len(digest)==64 and + all(c in "0123456789abcdef" for c in digest) and + sha256(ROOT/policy["patch_path"])==digest, + "scanner patch bytes differ from reviewed lock") + + +def prepare(*, output: Path) -> dict: + policy=json.loads(LOCK.read_text(encoding="utf-8")) + check_lock(policy) + require(output.resolve().is_relative_to(ROOT.resolve()), + "scanner build output must remain within checkout") + require(not output.is_symlink(),"scanner build output cannot be a symlink") + output.mkdir(parents=True,exist_ok=True) + env=os.environ.copy() + env.update({"GOPROXY":"off","GOSUMDB":"off","GOTOOLCHAIN":"local", + "GOFLAGS":"-mod=vendor","CGO_ENABLED":"0", + "GOOS":"linux","GOARCH":"amd64"}) + tool=command(["go","version"],env=env) + require(tool.startswith("go version go1.25.8 "), + "scanner requires exact Go 1.25.8 toolchain") + patch=ROOT/policy["patch_path"] + with tempfile.TemporaryDirectory(prefix="release1-scanner-src-") as td: + source=Path(td)/"upstream" + command(["git","clone","--depth=1","--single-branch", + "--branch",policy["upstream_tag"], + policy["upstream_repository"],str(source)],timeout=600) + require(command(["git","rev-parse","HEAD"],cwd=source)== + policy["upstream_commit"], + "scanner upstream source SHA mismatch") + require(command(["git","status","--porcelain"],cwd=source)=="", + "scanner upstream checkout not clean") + gomod=(source/"go.mod").read_text(encoding="utf-8") + require("\ngo 1.25.8\n" in gomod and + "github.com/anchore/syft v1.42.3" in gomod and + (source/"vendor/modules.txt").is_file(), + "scanner vendored dependency baseline mismatch") + command(["git","apply","--check",str(patch)],cwd=source) + command(["git","apply",str(patch)],cwd=source) + require(command(["git","diff","--name-only"],cwd=source)== + "internal/target.go", + "scanner patch modified unapproved source") + command(["git","diff","--check"],cwd=source) + patched=source/"internal/target.go" + require('WithHashers(crypto.SHA1, crypto.SHA256)' in + patched.read_text(encoding="utf-8"), + "scanner required hashers missing") + epoch=command(["git","show","-s","--format=%ct","HEAD"],cwd=source) + env["SOURCE_DATE_EPOCH"]=epoch + ldflags=("-buildid= "+ + "-X github.com/docker/buildkit-syft-scanner/version.Version=v1.11.0 "+ + "-X github.com/docker/buildkit-syft-scanner/version.SyftVersion=v1.42.3") + binary_a=output/"scanner-build-a" + binary_b=output/"scanner-build-b" + for dest in (binary_a,binary_b): + command(["go","build","-trimpath","-buildvcs=false", + "-ldflags",ldflags,"-o",str(dest), + "./cmd/syft-scanner"],cwd=source,env=env,timeout=1200) + binary_hash=sha256(binary_a) + require(binary_hash==sha256(binary_b), + "scanner reproducible executable bytes differ") + if policy.get("approved_binary_sha256") is not None: + require(binary_hash==policy["approved_binary_sha256"], + "scanner executable differs from reviewed binary identity") + final=output/"syft-scanner" + shutil.copyfile(binary_a,final) + final.chmod(0o555) + report={ + "schema_version":1, + "upstream_commit":policy["upstream_commit"], + "go_version":policy["toolchain_version"], + "vendored_syft_version":policy["vendored_syft_version"], + "source_patch_sha256":policy["patch_sha256"], + "binary_sha256":binary_hash, + "binary_reproducible":True, + "source_locked":True, + "source_date_epoch":int(epoch), + "public_bot_release":False, + "artifact_distribution_authorized":False, + } + (output/"scanner-source.json").write_text( + json.dumps(report,indent=2,sort_keys=True)+"\n",encoding="utf-8") + print(json.dumps(report,sort_keys=True)) + return report + + +def main() -> None: + p=argparse.ArgumentParser() + p.add_argument("--output",type=Path, + default=ROOT/"build/test-results/release1-scanner") + args=p.parse_args() + try: + prepare(output=args.output) + except (OSError,ValueError,subprocess.SubprocessError,TimeoutError) as ex: + p.exit(2,"release scanner source candidate refused: "+ + type(ex).__name__+"\n") + + +if __name__=="__main__": + main() From 7ba31061df5486a719951192734b16a7dab9fd31 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sun, 18 Oct 2026 15:01:45 +0200 Subject: [PATCH 134/147] feat(RELEASE-1): deterministic local BuildKit scanner transport and separate digest-approval gate Compile the exact-source scanner, double-build its scratch image, use a digest-addressed temporary local registry on the BuildKit network, and refuse promotion until independent frozen image/binary digests match. --- scripts/ci-release-scanner.py | 205 +++++++++++++++++++ tests/release/test_release_scanner_source.py | 113 ++++++++++ 2 files changed, 318 insertions(+) create mode 100644 scripts/ci-release-scanner.py create mode 100644 tests/release/test_release_scanner_source.py diff --git a/scripts/ci-release-scanner.py b/scripts/ci-release-scanner.py new file mode 100644 index 00000000..7a8d9495 --- /dev/null +++ b/scripts/ci-release-scanner.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""Prepare a source-locked offline scanner through a disposable Docker registry. + +This transports exact scanner bytes between the host Docker daemon and a +docker-container BuildKit daemon. No image is pushed to a public registry. +A candidate manifest is NEVER its own release approval. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import subprocess +import time +from pathlib import Path +from urllib.request import Request, urlopen + +from scripts.prepare_release_scanner import ROOT, LOCK, prepare, require + +REGISTRY_HOST="127.0.0.1:5000" +REGISTRY_BUILDKIT="scanner-registry:5000" +NAME="allfather/release-scanner" +MEDIA_TYPES=("application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json") +OUT=ROOT/"build/test-results/release1-scanner" + + +def call(args: list[str], *, timeout: int=900) -> str: + result=subprocess.run(args,check=False,stdout=subprocess.PIPE, + stderr=subprocess.PIPE,timeout=timeout) + require(result.returncode==0, + "scanner Docker/registry operation refused: "+args[0]) + return result.stdout.decode("utf-8","replace").strip() + + +def docker_names() -> tuple[str,str]: + run=os.environ.get("GITHUB_RUN_ID","local") + attempt=os.environ.get("GITHUB_RUN_ATTEMPT","1") + require(run.isdigit() or run=="local","scanner GitHub run identity malformed") + require(attempt.isdigit(),"scanner GitHub attempt identity malformed") + return "release1-scanner-"+run+"-"+attempt, "release1-scanner-registry-"+run + + +def builder_name() -> str: + mode=os.environ.get("RELEASE1_SCANNER_BUILDER","release1-scanner-probe") + require(mode in ("release1-scanner-probe","release1-builder"), + "unapproved scanner builder name") + return mode + + +def cleanup() -> None: + network,registry=docker_names() + for args in (["docker","buildx","rm","--force",builder_name()], + ["docker","rm","-f",registry], + ["docker","network","rm",network]): + try: + subprocess.run(args,stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL,timeout=35,check=False) + except (OSError,subprocess.TimeoutExpired): + pass + + +def response_digest(tag: str) -> str: + url="http://"+REGISTRY_HOST+"/v2/"+NAME+"/manifests/"+tag + req=Request(url,headers={"Accept":", ".join(MEDIA_TYPES)}) + with urlopen(req,timeout=12) as response: + media=response.headers.get("Content-Type","").split(";",1)[0] + raw=response.read(8*1024*1024+1) + header=response.headers.get("Docker-Content-Digest") + require(media in MEDIA_TYPES and len(raw)<=8*1024*1024, + "local scanner manifest content type or size invalid") + digest="sha256:"+hashlib.sha256(raw).hexdigest() + require(header==digest and + json.loads(raw).get("schemaVersion")==2, + "local scanner manifest hash, header or schema mismatch") + return digest + + +def setup() -> dict: + require(os.environ.get("SOURCE_SHA") and + os.environ.get("GITHUB_ENV"), + "scanner setup is restricted to exact-head GitHub CI") + require(call(["git","rev-parse","HEAD"])== + os.environ["SOURCE_SHA"], + "scanner setup checkout differs from exact requested SHA") + policy=json.loads(LOCK.read_text(encoding="utf-8")) + require(policy.get("registry_transport_image")== + "docker.io/library/registry@sha256:" + "a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373", + "scanner registry transport image is not pinned") + OUT.mkdir(parents=True,exist_ok=True) + source=prepare(output=OUT) + network,registry=docker_names() + try: + call(["docker","network","create","--driver","bridge",network],timeout=90) + call(["docker","run","-d","--name",registry,"--network",network, + "--network-alias","scanner-registry", + "-p","127.0.0.1:5000:5000", + policy["registry_transport_image"]],timeout=90) + for attempt in range(25): + try: + req=Request("http://127.0.0.1:5000/v2/") + with urlopen(req,timeout=3) as r: + if r.status==200: + break + except (OSError,TimeoutError): + time.sleep(1) + else: + raise ValueError("isolated scanner registry readiness failed") + hashes=[] + for index in (1,2): + image=REGISTRY_HOST+"/"+NAME+":candidate-"+str(index) + call(["docker","buildx","build","--builder","default", + "--platform","linux/amd64","--no-cache","--pull=false", + "--provenance=false","--sbom=false","--load", + "--build-arg","SOURCE_DATE_EPOCH="+str(source["source_date_epoch"]), + "-f",str(ROOT/"deploy/release/scanner/Dockerfile"), + "-t",image,str(OUT)],timeout=360) + call(["docker","push",image],timeout=240) + hashes.append(response_digest("candidate-"+str(index))) + require(hashes[0]==hashes[1], + "scanner clean image rebuild changed OCI manifest bytes") + digest=hashes[0] + config=OUT/"buildkitd.scanner.toml" + config.write_text( + '[registry."scanner-registry:5000"]\n http = true\n', + encoding="utf-8") + call(["docker","buildx","create","--driver","docker-container", + "--driver-opt","network="+network,"--name",builder_name(), + "--buildkitd-config",str(config),"--use"],timeout=90) + call(["docker","buildx","inspect","--builder",builder_name(), + "--bootstrap"],timeout=180) + ref=REGISTRY_BUILDKIT+"/"+NAME+"@"+digest + # The verifier always addresses the exact exported manifest, never + # a tag that a later producer could repoint. + env=Path(os.environ["GITHUB_ENV"]) + with env.open("a",encoding="utf-8") as fd: + fd.write("RELEASE_SCANNER_REF="+ref+"\n") + fd.write("RELEASE_SCANNER_DIGEST="+digest+"\n") + record={ + "schema_version":1, + "upstream_commit":source["upstream_commit"], + "binary_sha256":source["binary_sha256"], + "manifest_digest":digest, + "image_reproducible":True, + "binary_reproducible":True, + "scanner_reference_kind":"ephemeral-local-registry-manifest", + "public_bot_release":False, + "artifact_distribution_authorized":False, + "approved_identity_matched":( + policy.get("approved_binary_sha256")==source["binary_sha256"] + and policy.get("approved_manifest_digest")==digest), + } + (OUT/"scanner-candidate.json").write_text( + json.dumps(record,indent=2,sort_keys=True)+"\n",encoding="utf-8") + print(json.dumps(record,sort_keys=True)) + return record + except BaseException: + cleanup() + raise + + +def verify_approval() -> None: + policy=json.loads(LOCK.read_text(encoding="utf-8")) + candidate=json.loads((OUT/"scanner-candidate.json").read_text("utf-8")) + require(candidate.get("image_reproducible") is True and + candidate.get("binary_reproducible") is True, + "scanner candidate reproducibility not proven") + require(candidate.get("upstream_commit")==policy.get("upstream_commit"), + "scanner candidate source identity mismatch") + # Only a separate committed reviewed lock can grant scanner identity. + require(type(policy.get("approved_binary_sha256")) is str and + type(policy.get("approved_manifest_digest")) is str and + policy["approved_binary_sha256"]==candidate.get("binary_sha256") and + policy["approved_manifest_digest"]==candidate.get("manifest_digest"), + "scanner candidate digest not yet frozen/approved") + print(json.dumps({ + "scanner_identity_approved":True, + "scanner_manifest_digest":candidate["manifest_digest"], + "source_locked":True, + "public_bot_release":False, + "artifact_distribution_authorized":False},sort_keys=True)) + + +def main(): + ap=argparse.ArgumentParser() + ap.add_argument("action",choices=("setup","verify-approval","cleanup")) + args=ap.parse_args() + if args.action=="cleanup": + cleanup() + return + try: + if args.action=="setup": + setup() + else: + verify_approval() + except (OSError,ValueError,subprocess.SubprocessError,TimeoutError, + KeyError,TypeError) as error: + ap.exit(2,"scanner CI candidate refused: "+type(error).__name__+ + " ("+str(error)[:160]+")\n") + + +if __name__=="__main__": + main() diff --git a/tests/release/test_release_scanner_source.py b/tests/release/test_release_scanner_source.py new file mode 100644 index 00000000..056f6800 --- /dev/null +++ b/tests/release/test_release_scanner_source.py @@ -0,0 +1,113 @@ +"""Bounded registry candidate identity and source-pin negative controls.""" +from __future__ import annotations +import hashlib +import importlib.util +import json +import os +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +ROOT=Path(__file__).resolve().parents[2] + + +def module(name,path): + spec=importlib.util.spec_from_file_location(name,ROOT/path) + result=importlib.util.module_from_spec(spec) + spec.loader.exec_module(result) + return result + + +source=module("release1_scanner_source","scripts/prepare-release-scanner.py") +ci=module("release1_scanner_ci","scripts/ci-release-scanner.py") + + +class ReleaseScannerSourceContractTests(unittest.TestCase): + def test_frozen_patch_matches_reviewed_digest(self): + policy=json.loads((ROOT/"qualification/release-scanner-v1.json").read_text()) + source.check_lock(policy) + self.assertEqual( + source.sha256(ROOT/policy["patch_path"]), + "87fbe492c4b504374b41a6303ebc75833559bacb83addf9e5520b9768cc37d09") + self.assertEqual(policy["upstream_commit"], + "d88056b4e5b61d0ca037340df91be47d343b4386") + self.assertEqual(policy["toolchain_version"],"go1.25.8") + self.assertEqual(policy["vendored_syft_version"],"v1.42.3") + + def test_mutated_patch_cannot_keep_approved_digest(self): + original=source.ROOT + with tempfile.TemporaryDirectory() as td: + target=Path(td)/"deploy/release/scanner" + target.mkdir(parents=True) + (target/"target.go.patch").write_text("PRIVATE_CANARY\n") + policy=json.loads(source.LOCK.read_text()) + with mock.patch.object(source,"ROOT",Path(td)): + with self.assertRaisesRegex(ValueError,"patch bytes"): + source.check_lock(policy) + + def test_unreviewed_source_and_authority_changes_fail(self): + source_policy=json.loads(source.LOCK.read_text()) + for field,value in ( + ("upstream_commit","a"*40), + ("toolchain_version","go1.26.3"), + ("vendored_syft_version","v1.51.0"), + ("public_bot_release",True), + ("required_file_hash_algorithms",["SHA256"]), + ): + with self.subTest(field=field): + changed=dict(source_policy,**{field:value}) + with self.assertRaisesRegex(ValueError,"source/build policy"): + source.check_lock(changed) + + def test_candidate_approval_requires_frozen_independent_digests(self): + with tempfile.TemporaryDirectory() as td: + folder=Path(td) + candidate={ + "schema_version":1, + "upstream_commit":"d88056b4e5b61d0ca037340df91be47d343b4386", + "binary_sha256":"b"*64, + "manifest_digest":"sha256:"+"a"*64, + "image_reproducible":True, + "binary_reproducible":True} + (folder/"scanner-candidate.json").write_text(json.dumps(candidate)) + with mock.patch.object(ci,"OUT",folder): + with self.assertRaisesRegex(ValueError,"not yet frozen"): + ci.verify_approval() + # These approvals remain separate from build-generated claims. + frozen=dict(json.loads(ci.LOCK.read_text()), + approved_binary_sha256="b"*64, + approved_manifest_digest="sha256:"+"a"*64) + with mock.patch.object(ci,"LOCK",folder/"reviewed.json"): + (folder/"reviewed.json").write_text(json.dumps(frozen)) + ci.verify_approval() + candidate["manifest_digest"]="sha256:"+"c"*64 + (folder/"scanner-candidate.json").write_text(json.dumps(candidate)) + with self.assertRaisesRegex(ValueError,"not yet frozen"): + ci.verify_approval() + + def test_registry_local_digest_is_full_sha256_manifest_identity(self): + manifest=json.dumps({"schemaVersion":2,"mediaType": + "application/vnd.docker.distribution.manifest.v2+json", + "config":{},"layers":[]}).encode() + expected="sha256:"+hashlib.sha256(manifest).hexdigest() + class Response: + status=200 + headers={"Content-Type": + "application/vnd.docker.distribution.manifest.v2+json", + "Docker-Content-Digest":expected} + def read(self,amount): + return manifest + def __enter__(self): return self + def __exit__(self,*args): pass + with mock.patch.object(ci,"urlopen",return_value=Response()): + self.assertEqual(ci.response_digest("candidate-1"),expected) + with mock.patch.object(ci,"urlopen",return_value=Response()): + Response.headers=dict(Response.headers, + **{"Docker-Content-Digest":"sha256:"+"0"*64}) + with self.assertRaisesRegex(ValueError,"hash"): + ci.response_digest("candidate-2") + + +if __name__=="__main__": + unittest.main() From 58fec306249a93267b5c0c8fed8410bc82f8b232 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sun, 18 Oct 2026 15:02:28 +0200 Subject: [PATCH 135/147] fix(RELEASE-1): load source-locked scanner preparation by exact sibling file path --- scripts/ci-release-scanner.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/ci-release-scanner.py b/scripts/ci-release-scanner.py index 7a8d9495..38cf0245 100644 --- a/scripts/ci-release-scanner.py +++ b/scripts/ci-release-scanner.py @@ -9,6 +9,7 @@ import argparse import hashlib +import importlib.util import json import os import subprocess @@ -16,7 +17,14 @@ from pathlib import Path from urllib.request import Request, urlopen -from scripts.prepare_release_scanner import ROOT, LOCK, prepare, require +_source_path=Path(__file__).with_name("prepare-release-scanner.py") +_spec=importlib.util.spec_from_file_location("release1_scanner_source",_source_path) +if _spec is None or _spec.loader is None: + raise ValueError("scanner source verifier unavailable") +_source=importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_source) +ROOT, LOCK, prepare, require=(_source.ROOT,_source.LOCK, + _source.prepare,_source.require) REGISTRY_HOST="127.0.0.1:5000" REGISTRY_BUILDKIT="scanner-registry:5000" From e18a2cd71c9aa041ff037fdfeed43d25fc03dbfe Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sun, 18 Oct 2026 15:04:29 +0200 Subject: [PATCH 136/147] fix(RELEASE-1 CI): consume exact-source v1.11.0 SHA1-capable scanner through local immutable registry Pin Go action, compile and verify scanner in both jobs, bind each BuildKit daemon to the isolated registry, require independent digest review after real SPDX/SLSA preflight and keep scanner artifacts private. --- .github/workflows/release-candidate.yml | 62 ++++++++++++++++++++----- 1 file changed, 50 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 41112222..0b3e152d 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -40,7 +40,7 @@ jobs: scanner-compatibility: needs: release-contracts runs-on: ubuntu-24.04 - timeout-minutes: 12 + timeout-minutes: 35 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 with: @@ -58,6 +58,11 @@ jobs: "scripts/spdx_license_contract.py", "qualification/spdx-license-identifiers-v3.29.0.json", "qualification/release-image-v1.json", + "qualification/release-scanner-v1.json", + "deploy/release/scanner/target.go.patch", + "deploy/release/scanner/Dockerfile", + "scripts/prepare-release-scanner.py", + "scripts/ci-release-scanner.py", ) for relative in critical: local=Path(relative) @@ -69,15 +74,20 @@ jobs: if original.returncode or original.stdout!=local.read_bytes(): raise SystemExit("VERDICT_SOURCE_BYTES_DIFFER_FROM_GIT") PY + - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 + with: + go-version: '1.25.8' + cache: false + - name: Prepare pinned scanner and isolated BuildKit registry + env: + RELEASE1_SCANNER_BUILDER: release1-scanner-probe + run: python3 scripts/ci-release-scanner.py setup - name: Independently qualify pinned scanner protocol without engine build run: | set -euo pipefail test "$(git rev-parse HEAD)" = "$SOURCE_SHA" BASE="$(python3 -c 'import json;print(json.load(open("qualification/release-image-v1.json"))["base_image"])')" - SCANNER="$(python3 -c 'import json;print(json.load(open("qualification/release-image-v1.json"))["sbom_generator_image"])')" - docker buildx create --driver docker-container --name release1-scanner-probe --use - docker buildx inspect --bootstrap - docker buildx imagetools inspect "$SCANNER" --raw > /dev/null + SCANNER="$RELEASE_SCANNER_REF" mkdir -p build/test-results/release1-scanner cat > "$RUNNER_TEMP/Scanner.Dockerfile" <<'EOF' ARG BASE_IMAGE @@ -92,11 +102,21 @@ jobs: python3 scripts/check-release-scanner.py \ --archive build/test-results/release1-scanner/scanner.oci.tar \ --output build/test-results/release1-scanner/scanner-contract.json + - name: Require independently reviewed scanner manifest and binary + run: python3 scripts/ci-release-scanner.py verify-approval + - name: Tear down isolated scanner test builder + if: always() + env: + RELEASE1_SCANNER_BUILDER: release1-scanner-probe + run: python3 scripts/ci-release-scanner.py cleanup - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 if: always() with: name: release1-scanner-contract - path: build/test-results/release1-scanner/scanner-contract.json + path: | + build/test-results/release1-scanner/scanner-contract.json + build/test-results/release1-scanner/scanner-candidate.json + build/test-results/release1-scanner/scanner-source.json if-no-files-found: warn retention-days: 30 release-oci-integration: @@ -170,14 +190,21 @@ jobs: python3 scripts/verify-release-candidate.py \ --stage build/release1-stage \ --output build/test-results/release1/stage-report.json + - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 + with: + go-version: '1.25.8' + cache: false + - name: Prepare identical source-locked scanner for OCI builder + env: + RELEASE1_SCANNER_BUILDER: release1-builder + run: python3 scripts/ci-release-scanner.py setup - name: Build and independently test actual offline Docker image timeout-minutes: 20 run: | set -euo pipefail IMAGE_BASE="$(python3 -c 'import json; print(json.load(open("qualification/release-image-v1.json"))["base_image"])')" mkdir -p build/test-results/release1 - docker buildx create --driver docker-container --name release1-builder --use - docker buildx inspect --bootstrap + docker buildx inspect --builder release1-builder --bootstrap BUILD_LOG="$RUNNER_TEMP/release1-offline-image-build.log" if docker buildx build --builder release1-builder --platform linux/amd64 \ --file deploy/release/Dockerfile --build-arg "BASE_IMAGE=$IMAGE_BASE" \ @@ -265,10 +292,9 @@ jobs: run: | set -euo pipefail IMAGE_BASE="$(python3 -c 'import json; print(json.load(open("qualification/release-image-v1.json"))["base_image"])')" - SCANNER="$(python3 -c 'import json; print(json.load(open("qualification/release-image-v1.json"))["sbom_generator_image"])')" - # The pinned OCI index is a reviewed historic scanner baseline; - # never resolve the mutable stable-1 tag during qualification. - docker buildx imagetools inspect "$SCANNER" --raw > /dev/null + SCANNER="$RELEASE_SCANNER_REF" + # The job-generated digest alone cannot authorize a scanner image. + python3 scripts/ci-release-scanner.py verify-approval docker buildx build --builder release1-builder --platform linux/amd64 \ --file deploy/release/Dockerfile --build-arg "BASE_IMAGE=$IMAGE_BASE" \ --tag allfather/release1:offline \ @@ -305,6 +331,11 @@ jobs: "qualification/spdx-license-identifiers-v3.29.0.json", "scripts/check-release-scanner.py", "qualification/release-image-v1.json", + "qualification/release-scanner-v1.json", + "deploy/release/scanner/target.go.patch", + "deploy/release/scanner/Dockerfile", + "scripts/prepare-release-scanner.py", + "scripts/ci-release-scanner.py", ) for relative in critical: local=Path(relative) @@ -331,6 +362,11 @@ jobs: --wheel-inventory build/release1-stage/wheels/wheel-attestation.json \ --source-sha "$SOURCE_SHA" \ --output build/test-results/release1/aggregate.json + - name: Tear down isolated release scanner registry and builder + if: always() + env: + RELEASE1_SCANNER_BUILDER: release1-builder + run: python3 scripts/ci-release-scanner.py cleanup - name: Do not publish combined image; retain only approved evidence if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 @@ -348,6 +384,8 @@ jobs: build/test-results/release1/oci-report.json build/test-results/release1/host-report.json build/test-results/release1/aggregate.json + build/test-results/release1-scanner/scanner-candidate.json + build/test-results/release1-scanner/scanner-source.json build/test-results/release1/os-packages.txt build/test-results/release1/python-packages.txt build/test-results/release1/r00-white.log From bf6b919274852fc9ba6e31ec665b65a559aad413 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sun, 18 Oct 2026 15:05:51 +0200 Subject: [PATCH 137/147] fix(ASSURANCE): source-attest v1.11.0 scanner patch and gate reviewed local image identity Remove the SHA256-only public scanner reference, extend immutable stage and aggregate source inventories to all new scanner inputs, and test exact-head job usage. --- qualification/release-image-v1.json | 7 ++++--- scripts/build-release-candidate.py | 5 +++++ scripts/qualify-release-aggregate.py | 5 +++++ tests/release/test_release_contracts.py | 19 ++++++++++++------- tests/release/test_workflow_coverage.py | 5 +++++ 5 files changed, 31 insertions(+), 10 deletions(-) diff --git a/qualification/release-image-v1.json b/qualification/release-image-v1.json index 6ec8fe12..9514c1ae 100644 --- a/qualification/release-image-v1.json +++ b/qualification/release-image-v1.json @@ -61,8 +61,9 @@ "ca_bootstrap_snapshot_locked": false, "ca_bootstrap_sources": "deploy/release/bootstrap.sources", "ca_bootstrap_source_reason": "Pinned minimal base has no usable CA trust before first HTTPS APT access; only signed native Ubuntu APT metadata may install ca-certificates. Exact bootstrap package version is part of generated runtime inventory; publication blocked until reviewed.", - "sbom_generator_image": "docker.io/docker/buildkit-syft-scanner@sha256:79e7b013cbec16bbb436f312819a49a4a57752b2270c1a9332ae1a10fcc82a68", - "sbom_generator_evidence": "Docker Hub stable-1 multi-platform OCI index digest (reviewed pinned baseline; no floating tag at build time)", + "sbom_generator_image": null, + "sbom_generator_evidence": "Source-locked v1.11.0 Syft scanner with dual SHA1+SHA256; digest-addressed ephemeral registry use only after reproducible binary and reviewed manifest approval.", "slsa_provenance_predicate_type": "https://slsa.dev/provenance/v1", - "slsa_builder_id": "https://github.com/HMarcusWH/AllfatherChess/actions/workflows/release-candidate.yml" + "slsa_builder_id": "https://github.com/HMarcusWH/AllfatherChess/actions/workflows/release-candidate.yml", + "sbom_generator_policy": "qualification/release-scanner-v1.json" } diff --git a/scripts/build-release-candidate.py b/scripts/build-release-candidate.py index 64398d15..ea71fd53 100644 --- a/scripts/build-release-candidate.py +++ b/scripts/build-release-candidate.py @@ -49,6 +49,11 @@ "deploy/release/Dockerfile", "deploy/release/Dockerfile.dockerignore", "scripts/build-release-candidate.py", + "qualification/release-scanner-v1.json", + "deploy/release/scanner/target.go.patch", + "deploy/release/scanner/Dockerfile", + "scripts/prepare-release-scanner.py", + "scripts/ci-release-scanner.py", "scripts/qualify-release-aggregate.py", "scripts/verify-release-oci.py", "scripts/spdx_license_contract.py", diff --git a/scripts/qualify-release-aggregate.py b/scripts/qualify-release-aggregate.py index b30fc002..eaafbd0f 100644 --- a/scripts/qualify-release-aggregate.py +++ b/scripts/qualify-release-aggregate.py @@ -34,6 +34,11 @@ "qualification/spdx-license-identifiers-v3.29.0.json", "scripts/check-release-scanner.py", "qualification/release-image-v1.json", + "qualification/release-scanner-v1.json", + "deploy/release/scanner/target.go.patch", + "deploy/release/scanner/Dockerfile", + "scripts/prepare-release-scanner.py", + "scripts/ci-release-scanner.py", ) diff --git a/tests/release/test_release_contracts.py b/tests/release/test_release_contracts.py index 90d3427e..9100f331 100644 --- a/tests/release/test_release_contracts.py +++ b/tests/release/test_release_contracts.py @@ -131,16 +131,21 @@ def test_pinned_snapshot_retries_are_bounded_without_mirror_fallback(self): self.assertNotIn(unsafe,instructions) self.assertNotIn(unsafe,code_without_apt_message) - def test_sbom_generator_is_reviewed_immutable_oci_index(self): + def test_sbom_generator_is_source_locked_and_local_digest_addressed(self): image=json.loads((ROOT/"qualification/release-image-v1.json").read_text()) - scanner=image["sbom_generator_image"] - self.assertRegex(scanner, - r"^docker\.io/docker/buildkit-syft-scanner@sha256:[0-9a-f]{64}$") + scanner=json.loads((ROOT/image["sbom_generator_policy"]).read_text()) + self.assertIsNone(image["sbom_generator_image"]) + self.assertEqual(scanner["upstream_commit"], + "d88056b4e5b61d0ca037340df91be47d343b4386") + self.assertEqual(scanner["required_file_hash_algorithms"], + ["SHA1","SHA256"]) + self.assertFalse(scanner["public_bot_release"]) + self.assertFalse(scanner["artifact_distribution_authorized"]) content=(ROOT/".github/workflows/release-candidate.yml").read_text() - self.assertIn('["sbom_generator_image"]',content) + self.assertEqual(content.count("ci-release-scanner.py setup"),2) + self.assertEqual(content.count("ci-release-scanner.py verify-approval"),2) + self.assertIn('SCANNER="$RELEASE_SCANNER_REF"',content) self.assertIn('--attest "type=sbom,generator=$SCANNER"',content) - self.assertIn('docker buildx imagetools inspect "$SCANNER" --raw',content) - self.assertNotIn("--attest type=sbom \\",content) def test_readonly_runtime_requires_separate_replay_bind_mount(self): p=json.loads((ROOT/"qualification/release-image-v1.json").read_text()) diff --git a/tests/release/test_workflow_coverage.py b/tests/release/test_workflow_coverage.py index 780c27b9..b9261999 100644 --- a/tests/release/test_workflow_coverage.py +++ b/tests/release/test_workflow_coverage.py @@ -77,6 +77,11 @@ def test_release_verdict_sources_are_git_attested_before_import(self): "scripts/verify-release-oci.py", "scripts/spdx_license_contract.py", "qualification/spdx-license-identifiers-v3.29.0.json", + "qualification/release-scanner-v1.json", + "deploy/release/scanner/target.go.patch", + "deploy/release/scanner/Dockerfile", + "scripts/prepare-release-scanner.py", + "scripts/ci-release-scanner.py", ): with self.subTest(relative=relative): self.assertIn('"' + relative + '"',builder) From 91458a74023eac25d4c739aab7008c1569b6d591 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sun, 18 Oct 2026 15:07:33 +0200 Subject: [PATCH 138/147] docs(RELEASE-1): document staged scanner digest approval and offline source-locked Go upgrade avoidance Explain v1.11.0 SHA1 producer fix, reproducible binary/image preflight, local registry confinement, reviewed digest pin and independent scanner tests. --- docs/PR71_ASSURANCE_REPAIR_LEDGER.md | 6 ++ docs/RELEASE_1_OPERATIONS.md | 64 +++++++++++++++---- .../release/test_release_scanner_contract.py | 7 ++ 3 files changed, 66 insertions(+), 11 deletions(-) diff --git a/docs/PR71_ASSURANCE_REPAIR_LEDGER.md b/docs/PR71_ASSURANCE_REPAIR_LEDGER.md index dfda3404..0d70130f 100644 --- a/docs/PR71_ASSURANCE_REPAIR_LEDGER.md +++ b/docs/PR71_ASSURANCE_REPAIR_LEDGER.md @@ -128,3 +128,9 @@ ledger alone. including real RELEASE-1 OCI/smoke, guarded recovery, LC0 and LOCAL-1; then a fresh exact-head Codex code/security review with no outstanding unaddressed findings. Offline test authority never becomes online authority. + +## October 18 — pinned SHA1 scanner follow-up (candidate-only) + +Real RELEASE-1 run [39087354015](https://github.com/HMarcusWH/AllfatherChess/actions/runs/39087354015) passed all 181 fast contracts and failed only at pinned scanner real SPDX files: `SPDX file requires a valid SHA1 checksum`. Source analysis of upstream v1.11.0 (commit `d88056b4e5b61d0ca037340df91be47d343b4386`) traced the default file hasher to SHA256 alone. + +Implementation now sources a single SHA1+SHA256 patch in `deploy/release/scanner/target.go.patch`, compiles it twice using vendored dependencies, and routes it through an isolated job-local OCI registry. A candidate scanner digest is **not an approved image**. Full qualification remains blocked until both `approved_binary_sha256` and `approved_manifest_digest` are independently reviewed, frozen and rerun at one SHA. Only after real scanner/OCI positives and new Codex review may the PR be considered for merge; all public distribution/live-play grants remain false. diff --git a/docs/RELEASE_1_OPERATIONS.md b/docs/RELEASE_1_OPERATIONS.md index 5b11c32c..16c3f332 100644 --- a/docs/RELEASE_1_OPERATIONS.md +++ b/docs/RELEASE_1_OPERATIONS.md @@ -275,7 +275,7 @@ OCI runtime selection: declared platforms on every selected runnable index/manif R10: the `rate_limit_hold_completed` event must be request-correlated and recorded after 61.25 seconds have elapsed since the targeted real HTTP 429 response completion. The independent host verifier rejects a completed hold less than 61 seconds after this event. A server write does not by itself prove client receipt: the same first-epoch `global_rate_limit_block` guard audit and persistent SQLite cooldown evidence are independently required. The producer's fixed 100-second scenario deadline remains a bounded fail-closed timeout. -SBOM generator: the release image policy specifies the digest-pinned docker/buildkit-syft-scanner multi-platform image, with remote digest preflight. The default mutable scanner tag is not used during qualification. The pinned image digest is a historically available Docker Hub index and must be exercised in exact-head CI before claiming a valid generator. CI still computes full BuildKit metadata to verify attestation, but only allowlisted sanitized JSON reports, OS inventory and four local smoke logs are retained as download artifacts; private GitHub event payload and full provenance metadata are not retained. +SBOM generator: RELEASE-1 no longer uses the historical SHA256-only Docker Hub scanner. The scanner-only source lock is `qualification/release-scanner-v1.json`, bound to docker/buildkit-syft-scanner v1.11.0 at commit `d88056b4`, Go 1.25.8, vendored Syft 1.42.3 and the SHA256-approved `deploy/release/scanner/target.go.patch`. The patch adds real SHA1 hashing beside SHA256; it does not modify SPDX evidence after scanning. The scanner is compiled twice with vendored-only dependencies and builds two disposable scratch images to test binary and image reproducibility. Its manifest is resolved in a loopback-bound, runner-local registry and passed to BuildKit by immutable digest, never a mutable tag. CI still computes full BuildKit metadata to verify attestation but uploads only allowlisted sanitized reports, OS inventory and four local smoke logs; full scanner binaries, OCI archives, raw SBOM/provenance and private event payloads are never uploaded. The green supporting workflows and full image smoke testing remain necessary but insufficient: standalone OCI attestation verification, final host aggregate, dependency qualification and a fresh review must pass. The publication/licensing/live-game prohibitions are unchanged. @@ -283,16 +283,19 @@ The green supporting workflows and full image smoke testing remain necessary but **Pinned snapshot acquisition:** The image still starts from the digest-pinned Ubuntu base and two native APT sources with GPG Signed-By and the frozen Snapshot field. The minimal base bootstraps CA trust from signed native HTTP indexes as before. Remaining packages are installed with deploy/release/install-pinned-apt.sh: up to four bounded attempts for transient HTTP 429/502/503/504 responses, with old index lists discarded after failure. Hash, signature, certificate or unrecognized download failures are fatal; there is no mirror or unsigned fallback. An availability-only snapshot preflight runs before expensive engine compilation and emits a sanitized JSON diagnostic. It does not replace APT signature validation. -**Scanner preflight:** The scanner-compatibility job builds a minimal OCI -candidate without chess engines, requests the digest-pinned Syft SBOM and -minimal SLSA v1 provenance, then independently requires exactly one supported -SPDX document and one SLSA v1 statement with the builder identity frozen in -`qualification/release-image-v1.json`. Wrong/missing/duplicate provenance -cannot be hidden behind a valid SPDX sibling. The result proves a bounded -scanner/provenance interface only, **not** full image identity, publisher -signature, licensing, or release readiness. The real OCI export still -requires independently verified full-image attestation after four packaged -offline smoke cases. +**Scanner preflight:** The scanner-compatibility job builds the v1.11.0 +source-locked generator with SHA1 + SHA256, starts a disposable local registry +and builds a minimal test OCI image without chess engines. It requests one +SPDX SBOM and one minimal SLSA v1 provenance statement, then independently +verifies the exact builder ID frozen in `qualification/release-image-v1.json`. +The actual manifest digest and binary SHA256 are recorded as **candidates**. +They are never admitted just because the job produced them: the source lock's +`approved_manifest_digest` and `approved_binary_sha256` must be populated +through a separate review of clean reproducible builds. Until then the +scanner-compatibility job deliberately fails after reporting the bounded +candidate identities; the full OCI integration remains skipped. The completed +image identity still cannot grant publisher signature, licensing, network or +distribution authority. **SPDX source rules:** Package downloadLocation may be NONE, NOASSERTION, or a supported syntactically valid URL/VCS location without embedded secrets. File and optional package checksums require supported exact algorithm and canonical lowercase fixed-width digest. Variable-width MD6 is explicitly unsupported in the release profile. A structurally valid SPDX document is not evidence of actual package-download provenance. The SPDX schema and release-profile policy are separate claims. @@ -381,3 +384,42 @@ because `git rev-parse HEAD` is unchanged. These checks bind a qualification to its source bytes; they do **not** confer operator authority, license clearance, authenticated publisher signatures, network recovery claims or rights to distribute a binary image. + +## October 18 — scanner source-build and approval procedure + +The upstream v1.11.0 scanner uses Syft file hashing configured for SHA256 by +default; that conflicted with SPDX 2.x's required SHA1 on file records. The +source-attested `target.go.patch` adds `crypto.SHA1` and `crypto.SHA256` +through the existing Syft `WithFilesConfig(WithHashers(...))` API. A pinned +Go 1.25.8 compiler action builds only vendored dependencies offline. + +The CI source scanner lifecycle is: + +1. Compare all scanner scripts, patch, Dockerfile and lock bytes with the exact + Git HEAD. Fetch only upstream commit `d88056b4`, check the Go/Syft version + and apply exactly one patch to `internal/target.go`. +2. Compile twice and compare executable hashes. Build two `FROM scratch` + scanner images in the host Docker engine with a frozen source-date epoch. +3. Push **only to the current GitHub runner's** loopback-bound, digest-pinned + `registry:2.8.3` container; compare both manifest digest responses. +4. Configure a docker-container BuildKit daemon on the same Docker bridge, + allowing plain HTTP only for `scanner-registry:5000`, and address the + custom scanner by its manifest SHA256. +5. Run the real scanner against the pinned Ubuntu fixture and require genuine + SPDX/SLSA validation. Before marking scanner compatibility green, compare + the measured manifest and binary hashes with separately reviewed values in + `qualification/release-scanner-v1.json`. All results are offline only. +6. Recreate and verify the scanner independently on the full OCI integration + job. Destroy temporary registry, builder and network on exit. + +The scanner lock initially leaves approved binary and manifest SHA256 values +`null`: this is an intentional **approval gate**, not a temporary release +bypass. Review reproducibility and real SBOM evidence before committing the +measured SHA values in a second commit; the following CI run must match them. +No registry image or scanner binary is published, and no external credentials +are required. + +File records with SHA1 `000...000` (used by upstream Syft for entries without +regular-file digest metadata) may be structurally allowed by SPDX, but are +**not** proof of actual file contents. An independent known-regular-file hash +test is required before claiming scanner content-hash qualification. diff --git a/tests/release/test_release_scanner_contract.py b/tests/release/test_release_scanner_contract.py index 788b3da9..d61324e7 100644 --- a/tests/release/test_release_scanner_contract.py +++ b/tests/release/test_release_scanner_contract.py @@ -27,6 +27,13 @@ def test_real_scanner_declares_same_builder_identity_as_release(self): "https://github.com/HMarcusWH/AllfatherChess/actions/" "workflows/release-candidate.yml",probe) self.assertIn('--attest "type=sbom,generator=$SCANNER"',probe) + self.assertIn("ci-release-scanner.py setup",probe) + self.assertIn("ci-release-scanner.py verify-approval",probe) + self.assertIn('SCANNER="$RELEASE_SCANNER_REF"',probe) + scanner_policy=(ROOT/"qualification/release-scanner-v1.json").read_text() + self.assertIn('"required_file_hash_algorithms": [',scanner_policy) + self.assertIn('"SHA1"',scanner_policy) + self.assertIn('"SHA256"',scanner_policy) def test_valid_spdx_probe_never_claims_release_authority(self): with tempfile.TemporaryDirectory() as tmp: From f7f2717d0e63a21358a28a35a3ecc2bef4eb909b Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sun, 18 Oct 2026 15:12:37 +0200 Subject: [PATCH 139/147] fix(RELEASE-1): export scanner images reproducibly from containerized BuildKit Use the same controlled registry-connected builder for both independent scratch image exports with SOURCE_DATE_EPOCH and rewrite-timestamp=true, and retain safe pairwise digest evidence even if they differ. --- scripts/ci-release-scanner.py | 59 ++++++++++++++++++++--------------- 1 file changed, 33 insertions(+), 26 deletions(-) diff --git a/scripts/ci-release-scanner.py b/scripts/ci-release-scanner.py index 38cf0245..282bcbc4 100644 --- a/scripts/ci-release-scanner.py +++ b/scripts/ci-release-scanner.py @@ -116,20 +116,6 @@ def setup() -> dict: time.sleep(1) else: raise ValueError("isolated scanner registry readiness failed") - hashes=[] - for index in (1,2): - image=REGISTRY_HOST+"/"+NAME+":candidate-"+str(index) - call(["docker","buildx","build","--builder","default", - "--platform","linux/amd64","--no-cache","--pull=false", - "--provenance=false","--sbom=false","--load", - "--build-arg","SOURCE_DATE_EPOCH="+str(source["source_date_epoch"]), - "-f",str(ROOT/"deploy/release/scanner/Dockerfile"), - "-t",image,str(OUT)],timeout=360) - call(["docker","push",image],timeout=240) - hashes.append(response_digest("candidate-"+str(index))) - require(hashes[0]==hashes[1], - "scanner clean image rebuild changed OCI manifest bytes") - digest=hashes[0] config=OUT/"buildkitd.scanner.toml" config.write_text( '[registry."scanner-registry:5000"]\n http = true\n', @@ -139,19 +125,31 @@ def setup() -> dict: "--buildkitd-config",str(config),"--use"],timeout=90) call(["docker","buildx","inspect","--builder",builder_name(), "--bootstrap"],timeout=180) - ref=REGISTRY_BUILDKIT+"/"+NAME+"@"+digest - # The verifier always addresses the exact exported manifest, never - # a tag that a later producer could repoint. - env=Path(os.environ["GITHUB_ENV"]) - with env.open("a",encoding="utf-8") as fd: - fd.write("RELEASE_SCANNER_REF="+ref+"\n") - fd.write("RELEASE_SCANNER_DIGEST="+digest+"\n") - record={ + # Export directly from the reproducible BuildKit image exporter. + # Unlike Docker's legacy --load path this exposes timestamp rewriting + # and a stable OCI-format manifest, with no host Docker image store. + hashes=[] + for index in (1,2): + image=REGISTRY_BUILDKIT+"/"+NAME+":candidate-"+str(index) + call(["docker","buildx","build","--builder",builder_name(), + "--platform","linux/amd64","--no-cache","--pull=false", + "--provenance=false","--sbom=false", + "--build-arg","SOURCE_DATE_EPOCH="+str(source["source_date_epoch"]), + "-f",str(ROOT/"deploy/release/scanner/Dockerfile"), + "--output","type=image,name="+image+ + ",push=true,registry.insecure=true," + "rewrite-timestamp=true,oci-mediatypes=true", + str(OUT)],timeout=360) + hashes.append(response_digest("candidate-"+str(index))) + digest=hashes[0] + candidate={ "schema_version":1, "upstream_commit":source["upstream_commit"], "binary_sha256":source["binary_sha256"], "manifest_digest":digest, - "image_reproducible":True, + "candidate_1_manifest_digest":hashes[0], + "candidate_2_manifest_digest":hashes[1], + "image_reproducible":hashes[0]==hashes[1], "binary_reproducible":True, "scanner_reference_kind":"ephemeral-local-registry-manifest", "public_bot_release":False, @@ -161,9 +159,18 @@ def setup() -> dict: and policy.get("approved_manifest_digest")==digest), } (OUT/"scanner-candidate.json").write_text( - json.dumps(record,indent=2,sort_keys=True)+"\n",encoding="utf-8") - print(json.dumps(record,sort_keys=True)) - return record + json.dumps(candidate,indent=2,sort_keys=True)+"\n",encoding="utf-8") + print(json.dumps(candidate,sort_keys=True)) + require(candidate["image_reproducible"], + "scanner independent BuildKit image exports differ") + ref=REGISTRY_BUILDKIT+"/"+NAME+"@"+digest + # The verifier always addresses the exact exported manifest, never + # a tag that a later producer could repoint. + env=Path(os.environ["GITHUB_ENV"]) + with env.open("a",encoding="utf-8") as fd: + fd.write("RELEASE_SCANNER_REF="+ref+"\n") + fd.write("RELEASE_SCANNER_DIGEST="+digest+"\n") + return candidate except BaseException: cleanup() raise From 235e8d2efd1d12e72b3423fb8daac8ccf4147347 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sun, 18 Oct 2026 15:16:31 +0200 Subject: [PATCH 140/147] test(RELEASE-1): bind real scanner SHA1 and SHA256 claims to known pinned Ubuntu file bytes Independently extract OCI-attested Syft file checksums and compare against an actual regular package-owned dpkg executable, rejecting placeholder or schema-only hashes. --- scripts/check-release-scanner-content.py | 138 ++++++++++++++++++ tests/release/test_release_scanner_content.py | 49 +++++++ 2 files changed, 187 insertions(+) create mode 100644 scripts/check-release-scanner-content.py create mode 100644 tests/release/test_release_scanner_content.py diff --git a/scripts/check-release-scanner-content.py b/scripts/check-release-scanner-content.py new file mode 100644 index 00000000..aaa319ba --- /dev/null +++ b/scripts/check-release-scanner-content.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Independently verify real file bytes against the pinned Syft SPDX document. + +Never accept a schema-shaped SHA1 string or Syft's nonregular-file zero SHA1 +sentinel as proof that a regular file was hashed. +""" +from __future__ import annotations +import argparse +import hashlib +import json +import tarfile +from pathlib import Path + +MAX_BYTES=12*1024*1024 +SPDX="https://spdx.dev/Document" +STATEMENT="https://in-toto.io/Statement/v1" +EXPECTED_FILE=frozenset(("./usr/bin/dpkg","/usr/bin/dpkg","usr/bin/dpkg")) + + +def insist(condition, reason): + if not condition: + raise ValueError(reason) + + +def file_checksum_evidence(document: dict, contents: bytes) -> dict: + insist(type(document) is dict and type(contents) is bytes and + 0 dict: + with tarfile.open(archive,"r:*") as tar: + members={m.name:m for m in tar.getmembers() if m.isfile()} + def raw_json(key: str, descriptor:dict|None=None): + m=members.get(key) + insist(m is not None and 0 Date: Sun, 18 Oct 2026 15:17:55 +0200 Subject: [PATCH 141/147] fix(RELEASE-1 CI): verify actual SBOM SHA1/SHA256 against pinned Ubuntu executable Add independent host content-hash witness after real scanner preflight, attest the verifier source, retain only bounded verdicts and preserve separate scanner digest approval. --- .github/workflows/release-candidate.yml | 15 +++++++++++++++ docs/RELEASE_1_OPERATIONS.md | 11 +++++++++++ scripts/build-release-candidate.py | 1 + scripts/qualify-release-aggregate.py | 1 + tests/release/test_workflow_coverage.py | 1 + 5 files changed, 29 insertions(+) diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 0b3e152d..433b58ad 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -63,6 +63,7 @@ jobs: "deploy/release/scanner/Dockerfile", "scripts/prepare-release-scanner.py", "scripts/ci-release-scanner.py", + "scripts/check-release-scanner-content.py", ) for relative in critical: local=Path(relative) @@ -102,6 +103,18 @@ jobs: python3 scripts/check-release-scanner.py \ --archive build/test-results/release1-scanner/scanner.oci.tar \ --output build/test-results/release1-scanner/scanner-contract.json + - name: Verify actual SHA1 and SHA256 against pinned Ubuntu regular-file bytes + run: | + set -euo pipefail + BASE="$(python3 -c 'import json;print(json.load(open("qualification/release-image-v1.json"))["base_image"])')" + REF_FILE="$RUNNER_TEMP/release1-reference-dpkg" + CONTAINER="$(docker create "$BASE" /bin/true)" + trap 'docker rm -f "$CONTAINER" >/dev/null 2>&1 || true; rm -f "$REF_FILE"' EXIT + docker cp "$CONTAINER:/usr/bin/dpkg" "$REF_FILE" + python3 scripts/check-release-scanner-content.py \ + --archive build/test-results/release1-scanner/scanner.oci.tar \ + --reference-file "$REF_FILE" \ + --output build/test-results/release1-scanner/scanner-file-content.json - name: Require independently reviewed scanner manifest and binary run: python3 scripts/ci-release-scanner.py verify-approval - name: Tear down isolated scanner test builder @@ -115,6 +128,7 @@ jobs: name: release1-scanner-contract path: | build/test-results/release1-scanner/scanner-contract.json + build/test-results/release1-scanner/scanner-file-content.json build/test-results/release1-scanner/scanner-candidate.json build/test-results/release1-scanner/scanner-source.json if-no-files-found: warn @@ -336,6 +350,7 @@ jobs: "deploy/release/scanner/Dockerfile", "scripts/prepare-release-scanner.py", "scripts/ci-release-scanner.py", + "scripts/check-release-scanner-content.py", ) for relative in critical: local=Path(relative) diff --git a/docs/RELEASE_1_OPERATIONS.md b/docs/RELEASE_1_OPERATIONS.md index 16c3f332..e41643e7 100644 --- a/docs/RELEASE_1_OPERATIONS.md +++ b/docs/RELEASE_1_OPERATIONS.md @@ -423,3 +423,14 @@ File records with SHA1 `000...000` (used by upstream Syft for entries without regular-file digest metadata) may be structurally allowed by SPDX, but are **not** proof of actual file contents. An independent known-regular-file hash test is required before claiming scanner content-hash qualification. + +### Independent real-file content-hash witness + +The real scanner probe also extracts `/usr/bin/dpkg` from the exact digest- +pinned Ubuntu 24.04 base image without running the image. The independent host +verifier compares SHA1 and SHA256 over those actual bytes with the corresponding +SPDX `fileName` entry in the scanner's OCI-attested document. A hexadecimal +SHA1 string, an absent file or the nonregular-file zero placeholder does not +qualify. The reference executable is kept only in a temporary runner directory +and is never uploaded. The published witness contains safe Boolean verdicts, +not the raw file or SBOM. diff --git a/scripts/build-release-candidate.py b/scripts/build-release-candidate.py index ea71fd53..8610f2c6 100644 --- a/scripts/build-release-candidate.py +++ b/scripts/build-release-candidate.py @@ -54,6 +54,7 @@ "deploy/release/scanner/Dockerfile", "scripts/prepare-release-scanner.py", "scripts/ci-release-scanner.py", + "scripts/check-release-scanner-content.py", "scripts/qualify-release-aggregate.py", "scripts/verify-release-oci.py", "scripts/spdx_license_contract.py", diff --git a/scripts/qualify-release-aggregate.py b/scripts/qualify-release-aggregate.py index eaafbd0f..78f40fcc 100644 --- a/scripts/qualify-release-aggregate.py +++ b/scripts/qualify-release-aggregate.py @@ -39,6 +39,7 @@ "deploy/release/scanner/Dockerfile", "scripts/prepare-release-scanner.py", "scripts/ci-release-scanner.py", + "scripts/check-release-scanner-content.py", ) diff --git a/tests/release/test_workflow_coverage.py b/tests/release/test_workflow_coverage.py index b9261999..6c0c00f4 100644 --- a/tests/release/test_workflow_coverage.py +++ b/tests/release/test_workflow_coverage.py @@ -82,6 +82,7 @@ def test_release_verdict_sources_are_git_attested_before_import(self): "deploy/release/scanner/Dockerfile", "scripts/prepare-release-scanner.py", "scripts/ci-release-scanner.py", + "scripts/check-release-scanner-content.py", ): with self.subTest(relative=relative): self.assertIn('"' + relative + '"',builder) From 18b5e1f2fd65f7d0cc2125f7a4e3b7129db12920 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sun, 18 Oct 2026 15:19:24 +0200 Subject: [PATCH 142/147] fix(RELEASE-1): freeze independently reviewed reproducible scanner binary and OCI manifest Bind approved scanner identities to exact-source real Syft+SLSA positive run 39130571069. Require future independent rebuild and real known-file SHA1/SHA256 proof before full RELEASE-1 qualification. --- docs/PR71_ASSURANCE_REPAIR_LEDGER.md | 17 +++++++++++++++++ docs/RELEASE_1_OPERATIONS.md | 13 +++++++++---- qualification/release-scanner-v1.json | 8 +++++--- tests/release/test_release_scanner_source.py | 8 ++++++++ 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/docs/PR71_ASSURANCE_REPAIR_LEDGER.md b/docs/PR71_ASSURANCE_REPAIR_LEDGER.md index 0d70130f..87b7e565 100644 --- a/docs/PR71_ASSURANCE_REPAIR_LEDGER.md +++ b/docs/PR71_ASSURANCE_REPAIR_LEDGER.md @@ -134,3 +134,20 @@ unaddressed findings. Offline test authority never becomes online authority. Real RELEASE-1 run [39087354015](https://github.com/HMarcusWH/AllfatherChess/actions/runs/39087354015) passed all 181 fast contracts and failed only at pinned scanner real SPDX files: `SPDX file requires a valid SHA1 checksum`. Source analysis of upstream v1.11.0 (commit `d88056b4e5b61d0ca037340df91be47d343b4386`) traced the default file hasher to SHA256 alone. Implementation now sources a single SHA1+SHA256 patch in `deploy/release/scanner/target.go.patch`, compiles it twice using vendored dependencies, and routes it through an isolated job-local OCI registry. A candidate scanner digest is **not an approved image**. Full qualification remains blocked until both `approved_binary_sha256` and `approved_manifest_digest` are independently reviewed, frozen and rerun at one SHA. Only after real scanner/OCI positives and new Codex review may the PR be considered for merge; all public distribution/live-play grants remain false. + +## Scanner reproducibility positive, approval lock frozen + +Source-bound [RELEASE-1 run 39130571069](https://github.com/HMarcusWH/AllfatherChess/actions/runs/39130571069): +Go 1.25.8 produced two identical binary hashes, +`a1043ee5fa8285740548b600fd24d106b4e68211327238b4edbbe70765bd67ca`; +two BuildKit scratch exports produced the identical OCI manifest +`sha256:fe9efbc9d42e6ddbbe44f0f35e2daa45299123891076c1700414a74978a51d2a`. +The pinned Syft v1.11.0 patch then passed strict SPDX (93 packages, 2265 files) +and SLSA v1 builder checks. The run correctly refused final compatibility at +the independent **unapproved digest** gate, which now has these reviewed +identities frozen in `qualification/release-scanner-v1.json`. + +The following exact-head run must independently remeasure those hashes and +check actual base-image `/usr/bin/dpkg` contents against scanner SHA1/SHA256 +before any full RELEASE-1 image qualification. Code review, distribution and +host/live authorizations remain separate, closed gates. diff --git a/docs/RELEASE_1_OPERATIONS.md b/docs/RELEASE_1_OPERATIONS.md index e41643e7..f49e32ac 100644 --- a/docs/RELEASE_1_OPERATIONS.md +++ b/docs/RELEASE_1_OPERATIONS.md @@ -412,10 +412,15 @@ The CI source scanner lifecycle is: 6. Recreate and verify the scanner independently on the full OCI integration job. Destroy temporary registry, builder and network on exit. -The scanner lock initially leaves approved binary and manifest SHA256 values -`null`: this is an intentional **approval gate**, not a temporary release -bypass. Review reproducibility and real SBOM evidence before committing the -measured SHA values in a second commit; the following CI run must match them. +The initial scanner lock deliberately left binary and image identities +`null`, so the real scanner could be tested without self-approving its +manifest. Source-locked CI run 39130571069 built two matching Go binaries, +produced identical OCI manifests and validated a real 93-package/2265-file +SPDX document alongside the required SLSA v1 builder. Its measured binary +SHA256 and manifest digest are now frozen in +`qualification/release-scanner-v1.json`. Subsequent CI jobs must reproduce +both exact identities, independently prove a known regular-file hash, and +still satisfy all packaged OCI, safety and governance constraints. No registry image or scanner binary is published, and no external credentials are required. diff --git a/qualification/release-scanner-v1.json b/qualification/release-scanner-v1.json index ee977743..131d2c8f 100644 --- a/qualification/release-scanner-v1.json +++ b/qualification/release-scanner-v1.json @@ -15,11 +15,13 @@ ], "scanner_dockerfile": "deploy/release/scanner/Dockerfile", "registry_transport_image": "docker.io/library/registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373", - "approved_binary_sha256": null, - "approved_manifest_digest": null, + "approved_binary_sha256": "a1043ee5fa8285740548b600fd24d106b4e68211327238b4edbbe70765bd67ca", + "approved_manifest_digest": "sha256:fe9efbc9d42e6ddbbe44f0f35e2daa45299123891076c1700414a74978a51d2a", "reproducible_binary_required": true, "reproducible_image_required": true, "public_bot_release": false, "artifact_distribution_authorized": false, - "live_credentials_authorized": false + "live_credentials_authorized": false, + "approval_evidence_workflow": "https://github.com/HMarcusWH/AllfatherChess/actions/runs/39130571069", + "approval_basis": "Two source-locked binaries equal; two BuildKit image manifests equal; real scanner accepted SPDX 2.3 (93 packages, 2265 files, SHA1), SLSA v1 and expected builder. Additional independent file-content and packaged-OCI gates remain mandatory." } diff --git a/tests/release/test_release_scanner_source.py b/tests/release/test_release_scanner_source.py index 056f6800..3d995c09 100644 --- a/tests/release/test_release_scanner_source.py +++ b/tests/release/test_release_scanner_source.py @@ -35,6 +35,14 @@ def test_frozen_patch_matches_reviewed_digest(self): self.assertEqual(policy["toolchain_version"],"go1.25.8") self.assertEqual(policy["vendored_syft_version"],"v1.42.3") + def test_approved_binary_and_manifest_are_exact_reviewed_hashes(self): + policy=json.loads(source.LOCK.read_text()) + self.assertEqual(policy["approved_binary_sha256"], + "a1043ee5fa8285740548b600fd24d106b4e68211327238b4edbbe70765bd67ca") + self.assertEqual(policy["approved_manifest_digest"], + "sha256:fe9efbc9d42e6ddbbe44f0f35e2daa45299123891076c1700414a74978a51d2a") + self.assertFalse(policy["public_bot_release"]) + def test_mutated_patch_cannot_keep_approved_digest(self): original=source.ROOT with tempfile.TemporaryDirectory() as td: From a133604c448224ceb6012fe68e9f4a5ec448fc03 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sun, 18 Oct 2026 19:26:54 +0200 Subject: [PATCH 143/147] fix(RELEASE-1): require exact SLSA v1 builder identity on full exported OCI image Validate every image provenance against the frozen release predicate and workflow builder, reject duplicates, and add fully rehashed alternative-builder/v0.2 regression fixtures. --- scripts/verify-release-oci.py | 26 +++++++++++++++++---- tests/release/test_release_oci_integrity.py | 20 ++++++++++++++++ 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/scripts/verify-release-oci.py b/scripts/verify-release-oci.py index 488f3318..4f0390e7 100644 --- a/scripts/verify-release-oci.py +++ b/scripts/verify-release-oci.py @@ -644,13 +644,23 @@ def validate_target_platform(platform: object) -> None: def verify_attestation_subjects(attestations: list[dict], image: dict) -> set[str]: - """Independently bind BuildKit's in-toto subjects to the application manifest. + """Bind full-image attestations to the exact policy's SLSA v1 builder. - For an image-level qualification the image config or one component layer is - not an acceptable alternative subject. Attestations are NOT signatures. + A scanner-only compatibility probe is *not* provenance for the separately + exported application image. Attestations are not publisher signatures. """ + policy=json.loads((Path(__file__).resolve().parents[1] / + "qualification/release-image-v1.json").read_text()) + required_type=policy.get("slsa_provenance_predicate_type") + required_builder=policy.get("slsa_builder_id") + require(policy.get("public_bot_release") is False and + required_type=="https://slsa.dev/provenance/v1" and + type(required_builder) is str and + valid_identity_uri(required_builder), + "full-image SLSA identity policy missing or invalid") image_digest = image["manifest_digest"] predicates = set() + provenance_count=0 require(attestations, "OCI archive has no attestation manifests") for item in attestations: descriptor, statement = item["descriptor"], item["statement"] @@ -706,11 +716,17 @@ def verify_attestation_subjects(attestations: list[dict], image: dict) -> set[st if kind == SPDX: validate_spdx_document(predicate) else: + require(kind == required_type, + "full-image SLSA provenance must use pinned v1 predicate") validate_slsa(kind, predicate) + require(predicate["runDetails"]["builder"]["id"] == + required_builder, + "full-image SLSA builder differs from pinned release policy") + provenance_count += 1 predicates.add(kind) require(SPDX in predicates, "required image SBOM attestation not exported") - require(bool(predicates.intersection(SLSA)), - "required image provenance attestation not exported") + require(provenance_count == 1, + "full image requires exactly one pinned SLSA v1 provenance statement") return predicates def verify_archive(archive: Path, metadata: Path, *, docker_inspect: Path | None = None): diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py index 238efac9..2af1c7e3 100644 --- a/tests/release/test_release_oci_integrity.py +++ b/tests/release/test_release_oci_integrity.py @@ -929,6 +929,26 @@ def test_attestation_manifest_subject_must_match_image(self): with self.assertRaisesRegex(ValueError,"manifest subject"): oci.verify_archive(archive,meta,docker_inspect=inspection) + def test_full_image_provenance_matches_exact_release_builder_and_version(self): + # Synthetic archives rehash *all* affected OCI layers/descriptors, so + # the negative is about provenance identity, not a checksum mismatch. + for variant, expected in ( + ("other-builder", "builder differs from pinned"), + ("v02", "pinned v1 predicate"), + ): + with self.subTest(variant=variant), tempfile.TemporaryDirectory() as d: + archive, meta, inspection = self.fixture( + Path(d), slsa_case=variant) + with self.assertRaisesRegex(ValueError, expected): + oci.verify_archive(archive, meta, docker_inspect=inspection) + + def test_duplicate_image_provenance_cannot_claim_integrity(self): + with tempfile.TemporaryDirectory() as d: + archive, meta, inspection = self.fixture( + Path(d), duplicate_slsa=True) + with self.assertRaisesRegex(ValueError, "exactly one pinned SLSA"): + oci.verify_archive(archive, meta, docker_inspect=inspection) + def test_garbled_predicate_cannot_claim_sbom_or_provenance(self): with tempfile.TemporaryDirectory() as d: archive,meta,inspection=self.fixture(Path(d),invalid_predicate=True) From 5a54cdef2b785efe594d7358c8ffb4a725778cfc Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sun, 18 Oct 2026 19:28:32 +0200 Subject: [PATCH 144/147] fix(ONLINE-4): bind recovery phase logs and replay files to exact contained paths Reject duplicate/misindexed ONLINE-4B phase logs, absolute or symlink-escaped paths, and ONLINE-4A replay manifest/route symlink traversal; add hermetic negative controls. --- scripts/qualify-online-recovery-v2.py | 44 ++++++++++++++++------ scripts/qualify-online-recovery.py | 28 +++++++++++--- tests/lichess/test_recovery_evidence.py | 43 +++++++++++++++++++++ tests/lichess/test_recovery_v2_evidence.py | 31 +++++++++++++++ 4 files changed, 130 insertions(+), 16 deletions(-) diff --git a/scripts/qualify-online-recovery-v2.py b/scripts/qualify-online-recovery-v2.py index d035abef..eb594f3c 100644 --- a/scripts/qualify-online-recovery-v2.py +++ b/scripts/qualify-online-recovery-v2.py @@ -418,6 +418,38 @@ def event_ids(event_name): return False +def verify_phase_logs(folder: Path, logging: object, + phases: list[dict]) -> None: + """Bind sanitized log bytes to the correct phase and exact child path. + + A producer-supplied hash over a producer-selected arbitrary path does not + independently attest the configured phase or prevent symlink traversal. + """ + require(type(logging) is list and len(logging)==len(phases), + "missing sanitized bridge phase logs") + root=folder.resolve() + for index, row in enumerate(logging, start=1): + expected=f"bridge-phase-{index}.log" + require(type(row) is dict and type(row.get("phase")) is int and + row["phase"]==index and row.get("path")==expected, + "bridge phase log index or filename mismatch") + capture=row.get("capture") + require(type(capture) is dict and + type(capture.get("sanitized_sha256")) is str and + type(capture.get("sanitized_bytes")) is int and + 0<=capture["sanitized_bytes"]<=8*1024*1024, + "bridge log size or hash declaration invalid") + path=folder/expected + require(path.is_file() and not path.is_symlink() and + path.resolve().is_relative_to(root) and + path.parent.resolve()==root and + path.stat().st_size==capture["sanitized_bytes"] and + hash_file(path)==capture["sanitized_sha256"], + "bridge phase log is missing, linked, escaped or modified") + require("offline-fixture-token" not in path.read_text(errors="replace"), + "synthetic credentials appeared in sanitized log") + + def verify_one(folder: Path, *, scenario: dict, color: str, sha: str, bridge: str, attested_paths: list[str], expected_overlay: dict) -> dict: @@ -475,17 +507,7 @@ def verify_one(folder: Path, *, scenario: dict, color: str, "missing or additional recovery phase") require(all(isinstance(p.get("bridge_exit"), int) for p in phases), "bridge process was not reaped") - logging = result.get("logging") - require(isinstance(logging, list) and len(logging) == len(phases), - "missing sanitized bridge phase logs") - for row in logging: - path = folder / row["path"] - capture = row["capture"] - require(path.is_file() and hash_file(path) == capture["sanitized_sha256"] - and path.stat().st_size == capture["sanitized_bytes"], - "bridge log evidence incomplete") - require("offline-fixture-token" not in path.read_text(errors="replace"), - "synthetic credentials appeared in sanitized log") + verify_phase_logs(folder, result.get("logging"), phases) journal = result.get("journal") verify_persisted_sqlite(folder, journal) details = verify_journal(journal, trace, game, result, guarded) diff --git a/scripts/qualify-online-recovery.py b/scripts/qualify-online-recovery.py index 84406322..87129f45 100644 --- a/scripts/qualify-online-recovery.py +++ b/scripts/qualify-online-recovery.py @@ -329,15 +329,31 @@ def verify_scenario_witness(scenario: dict, activation: dict, for r in trace), "duplicate state witness absent") +def replay_evidence_file(case_dir: Path, relative: str) -> Path: + """Resolve a replay file without following producer-controlled links.""" + ensure(type(relative) is str and + relative.startswith("replays/") and + "\\" not in relative and + not Path(relative).is_absolute() and + not any(p in ("", ".", "..") for p in relative.split("/")), + "unsafe replay manifest path") + root=case_dir.resolve() + cursor=case_dir + for part in relative.split("/"): + cursor=cursor/part + ensure(not cursor.is_symlink(), "replay symlink evidence rejected") + ensure(cursor.is_file() and cursor.resolve().is_relative_to(root), + "replay evidence missing or escapes case directory") + return cursor + + def validate_replays(case_dir: Path) -> dict: rows = read_json(case_dir / "replay-index.json") ensure(isinstance(rows, list) and len(rows) <= 128, "invalid replay index") for row in rows: path = row.get("manifest") - ensure(isinstance(path, str) and path.startswith("replays/") and - ".." not in Path(path).parts, "unsafe replay manifest path") - manifest = case_dir / path - ensure(manifest.is_file() and digest(manifest) == row.get("sha256"), + manifest = replay_evidence_file(case_dir,path) + ensure(digest(manifest) == row.get("sha256"), "replay manifest bytes mismatch") document = read_json(manifest) ensure(document.get("run_id") == row.get("run_id") and @@ -346,7 +362,9 @@ def validate_replays(case_dir: Path) -> dict: ensure((document.get("position") or {}).get("position_id") == row.get("position_id"), "replay position identity mismatch") if "route_sha256" in row: - ensure(digest(manifest.parent / "route.json") == row["route_sha256"], + route = replay_evidence_file( + case_dir, str(Path(path).parent / "route.json")) + ensure(digest(route) == row["route_sha256"], "routing artifact tampered") return {"manifests_retained": len(rows), "game_to_replay_link_qualified": False} diff --git a/tests/lichess/test_recovery_evidence.py b/tests/lichess/test_recovery_evidence.py index 6b8cd330..e8f62161 100644 --- a/tests/lichess/test_recovery_evidence.py +++ b/tests/lichess/test_recovery_evidence.py @@ -24,6 +24,49 @@ class IndependentEvidenceTests(unittest.TestCase): + def test_replay_manifests_and_routes_cannot_escape_case_directory(self): + import hashlib + with tempfile.TemporaryDirectory() as td: + root=Path(td) + case=root/"case" + entry=case/"replays"/"generation-1" + entry.mkdir(parents=True) + manifest=entry/"manifest.json" + content={"run_id":"offline-test","generation":1, + "position":{"position_id":"0"*64}} + manifest.write_text(json.dumps(content),encoding="utf-8") + route=entry/"route.json" + route.write_text("{}",encoding="utf-8") + record={"manifest":"replays/generation-1/manifest.json", + "sha256":QUALIFIER.digest(manifest), + "run_id":"offline-test","generation":1, + "position_id":"0"*64, + "route_sha256":QUALIFIER.digest(route)} + (case/"replay-index.json").write_text(json.dumps([record])) + QUALIFIER.validate_replays(case) + for bad in ("../external.json","replays/../external.json", + "replays//generation-1/manifest.json"): + with self.subTest(path=bad): + (case/"replay-index.json").write_text( + json.dumps([{**record,"manifest":bad}])) + with self.assertRaises(ValueError): + QUALIFIER.validate_replays(case) + (case/"replay-index.json").write_text(json.dumps([record])) + outside=root/"outside.json" + outside.write_bytes(manifest.read_bytes()) + manifest.unlink() + manifest.symlink_to(outside) + with self.assertRaisesRegex(ValueError,"symlink"): + QUALIFIER.validate_replays(case) + manifest.unlink() + manifest.write_bytes(outside.read_bytes()) + external_route=root/"route.json" + external_route.write_bytes(route.read_bytes()) + route.unlink() + route.symlink_to(external_route) + with self.assertRaisesRegex(ValueError,"symlink"): + QUALIFIER.validate_replays(case) + def test_http_target_policy_is_source_attested_and_tamper_detected(self): exact_sha="a"*40 names=json.loads((ROOT/"qualification/online-recovery-v1.json") diff --git a/tests/lichess/test_recovery_v2_evidence.py b/tests/lichess/test_recovery_v2_evidence.py index cacb7e3a..8220a28d 100644 --- a/tests/lichess/test_recovery_v2_evidence.py +++ b/tests/lichess/test_recovery_v2_evidence.py @@ -1,6 +1,7 @@ """Independent OFFLINE-4B evidence checks: actual SQLite bytes, not producer flags.""" from __future__ import annotations import copy +import hashlib import importlib.util import json import tempfile @@ -17,6 +18,36 @@ class RecoveryV2EvidenceTests(unittest.TestCase): + def test_phase_logs_are_unique_exact_and_contained(self): + with tempfile.TemporaryDirectory() as td: + folder=Path(td)/"case" + folder.mkdir() + for phase in (1,2): + (folder/f"bridge-phase-{phase}.log").write_text( + f"sanitized phase {phase}\n",encoding="utf-8") + def record(phase): + path=folder/f"bridge-phase-{phase}.log" + return {"phase":phase,"path":path.name, + "capture":{"sanitized_sha256":qual.hash_file(path), + "sanitized_bytes":path.stat().st_size}} + good=[record(1),record(2)] + qual.verify_phase_logs(folder,good,[{},{}]) + for bad in ( + [good[0],good[0]], + [{**good[0],"phase":2},good[1]], + [{**good[0],"path":"../private.log"},good[1]], + [{**good[0],"path":"/tmp/private.log"},good[1]], + ): + with self.subTest(bad=bad),self.assertRaises(ValueError): + qual.verify_phase_logs(folder,bad,[{},{}]) + original=folder/"bridge-phase-1.log" + external=Path(td)/"external.log" + external.write_bytes(original.read_bytes()) + original.unlink() + original.symlink_to(external) + with self.assertRaisesRegex(ValueError,"linked"): + qual.verify_phase_logs(folder,good,[{},{}]) + def test_http_target_policy_is_in_exact_guarded_source_closure(self): exact_sha="b"*40 names=json.loads((ROOT/"qualification/online-recovery-v2.json") From 64086c2225eb943e7d5d13220a636d78f1e7d623 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sun, 18 Oct 2026 19:32:48 +0200 Subject: [PATCH 145/147] fix(ASSURANCE): execute independent recovery and OCI verifiers from exact-commit Git worktrees Source admission now precedes Python imports: reconstruct all three qualifier entrypoints from immutable Git bytes after mutable build jobs, verify complete recovery import closures before execution, and run RELEASE-1 smoke/OCI/aggregate from isolated trusted source. --- .github/workflows/online-recovery-v2.yml | 19 ++++++-- .github/workflows/online-recovery.yml | 16 ++++-- .github/workflows/release-candidate.yml | 62 +++++++++++++++--------- 3 files changed, 65 insertions(+), 32 deletions(-) diff --git a/.github/workflows/online-recovery-v2.yml b/.github/workflows/online-recovery-v2.yml index 34598f77..ec68f150 100644 --- a/.github/workflows/online-recovery-v2.yml +++ b/.github/workflows/online-recovery-v2.yml @@ -166,8 +166,17 @@ jobs: run: | bash scripts/ci-install-apt-deps.sh python3-pip python3-venv python3 -m pip install --break-system-packages 'chess==1.11.2' 'PyYAML==6.0.3' - - name: Rebuild expected overlay from independent pinned upstream - run: python3 deploy/lichess/fetch_bridge.py + - name: Verify independent exact-source worktree before importing recovery code + run: | + set -euo pipefail + VERIFIED="$RUNNER_TEMP/online4b-verified-${GITHUB_RUN_ID}" + git worktree add --detach "$VERIFIED" "$ALLFATHER_SOURCE_SHA" + test "$(git -C "$VERIFIED" rev-parse HEAD)" = "$ALLFATHER_SOURCE_SHA" + git -C "$VERIFIED" diff --exit-code + python3 "$VERIFIED/scripts/verify_source_closure.py" \ + --profile online-recovery-v2 --source-sha "$ALLFATHER_SOURCE_SHA" + python3 "$VERIFIED/deploy/lichess/fetch_bridge.py" + echo "ONLINE4B_VERIFIED_ROOT=$VERIFIED" >> "$GITHUB_ENV" - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 continue-on-error: true with: @@ -175,10 +184,10 @@ jobs: path: build/test-results/online-recovery-v2 - name: Independently qualify guarded safety and restart continuation run: | - python3 scripts/qualify-online-recovery-v2.py \ - --evidence build/test-results/online-recovery-v2 \ + python3 "$ONLINE4B_VERIFIED_ROOT/scripts/qualify-online-recovery-v2.py" \ + --evidence "$GITHUB_WORKSPACE/build/test-results/online-recovery-v2" \ --source-sha "$ALLFATHER_SOURCE_SHA" \ - --output build/test-results/online4b-verdict/report.json + --output "$GITHUB_WORKSPACE/build/test-results/online4b-verdict/report.json" - name: Summarize containment versus recovery claims if: always() run: | diff --git a/.github/workflows/online-recovery.yml b/.github/workflows/online-recovery.yml index f4ef4aad..cc4088ff 100644 --- a/.github/workflows/online-recovery.yml +++ b/.github/workflows/online-recovery.yml @@ -165,12 +165,22 @@ jobs: with: name: online4a-raw-evidence path: build/test-results/online-recovery-v1 + - name: Establish Git-committed independent verifier source before imports + run: | + set -euo pipefail + VERIFIED="$RUNNER_TEMP/online4a-verified-${GITHUB_RUN_ID}" + git worktree add --detach "$VERIFIED" "$ALLFATHER_SOURCE_SHA" + test "$(git -C "$VERIFIED" rev-parse HEAD)" = "$ALLFATHER_SOURCE_SHA" + git -C "$VERIFIED" diff --exit-code + python3 "$VERIFIED/scripts/verify_source_closure.py" \ + --profile online-recovery-v1 --source-sha "$ALLFATHER_SOURCE_SHA" + echo "ONLINE4A_VERIFIED_ROOT=$VERIFIED" >> "$GITHUB_ENV" - name: Independently reconstruct commits and fault witnesses run: | - python3 scripts/qualify-online-recovery.py \ - --evidence build/test-results/online-recovery-v1 \ + python3 "$ONLINE4A_VERIFIED_ROOT/scripts/qualify-online-recovery.py" \ + --evidence "$GITHUB_WORKSPACE/build/test-results/online-recovery-v1" \ --source-sha "$ALLFATHER_SOURCE_SHA" \ - --output build/test-results/online4a-verdict/report.json + --output "$GITHUB_WORKSPACE/build/test-results/online4a-verdict/report.json" - name: Publish diagnostic qualification summary if: always() run: | diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 433b58ad..4cbe5aea 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -296,11 +296,19 @@ jobs: | tee "build/test-results/release1/$CASE.log" done sudo chmod -R a+rX build/test-results/release1-volume + - name: Checkout isolated exact-commit source for every independent RELEASE-1 verifier + run: | + set -euo pipefail + VERIFIED="$RUNNER_TEMP/release1-verified-${GITHUB_RUN_ID}" + git worktree add --detach "$VERIFIED" "$SOURCE_SHA" + test "$(git -C "$VERIFIED" rev-parse HEAD)" = "$SOURCE_SHA" + git -C "$VERIFIED" diff --exit-code + echo "RELEASE1_VERIFIED_ROOT=$VERIFIED" >> "$GITHUB_ENV" - name: Independently reconstruct all packaged chess and HTTP requests run: | - python3 scripts/qualify-release-smoke.py \ - --smoke-dir build/test-results/release1-volume \ - --output build/test-results/release1/smoke-verdict.json + python3 "$RELEASE1_VERIFIED_ROOT/scripts/qualify-release-smoke.py" \ + --smoke-dir "$GITHUB_WORKSPACE/build/test-results/release1-volume" \ + --output "$GITHUB_WORKSPACE/build/test-results/release1/smoke-verdict.json" - name: Export standalone OCI archive with SBOM/provenance attestations timeout-minutes: 20 run: | @@ -316,15 +324,19 @@ jobs: --attest type=provenance,mode=min,builder-id=https://github.com/HMarcusWH/AllfatherChess/actions/workflows/release-candidate.yml \ --attest "type=sbom,generator=$SCANNER" \ --output type=oci,dest=build/release1-offline.oci.tar . - python3 scripts/inspect-release-oci.py \ - --archive build/release1-offline.oci.tar \ - --metadata build/test-results/release1/oci-build-metadata.json \ - --output build/test-results/release1/oci-attestation-structure.json - python3 scripts/verify-release-oci.py \ - --archive build/release1-offline.oci.tar \ - --metadata build/test-results/release1/oci-build-metadata.json \ - --docker-inspect build/test-results/release1/docker-inspect.json \ - --output build/test-results/release1/oci-report.json + # Both executables and the SPDX parser/catalogue come from the + # isolated Git checkout created after all mutable build steps. + test "$(git -C "$RELEASE1_VERIFIED_ROOT" rev-parse HEAD)" = "$SOURCE_SHA" + git -C "$RELEASE1_VERIFIED_ROOT" diff --exit-code + python3 "$RELEASE1_VERIFIED_ROOT/scripts/inspect-release-oci.py" \ + --archive "$GITHUB_WORKSPACE/build/release1-offline.oci.tar" \ + --metadata "$GITHUB_WORKSPACE/build/test-results/release1/oci-build-metadata.json" \ + --output "$GITHUB_WORKSPACE/build/test-results/release1/oci-attestation-structure.json" + python3 "$RELEASE1_VERIFIED_ROOT/scripts/verify-release-oci.py" \ + --archive "$GITHUB_WORKSPACE/build/release1-offline.oci.tar" \ + --metadata "$GITHUB_WORKSPACE/build/test-results/release1/oci-build-metadata.json" \ + --docker-inspect "$GITHUB_WORKSPACE/build/test-results/release1/docker-inspect.json" \ + --output "$GITHUB_WORKSPACE/build/test-results/release1/oci-report.json" - name: Verify every offline verdict module before aggregate execution run: | set -euo pipefail @@ -364,19 +376,21 @@ jobs: PY - name: Observe test-runner capacity and aggregate evidence without promoting release run: | - python3 scripts/qualify-release-host.py \ - --root . --persistent build/test-results/release1-volume \ - --runtime /tmp --output build/test-results/release1/host-report.json - python3 scripts/qualify-release-aggregate.py \ - --stage build/release1-stage \ - --oci build/test-results/release1/oci-report.json \ - --oci-archive build/release1-offline.oci.tar \ - --host build/test-results/release1/host-report.json \ - --smoke-dir build/test-results/release1-volume \ - --os-inventory build/test-results/release1/os-packages.txt \ - --wheel-inventory build/release1-stage/wheels/wheel-attestation.json \ + python3 "$RELEASE1_VERIFIED_ROOT/scripts/qualify-release-host.py" \ + --root "$RELEASE1_VERIFIED_ROOT" \ + --persistent "$GITHUB_WORKSPACE/build/test-results/release1-volume" \ + --runtime /tmp \ + --output "$GITHUB_WORKSPACE/build/test-results/release1/host-report.json" + python3 "$RELEASE1_VERIFIED_ROOT/scripts/qualify-release-aggregate.py" \ + --stage "$GITHUB_WORKSPACE/build/release1-stage" \ + --oci "$GITHUB_WORKSPACE/build/test-results/release1/oci-report.json" \ + --oci-archive "$GITHUB_WORKSPACE/build/release1-offline.oci.tar" \ + --host "$GITHUB_WORKSPACE/build/test-results/release1/host-report.json" \ + --smoke-dir "$GITHUB_WORKSPACE/build/test-results/release1-volume" \ + --os-inventory "$GITHUB_WORKSPACE/build/test-results/release1/os-packages.txt" \ + --wheel-inventory "$GITHUB_WORKSPACE/build/release1-stage/wheels/wheel-attestation.json" \ --source-sha "$SOURCE_SHA" \ - --output build/test-results/release1/aggregate.json + --output "$GITHUB_WORKSPACE/build/test-results/release1/aggregate.json" - name: Tear down isolated release scanner registry and builder if: always() env: From dba31b289729e4e9316f12a189679daa0dd127c5 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sun, 18 Oct 2026 19:35:09 +0200 Subject: [PATCH 146/147] test(ASSURANCE): prove exact-commit execution and record five manual-review counterexamples Add isolated-worktree tamper oracle, workflow ordering and source-closure tests for ONLINE-4A/4B and RELEASE-1, plus historical repair ledger and operations notes. --- docs/PR71_ASSURANCE_REPAIR_LEDGER.md | 31 +++++++++++++++++++ docs/RELEASE_1_OPERATIONS.md | 26 ++++++++++++++++ tests/lichess/test_recovery_evidence.py | 11 +++++++ tests/lichess/test_recovery_v2_evidence.py | 11 +++++++ .../test_release_source_attestation.py | 18 +++++++++++ tests/release/test_workflow_coverage.py | 18 +++++++++++ 6 files changed, 115 insertions(+) diff --git a/docs/PR71_ASSURANCE_REPAIR_LEDGER.md b/docs/PR71_ASSURANCE_REPAIR_LEDGER.md index 87b7e565..5843b29c 100644 --- a/docs/PR71_ASSURANCE_REPAIR_LEDGER.md +++ b/docs/PR71_ASSURANCE_REPAIR_LEDGER.md @@ -151,3 +151,34 @@ The following exact-head run must independently remeasure those hashes and check actual base-image `/usr/bin/dpkg` contents against scanner SHA1/SHA256 before any full RELEASE-1 image qualification. Code review, distribution and host/live authorizations remain separate, closed gates. + +## October 18 — independent manual review follow-up after green 11/11 + +Independent exact-head [manual review](https://github.com/HMarcusWH/AllfatherChess/pull/71#pullrequestreview-5564010246) +identified five source-admission and evidence-integrity counterexamples on +`18b5e1f2fd65`: full-image SLSA builder/version not pinned; OCI verifier +executed before committed-source check; ONLINE-4B phase log re-use and +path escapes; ONLINE-4A replay route/manifest symlink escapes; ONLINE-4A/4B +mutable imports executing before committed-byte verification. + +The source repair wave: +- Full OCI verifies exactly one SLSA **v1** statement with the same pinned + builder ID as the actual release policy (not merely the scanner probe). +- Rehashed image fixtures with v0.2, wrong builder, or duplicate provenance + fail even when all descriptor hashes match. +- Independent RELEASE-1 smoke, OCI diagnostics, OCI assurance and aggregate + execute in a fresh detached `git worktree` at the exact source commit, + created after mutable build steps. +- ONLINE-4A/4B separate independent verifier jobs construct their own + fresh detached exact-commit worktree and verify full attested source closure + **before any local recovery Python imports**; ONLINE-4B fetches independently + pinned bridge source inside that worktree. +- ONLINE-4B binds sanitized bridge logs to exact phase index and + `bridge-phase-N.log` with file containment, symlink refusal and byte digest. +- ONLINE-4A replay manifest and route files refuse symlink escapes, + nonregular files and lexical traversal. + +All five fixes have regression or source-runner contract tests. No item is +declared qualified until the **new** exact-head real CI matrix and independent +review pass. Prior green runs and historical comment resolution do not satisfy +the new head's review gate. diff --git a/docs/RELEASE_1_OPERATIONS.md b/docs/RELEASE_1_OPERATIONS.md index f49e32ac..937b3954 100644 --- a/docs/RELEASE_1_OPERATIONS.md +++ b/docs/RELEASE_1_OPERATIONS.md @@ -439,3 +439,29 @@ SHA1 string, an absent file or the nonregular-file zero placeholder does not qualify. The reference executable is kept only in a temporary runner directory and is never uploaded. The published witness contains safe Boolean verdicts, not the raw file or SBOM. + +## October 18 — final independent-review repairs + +The manual final boarding review exposed a difference between the scanner-only +SLSA identity check and the **real application OCI** gate. Full image provenance +is now bound directly to `qualification/release-image-v1.json`: +exactly one SLSA v1 predicate, pinned workflow builder ID and exact +application-manifest subject, even when a caller supplies a correctly +rehashed but different SLSA archive. A scanner-only success cannot waive it. + +Build and qualification now have separate execution source domains. +The offline producer assembles chess evidence and an OCI archive in its +working copy. The independent RELEASE-1 verifiers run *after those steps* +from a new detached worktree at the exact head SHA; they do not import +the potentially modified producer checkout. ONLINE-4A and ONLINE-4B +independent jobs likewise create isolated worktrees and run +`verify_source_closure.py` against committed bytes *before* loading local +recovery code. The v2 verifier fetches its independently pinned bridge +checkout under its own verified root. This prevents a stale green HEAD from +authorizing a mutated executable module. + +ONLINE-4B accepts only distinct, phase-indexed `bridge-phase-N.log` evidence +inside its scenario directory. ONLINE-4A refuses symlinked or +path-escaping replay manifests and `route.json` before hashing. +These checks are evidence admission rules, not declarations of successful +live network recovery or host/public-play approval. diff --git a/tests/lichess/test_recovery_evidence.py b/tests/lichess/test_recovery_evidence.py index e8f62161..25f6a487 100644 --- a/tests/lichess/test_recovery_evidence.py +++ b/tests/lichess/test_recovery_evidence.py @@ -67,6 +67,17 @@ def test_replay_manifests_and_routes_cannot_escape_case_directory(self): with self.assertRaisesRegex(ValueError,"symlink"): QUALIFIER.validate_replays(case) + def test_independent_online4a_imports_only_from_git_worktree_after_closure(self): + yml=(ROOT/".github/workflows/online-recovery.yml").read_text() + step="Establish Git-committed independent verifier source before imports" + self.assertIn(step,yml) + self.assertIn('git worktree add --detach "$VERIFIED" "$ALLFATHER_SOURCE_SHA"',yml) + self.assertIn('--profile online-recovery-v1 --source-sha "$ALLFATHER_SOURCE_SHA"',yml) + self.assertIn( + '"$ONLINE4A_VERIFIED_ROOT/scripts/qualify-online-recovery.py"',yml) + self.assertLess(yml.index(step), + yml.index("Independently reconstruct commits and fault witnesses")) + def test_http_target_policy_is_source_attested_and_tamper_detected(self): exact_sha="a"*40 names=json.loads((ROOT/"qualification/online-recovery-v1.json") diff --git a/tests/lichess/test_recovery_v2_evidence.py b/tests/lichess/test_recovery_v2_evidence.py index 8220a28d..a9be670a 100644 --- a/tests/lichess/test_recovery_v2_evidence.py +++ b/tests/lichess/test_recovery_v2_evidence.py @@ -48,6 +48,17 @@ def record(phase): with self.assertRaisesRegex(ValueError,"linked"): qual.verify_phase_logs(folder,good,[{},{}]) + def test_independent_online4b_imports_only_after_git_source_check(self): + yml=(ROOT/".github/workflows/online-recovery-v2.yml").read_text() + step="Verify independent exact-source worktree before importing recovery code" + self.assertIn(step,yml) + self.assertIn('git worktree add --detach "$VERIFIED" "$ALLFATHER_SOURCE_SHA"',yml) + self.assertIn('--profile online-recovery-v2 --source-sha "$ALLFATHER_SOURCE_SHA"',yml) + self.assertIn( + '"$ONLINE4B_VERIFIED_ROOT/scripts/qualify-online-recovery-v2.py"',yml) + self.assertLess(yml.index(step), + yml.index("Independently qualify guarded safety")) + def test_http_target_policy_is_in_exact_guarded_source_closure(self): exact_sha="b"*40 names=json.loads((ROOT/"qualification/online-recovery-v2.json") diff --git a/tests/release/test_release_source_attestation.py b/tests/release/test_release_source_attestation.py index 076c8b09..3a0f569f 100644 --- a/tests/release/test_release_source_attestation.py +++ b/tests/release/test_release_source_attestation.py @@ -54,6 +54,24 @@ def test_clean_source_loads_from_sealed_snapshot(self): self.assertTrue(callable(smoke.verify_file)) self.assertNotEqual(sealed.resolve(),self.root.resolve()) + def test_isolated_git_worktree_preserves_original_committed_verifier_bytes(self): + # A build step can mutate the producer checkout while preserving HEAD. + # A new detached worktree must execute Git's original bytes instead. + with tempfile.TemporaryDirectory(prefix="release1-isolated-") as td: + verified=Path(td)/"verified" + self.git("worktree","add","--detach",str(verified),self.source) + victim="scripts/qualify-release-smoke.py" + original=(verified/victim).read_bytes() + (self.root/victim).write_bytes(b"raise SystemExit('FORGED')\n") + self.assertEqual((verified/victim).read_bytes(),original) + with self.assertRaisesRegex(ValueError,"staged attestation"): + with aggregate.verified_release_snapshot( + self.root,self.source,self.attestation): + self.fail("mutable producer source was accepted") + with aggregate.verified_release_snapshot( + verified,self.source,self.attestation) as sealed: + self.assertEqual((sealed/victim).read_bytes(),original) + def test_worktree_mutation_cannot_reuse_old_manifest_and_head(self): path=self.root/"scripts/qualify-release-smoke.py" path.write_bytes(path.read_bytes()+b"\n# PRIVATE_CANARY\n") diff --git a/tests/release/test_workflow_coverage.py b/tests/release/test_workflow_coverage.py index 6c0c00f4..4e291b58 100644 --- a/tests/release/test_workflow_coverage.py +++ b/tests/release/test_workflow_coverage.py @@ -90,6 +90,24 @@ def test_release_verdict_sources_are_git_attested_before_import(self): self.assertIn("with verified_release_snapshot",source_code) self.assertIn("Git committed bytes",source_code) + def test_every_independent_release_verifier_uses_isolated_committed_source(self): + source=(ROOT/".github/workflows/release-candidate.yml").read_text() + self.assertIn("Checkout isolated exact-commit source",source) + self.assertIn('git worktree add --detach "$VERIFIED" "$SOURCE_SHA"',source) + for name in ( + "qualify-release-smoke.py", "inspect-release-oci.py", + "verify-release-oci.py", "qualify-release-host.py", + "qualify-release-aggregate.py", + ): + with self.subTest(executable=name): + self.assertIn( + '"$RELEASE1_VERIFIED_ROOT/scripts/'+name+'"',source) + self.assertNotIn("python3 scripts/"+name+" \\",source) + self.assertLess(source.index("Checkout isolated exact-commit source"), + source.index("Independently reconstruct all packaged")) + self.assertLess(source.index("Checkout isolated exact-commit source"), + source.index("Export standalone OCI archive")) + def test_release_artifacts_exclude_raw_provenance_and_image_bytes(self): content=(ROOT/".github/workflows/release-candidate.yml").read_text() upload=content.split("- name: Do not publish combined image",1)[1] From 90e98e64208c1d3fac172ca62f3ee9fd909974e5 Mon Sep 17 00:00:00 2001 From: Marcus Hermansson Date: Sun, 18 Oct 2026 19:37:40 +0200 Subject: [PATCH 147/147] test(RELEASE-1): assert absolute OCI archive input after isolated Git verifier checkout --- tests/release/test_workflow_coverage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/release/test_workflow_coverage.py b/tests/release/test_workflow_coverage.py index 4e291b58..514ac21a 100644 --- a/tests/release/test_workflow_coverage.py +++ b/tests/release/test_workflow_coverage.py @@ -131,7 +131,7 @@ def test_offline_oci_export_is_named_and_diagnostics_precede_admission(self): self.assertIn("scripts/qualify-release-smoke.py",content) self.assertLess(content.index("scripts/qualify-release-smoke.py"), content.index("- name: Export standalone OCI archive")) - self.assertIn("--oci-archive build/release1-offline.oci.tar",content) + self.assertIn('--oci-archive "$GITHUB_WORKSPACE/build/release1-offline.oci.tar"',content) self.assertIn("snapshot-acquisition-failure.json",content) self.assertIn("snapshot-availability.json", content) self.assertLess(content.index("scripts/check-release-snapshot.py"),