From 99a06006fd26c5dec60464c3f2755da1ff901d03 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 16:39:31 +0500 Subject: [PATCH 01/15] feat(step7): host qualification and the execution binding, kept apart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two artifacts, because they answer two questions. Qualification: does this one environment satisfy the T0-7 predicate. Execution binding: which Linux and Windows environments, which candidates and which instrument form THIS campaign. A utility that checks a CPU governor must not become the root of campaign identity, so `hostqual.py` neither issues nor owns the binding. `hostqual.py` splits evidence into the two classes that are not the same thing. DECLARED provisioning — dedication, absence of other users, hypervisor configuration — is content-addressed, shape-checked and never called machine proof: a guest OS cannot establish what the hypervisor is doing. MACHINE-OBSERVED is what a checker actually asserts: `manifest.provenance.ci == false` (the field is always present, so a predicate demanding its absence could never be met), `performance` governor on every applicable CPU plus an identified turbo mechanism, the Windows active plan with AC processor state pinned at 100/100, and the stratum's memory mechanism actually existing on the host. Quiesce is implemented exactly, not approximately: twelve 5 s intervals over the final minute, `/proc/stat` on Linux and `GetSystemTimes` on Windows, mean below 5 % and no interval above 20 %. A missing sample, a counter that went backwards or a zero denominator is NOT_ELIGIBLE rather than a skipped interval — an unreadable machine is not a quiet machine. A quiesce failure starts no clock, so it is not INVALID and costs no retry budget. A qualification is not a certificate of perpetual quiet. Session eligibility is proved again per session, against a fresh manifest, and the record names the execution binding, the qualification and that manifest — so a qualified host that this campaign never bound cannot be substituted into the middle of it. `execbinding.py` carries references and identity, never copies: duplicating the governor or the provisioning blob would create two copies of one fact, and two copies drift. It requires both strata, refuses an unqualified or mislabelled host, refuses a binding whose strata share one memory metric, and refuses to overwrite an existing binding — a rebuild before the first clock is legitimate but never silent. Both platform branches were driven live, not only by fixture. On Windows the first live run found a defect in this tool's own powercfg parse: it read the range's minimum as the current setting and reported 0 % on a machine pinned at 100 %. The block ends with the two current indices, AC then DC, and everything before them describes the possible range. Fixed here, with the labels never parsed — on this Russian Windows every one of them is localized. On Linux the tool correctly refuses WSL, where no cpufreq governor exists. step 7 host qualification controls: 10 passed, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh --- scripts/step7/execbinding.py | 237 +++++++++++++++ scripts/step7/hostqual.py | 547 +++++++++++++++++++++++++++++++++++ tests/test_step7_hostqual.py | 467 ++++++++++++++++++++++++++++++ 3 files changed, 1251 insertions(+) create mode 100644 scripts/step7/execbinding.py create mode 100644 scripts/step7/hostqual.py create mode 100644 tests/test_step7_hostqual.py diff --git a/scripts/step7/execbinding.py b/scripts/step7/execbinding.py new file mode 100644 index 00000000..ba65185b --- /dev/null +++ b/scripts/step7/execbinding.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +"""P-022 / #263 — step 7: the execution binding, one per campaign. + +Host qualification answers "is this one environment fit". This answers a +different question: **which** Linux and Windows environments, which candidates +and which instrument form THIS measurement campaign. The two are separate +artifacts on purpose — a utility that checks a CPU governor must not become the +root of campaign identity, and a campaign identity must not be re-derived from +whichever qualified host happens to be lying around. + +The binding carries **references and identity**, never copies. Governors, power +plans, CPU samples and the provisioning declaration stay in the qualification +record, which owns them; duplicating them here would create two copies of one +fact, and two copies drift. + +The chain is acyclic, and each link names only the one before it: + + provisioning declaration -> envcapture manifest -> host qualification + -> execution binding -> training session(s) -> N -> D7 C1 -> D7 C2 + -> decisive collection + +D7 later binds `execution_binding_sha256`. It does not restate the machines. + +Lifecycle, enforced here as far as a tool can and stated where it cannot: + + BEFORE the first clock the binding may be rebuilt whenever a host or a + candidate changes. `--emit` refuses to overwrite an + existing file, so a rebuild is a deliberate act. + AFTER the first clock the binding is immutable. A change to any bound + component does not patch the running campaign: the old + campaign stops and a new binding identity begins. + `--verify` detects the drift; it cannot un-run a clock. + +Usage: + python scripts/step7/execbinding.py --emit --t0 \\ + --t0-commit --instrument-commit --harness-digest \\ + --workloads \\ + --linux --linux-candidate \\ + --windows --windows-candidate + python scripts/step7/execbinding.py --verify \\ + --linux --windows + python scripts/step7/execbinding.py --selftest +""" + +from __future__ import annotations + +import argparse +import datetime +import hashlib +import json +import sys +from pathlib import Path + +BINDING_SCHEMA = "own.net/p022/execution-binding" +SCHEMA_VERSION = 1 +STRATA = ("linux", "windows") + +# Kept in step with hostqual's own declaration; a control proves they agree. +MEMORY_METRIC_RESIDENT = "max_process_peak_resident" +MEMORY_METRIC_COMMIT = "max_process_peak_commit" +STRATUM_METRIC = {"linux": MEMORY_METRIC_RESIDENT, "windows": MEMORY_METRIC_COMMIT} + +REQUIRED_STRATUM_KEYS = ("qualification_sha256", "environment_id", "host_fingerprint", + "candidate_sha256", "candidate_bytes", "memory_metric") + + +class BindingRefused(Exception): + """Raised by name, so a caller that reads through gets an exception.""" + + +def sha256_file(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _now() -> str: + return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds") + + +def _stratum_block(stratum: str, qualification_path: Path, + candidate_path: Path) -> dict[str, object]: + doc = json.loads(qualification_path.read_text(encoding="utf-8")) + if doc.get("kind") != "own.net/p022/host-qualification": + raise BindingRefused(f"{qualification_path} is not a host-qualification artifact") + if doc.get("stratum") != stratum: + raise BindingRefused( + f"{qualification_path} declares stratum {doc.get('stratum')!r}, bound as {stratum!r}") + if not doc.get("qualified"): + raise BindingRefused(f"{qualification_path} does not say qualified; an unqualified host " + "may not enter a campaign") + metric = doc.get("memory_metric") + if metric != STRATUM_METRIC[stratum]: + raise BindingRefused( + f"stratum {stratum} must carry {STRATUM_METRIC[stratum]!r}, not {metric!r}") + raw = candidate_path.read_bytes() + return { + "qualification_sha256": sha256_file(qualification_path), + "environment_id": doc.get("environment_id"), + "host_fingerprint": doc.get("host_fingerprint"), + "candidate_sha256": hashlib.sha256(raw).hexdigest(), + "candidate_bytes": len(raw), + "memory_metric": metric, + } + + +def build(t0_path: Path, t0_commit: str, t0_blob_sha: str, instrument_commit: str, + harness_digest: str, workloads_path: Path, + strata: dict[str, tuple[Path, Path]]) -> dict[str, object]: + missing = [s for s in STRATA if s not in strata] + if missing: + raise BindingRefused(f"both strata are required; missing {missing}. `U_linux` and " + "`U_windows` are never pooled, and never optional either") + binding: dict[str, object] = { + "kind": BINDING_SCHEMA, + "schema": SCHEMA_VERSION, + "t0": {"commit": t0_commit, "blob_sha": t0_blob_sha, "sha256": sha256_file(t0_path)}, + "instrument": {"accepted_commit": instrument_commit, "harness_digest": harness_digest}, + "workloads": {"manifest_sha256": sha256_file(workloads_path)}, + "bound_at": _now(), + } + for stratum in STRATA: + binding[stratum] = _stratum_block(stratum, *strata[stratum]) + return binding + + +def validate(binding: dict) -> list[str]: + problems: list[str] = [] + if binding.get("kind") != BINDING_SCHEMA: + problems.append(f"kind is {binding.get('kind')!r}, not {BINDING_SCHEMA!r}") + if binding.get("schema") != SCHEMA_VERSION: + problems.append(f"schema is {binding.get('schema')!r}, not {SCHEMA_VERSION}") + for section, keys in (("t0", ("commit", "blob_sha", "sha256")), + ("instrument", ("accepted_commit", "harness_digest")), + ("workloads", ("manifest_sha256",))): + block = binding.get(section) + if not isinstance(block, dict): + problems.append(f"{section} is missing") + continue + problems.extend(f"{section}.{k} is missing or empty" + for k in keys if not block.get(k)) + for stratum in STRATA: + block = binding.get(stratum) + if not isinstance(block, dict): + problems.append(f"{stratum} is missing") + continue + problems.extend(f"{stratum}.{k} is missing" + for k in REQUIRED_STRATUM_KEYS if block.get(k) in (None, "")) + if block.get("memory_metric") != STRATUM_METRIC[stratum]: + problems.append(f"{stratum}.memory_metric is {block.get('memory_metric')!r}, " + f"not {STRATUM_METRIC[stratum]!r}") + if not isinstance(block.get("candidate_bytes"), int): + problems.append(f"{stratum}.candidate_bytes is not an integer") + if isinstance(binding.get("linux"), dict) and isinstance(binding.get("windows"), dict): + if binding["linux"].get("memory_metric") == binding["windows"].get("memory_metric"): + problems.append("both strata carry the same memory metric; they measure different " + "physical quantities and may not be pooled") + return problems + + +def verify(binding_path: Path, qualifications: dict[str, Path]) -> list[str]: + """Does the campaign still describe the hosts it was bound to?""" + binding = json.loads(binding_path.read_text(encoding="utf-8")) + problems = validate(binding) + for stratum, path in qualifications.items(): + block = binding.get(stratum) + if not isinstance(block, dict): + continue + live = sha256_file(path) + if block.get("qualification_sha256") != live: + problems.append( + f"{stratum}: the qualification now hashes to {live[:12]}, bound as " + f"{str(block.get('qualification_sha256'))[:12]}. After the first clock this is " + "not a patch: the campaign stops and a new binding identity begins") + return problems + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--emit", type=Path) + parser.add_argument("--verify", type=Path) + parser.add_argument("--selftest", action="store_true") + parser.add_argument("--t0", type=Path) + parser.add_argument("--t0-commit", default="") + parser.add_argument("--t0-blob-sha", default="") + parser.add_argument("--instrument-commit", default="") + parser.add_argument("--harness-digest", default="") + parser.add_argument("--workloads", type=Path) + parser.add_argument("--linux", type=Path) + parser.add_argument("--linux-candidate", type=Path) + parser.add_argument("--windows", type=Path) + parser.add_argument("--windows-candidate", type=Path) + args = parser.parse_args(argv) + + if args.selftest: + print(json.dumps({"kind": BINDING_SCHEMA, "schema": SCHEMA_VERSION, + "strata": STRATUM_METRIC, + "required_stratum_keys": list(REQUIRED_STRATUM_KEYS)}, indent=2)) + return 0 + + if args.verify: + qualifications = {s: p for s, p in (("linux", args.linux), ("windows", args.windows)) + if p is not None} + problems = verify(args.verify, qualifications) + for problem in problems: + print(f"BINDING-DRIFT: {problem}") + print("binding verified" if not problems else f"{len(problems)} problem(s)") + return 1 if problems else 0 + + if args.emit: + if args.emit.exists(): + # Before the first clock a rebuild is legitimate; it is never silent. + print(f"refused: {args.emit} exists. A rebuild is a deliberate act — remove it " + "first, and only before the first clock.", file=sys.stderr) + return 2 + required = {"t0": args.t0, "workloads": args.workloads, "linux": args.linux, + "linux-candidate": args.linux_candidate, "windows": args.windows, + "windows-candidate": args.windows_candidate} + absent = sorted(k for k, v in required.items() if v is None) + if absent: + parser.error(f"--emit requires {absent}") + binding = build(args.t0, args.t0_commit, args.t0_blob_sha, args.instrument_commit, + args.harness_digest, args.workloads, + {"linux": (args.linux, args.linux_candidate), + "windows": (args.windows, args.windows_candidate)}) + problems = validate(binding) + if problems: + raise BindingRefused("; ".join(problems)) + args.emit.write_text(json.dumps(binding, indent=2, sort_keys=True) + "\n", + encoding="utf-8") + print(f"bound: {sha256_file(args.emit)}") + return 0 + + parser.error("choose --emit, --verify or --selftest") + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/step7/hostqual.py b/scripts/step7/hostqual.py new file mode 100644 index 00000000..346e59e3 --- /dev/null +++ b/scripts/step7/hostqual.py @@ -0,0 +1,547 @@ +#!/usr/bin/env python3 +"""P-022 / #263 — step 7: host qualification and session eligibility. + +Two questions, deliberately never merged into one artifact: + + QUALIFICATION does this environment satisfy the T0-7 host predicate? + SESSION is this session, on that qualified host, eligible to start NOW? + +A qualification is not a certificate of perpetual quiet. It proves the host can +satisfy the predicate and names the bytes it was proved against; the current +fitness of a particular session is proved again, per session, or "a fresh +manifest per session" becomes decorative paperwork. + +This tool does NOT own the execution binding. Qualification answers "is this one +environment fit"; the binding answers "which Linux and Windows environments, +which candidates and which instrument form this campaign". A utility that checks +a CPU governor must not become the root of campaign identity — see +``execbinding.py``. + +It carries no Rust-vs-Python number, starts no clock over a candidate, and +produces no measurement. The CPU sampling below is environment observation for +eligibility: it never times, and never touches, the thing under test. + +Two classes of evidence, kept apart because only one of them is proof: + + DECLARED provisioning facts a guest OS cannot establish — dedication, + hypervisor configuration, that no one else is using the box. + Recorded, content-addressed, and never called machine proof. + MACHINE-OBSERVED what a checker actually asserts here and now. + +Usage: + python scripts/step7/hostqual.py --qualify --stratum linux \\ + --environment-id --provisioning --manifest \\ + --emit + python scripts/step7/hostqual.py --session-preflight --binding \\ + --qualification --manifest --emit + python scripts/step7/hostqual.py --selftest +""" + +from __future__ import annotations + +import argparse +import ctypes +import datetime +import hashlib +import json +import os +import re +import subprocess +import sys +import time +from pathlib import Path + +QUALIFICATION_SCHEMA = "own.net/p022/host-qualification" +SESSION_SCHEMA = "own.net/p022/session-eligibility" +PROVISIONING_SCHEMA = "own.net/p022/host-provisioning" +SCHEMA_VERSION = 1 + +# The closed memory vocabulary. Declared here rather than imported so this tool +# does not reach into the frozen harness; `hostqual-memory-vocabulary` proves the +# two sets are identical, so a drift between them is a test failure and not a +# surprise in the field. +MEMORY_METRIC_RESIDENT = "max_process_peak_resident" +MEMORY_METRIC_COMMIT = "max_process_peak_commit" +STRATUM_METRIC = {"linux": MEMORY_METRIC_RESIDENT, "windows": MEMORY_METRIC_COMMIT} + +PREDICATE_KEYS = ("ci", "single_tenant", "power_policy", "required_memory_metric") + +# Quiesce, exactly as T0-7 fixes it. No cadence is left to a reader. +QUIESCE_WINDOW_S = 120 +QUIESCE_INTERVAL_S = 5 +QUIESCE_INTERVALS = 12 # the final 60 s +QUIESCE_MEAN_MAX = 0.05 +QUIESCE_INTERVAL_MAX = 0.20 + +# Provisioning keys. A key that does not apply is present with an explicit +# "n/a: ", never missing: an absent declaration is not a declaration. +PROVISIONING_BOOLEANS = ("dedicated_to_p022", "no_concurrent_user_workload", + "hosted_ci_runner", "prohibited_background_declared_inactive") +PROVISIONING_VM_BOOLEANS = ("is_vm", "fixed_vcpu", "fixed_ram", + "live_migration_disabled", "dynamic_memory_disabled") +PROVISIONING_STRINGS = ("environment_id", "host_fingerprint", "operator", "recorded_at") + +# Windows processor-state settings, by GUID so no localized label is parsed. +WIN_SUB_PROCESSOR = "54533251-82be-4824-96c1-47b60b740d00" +WIN_PROCTHROTTLEMIN = "893dee8e-2bef-41e0-89c6-b55d0929964c" +WIN_PROCTHROTTLEMAX = "bc5038f7-23e0-4960-96da-33abaf5935ec" +WIN_REQUIRED_STATE = 100 +WIN_ACCEPTED_PLANS = ("8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c", # High performance + "e9a42b02-d5df-448d-aa00-03f14749eb61") # Ultimate Performance + + +class QualificationRefused(Exception): + """Raised by name. A caller that reads through gets an exception, not a record.""" + + +def _now() -> str: + return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds") + + +def sha256_bytes(raw: bytes) -> str: + return hashlib.sha256(raw).hexdigest() + + +def sha256_file(path: Path) -> str: + return sha256_bytes(path.read_bytes()) + + +def _console_encoding() -> str: + """The encoding a console tool's bytes arrive in. + + `text=True` would decode with the locale's ANSI code page while a console + tool writes in the console output code page; on a non-English Windows the + two disagree and the value becomes mojibake that moves with the ambient code + page. Same defect, same fix as the capture tool's. + """ + if os.name == "nt": + try: + kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] # Windows-only + for query in ("GetConsoleOutputCP", "GetOEMCP"): + code_page = getattr(kernel32, query)() + if code_page: + return f"cp{code_page}" + except (AttributeError, OSError, ValueError): + pass + return "utf-8" + + +def _tool(argv: list[str]) -> tuple[int, str] | None: + try: + proc = subprocess.run(argv, capture_output=True, check=False) + except (OSError, ValueError): + return None + enc = _console_encoding() + return proc.returncode, (proc.stdout.decode(enc, "replace") + + proc.stderr.decode(enc, "replace")).strip() + + +def _text(path: str) -> str | None: + try: + return Path(path).read_text(encoding="utf-8", errors="replace").strip() + except OSError: + return None + + +def check(name: str, ok: bool, detail: str) -> dict[str, object]: + return {"check": name, "result": "pass" if ok else "fail", "detail": detail} + + +# --- the predicate, one function per clause --------------------------------- + + +def check_ci(manifest: dict) -> dict[str, object]: + """`ci == false`, not "the field is absent". + + The capture tool writes `ci` as a boolean on every manifest, so a predicate + demanding its absence could never be satisfied by any real manifest. + """ + provenance = manifest.get("provenance") + if not isinstance(provenance, dict) or "ci" not in provenance: + return check("ci", False, "the manifest carries no provenance.ci field") + value = provenance["ci"] + if not isinstance(value, bool): + return check("ci", False, f"provenance.ci is {type(value).__name__}, not a boolean") + return check("ci", value is False, f"manifest.provenance.ci == {json.dumps(value)}") + + +def validate_provisioning(doc: dict) -> list[str]: + problems: list[str] = [] + if doc.get("kind") != PROVISIONING_SCHEMA: + problems.append(f"kind is {doc.get('kind')!r}, not {PROVISIONING_SCHEMA!r}") + if doc.get("schema") != SCHEMA_VERSION: + problems.append(f"schema is {doc.get('schema')!r}, not {SCHEMA_VERSION}") + for key in PROVISIONING_STRINGS: + if not isinstance(doc.get(key), str) or not doc.get(key): + problems.append(f"{key} is missing or not a non-empty string") + for key in PROVISIONING_BOOLEANS: + problems.extend(_declared_problem(key, doc.get(key, ""))) + virt = doc.get("virtualization") + if not isinstance(virt, dict): + problems.append("virtualization is missing") + else: + for key in PROVISIONING_VM_BOOLEANS: + problems.extend(_declared_problem(f"virtualization.{key}", + virt.get(key, ""))) + return problems + + +def _declared_problem(key: str, value: object) -> list[str]: + if isinstance(value, bool): + return [] + if isinstance(value, str) and value.startswith("n/a: ") and len(value) > 5: + return [] + return [f"{key} must be a boolean or an explicit 'n/a: ', got {value!r}"] + + +def check_single_tenant(provisioning: dict, manifest: dict) -> dict[str, object]: + """Declared provisioning, shape-checked; runtime invariants, machine-checked. + + The declaration is never called proof. A guest operating system cannot look + at a hypervisor and establish that no neighbour arrived on the same iron, and + a checker that claimed otherwise would be security theatre in a lab coat. + """ + problems = validate_provisioning(provisioning) + if problems: + return check("single_tenant", False, + "the provisioning declaration does not validate: " + "; ".join(problems)) + declared_false = [k for k in ("dedicated_to_p022", "no_concurrent_user_workload") + if provisioning.get(k) is False] + if declared_false: + return check("single_tenant", False, + f"provisioning declares {declared_false} false") + if provisioning.get("hosted_ci_runner") is True: + return check("single_tenant", False, + "provisioning declares a hosted CI runner, which is not measurement-grade") + identity = manifest.get("identity") + if not isinstance(identity, dict): + return check("single_tenant", False, "the manifest carries no identity block") + mismatch = [k for k in ("environment_id", "host_fingerprint") + if _observed(identity, k) != provisioning.get(k)] + if mismatch: + return check("single_tenant", False, + f"provisioning and manifest disagree on {mismatch}; the declaration " + "describes a different machine than the one captured") + return check("single_tenant", True, + "provisioning declaration validates and names this machine; runtime " + "invariants are recorded for the session checks to compare against") + + +def _observed(identity: dict, field: str) -> object: + entry = identity.get(field) + if isinstance(entry, dict) and entry.get("status") == "observed": + return entry.get("value") + return None + + +def check_power_policy() -> dict[str, object]: + return _power_windows() if os.name == "nt" else _power_linux() + + +def _power_linux() -> dict[str, object]: + governors: dict[str, str] = {} + root = Path("/sys/devices/system/cpu") + for cpu in sorted(root.glob("cpu[0-9]*")): + value = _text(str(cpu / "cpufreq" / "scaling_governor")) + if value: + governors[cpu.name] = value + if not governors: + return check("power_policy", False, + "no cpufreq/scaling_governor on any CPU; the frequency policy is not " + "observable here, so this host is not eligible") + wrong = {c: g for c, g in governors.items() if g != "performance"} + if wrong: + return check("power_policy", False, f"governor is not 'performance' on {sorted(wrong)}") + boost = _turbo_linux() + if boost is None: + return check("power_policy", False, + "no turbo/boost mechanism could be identified (neither " + "intel_pstate/no_turbo nor cpufreq/boost); the state cannot be recorded " + "or rechecked, so this host is not eligible") + return check("power_policy", True, + f"governor=performance on all {len(governors)} CPUs; {boost}") + + +def _turbo_linux() -> str | None: + no_turbo = _text("/sys/devices/system/cpu/intel_pstate/no_turbo") + if no_turbo is not None: + return f"intel_pstate/no_turbo={no_turbo}" + boost = _text("/sys/devices/system/cpu/cpufreq/boost") + if boost is not None: + return f"cpufreq/boost={boost}" + return None + + +def _power_windows() -> dict[str, object]: + active = _tool(["powercfg", "/getactivescheme"]) + if not active or active[0] != 0: + return check("power_policy", False, "powercfg /getactivescheme produced no scheme") + guids = re.findall(r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}", active[1]) + if not guids: + return check("power_policy", False, f"no scheme GUID in powercfg output: {active[1]!r}") + plan = guids[0].lower() + if plan not in WIN_ACCEPTED_PLANS: + return check("power_policy", False, + f"active plan {plan} is neither High Performance nor Ultimate Performance") + states: dict[str, int] = {} + for label, setting in (("minimum", WIN_PROCTHROTTLEMIN), ("maximum", WIN_PROCTHROTTLEMAX)): + value = _win_ac_index(setting) + if value is None: + return check("power_policy", False, + f"the AC {label} processor state could not be read from powercfg") + states[label] = value + wrong = {k: v for k, v in states.items() if v != WIN_REQUIRED_STATE} + if wrong: + return check("power_policy", False, + f"processor state must be {WIN_REQUIRED_STATE}% on AC; got {wrong}") + return check("power_policy", True, + f"active plan {plan}, AC processor state min=max={WIN_REQUIRED_STATE}%") + + +def _win_ac_index(setting_guid: str) -> int | None: + """The AC setting index, read by GUID and by position. + + Parsed as hexadecimal indices rather than by label: the labels are localized, + and a checker that greps English prose fails on a Russian Windows for a + reason that has nothing to do with the machine. + """ + found = _tool(["powercfg", "/query", "SCHEME_CURRENT", WIN_SUB_PROCESSOR, setting_guid]) + if not found or found[0] != 0: + return None + indices = re.findall(r"0x([0-9a-fA-F]{8})", found[1]) + # The block ends with the two current indices, AC then DC. Everything before + # them describes the possible RANGE — minimum, maximum, increment — and a + # first-match parse reads the range's minimum as the current setting, which + # is how this returned 0% on a machine pinned at 100%. + if len(indices) < 2: + return None + return int(indices[-2], 16) + + +def check_memory_metric(stratum: str) -> dict[str, object]: + """The stratum's memory metric must exist as a mechanism on this host. + + A host on which the required primary metric has no mechanism is not eligible; + T0-4 case A says such a session never starts, rather than starting and then + failing. + """ + expected = STRATUM_METRIC[stratum] + if stratum == "linux": + available = hasattr(os, "wait4") + mechanism = "posix os.wait4 (ru_maxrss)" + else: + available = os.name == "nt" + mechanism = "win32 job object (PeakProcessMemoryUsed)" + if not available: + return check("required_memory_metric", False, + f"stratum {stratum} requires {expected} via {mechanism}, which this host " + "does not provide") + return check("required_memory_metric", True, f"{expected} via {mechanism}") + + +# --- quiesce, sampled exactly ------------------------------------------------ + + +def _cpu_counters() -> tuple[int, int] | None: + """(busy, total) from the platform's own aggregate counter.""" + if os.name == "nt": + idle, kernel, user = (ctypes.c_ulonglong(), ctypes.c_ulonglong(), ctypes.c_ulonglong()) + try: + ok = ctypes.windll.kernel32.GetSystemTimes( # type: ignore[attr-defined] + ctypes.byref(idle), ctypes.byref(kernel), ctypes.byref(user)) + except (AttributeError, OSError): + return None + if not ok: + return None + total = kernel.value + user.value # kernel time includes idle + return total - idle.value, total + line = _text("/proc/stat") + if not line or not line.startswith("cpu "): + first = (line or "").splitlines()[0] if line else "" + if not first.startswith("cpu "): + return None + line = first + fields = [int(x) for x in line.split()[1:] if x.isdigit()] + if len(fields) < 5: + return None + total = sum(fields) + idle = fields[3] + fields[4] # idle + iowait + return total - idle, total + + +def quiesce(sleep=time.sleep, counters=_cpu_counters, + intervals: int = QUIESCE_INTERVALS) -> dict[str, object]: + """The final 60 s of the window, as twelve 5 s intervals. + + A missing sample, a counter that went backwards or a zero denominator is + NOT_ELIGIBLE, never a skipped interval: an unreadable machine is not a quiet + machine. + """ + samples: list[float] = [] + previous = counters() + if previous is None: + return {"eligible": False, "reason": "the CPU counter could not be read at all", + "samples": [], "mean": None, "max": None} + for _ in range(intervals): + sleep(QUIESCE_INTERVAL_S) + current = counters() + if current is None: + return {"eligible": False, "reason": "a CPU sample could not be read", + "samples": samples, "mean": None, "max": None} + d_busy, d_total = current[0] - previous[0], current[1] - previous[1] + previous = current + if d_total <= 0 or d_busy < 0: + return {"eligible": False, + "reason": f"the CPU counter did not advance sanely (busy {d_busy}, " + f"total {d_total})", + "samples": samples, "mean": None, "max": None} + samples.append(d_busy / d_total) + mean = sum(samples) / len(samples) + worst = max(samples) + if mean >= QUIESCE_MEAN_MAX: + return {"eligible": False, "reason": f"mean utilisation {mean:.4f} is not below " + f"{QUIESCE_MEAN_MAX}", "samples": samples, "mean": mean, "max": worst} + if worst > QUIESCE_INTERVAL_MAX: + return {"eligible": False, "reason": f"an interval reached {worst:.4f}, above " + f"{QUIESCE_INTERVAL_MAX}", "samples": samples, "mean": mean, "max": worst} + return {"eligible": True, "reason": "", "samples": samples, "mean": mean, "max": worst} + + +# --- the two records --------------------------------------------------------- + + +def qualify(stratum: str, environment_id: str, provisioning_path: Path, + manifest_path: Path) -> dict[str, object]: + if stratum not in STRATUM_METRIC: + raise QualificationRefused(f"unknown stratum {stratum!r}") + provisioning = json.loads(provisioning_path.read_text(encoding="utf-8")) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + identity = manifest.get("identity") if isinstance(manifest.get("identity"), dict) else {} + checks = [check_ci(manifest), + check_single_tenant(provisioning, manifest), + check_power_policy(), + check_memory_metric(stratum)] + by_name = {c["check"]: c for c in checks} + missing = [k for k in PREDICATE_KEYS if k not in by_name] + if missing: + raise QualificationRefused(f"the predicate did not produce {missing}") + qualified = all(c["result"] == "pass" for c in checks) + return { + "kind": QUALIFICATION_SCHEMA, + "schema": SCHEMA_VERSION, + "stratum": stratum, + "environment_id": environment_id, + "host_fingerprint": _observed(identity, "host_fingerprint"), + "provisioning": {"sha256": sha256_file(provisioning_path)}, + "environment_manifest": {"sha256": sha256_file(manifest_path)}, + "qualification_tool": {"sha256": sha256_file(Path(__file__).resolve())}, + "predicate": {name: by_name[name]["result"] for name in PREDICATE_KEYS}, + "predicate_detail": {name: by_name[name]["detail"] for name in PREDICATE_KEYS}, + "memory_metric": STRATUM_METRIC[stratum], + "qualified": qualified, + "qualified_at": _now(), + "not_a_session_certificate": ( + "this record proves the host can satisfy the predicate against the bytes named " + "above; the fitness of any particular session is proved again, per session"), + } + + +def session_eligibility(binding_path: Path, qualification_path: Path, manifest_path: Path, + quiesce_result: dict[str, object] | None = None) -> dict[str, object]: + binding = json.loads(binding_path.read_text(encoding="utf-8")) + qualification = json.loads(qualification_path.read_text(encoding="utf-8")) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + stratum = str(qualification.get("stratum")) + reasons: list[str] = [] + + if not qualification.get("qualified"): + reasons.append("the referenced qualification does not say qualified") + bound = (binding.get(stratum) or {}) if isinstance(binding.get(stratum), dict) else {} + if bound.get("qualification_sha256") != sha256_file(qualification_path): + reasons.append("the execution binding does not name this qualification; a qualified " + "host that is not part of this campaign may not be substituted into it") + if bound.get("memory_metric") != qualification.get("memory_metric"): + reasons.append("the binding and the qualification disagree about the memory metric") + + ci = check_ci(manifest) + if ci["result"] != "pass": + reasons.append(str(ci["detail"])) + power = check_power_policy() + if power["result"] != "pass": + reasons.append(str(power["detail"])) + + identity = manifest.get("identity") if isinstance(manifest.get("identity"), dict) else {} + if _observed(identity, "host_fingerprint") != qualification.get("host_fingerprint"): + reasons.append("the fresh manifest is not the machine this qualification describes") + + result = quiesce_result if quiesce_result is not None else quiesce() + if not result.get("eligible"): + reasons.append(f"quiesce: {result.get('reason')}") + + return { + "kind": SESSION_SCHEMA, + "schema": SCHEMA_VERSION, + "stratum": stratum, + "execution_binding_sha256": sha256_file(binding_path), + "qualification_sha256": sha256_file(qualification_path), + "fresh_environment_manifest_sha256": sha256_file(manifest_path), + "power_policy": power, + "ci": ci, + "quiesce": result, + "eligible": not reasons, + "reasons": reasons, + "recorded_at": _now(), + "note": ("eligibility is not a measurement and not a verdict: a refusal here means the " + "session does not start, which is not INVALID, because no clock has run"), + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--qualify", action="store_true") + parser.add_argument("--session-preflight", action="store_true") + parser.add_argument("--selftest", action="store_true") + parser.add_argument("--stratum", choices=sorted(STRATUM_METRIC)) + parser.add_argument("--environment-id") + parser.add_argument("--provisioning", type=Path) + parser.add_argument("--manifest", type=Path) + parser.add_argument("--binding", type=Path) + parser.add_argument("--qualification", type=Path) + parser.add_argument("--emit", type=Path) + args = parser.parse_args(argv) + + if args.selftest: + print(json.dumps({"tool_sha256": sha256_file(Path(__file__).resolve()), + "strata": STRATUM_METRIC, + "quiesce": {"window_s": QUIESCE_WINDOW_S, + "intervals": QUIESCE_INTERVALS, + "interval_s": QUIESCE_INTERVAL_S, + "mean_max": QUIESCE_MEAN_MAX, + "interval_max": QUIESCE_INTERVAL_MAX}, + "predicate_keys": list(PREDICATE_KEYS)}, indent=2)) + return 0 + + if args.qualify: + for name in ("stratum", "environment_id", "provisioning", "manifest"): + if getattr(args, name) is None: + parser.error(f"--qualify requires --{name.replace('_', '-')}") + record = qualify(args.stratum, args.environment_id, args.provisioning, args.manifest) + elif args.session_preflight: + for name in ("binding", "qualification", "manifest"): + if getattr(args, name) is None: + parser.error(f"--session-preflight requires --{name}") + record = session_eligibility(args.binding, args.qualification, args.manifest) + else: + parser.error("choose --qualify, --session-preflight or --selftest") + + text = json.dumps(record, indent=2, sort_keys=True, ensure_ascii=False) + "\n" + if args.emit: + args.emit.write_text(text, encoding="utf-8") + else: + print(text, end="") + ok = bool(record.get("qualified") if args.qualify else record.get("eligible")) + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_step7_hostqual.py b/tests/test_step7_hostqual.py new file mode 100644 index 00000000..13d2ec8d --- /dev/null +++ b/tests/test_step7_hostqual.py @@ -0,0 +1,467 @@ +#!/usr/bin/env python3 +"""#263 step 7 — controls on host qualification and the execution binding. + + hostqual-memory-vocabulary one closed set, three files, no drift + hostqual-ci-predicate ci == false, not "the field is absent" + hostqual-provisioning-shape a declaration with a hole is not a declaration + hostqual-quiesce-arithmetic quiet passes; spike, mean, gap and rewind do not + hostqual-session-binds a qualified host outside this campaign is refused + hostqual-not-a-certificate qualification proves capability, never session fitness + execbinding-references the binding carries references, not copies + execbinding-strata two strata, two metrics, both qualified + execbinding-no-overwrite a rebuild is deliberate, never silent + tools-do-not-import-harness qualification never reaches into the frozen instrument + +Both platform branches are exercised through synthetic fixtures, so the Windows +rules are driven on Linux and the Linux rules on Windows. Where a control can +only observe the live branch it says so rather than claiming both were run. + +Failures print `FAIL[]: `; nothing stops at the first one. + +Run: python tests/test_step7_hostqual.py +""" + +from __future__ import annotations + +import ast +import contextlib +import io +import json +import sys +import tempfile +from collections.abc import Callable +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "scripts")) +sys.path.insert(0, str(ROOT / "scripts" / "step7")) + +import execbinding as eb # noqa: E402 +import hostqual as hq # noqa: E402 +import perf_baseline as pb # noqa: E402 + +_FAILURES: list[tuple[str, str]] = [] +_PASSES: list[str] = [] + + +def fail(check: str, detail: str) -> None: + _FAILURES.append((check, detail)) + print(f"FAIL[{check}]: {detail}") + + +def ok(check: str, detail: str = "") -> None: + _PASSES.append(check) + print(f"ok[{check}]: {detail}" if detail else f"ok[{check}]") + + +def guarded(check: str, control: Callable[[], None]) -> None: + try: + control() + except Exception as exc: # a crashing control is a finding, not a traceback + fail(check, f"the control raised {type(exc).__name__}: {exc}") + + +def manifest(ci: bool = False, fingerprint: str = "sha256:abc", + environment_id: str = "env-1") -> dict: + return { + "identity": { + "environment_id": {"status": "observed", "value": environment_id}, + "host_fingerprint": {"status": "observed", "value": fingerprint}, + }, + "provenance": {"ci": ci, "runner_name": None}, + } + + +def provisioning(**overrides: object) -> dict: + doc: dict[str, object] = { + "kind": hq.PROVISIONING_SCHEMA, + "schema": 1, + "environment_id": "env-1", + "host_fingerprint": "sha256:abc", + "operator": "owner", + "recorded_at": "2026-09-16T00:00:00+00:00", + "dedicated_to_p022": True, + "no_concurrent_user_workload": True, + "hosted_ci_runner": False, + "prohibited_background_declared_inactive": True, + "virtualization": {"is_vm": False, + "fixed_vcpu": "n/a: bare metal", + "fixed_ram": "n/a: bare metal", + "live_migration_disabled": "n/a: bare metal", + "dynamic_memory_disabled": "n/a: bare metal"}, + } + doc.update(overrides) + return doc + + +def write(tmp: Path, name: str, doc: dict) -> Path: + path = tmp / name + path.write_text(json.dumps(doc, indent=2), encoding="utf-8") + return path + + +def qualification(stratum: str = "linux", qualified: bool = True, **overrides) -> dict: + doc = { + "kind": "own.net/p022/host-qualification", + "schema": 1, + "stratum": stratum, + "environment_id": f"env-{stratum}", + "host_fingerprint": "sha256:abc", + "provisioning": {"sha256": "0" * 64}, + "environment_manifest": {"sha256": "1" * 64}, + "qualification_tool": {"sha256": "2" * 64}, + "predicate": {k: "pass" for k in hq.PREDICATE_KEYS}, + "memory_metric": hq.STRATUM_METRIC[stratum], + "qualified": qualified, + "qualified_at": "2026-09-16T00:00:00+00:00", + } + doc.update(overrides) + return doc + + +# --- the vocabulary --------------------------------------------------------- + + +def control_memory_vocabulary() -> None: + """One closed set, declared in three files, proved identical here. + + The tools do not import the frozen instrument, so the set is written down + more than once. That is a deliberate decoupling with a control on top: a + drift between the copies fails a test rather than mislabelling a campaign. + """ + tool_set = set(hq.STRATUM_METRIC.values()) + binding_set = set(eb.STRATUM_METRIC.values()) + if tool_set != pb.MEMORY_METRICS: + fail("hostqual-memory-vocabulary", + f"hostqual declares {sorted(tool_set)}, the instrument {sorted(pb.MEMORY_METRICS)}") + return + if binding_set != pb.MEMORY_METRICS: + fail("hostqual-memory-vocabulary", + f"execbinding declares {sorted(binding_set)}, the instrument " + f"{sorted(pb.MEMORY_METRICS)}") + return + if hq.STRATUM_METRIC != eb.STRATUM_METRIC: + fail("hostqual-memory-vocabulary", "the two tools map strata to metrics differently") + return + if hq.STRATUM_METRIC["linux"] == hq.STRATUM_METRIC["windows"]: + fail("hostqual-memory-vocabulary", "both strata map to one metric; they measure " + "different physical quantities") + return + ok("hostqual-memory-vocabulary", + f"linux={hq.STRATUM_METRIC['linux']}, windows={hq.STRATUM_METRIC['windows']}, " + "identical to the instrument's closed set in both tools") + + +# --- the predicate ---------------------------------------------------------- + + +def control_ci_predicate() -> None: + """`ci == false`, never "the field is absent". + + The capture tool writes `ci` as a boolean on every manifest, so a predicate + demanding its absence could not be satisfied by any real manifest — which is + exactly the ambiguity the freeze review found. + """ + cases = [("ci false", manifest(ci=False), "pass"), + ("ci true", manifest(ci=True), "fail")] + for label, doc, expected in cases: + got = hq.check_ci(doc)["result"] + if got != expected: + fail("hostqual-ci-predicate", f"{label} gave {got}, expected {expected}") + return + missing = manifest() + del missing["provenance"]["ci"] + if hq.check_ci(missing)["result"] != "fail": + fail("hostqual-ci-predicate", "a manifest with no ci field was accepted") + return + wrong_type = manifest() + wrong_type["provenance"]["ci"] = "false" + if hq.check_ci(wrong_type)["result"] != "fail": + fail("hostqual-ci-predicate", "the string 'false' was accepted as a boolean") + return + ok("hostqual-ci-predicate", + "false passes, true fails, and an absent or non-boolean ci is refused rather than " + "read as absence-means-not-CI") + + +def control_provisioning_shape() -> None: + if validate := hq.validate_provisioning(provisioning()): + fail("hostqual-provisioning-shape", f"a complete declaration was refused: {validate}") + return + holed = provisioning() + del holed["dedicated_to_p022"] + if not hq.validate_provisioning(holed): + fail("hostqual-provisioning-shape", "a declaration with a missing key was accepted") + return + vague = provisioning(fixed_vcpu="unknown") + vague["virtualization"]["fixed_vcpu"] = "unknown" + if not hq.validate_provisioning(vague): + fail("hostqual-provisioning-shape", + "a bare string was accepted where a boolean or an explicit 'n/a: ' " + "is required") + return + if hq.check_single_tenant(provisioning(dedicated_to_p022=False), + manifest())["result"] != "fail": + fail("hostqual-provisioning-shape", "a host declared not dedicated was qualified") + return + if hq.check_single_tenant(provisioning(hosted_ci_runner=True), + manifest())["result"] != "fail": + fail("hostqual-provisioning-shape", "a declared hosted CI runner was qualified") + return + if hq.check_single_tenant(provisioning(), manifest(fingerprint="sha256:other") + )["result"] != "fail": + fail("hostqual-provisioning-shape", + "a declaration describing a different machine than the manifest was accepted") + return + ok("hostqual-provisioning-shape", + "complete declarations pass, a hole is refused, 'unknown' is not an n/a, and a " + "declaration that names another machine cannot qualify this one") + + +def control_quiesce_arithmetic() -> None: + """Driven on injected counters, so the rule is proved without waiting 120 s.""" + def counters(series: list[tuple[int, int]]): + it = iter(series) + return lambda: next(it, None) + + quiet = [(0, 0)] + [(i, i * 100) for i in range(1, hq.QUIESCE_INTERVALS + 1)] + result = hq.quiesce(sleep=lambda _s: None, counters=counters(quiet)) + if not result["eligible"]: + fail("hostqual-quiesce-arithmetic", f"a 1% machine was refused: {result['reason']}") + return + if len(result["samples"]) != hq.QUIESCE_INTERVALS: + fail("hostqual-quiesce-arithmetic", + f"{len(result['samples'])} samples, expected {hq.QUIESCE_INTERVALS}") + return + + spike = [(0, 0)] + [(i * 30 if i == 4 else i, i * 100) for i in + range(1, hq.QUIESCE_INTERVALS + 1)] + if hq.quiesce(sleep=lambda _s: None, counters=counters(spike))["eligible"]: + fail("hostqual-quiesce-arithmetic", "an interval above the cap was accepted") + return + + busy = [(0, 0)] + [(i * 10, i * 100) for i in range(1, hq.QUIESCE_INTERVALS + 1)] + if hq.quiesce(sleep=lambda _s: None, counters=counters(busy))["eligible"]: + fail("hostqual-quiesce-arithmetic", "a 10% mean was accepted below a 5% limit") + return + + short = [(0, 0)] + [(i, i * 100) for i in range(1, 4)] + gap = hq.quiesce(sleep=lambda _s: None, counters=counters(short)) + if gap["eligible"] or "sample" not in str(gap["reason"]): + fail("hostqual-quiesce-arithmetic", f"a missing sample was not refused: {gap}") + return + + rewind = [(0, 0), (5, 100), (1, 50)] + back = hq.quiesce(sleep=lambda _s: None, counters=counters(rewind)) + if back["eligible"]: + fail("hostqual-quiesce-arithmetic", "a counter that went backwards was accepted") + return + ok("hostqual-quiesce-arithmetic", + f"{hq.QUIESCE_INTERVALS} intervals of {hq.QUIESCE_INTERVAL_S}s; quiet passes, a spike " + "over 20%, a 10% mean, a missing sample and a rewound counter each refuse") + + +# --- the two artifacts stay two --------------------------------------------- + + +def control_not_a_certificate() -> None: + with tempfile.TemporaryDirectory() as raw: + tmp = Path(raw) + qual = write(tmp, "q.json", qualification()) + record = json.loads(qual.read_text(encoding="utf-8")) + leaked = [k for k in ("quiesce", "eligible", "cpu_samples") if k in record] + if leaked: + fail("hostqual-not-a-certificate", + f"the qualification carries session-fitness fields {leaked}; a qualification " + "that certifies quiet makes 'a fresh manifest per session' decorative") + return + ok("hostqual-not-a-certificate", + "the qualification proves capability against named bytes and carries no session " + "quiesce, no eligibility and no Rust-vs-Python number") + + +def control_session_binds_campaign() -> None: + with tempfile.TemporaryDirectory() as raw: + tmp = Path(raw) + qual = write(tmp, "q.json", qualification()) + fresh = write(tmp, "m.json", manifest()) + bound = {"linux": {"qualification_sha256": hq.sha256_file(qual), + "memory_metric": hq.STRATUM_METRIC["linux"]}} + good = write(tmp, "b.json", bound) + quiet = {"eligible": True, "reason": "", "samples": [0.01], "mean": 0.01, "max": 0.01} + record = hq.session_eligibility(good, qual, fresh, quiesce_result=quiet) + for key in ("execution_binding_sha256", "qualification_sha256", + "fresh_environment_manifest_sha256"): + if not record.get(key): + fail("hostqual-session-binds", f"the session record does not carry {key}") + return + + other = write(tmp, "b2.json", {"linux": {"qualification_sha256": "9" * 64, + "memory_metric": hq.STRATUM_METRIC["linux"]}}) + substituted = hq.session_eligibility(other, qual, fresh, quiesce_result=quiet) + if substituted["eligible"]: + fail("hostqual-session-binds", + "a qualified host the binding does not name was allowed into the campaign") + return + noisy = hq.session_eligibility(good, qual, fresh, + quiesce_result={"eligible": False, "reason": "loud", + "samples": [], "mean": None, "max": None}) + if noisy["eligible"]: + fail("hostqual-session-binds", "a session was eligible despite a failed quiesce") + return + ok("hostqual-session-binds", + "a session names binding, qualification and its fresh manifest; a qualified host outside " + "this campaign and a failed quiesce each refuse the start") + + +def control_execbinding_references() -> None: + with tempfile.TemporaryDirectory() as raw: + tmp = Path(raw) + lin = write(tmp, "ql.json", qualification("linux")) + win = write(tmp, "qw.json", qualification("windows")) + cand_l = tmp / "cand.linux" + cand_l.write_bytes(b"linux candidate") + cand_w = tmp / "cand.win" + cand_w.write_bytes(b"windows candidate bytes") + t0 = tmp / "t0.md" + t0.write_text("T0", encoding="utf-8") + loads = write(tmp, "w.json", {"decisive": []}) + binding = eb.build(t0, "c0ffee", "b10b", "acce97", "104c384d", loads, + {"linux": (lin, cand_l), "windows": (win, cand_w)}) + problems = eb.validate(binding) + if problems: + fail("execbinding-references", f"a well-formed binding was refused: {problems}") + return + blob = json.dumps(binding) + copied = [word for word in ("governor", "power_plan", "scaling_governor", "cpu_samples", + "provisioning", "predicate_detail") if word in blob] + if copied: + fail("execbinding-references", + f"the binding copies qualification detail {copied}; it must carry references, " + "because two copies of one fact drift") + return + if binding["linux"]["candidate_bytes"] == binding["windows"]["candidate_bytes"]: + fail("execbinding-references", "the fixture cannot tell the two candidates apart") + return + ok("execbinding-references", + "the binding carries t0, instrument, workloads and one reference block per stratum, " + "and no copy of the qualification's own facts") + + +def control_execbinding_strata() -> None: + with tempfile.TemporaryDirectory() as raw: + tmp = Path(raw) + cand = tmp / "c" + cand.write_bytes(b"x") + t0 = tmp / "t0.md" + t0.write_text("T0", encoding="utf-8") + loads = write(tmp, "w.json", {"decisive": []}) + lin = write(tmp, "ql.json", qualification("linux")) + win = write(tmp, "qw.json", qualification("windows")) + + try: + eb.build(t0, "c", "b", "a", "d", loads, {"linux": (lin, cand)}) + except eb.BindingRefused: + pass + else: + fail("execbinding-strata", "a campaign with one stratum was bound") + return + + unqualified = write(tmp, "qu.json", qualification("windows", qualified=False)) + try: + eb.build(t0, "c", "b", "a", "d", loads, + {"linux": (lin, cand), "windows": (unqualified, cand)}) + except eb.BindingRefused: + pass + else: + fail("execbinding-strata", "an unqualified host entered a campaign") + return + + mislabelled = write(tmp, "qm.json", + qualification("windows", memory_metric=hq.MEMORY_METRIC_RESIDENT)) + try: + eb.build(t0, "c", "b", "a", "d", loads, + {"linux": (lin, cand), "windows": (mislabelled, cand)}) + except eb.BindingRefused: + pass + else: + fail("execbinding-strata", "a Windows stratum carrying the resident metric was bound") + return + + both_same = eb.build(t0, "c", "b", "a", "d", loads, + {"linux": (lin, cand), "windows": (win, cand)}) + both_same["windows"]["memory_metric"] = hq.MEMORY_METRIC_RESIDENT + if not eb.validate(both_same): + fail("execbinding-strata", "a binding whose strata share one metric validated") + return + ok("execbinding-strata", + "both strata are required, an unqualified or mislabelled host is refused, and two " + "strata carrying one metric do not validate") + + +def control_execbinding_no_overwrite() -> None: + with tempfile.TemporaryDirectory() as raw: + tmp = Path(raw) + existing = tmp / "binding.json" + existing.write_text("{}", encoding="utf-8") + # Captured, not printed: a green run that prints a refusal teaches readers + # to ignore refusals. + noise = io.StringIO() + with contextlib.redirect_stderr(noise): + rc = eb.main(["--emit", str(existing)]) + if "refused" not in noise.getvalue(): + fail("execbinding-no-overwrite", "the refusal was silent") + return + if rc != 2: + fail("execbinding-no-overwrite", + f"emitting over an existing binding returned {rc}, not a refusal") + return + if existing.read_text(encoding="utf-8") != "{}": + fail("execbinding-no-overwrite", "the existing binding was modified") + return + ok("execbinding-no-overwrite", + "a rebuild before the first clock is legitimate but never silent: the emitter refuses " + "to overwrite and says so") + + +def control_tools_do_not_import_harness() -> None: + """The qualification layer does not reach into the frozen instrument. + + Proved by AST rather than by text, because this file's own docstrings name + `perf_baseline` to say the tools do not import it. + """ + for name in ("hostqual.py", "execbinding.py"): + tree = ast.parse((ROOT / "scripts" / "step7" / name).read_text(encoding="utf-8")) + imported: list[str] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imported += [a.name for a in node.names] + elif isinstance(node, ast.ImportFrom) and node.module: + imported.append(node.module) + reached = [m for m in imported if m.split(".")[0] in ("perf_baseline", "envcapture")] + if reached: + fail("tools-do-not-import-harness", f"{name} imports {reached}") + return + ok("tools-do-not-import-harness", + "neither tool imports the instrument or the capture tool; the shared vocabulary is " + "held together by a control instead of by coupling") + + +def run() -> int: + guarded("hostqual-memory-vocabulary", control_memory_vocabulary) + guarded("hostqual-ci-predicate", control_ci_predicate) + guarded("hostqual-provisioning-shape", control_provisioning_shape) + guarded("hostqual-quiesce-arithmetic", control_quiesce_arithmetic) + guarded("hostqual-not-a-certificate", control_not_a_certificate) + guarded("hostqual-session-binds", control_session_binds_campaign) + guarded("execbinding-references", control_execbinding_references) + guarded("execbinding-strata", control_execbinding_strata) + guarded("execbinding-no-overwrite", control_execbinding_no_overwrite) + guarded("tools-do-not-import-harness", control_tools_do_not_import_harness) + print() + print(f"step 7 host qualification controls: {len(_PASSES)} passed, {len(_FAILURES)} failed") + return 1 if _FAILURES else 0 + + +if __name__ == "__main__": + sys.exit(run()) From 7e8dfe083ff4afb137a01d82320f48140c4c25f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 16:59:35 +0500 Subject: [PATCH 02/15] fix(step7): a qualification is versioned by the T0 it claims to satisfy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mini-review found a hole in the middle of a chain that was otherwise content-addressed end to end: a qualification said "this host satisfies T0-7" without naming WHICH T0. SHAs everywhere and the causation missing anyway. Q1 — `--qualify` now requires the T0 path and commit, proves the commit exists, that `blob_sha` really is `commit:path`, that the bytes hash to the recorded sha256, and that the document declares FROZEN. A qualification against a NOT_FROZEN T0 is refused, so today's reconnaissance cannot become campaign evidence by reuse. `execbinding` refuses a host qualified under a different T0 than the campaign binds. Q2 — `--environment-id` is gone. The id is the manifest's own observed value and the provisioning declaration must agree with it: one identity, not three strings that agree while everyone behaves. Q3 — provisioning is checked by VALUE, not only by shape. `dedicated_to_p022` and `no_concurrent_user_workload` must be true, `hosted_ci_runner` false, `is_vm` a real boolean; a VM must promise fixed vCPU, fixed RAM, no live migration and no dynamic memory, with `n/a` unavailable to it, and a physical host must answer those four with an explicit `n/a: ` rather than bare booleans. A VM declaring `fixed_vcpu: false` could previously qualify. Q4 — per-session operator facts left the host record. "No campaign workload", "no interactive user", "no prohibited background job" are properties of a moment, and now live in a session declaration bound by sha256 into eligibility. Q5 — the 120 s window is waited, not asserted. The quiet minute really elapses before the twelve 5 s intervals; a control drives an injected clock and requires 60 + 60. A constant nobody waits for is documentation. Q6 — identity is compared as a whole. The qualification records a canonical hash of the manifest's identity block and the session must reproduce it, so a changed kernel, CPU count, RAM or toolchain can no longer walk past a matching fingerprint. Q7/Q8 — the candidate is hashed against the binding before the clock, and a new `--session-postflight` pass re-checks identity, power and candidate afterwards and requires closing-probe evidence. Preflight may not certify what a session did after it started. Q9 — power is a structured snapshot, compared by equality through the session. Owner ruling applied: Windows requires 100% on AC *and* DC, so a machine cannot be compliant while plugged in and change policy when the power source does. Q10/Q11 — the binding proves its inputs instead of trusting strings. The harness digest is recomputed from the instrument sources at the bound commit by the frozen formula without importing the harness; the workload manifest comes from that commit's git object; `--verify` re-proves T0, instrument, manifest, both qualifications and both candidates. Q12 — a preflight and a postflight carrying two different `execution_binding_sha256` values cannot meet in one session. Q13 — a provisioning template lands in `scripts/step7/examples/`, outside any evidence directory, marked EXAMPLE / NOT EVIDENCE, with no real identity in it, and a control keeps it valid under the rules it teaches. step 7 host qualification controls: 18 passed, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh --- .../examples/host-provisioning.example.json | 27 + scripts/step7/execbinding.py | 291 +++++-- scripts/step7/hostqual.py | 648 +++++++++----- tests/test_step7_hostqual.py | 810 ++++++++++++------ 4 files changed, 1188 insertions(+), 588 deletions(-) create mode 100644 scripts/step7/examples/host-provisioning.example.json diff --git a/scripts/step7/examples/host-provisioning.example.json b/scripts/step7/examples/host-provisioning.example.json new file mode 100644 index 00000000..0423dd26 --- /dev/null +++ b/scripts/step7/examples/host-provisioning.example.json @@ -0,0 +1,27 @@ +{ + "_EXAMPLE": "EXAMPLE / NOT EVIDENCE. This file is a shape to copy, never a declaration. It lives outside docs/evidence/ so it cannot be mistaken for one, and it carries no real environment id, fingerprint, operator or host name. A real declaration is signed off by an operator who has checked every line of it, and is content-addressed into a host qualification.", + "_FORM": "This is the PHYSICAL-HOST form: is_vm is false, so the four VM-only fields are explicit 'n/a: ' strings. On a VM invert it — is_vm true, and fixed_vcpu, fixed_ram, live_migration_disabled and dynamic_memory_disabled must ALL be literal true. A VM that cannot promise fixed vCPU, fixed RAM, no live migration and no dynamic memory is not a measurement host, and 'n/a' is not available there.", + "_DECLARED": "Everything here is DECLARED evidence. A guest operating system cannot establish that nobody else is using the machine or that the hypervisor will not migrate it; the checker validates shape and value and binds these bytes by sha256, and never calls them machine proof.", + "_SESSION": "Per-session facts are NOT here. 'No campaign workload is running', 'no interactive user', 'no prohibited background job' are properties of a moment, not of a machine, and belong in own.net/p022/session-declaration, taken fresh for each session.", + + "kind": "own.net/p022/host-provisioning", + "schema": 1, + + "environment_id": "EXAMPLE-environment-id-assigned-by-the-owner", + "host_fingerprint": "sha256:EXAMPLE-copy-the-value-the-environment-manifest-observed", + + "dedicated_to_p022": true, + "no_concurrent_user_workload": true, + "hosted_ci_runner": false, + + "virtualization": { + "is_vm": false, + "fixed_vcpu": "n/a: physical host, no hypervisor allocates its CPUs", + "fixed_ram": "n/a: physical host, installed memory does not balloon", + "live_migration_disabled": "n/a: physical host, nothing can migrate it", + "dynamic_memory_disabled": "n/a: physical host, no dynamic memory to disable" + }, + + "operator": "EXAMPLE-operator-name", + "recorded_at": "EXAMPLE-2026-01-01T00:00:00+00:00" +} diff --git a/scripts/step7/execbinding.py b/scripts/step7/execbinding.py index ba65185b..01c23ac0 100644 --- a/scripts/step7/execbinding.py +++ b/scripts/step7/execbinding.py @@ -3,43 +3,47 @@ Host qualification answers "is this one environment fit". This answers a different question: **which** Linux and Windows environments, which candidates -and which instrument form THIS measurement campaign. The two are separate -artifacts on purpose — a utility that checks a CPU governor must not become the -root of campaign identity, and a campaign identity must not be re-derived from -whichever qualified host happens to be lying around. - -The binding carries **references and identity**, never copies. Governors, power -plans, CPU samples and the provisioning declaration stay in the qualification -record, which owns them; duplicating them here would create two copies of one -fact, and two copies drift. - -The chain is acyclic, and each link names only the one before it: - - provisioning declaration -> envcapture manifest -> host qualification - -> execution binding -> training session(s) -> N -> D7 C1 -> D7 C2 - -> decisive collection - -D7 later binds `execution_binding_sha256`. It does not restate the machines. - -Lifecycle, enforced here as far as a tool can and stated where it cannot: - - BEFORE the first clock the binding may be rebuilt whenever a host or a - candidate changes. `--emit` refuses to overwrite an - existing file, so a rebuild is a deliberate act. - AFTER the first clock the binding is immutable. A change to any bound - component does not patch the running campaign: the old - campaign stops and a new binding identity begins. - `--verify` detects the drift; it cannot un-run a clock. +and which instrument form THIS campaign. A utility that checks a CPU governor +must not become the root of campaign identity, so the two stay apart. + +Nothing here is taken on the caller's word. Every bound component is **proved** +against git objects and file bytes at emit time, and re-proved by `--verify`: + + T0 the commit exists, `blob_sha` really is `commit:path`, the bytes + hash to `sha256`, and the document says FROZEN. + instrument the harness digest is RECOMPUTED from the instrument sources at + the named commit, using the frozen formula, without importing the + harness. Because the workload manifest is one of those sources, + proving the digest at a commit also proves the manifest at that + commit — so there is no second anchor to write and none to get + wrong. + hosts each qualification says qualified, and names the SAME T0 as this + binding. A qualification earned against an older T0 cannot enter + a newer campaign. + candidates sha256 and byte length of the exact files. + +The binding carries references and identity, never copies: duplicating the +governor or the provisioning blob would make two copies of one fact, and two +copies drift. + + provisioning -> envcapture -> qualification -> execution binding + -> training -> N -> D7 C1 -> D7 C2 -> decisive collection + +D7 later binds `execution_binding_sha256`; it does not restate the machines. + +Lifecycle: before the first clock a rebuild is legitimate but never silent — +`--emit` refuses to overwrite. After the first clock the binding is immutable, +and any drift `--verify` reports is not a patch: the campaign stops and a new +binding identity begins. Usage: - python scripts/step7/execbinding.py --emit --t0 \\ - --t0-commit --instrument-commit --harness-digest \\ - --workloads \\ - --linux --linux-candidate \\ - --windows --windows-candidate - python scripts/step7/execbinding.py --verify \\ - --linux --windows - python scripts/step7/execbinding.py --selftest + execbinding.py --emit --t0-path

--t0-commit \\ + --instrument-commit --harness-digest \\ + --linux --linux-candidate \\ + --windows --windows-candidate + execbinding.py --verify --linux --windows \\ + --linux-candidate --windows-candidate + execbinding.py --selftest """ from __future__ import annotations @@ -48,18 +52,25 @@ import datetime import hashlib import json +import re +import subprocess import sys from pathlib import Path BINDING_SCHEMA = "own.net/p022/execution-binding" +QUALIFICATION_SCHEMA = "own.net/p022/host-qualification" SCHEMA_VERSION = 1 STRATA = ("linux", "windows") -# Kept in step with hostqual's own declaration; a control proves they agree. MEMORY_METRIC_RESIDENT = "max_process_peak_resident" MEMORY_METRIC_COMMIT = "max_process_peak_commit" STRATUM_METRIC = {"linux": MEMORY_METRIC_RESIDENT, "windows": MEMORY_METRIC_COMMIT} +# The instrument's identity is the content of these files, in this order. The +# manifest is one of them on purpose — see the module docstring. +INSTRUMENT_SOURCES = ("scripts/perf_baseline.py", "docs/evidence/p022-263a-workloads.json") +WORKLOAD_MANIFEST = "docs/evidence/p022-263a-workloads.json" + REQUIRED_STRATUM_KEYS = ("qualification_sha256", "environment_id", "host_fingerprint", "candidate_sha256", "candidate_bytes", "memory_metric") @@ -68,57 +79,131 @@ class BindingRefused(Exception): """Raised by name, so a caller that reads through gets an exception.""" +def sha256_bytes(raw: bytes) -> str: + return hashlib.sha256(raw).hexdigest() + + def sha256_file(path: Path) -> str: - return hashlib.sha256(path.read_bytes()).hexdigest() + return sha256_bytes(path.read_bytes()) def _now() -> str: return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds") -def _stratum_block(stratum: str, qualification_path: Path, - candidate_path: Path) -> dict[str, object]: +def _git(repo: Path, *args: str) -> tuple[int, bytes]: + try: + proc = subprocess.run(["git", "-C", str(repo), *args], capture_output=True, check=False) + except (OSError, ValueError): + return 127, b"" + return proc.returncode, proc.stdout + + +def normalized_text(data: bytes) -> bytes: + """Line endings normalized, so identity is content and not checkout policy.""" + return data.replace(b"\r\n", b"\n") + + +def harness_digest_at(repo: Path, commit: str) -> str | None: + """The frozen formula, recomputed from git blobs — not imported. + + Importing the harness to check the harness would prove only that a function + agrees with itself. + """ + lines = [] + for path in INSTRUMENT_SOURCES: + rc, raw = _git(repo, "cat-file", "blob", f"{commit}:{path}") + if rc != 0: + return None + lines.append(f"{Path(path).name}:{sha256_bytes(normalized_text(raw))}") + return sha256_bytes("\n".join(lines).encode("utf-8")) + + +def t0_at(repo: Path, path: str, commit: str) -> dict[str, object]: + rc, _ = _git(repo, "cat-file", "-e", f"{commit}^{{commit}}") + if rc != 0: + raise BindingRefused(f"T0 commit {commit} does not exist") + rc, blob = _git(repo, "rev-parse", f"{commit}:{path}") + if rc != 0: + raise BindingRefused(f"T0 path {path} does not exist at {commit}") + rc, raw = _git(repo, "cat-file", "blob", f"{commit}:{path}") + if rc != 0: + raise BindingRefused(f"the T0 blob at {commit}:{path} could not be read") + status = re.search(r"^\s*(NOT_FROZEN|FROZEN)\.?\s*$", raw.decode("utf-8", "replace"), + re.MULTILINE) + declared = status.group(1) if status else "" + if declared != "FROZEN": + raise BindingRefused( + f"T0 at {commit}:{path} declares {declared}; a campaign cannot be bound to a " + "protocol whose rules may still change") + return {"commit": commit, "path": path, "blob_sha": blob.decode().strip(), + "sha256": sha256_bytes(raw), "status": declared} + + +def _stratum_block(stratum: str, qualification_path: Path, candidate_path: Path, + t0_block: dict) -> dict[str, object]: doc = json.loads(qualification_path.read_text(encoding="utf-8")) - if doc.get("kind") != "own.net/p022/host-qualification": + if doc.get("kind") != QUALIFICATION_SCHEMA: raise BindingRefused(f"{qualification_path} is not a host-qualification artifact") if doc.get("stratum") != stratum: raise BindingRefused( f"{qualification_path} declares stratum {doc.get('stratum')!r}, bound as {stratum!r}") if not doc.get("qualified"): - raise BindingRefused(f"{qualification_path} does not say qualified; an unqualified host " - "may not enter a campaign") - metric = doc.get("memory_metric") - if metric != STRATUM_METRIC[stratum]: + raise BindingRefused(f"{qualification_path} does not say qualified") + qualified_t0 = doc.get("t0") if isinstance(doc.get("t0"), dict) else {} + if (qualified_t0.get("sha256") != t0_block["sha256"] + or qualified_t0.get("commit") != t0_block["commit"]): raise BindingRefused( - f"stratum {stratum} must carry {STRATUM_METRIC[stratum]!r}, not {metric!r}") + f"{stratum}: the host was qualified against T0 " + f"{str(qualified_t0.get('sha256'))[:12]} at {qualified_t0.get('commit')}, and this " + f"campaign binds {t0_block['sha256'][:12]} at {t0_block['commit']}. A qualification " + "earned under one protocol is not evidence under another") + if doc.get("memory_metric") != STRATUM_METRIC[stratum]: + raise BindingRefused(f"stratum {stratum} must carry {STRATUM_METRIC[stratum]!r}, " + f"not {doc.get('memory_metric')!r}") raw = candidate_path.read_bytes() return { "qualification_sha256": sha256_file(qualification_path), "environment_id": doc.get("environment_id"), "host_fingerprint": doc.get("host_fingerprint"), - "candidate_sha256": hashlib.sha256(raw).hexdigest(), + "environment_identity_sha256": doc.get("environment_identity_sha256"), + "candidate_sha256": sha256_bytes(raw), "candidate_bytes": len(raw), - "memory_metric": metric, + "memory_metric": doc.get("memory_metric"), } -def build(t0_path: Path, t0_commit: str, t0_blob_sha: str, instrument_commit: str, - harness_digest: str, workloads_path: Path, - strata: dict[str, tuple[Path, Path]]) -> dict[str, object]: +def build(repo: Path, t0_path: str, t0_commit: str, instrument_commit: str, + harness_digest: str, strata: dict[str, tuple[Path, Path]]) -> dict[str, object]: missing = [s for s in STRATA if s not in strata] if missing: raise BindingRefused(f"both strata are required; missing {missing}. `U_linux` and " "`U_windows` are never pooled, and never optional either") + t0_block = t0_at(repo, t0_path, t0_commit) + + live = harness_digest_at(repo, instrument_commit) + if live is None: + raise BindingRefused(f"the instrument sources could not be read at {instrument_commit}") + if live != harness_digest: + raise BindingRefused( + f"the instrument at {instrument_commit} hashes to {live[:12]}, not the accepted " + f"{harness_digest[:12]}; a binding may not name a digest the sources do not produce") + rc, manifest_raw = _git(repo, "cat-file", "blob", f"{instrument_commit}:{WORKLOAD_MANIFEST}") + if rc != 0: + raise BindingRefused(f"the workload manifest is absent at {instrument_commit}") + binding: dict[str, object] = { "kind": BINDING_SCHEMA, "schema": SCHEMA_VERSION, - "t0": {"commit": t0_commit, "blob_sha": t0_blob_sha, "sha256": sha256_file(t0_path)}, + "t0": t0_block, "instrument": {"accepted_commit": instrument_commit, "harness_digest": harness_digest}, - "workloads": {"manifest_sha256": sha256_file(workloads_path)}, + # Taken from the git object at the instrument commit, never from a + # working-tree file that happened to be passed under the same flag. + "workloads": {"path": WORKLOAD_MANIFEST, "manifest_sha256": sha256_bytes(manifest_raw)}, "bound_at": _now(), } for stratum in STRATA: - binding[stratum] = _stratum_block(stratum, *strata[stratum]) + binding[stratum] = _stratum_block(stratum, *strata[stratum], t0_block) return binding @@ -128,15 +213,14 @@ def validate(binding: dict) -> list[str]: problems.append(f"kind is {binding.get('kind')!r}, not {BINDING_SCHEMA!r}") if binding.get("schema") != SCHEMA_VERSION: problems.append(f"schema is {binding.get('schema')!r}, not {SCHEMA_VERSION}") - for section, keys in (("t0", ("commit", "blob_sha", "sha256")), + for section, keys in (("t0", ("commit", "path", "blob_sha", "sha256")), ("instrument", ("accepted_commit", "harness_digest")), - ("workloads", ("manifest_sha256",))): + ("workloads", ("path", "manifest_sha256"))): block = binding.get(section) if not isinstance(block, dict): problems.append(f"{section} is missing") continue - problems.extend(f"{section}.{k} is missing or empty" - for k in keys if not block.get(k)) + problems.extend(f"{section}.{k} is missing or empty" for k in keys if not block.get(k)) for stratum in STRATA: block = binding.get(stratum) if not isinstance(block, dict): @@ -149,27 +233,70 @@ def validate(binding: dict) -> list[str]: f"not {STRATUM_METRIC[stratum]!r}") if not isinstance(block.get("candidate_bytes"), int): problems.append(f"{stratum}.candidate_bytes is not an integer") - if isinstance(binding.get("linux"), dict) and isinstance(binding.get("windows"), dict): - if binding["linux"].get("memory_metric") == binding["windows"].get("memory_metric"): + linux, windows = binding.get("linux"), binding.get("windows") + if isinstance(linux, dict) and isinstance(windows, dict): + if linux.get("memory_metric") == windows.get("memory_metric"): problems.append("both strata carry the same memory metric; they measure different " "physical quantities and may not be pooled") return problems -def verify(binding_path: Path, qualifications: dict[str, Path]) -> list[str]: - """Does the campaign still describe the hosts it was bound to?""" +def verify(repo: Path, binding_path: Path, qualifications: dict[str, Path], + candidates: dict[str, Path]) -> list[str]: + """Re-prove every bound component that can drift or be substituted. + + A verifier whose docstring says campaign identity while it checks two hashes + is a future incident report. + """ binding = json.loads(binding_path.read_text(encoding="utf-8")) problems = validate(binding) + + t0 = binding.get("t0") if isinstance(binding.get("t0"), dict) else {} + try: + live_t0 = t0_at(repo, str(t0.get("path")), str(t0.get("commit"))) + if live_t0["sha256"] != t0.get("sha256") or live_t0["blob_sha"] != t0.get("blob_sha"): + problems.append("T0: the bytes at the bound commit are not the bytes bound") + except BindingRefused as exc: + problems.append(f"T0: {exc}") + + instrument = binding.get("instrument") if isinstance(binding.get("instrument"), dict) else {} + live_digest = harness_digest_at(repo, str(instrument.get("accepted_commit"))) + if live_digest is None: + problems.append("instrument: the sources could not be read at the bound commit") + elif live_digest != instrument.get("harness_digest"): + problems.append(f"instrument: the sources at the bound commit now hash to " + f"{live_digest[:12]}, bound as " + f"{str(instrument.get('harness_digest'))[:12]}") + rc, manifest_raw = _git(repo, "cat-file", "blob", + f"{instrument.get('accepted_commit')}:{WORKLOAD_MANIFEST}") + workloads = binding.get("workloads") if isinstance(binding.get("workloads"), dict) else {} + if rc != 0: + problems.append("workloads: the manifest is absent at the bound instrument commit") + elif sha256_bytes(manifest_raw) != workloads.get("manifest_sha256"): + problems.append("workloads: the manifest at the bound commit is not the one bound") + for stratum, path in qualifications.items(): - block = binding.get(stratum) - if not isinstance(block, dict): - continue + block = binding.get(stratum) if isinstance(binding.get(stratum), dict) else {} live = sha256_file(path) if block.get("qualification_sha256") != live: problems.append( f"{stratum}: the qualification now hashes to {live[:12]}, bound as " - f"{str(block.get('qualification_sha256'))[:12]}. After the first clock this is " - "not a patch: the campaign stops and a new binding identity begins") + f"{str(block.get('qualification_sha256'))[:12]}") + continue + doc = json.loads(path.read_text(encoding="utf-8")) + qualified_t0 = doc.get("t0") if isinstance(doc.get("t0"), dict) else {} + if qualified_t0.get("sha256") != t0.get("sha256"): + problems.append(f"{stratum}: the qualification names a different T0 than the binding") + + for stratum, path in candidates.items(): + block = binding.get(stratum) if isinstance(binding.get(stratum), dict) else {} + raw = path.read_bytes() + if sha256_bytes(raw) != block.get("candidate_sha256") or len(raw) != block.get( + "candidate_bytes"): + problems.append( + f"{stratum}: the candidate present is {sha256_bytes(raw)[:12]} / {len(raw)} B, " + f"bound as {str(block.get('candidate_sha256'))[:12]} / " + f"{block.get('candidate_bytes')} B") return problems @@ -178,12 +305,11 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--emit", type=Path) parser.add_argument("--verify", type=Path) parser.add_argument("--selftest", action="store_true") - parser.add_argument("--t0", type=Path) - parser.add_argument("--t0-commit", default="") - parser.add_argument("--t0-blob-sha", default="") - parser.add_argument("--instrument-commit", default="") - parser.add_argument("--harness-digest", default="") - parser.add_argument("--workloads", type=Path) + parser.add_argument("--repo", type=Path, default=Path(__file__).resolve().parents[2]) + parser.add_argument("--t0-path") + parser.add_argument("--t0-commit") + parser.add_argument("--instrument-commit") + parser.add_argument("--harness-digest") parser.add_argument("--linux", type=Path) parser.add_argument("--linux-candidate", type=Path) parser.add_argument("--windows", type=Path) @@ -192,14 +318,16 @@ def main(argv: list[str] | None = None) -> int: if args.selftest: print(json.dumps({"kind": BINDING_SCHEMA, "schema": SCHEMA_VERSION, - "strata": STRATUM_METRIC, + "strata": STRATUM_METRIC, "instrument_sources": INSTRUMENT_SOURCES, "required_stratum_keys": list(REQUIRED_STRATUM_KEYS)}, indent=2)) return 0 if args.verify: qualifications = {s: p for s, p in (("linux", args.linux), ("windows", args.windows)) if p is not None} - problems = verify(args.verify, qualifications) + candidates = {s: p for s, p in (("linux", args.linux_candidate), + ("windows", args.windows_candidate)) if p is not None} + problems = verify(args.repo, args.verify, qualifications, candidates) for problem in problems: print(f"BINDING-DRIFT: {problem}") print("binding verified" if not problems else f"{len(problems)} problem(s)") @@ -207,18 +335,19 @@ def main(argv: list[str] | None = None) -> int: if args.emit: if args.emit.exists(): - # Before the first clock a rebuild is legitimate; it is never silent. print(f"refused: {args.emit} exists. A rebuild is a deliberate act — remove it " "first, and only before the first clock.", file=sys.stderr) return 2 - required = {"t0": args.t0, "workloads": args.workloads, "linux": args.linux, - "linux-candidate": args.linux_candidate, "windows": args.windows, - "windows-candidate": args.windows_candidate} - absent = sorted(k for k, v in required.items() if v is None) + needed = {"t0-path": args.t0_path, "t0-commit": args.t0_commit, + "instrument-commit": args.instrument_commit, + "harness-digest": args.harness_digest, "linux": args.linux, + "linux-candidate": args.linux_candidate, "windows": args.windows, + "windows-candidate": args.windows_candidate} + absent = sorted(k for k, v in needed.items() if v is None) if absent: parser.error(f"--emit requires {absent}") - binding = build(args.t0, args.t0_commit, args.t0_blob_sha, args.instrument_commit, - args.harness_digest, args.workloads, + binding = build(args.repo, args.t0_path, args.t0_commit, args.instrument_commit, + args.harness_digest, {"linux": (args.linux, args.linux_candidate), "windows": (args.windows, args.windows_candidate)}) problems = validate(binding) diff --git a/scripts/step7/hostqual.py b/scripts/step7/hostqual.py index 346e59e3..b69fef10 100644 --- a/scripts/step7/hostqual.py +++ b/scripts/step7/hostqual.py @@ -1,40 +1,36 @@ #!/usr/bin/env python3 -"""P-022 / #263 — step 7: host qualification and session eligibility. +"""P-022 / #263 — step 7: host qualification, session eligibility, admissibility. -Two questions, deliberately never merged into one artifact: +Three questions, three artifacts, never merged: - QUALIFICATION does this environment satisfy the T0-7 host predicate? - SESSION is this session, on that qualified host, eligible to start NOW? + QUALIFICATION does this environment satisfy the host predicate of a NAMED, + FROZEN T0? A qualification that floats free of T0 says only + "this host passed some predicate once", which is a content- + addressed chain with the causation removed from the middle. + ELIGIBILITY is this session, on that qualified host, able to start NOW? + ADMISSIBILITY did the attempt that ran remain the one that was authorised? -A qualification is not a certificate of perpetual quiet. It proves the host can -satisfy the predicate and names the bytes it was proved against; the current -fitness of a particular session is proved again, per session, or "a fresh -manifest per session" becomes decorative paperwork. - -This tool does NOT own the execution binding. Qualification answers "is this one -environment fit"; the binding answers "which Linux and Windows environments, -which candidates and which instrument form this campaign". A utility that checks -a CPU governor must not become the root of campaign identity — see -``execbinding.py``. - -It carries no Rust-vs-Python number, starts no clock over a candidate, and -produces no measurement. The CPU sampling below is environment observation for -eligibility: it never times, and never touches, the thing under test. +This tool does NOT own the execution binding — that is `execbinding.py`. A +utility that checks a CPU governor must not become the root of campaign +identity. Two classes of evidence, kept apart because only one of them is proof: - DECLARED provisioning facts a guest OS cannot establish — dedication, - hypervisor configuration, that no one else is using the box. - Recorded, content-addressed, and never called machine proof. - MACHINE-OBSERVED what a checker actually asserts here and now. + DECLARED provisioning and per-session operator facts. A guest OS + cannot establish them. Content-addressed, shape- and + value-checked, and never called machine proof. + MACHINE-OBSERVED what a checker asserts here and now. Usage: - python scripts/step7/hostqual.py --qualify --stratum linux \\ - --environment-id --provisioning --manifest \\ - --emit - python scripts/step7/hostqual.py --session-preflight --binding \\ - --qualification --manifest --emit - python scripts/step7/hostqual.py --selftest + hostqual.py --qualify --stratum linux --t0-path

--t0-commit \\ + --provisioning --manifest --emit + hostqual.py --session-preflight --binding --qualification \\ + --manifest --declaration --candidate \\ + --emit + hostqual.py --session-postflight --binding --qualification \\ + --preflight --manifest --candidate \\ + --closing-probe --emit + hostqual.py --selftest """ from __future__ import annotations @@ -52,36 +48,38 @@ from pathlib import Path QUALIFICATION_SCHEMA = "own.net/p022/host-qualification" -SESSION_SCHEMA = "own.net/p022/session-eligibility" +ELIGIBILITY_SCHEMA = "own.net/p022/session-eligibility" +ADMISSIBILITY_SCHEMA = "own.net/p022/session-admissibility" PROVISIONING_SCHEMA = "own.net/p022/host-provisioning" +DECLARATION_SCHEMA = "own.net/p022/session-declaration" SCHEMA_VERSION = 1 -# The closed memory vocabulary. Declared here rather than imported so this tool -# does not reach into the frozen harness; `hostqual-memory-vocabulary` proves the -# two sets are identical, so a drift between them is a test failure and not a -# surprise in the field. +# The closed memory vocabulary, declared rather than imported so this tool never +# reaches into the frozen instrument; `hostqual-memory-vocabulary` proves the +# copies agree, so a drift is a test failure and not a mislabelled campaign. MEMORY_METRIC_RESIDENT = "max_process_peak_resident" MEMORY_METRIC_COMMIT = "max_process_peak_commit" STRATUM_METRIC = {"linux": MEMORY_METRIC_RESIDENT, "windows": MEMORY_METRIC_COMMIT} -PREDICATE_KEYS = ("ci", "single_tenant", "power_policy", "required_memory_metric") +PREDICATE_KEYS = ("t0", "environment_identity", "ci", "single_tenant", "power_policy", + "required_memory_metric") -# Quiesce, exactly as T0-7 fixes it. No cadence is left to a reader. -QUIESCE_WINDOW_S = 120 +# Quiesce: 120 s without campaign workload, of which the final 60 s is measured. +QUIESCE_QUIET_S = 60 # unmeasured, but really waited QUIESCE_INTERVAL_S = 5 -QUIESCE_INTERVALS = 12 # the final 60 s +QUIESCE_INTERVALS = 12 # 12 x 5 s = the measured final minute +QUIESCE_WINDOW_S = QUIESCE_QUIET_S + QUIESCE_INTERVAL_S * QUIESCE_INTERVALS QUIESCE_MEAN_MAX = 0.05 QUIESCE_INTERVAL_MAX = 0.20 -# Provisioning keys. A key that does not apply is present with an explicit -# "n/a: ", never missing: an absent declaration is not a declaration. -PROVISIONING_BOOLEANS = ("dedicated_to_p022", "no_concurrent_user_workload", - "hosted_ci_runner", "prohibited_background_declared_inactive") -PROVISIONING_VM_BOOLEANS = ("is_vm", "fixed_vcpu", "fixed_ram", - "live_migration_disabled", "dynamic_memory_disabled") +PROVISIONING_REQUIRED_TRUE = ("dedicated_to_p022", "no_concurrent_user_workload") +PROVISIONING_REQUIRED_FALSE = ("hosted_ci_runner",) +PROVISIONING_VM_BOOLEANS = ("fixed_vcpu", "fixed_ram", "live_migration_disabled", + "dynamic_memory_disabled") PROVISIONING_STRINGS = ("environment_id", "host_fingerprint", "operator", "recorded_at") +DECLARATION_REQUIRED_TRUE = ("no_campaign_workload", "no_interactive_user_workload", + "no_prohibited_background_job_active") -# Windows processor-state settings, by GUID so no localized label is parsed. WIN_SUB_PROCESSOR = "54533251-82be-4824-96c1-47b60b740d00" WIN_PROCTHROTTLEMIN = "893dee8e-2bef-41e0-89c6-b55d0929964c" WIN_PROCTHROTTLEMAX = "bc5038f7-23e0-4960-96da-33abaf5935ec" @@ -106,13 +104,32 @@ def sha256_file(path: Path) -> str: return sha256_bytes(path.read_bytes()) +def canonical_sha256(value: object) -> str: + """A content-addressed projection of a JSON value. + + Used for the environment identity block: not a second copy of the facts, a + hash OF the authoritative ones, so a drift anywhere in the set is one + comparison rather than a list of fields someone has to remember to extend. + """ + return sha256_bytes(json.dumps(value, sort_keys=True, separators=(",", ":"), + ensure_ascii=False).encode("utf-8")) + + +def _git(repo: Path, *args: str) -> tuple[int, bytes]: + try: + proc = subprocess.run(["git", "-C", str(repo), *args], capture_output=True, check=False) + except (OSError, ValueError): + return 127, b"" + return proc.returncode, proc.stdout + + def _console_encoding() -> str: """The encoding a console tool's bytes arrive in. `text=True` would decode with the locale's ANSI code page while a console tool writes in the console output code page; on a non-English Windows the - two disagree and the value becomes mojibake that moves with the ambient code - page. Same defect, same fix as the capture tool's. + two disagree and an identity-bearing value becomes mojibake that moves with + the ambient code page. """ if os.name == "nt": try: @@ -147,25 +164,53 @@ def check(name: str, ok: bool, detail: str) -> dict[str, object]: return {"check": name, "result": "pass" if ok else "fail", "detail": detail} -# --- the predicate, one function per clause --------------------------------- +# --- T0: a qualification is versioned by the protocol it claims to satisfy --- -def check_ci(manifest: dict) -> dict[str, object]: - """`ci == false`, not "the field is absent". +def t0_status(text: str) -> dict[str, object]: + """FROZEN / NOT_FROZEN and the authorization flag, read from the status block.""" + frozen = re.search(r"^\s*(NOT_FROZEN|FROZEN)\.?\s*$", text, re.MULTILINE) + authorized = re.search(r"^\s*collection_authorized:\s*(true|false)\s*$", text, re.MULTILINE) + return {"frozen": bool(frozen and frozen.group(1) == "FROZEN"), + "declared": frozen.group(1) if frozen else "", + "collection_authorized": (authorized.group(1) == "true") if authorized else None} + + +def bind_t0(repo: Path, path: str, commit: str) -> tuple[dict[str, object], dict[str, object]]: + """Prove the T0 the caller names, then read its status from those exact bytes.""" + rc, _ = _git(repo, "cat-file", "-e", f"{commit}^{{commit}}") + if rc != 0: + return {}, check("t0", False, f"commit {commit} does not exist in {repo}") + rc, blob_sha = _git(repo, "rev-parse", f"{commit}:{path}") + if rc != 0: + return {}, check("t0", False, f"{path} does not exist at {commit}") + rc, raw = _git(repo, "cat-file", "blob", f"{commit}:{path}") + if rc != 0: + return {}, check("t0", False, f"the blob at {commit}:{path} could not be read") + status = t0_status(raw.decode("utf-8", "replace")) + block = {"commit": commit, "path": path, "blob_sha": blob_sha.decode().strip(), + "sha256": sha256_bytes(raw), "status": status["declared"], + "collection_authorized": status["collection_authorized"]} + if not status["frozen"]: + return block, check("t0", False, + f"T0 at {commit}:{path} declares {status['declared']}. A host cannot " + "be qualified against a protocol whose predicate may still change; " + "work done against it stays exploratory") + return block, check("t0", True, + f"T0 {block['blob_sha'][:12]} at {commit} is FROZEN; " + f"collection_authorized={status['collection_authorized']}") - The capture tool writes `ci` as a boolean on every manifest, so a predicate - demanding its absence could never be satisfied by any real manifest. - """ - provenance = manifest.get("provenance") - if not isinstance(provenance, dict) or "ci" not in provenance: - return check("ci", False, "the manifest carries no provenance.ci field") - value = provenance["ci"] - if not isinstance(value, bool): - return check("ci", False, f"provenance.ci is {type(value).__name__}, not a boolean") - return check("ci", value is False, f"manifest.provenance.ci == {json.dumps(value)}") + +# --- the declared evidence --------------------------------------------------- + + +def _declared_na(value: object) -> bool: + return isinstance(value, str) and value.startswith("n/a: ") and len(value) > 5 def validate_provisioning(doc: dict) -> list[str]: + """Shape AND value. A structurally perfect declaration of the wrong facts is + not a valid declaration, it is a refusal written politely.""" problems: list[str] = [] if doc.get("kind") != PROVISIONING_SCHEMA: problems.append(f"kind is {doc.get('kind')!r}, not {PROVISIONING_SCHEMA!r}") @@ -174,164 +219,173 @@ def validate_provisioning(doc: dict) -> list[str]: for key in PROVISIONING_STRINGS: if not isinstance(doc.get(key), str) or not doc.get(key): problems.append(f"{key} is missing or not a non-empty string") - for key in PROVISIONING_BOOLEANS: - problems.extend(_declared_problem(key, doc.get(key, ""))) + for key in PROVISIONING_REQUIRED_TRUE: + if doc.get(key) is not True: + problems.append(f"{key} must be declared true, got {doc.get(key, '')!r}") + for key in PROVISIONING_REQUIRED_FALSE: + if doc.get(key) is not False: + problems.append(f"{key} must be declared false, got {doc.get(key, '')!r}") + virt = doc.get("virtualization") if not isinstance(virt, dict): - problems.append("virtualization is missing") - else: - for key in PROVISIONING_VM_BOOLEANS: - problems.extend(_declared_problem(f"virtualization.{key}", - virt.get(key, ""))) + return problems + ["virtualization is missing"] + is_vm = virt.get("is_vm") + if not isinstance(is_vm, bool): + return problems + [f"virtualization.is_vm must be a real boolean, got {is_vm!r}: " + "whether this is a VM is not a question a host may decline"] + for key in PROVISIONING_VM_BOOLEANS: + value = virt.get(key, "") + if is_vm: + if value is not True: + problems.append(f"virtualization.{key} must be true on a VM, got {value!r}") + elif not _declared_na(value): + problems.append(f"virtualization.{key} must be an explicit 'n/a: ' on a " + f"physical host, got {value!r}") return problems -def _declared_problem(key: str, value: object) -> list[str]: - if isinstance(value, bool): - return [] - if isinstance(value, str) and value.startswith("n/a: ") and len(value) > 5: - return [] - return [f"{key} must be a boolean or an explicit 'n/a: ', got {value!r}"] +def validate_declaration(doc: dict) -> list[str]: + problems: list[str] = [] + if doc.get("kind") != DECLARATION_SCHEMA: + problems.append(f"kind is {doc.get('kind')!r}, not {DECLARATION_SCHEMA!r}") + if doc.get("schema") != SCHEMA_VERSION: + problems.append(f"schema is {doc.get('schema')!r}, not {SCHEMA_VERSION}") + for key in ("operator", "recorded_at"): + if not isinstance(doc.get(key), str) or not doc.get(key): + problems.append(f"{key} is missing or not a non-empty string") + for key in DECLARATION_REQUIRED_TRUE: + if doc.get(key) is not True: + problems.append(f"{key} must be declared true, got {doc.get(key, '')!r}") + return problems def check_single_tenant(provisioning: dict, manifest: dict) -> dict[str, object]: - """Declared provisioning, shape-checked; runtime invariants, machine-checked. - - The declaration is never called proof. A guest operating system cannot look - at a hypervisor and establish that no neighbour arrived on the same iron, and - a checker that claimed otherwise would be security theatre in a lab coat. - """ problems = validate_provisioning(provisioning) if problems: return check("single_tenant", False, "the provisioning declaration does not validate: " + "; ".join(problems)) - declared_false = [k for k in ("dedicated_to_p022", "no_concurrent_user_workload") - if provisioning.get(k) is False] - if declared_false: - return check("single_tenant", False, - f"provisioning declares {declared_false} false") - if provisioning.get("hosted_ci_runner") is True: - return check("single_tenant", False, - "provisioning declares a hosted CI runner, which is not measurement-grade") - identity = manifest.get("identity") - if not isinstance(identity, dict): - return check("single_tenant", False, "the manifest carries no identity block") + identity = manifest.get("identity") if isinstance(manifest.get("identity"), dict) else {} mismatch = [k for k in ("environment_id", "host_fingerprint") - if _observed(identity, k) != provisioning.get(k)] + if observed(identity, k) != provisioning.get(k)] if mismatch: return check("single_tenant", False, f"provisioning and manifest disagree on {mismatch}; the declaration " "describes a different machine than the one captured") return check("single_tenant", True, - "provisioning declaration validates and names this machine; runtime " - "invariants are recorded for the session checks to compare against") + "provisioning validates by shape and by value and names this machine; it is " + "declared evidence and is never called machine proof") -def _observed(identity: dict, field: str) -> object: +def observed(identity: dict, field: str) -> object: entry = identity.get(field) if isinstance(entry, dict) and entry.get("status") == "observed": return entry.get("value") return None -def check_power_policy() -> dict[str, object]: - return _power_windows() if os.name == "nt" else _power_linux() +# --- the machine-observed predicate ----------------------------------------- -def _power_linux() -> dict[str, object]: - governors: dict[str, str] = {} - root = Path("/sys/devices/system/cpu") - for cpu in sorted(root.glob("cpu[0-9]*")): +def check_ci(manifest: dict) -> dict[str, object]: + provenance = manifest.get("provenance") + if not isinstance(provenance, dict) or "ci" not in provenance: + return check("ci", False, "the manifest carries no provenance.ci field") + value = provenance["ci"] + if not isinstance(value, bool): + return check("ci", False, f"provenance.ci is {type(value).__name__}, not a boolean") + return check("ci", value is False, f"manifest.provenance.ci == {json.dumps(value)}") + + +def power_snapshot() -> dict[str, object]: + """A structured, comparable snapshot — not prose. + + Preflight and postflight compare these by equality, so the shape has to be + the evidence rather than a sentence a human would have to re-read. + """ + if os.name == "nt": + active = _tool(["powercfg", "/getactivescheme"]) + guids = re.findall(r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}", + active[1]) if active and active[0] == 0 else [] + snap: dict[str, object] = {"platform": "windows", + "plan_guid": guids[0].lower() if guids else None} + for label, setting in (("min", WIN_PROCTHROTTLEMIN), ("max", WIN_PROCTHROTTLEMAX)): + ac, dc = _win_indices(setting) + snap[f"processor_{label}_ac"] = ac + snap[f"processor_{label}_dc"] = dc + return snap + governors = {} + for cpu in sorted(Path("/sys/devices/system/cpu").glob("cpu[0-9]*")): value = _text(str(cpu / "cpufreq" / "scaling_governor")) if value: governors[cpu.name] = value + boost: dict[str, object] = {"mechanism": None, "value": None} + for mechanism, path in (("intel_pstate/no_turbo", + "/sys/devices/system/cpu/intel_pstate/no_turbo"), + ("cpufreq/boost", "/sys/devices/system/cpu/cpufreq/boost")): + value = _text(path) + if value is not None: + boost = {"mechanism": mechanism, "value": value} + break + return {"platform": "linux", "governors": governors, "boost": boost} + + +def _win_indices(setting_guid: str) -> tuple[int | None, int | None]: + """(AC, DC) setting indices, by GUID and by position. + + The block ends with the two current indices, AC then DC; everything before + them describes the possible RANGE. A first-match parse reads the range's + minimum as the current setting, which is how an earlier version of this tool + reported 0% on a machine pinned at 100%. Labels are never parsed: on this + machine every one of them is localized. + """ + found = _tool(["powercfg", "/query", "SCHEME_CURRENT", WIN_SUB_PROCESSOR, setting_guid]) + if not found or found[0] != 0: + return None, None + indices = re.findall(r"0x([0-9a-fA-F]{8})", found[1]) + if len(indices) < 2: + return None, None + return int(indices[-2], 16), int(indices[-1], 16) + + +def check_power_policy(snapshot: dict[str, object]) -> dict[str, object]: + if snapshot.get("platform") == "windows": + plan = snapshot.get("plan_guid") + if plan not in WIN_ACCEPTED_PLANS: + return check("power_policy", False, + f"active plan {plan} is neither High Performance nor Ultimate") + states = {k: snapshot.get(k) for k in ("processor_min_ac", "processor_max_ac", + "processor_min_dc", "processor_max_dc")} + wrong = {k: v for k, v in states.items() if v != WIN_REQUIRED_STATE} + if wrong: + return check("power_policy", False, + f"processor state must be {WIN_REQUIRED_STATE}% on AC *and* DC; got " + f"{wrong}. A machine that is compliant while plugged in and changes " + "policy when the power source does is not a fixed environment") + return check("power_policy", True, f"plan {plan}, AC and DC processor state min=max=100%") + governors = snapshot.get("governors") or {} if not governors: return check("power_policy", False, "no cpufreq/scaling_governor on any CPU; the frequency policy is not " "observable here, so this host is not eligible") - wrong = {c: g for c, g in governors.items() if g != "performance"} - if wrong: - return check("power_policy", False, f"governor is not 'performance' on {sorted(wrong)}") - boost = _turbo_linux() - if boost is None: - return check("power_policy", False, - "no turbo/boost mechanism could be identified (neither " - "intel_pstate/no_turbo nor cpufreq/boost); the state cannot be recorded " - "or rechecked, so this host is not eligible") - return check("power_policy", True, - f"governor=performance on all {len(governors)} CPUs; {boost}") - - -def _turbo_linux() -> str | None: - no_turbo = _text("/sys/devices/system/cpu/intel_pstate/no_turbo") - if no_turbo is not None: - return f"intel_pstate/no_turbo={no_turbo}" - boost = _text("/sys/devices/system/cpu/cpufreq/boost") - if boost is not None: - return f"cpufreq/boost={boost}" - return None - - -def _power_windows() -> dict[str, object]: - active = _tool(["powercfg", "/getactivescheme"]) - if not active or active[0] != 0: - return check("power_policy", False, "powercfg /getactivescheme produced no scheme") - guids = re.findall(r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}", active[1]) - if not guids: - return check("power_policy", False, f"no scheme GUID in powercfg output: {active[1]!r}") - plan = guids[0].lower() - if plan not in WIN_ACCEPTED_PLANS: - return check("power_policy", False, - f"active plan {plan} is neither High Performance nor Ultimate Performance") - states: dict[str, int] = {} - for label, setting in (("minimum", WIN_PROCTHROTTLEMIN), ("maximum", WIN_PROCTHROTTLEMAX)): - value = _win_ac_index(setting) - if value is None: - return check("power_policy", False, - f"the AC {label} processor state could not be read from powercfg") - states[label] = value - wrong = {k: v for k, v in states.items() if v != WIN_REQUIRED_STATE} + wrong = sorted(c for c, g in governors.items() if g != "performance") if wrong: + return check("power_policy", False, f"governor is not 'performance' on {wrong}") + boost = snapshot.get("boost") or {} + if not boost.get("mechanism"): return check("power_policy", False, - f"processor state must be {WIN_REQUIRED_STATE}% on AC; got {wrong}") + "no turbo/boost mechanism could be identified, so its state cannot be " + "recorded or rechecked through the session") return check("power_policy", True, - f"active plan {plan}, AC processor state min=max={WIN_REQUIRED_STATE}%") - - -def _win_ac_index(setting_guid: str) -> int | None: - """The AC setting index, read by GUID and by position. - - Parsed as hexadecimal indices rather than by label: the labels are localized, - and a checker that greps English prose fails on a Russian Windows for a - reason that has nothing to do with the machine. - """ - found = _tool(["powercfg", "/query", "SCHEME_CURRENT", WIN_SUB_PROCESSOR, setting_guid]) - if not found or found[0] != 0: - return None - indices = re.findall(r"0x([0-9a-fA-F]{8})", found[1]) - # The block ends with the two current indices, AC then DC. Everything before - # them describes the possible RANGE — minimum, maximum, increment — and a - # first-match parse reads the range's minimum as the current setting, which - # is how this returned 0% on a machine pinned at 100%. - if len(indices) < 2: - return None - return int(indices[-2], 16) + f"governor=performance on all {len(governors)} CPUs; " + f"{boost['mechanism']}={boost['value']}") def check_memory_metric(stratum: str) -> dict[str, object]: - """The stratum's memory metric must exist as a mechanism on this host. - - A host on which the required primary metric has no mechanism is not eligible; - T0-4 case A says such a session never starts, rather than starting and then - failing. - """ expected = STRATUM_METRIC[stratum] if stratum == "linux": - available = hasattr(os, "wait4") - mechanism = "posix os.wait4 (ru_maxrss)" + available, mechanism = hasattr(os, "wait4"), "posix os.wait4 (ru_maxrss)" else: - available = os.name == "nt" - mechanism = "win32 job object (PeakProcessMemoryUsed)" + available, mechanism = os.name == "nt", "win32 job object (PeakProcessMemoryUsed)" if not available: return check("required_memory_metric", False, f"stratum {stratum} requires {expected} via {mechanism}, which this host " @@ -339,11 +393,10 @@ def check_memory_metric(stratum: str) -> dict[str, object]: return check("required_memory_metric", True, f"{expected} via {mechanism}") -# --- quiesce, sampled exactly ------------------------------------------------ +# --- quiesce ----------------------------------------------------------------- def _cpu_counters() -> tuple[int, int] | None: - """(busy, total) from the platform's own aggregate counter.""" if os.name == "nt": idle, kernel, user = (ctypes.c_ulonglong(), ctypes.c_ulonglong(), ctypes.c_ulonglong()) try: @@ -355,144 +408,244 @@ def _cpu_counters() -> tuple[int, int] | None: return None total = kernel.value + user.value # kernel time includes idle return total - idle.value, total - line = _text("/proc/stat") - if not line or not line.startswith("cpu "): - first = (line or "").splitlines()[0] if line else "" - if not first.startswith("cpu "): - return None - line = first + raw = _text("/proc/stat") + line = (raw or "").splitlines()[0] if raw else "" + if not line.startswith("cpu "): + return None fields = [int(x) for x in line.split()[1:] if x.isdigit()] if len(fields) < 5: return None total = sum(fields) - idle = fields[3] + fields[4] # idle + iowait - return total - idle, total + return total - (fields[3] + fields[4]), total # idle + iowait def quiesce(sleep=time.sleep, counters=_cpu_counters, intervals: int = QUIESCE_INTERVALS) -> dict[str, object]: - """The final 60 s of the window, as twelve 5 s intervals. + """120 s without campaign workload, of which the final 60 s is measured. - A missing sample, a counter that went backwards or a zero denominator is - NOT_ELIGIBLE, never a skipped interval: an unreadable machine is not a quiet - machine. + The quiet minute is waited, not asserted. An earlier version declared a + 120 s window and sampled immediately for 60 s, which turned the constant + into documentation. """ + sleep(QUIESCE_QUIET_S) samples: list[float] = [] previous = counters() if previous is None: return {"eligible": False, "reason": "the CPU counter could not be read at all", - "samples": [], "mean": None, "max": None} + "quiet_seconds": QUIESCE_QUIET_S, "samples": [], "mean": None, "max": None} for _ in range(intervals): sleep(QUIESCE_INTERVAL_S) current = counters() if current is None: return {"eligible": False, "reason": "a CPU sample could not be read", - "samples": samples, "mean": None, "max": None} + "quiet_seconds": QUIESCE_QUIET_S, "samples": samples, + "mean": None, "max": None} d_busy, d_total = current[0] - previous[0], current[1] - previous[1] previous = current if d_total <= 0 or d_busy < 0: return {"eligible": False, "reason": f"the CPU counter did not advance sanely (busy {d_busy}, " f"total {d_total})", - "samples": samples, "mean": None, "max": None} + "quiet_seconds": QUIESCE_QUIET_S, "samples": samples, + "mean": None, "max": None} samples.append(d_busy / d_total) - mean = sum(samples) / len(samples) - worst = max(samples) + mean, worst = sum(samples) / len(samples), max(samples) + result = {"quiet_seconds": QUIESCE_QUIET_S, "samples": samples, "mean": mean, "max": worst} if mean >= QUIESCE_MEAN_MAX: - return {"eligible": False, "reason": f"mean utilisation {mean:.4f} is not below " - f"{QUIESCE_MEAN_MAX}", "samples": samples, "mean": mean, "max": worst} + return {**result, "eligible": False, + "reason": f"mean utilisation {mean:.4f} is not below {QUIESCE_MEAN_MAX}"} if worst > QUIESCE_INTERVAL_MAX: - return {"eligible": False, "reason": f"an interval reached {worst:.4f}, above " - f"{QUIESCE_INTERVAL_MAX}", "samples": samples, "mean": mean, "max": worst} - return {"eligible": True, "reason": "", "samples": samples, "mean": mean, "max": worst} + return {**result, "eligible": False, + "reason": f"an interval reached {worst:.4f}, above {QUIESCE_INTERVAL_MAX}"} + return {**result, "eligible": True, "reason": ""} -# --- the two records --------------------------------------------------------- +# --- the three records ------------------------------------------------------- -def qualify(stratum: str, environment_id: str, provisioning_path: Path, - manifest_path: Path) -> dict[str, object]: +def qualify(stratum: str, repo: Path, t0_path: str, t0_commit: str, + provisioning_path: Path, manifest_path: Path) -> dict[str, object]: if stratum not in STRATUM_METRIC: raise QualificationRefused(f"unknown stratum {stratum!r}") provisioning = json.loads(provisioning_path.read_text(encoding="utf-8")) manifest = json.loads(manifest_path.read_text(encoding="utf-8")) identity = manifest.get("identity") if isinstance(manifest.get("identity"), dict) else {} - checks = [check_ci(manifest), - check_single_tenant(provisioning, manifest), - check_power_policy(), + + t0_block, t0_check = bind_t0(repo, t0_path, t0_commit) + environment_id = observed(identity, "environment_id") + id_check = check("environment_identity", bool(environment_id), + f"environment_id {environment_id!r} is the manifest's own observed value; " + "the provisioning declaration must agree with it and there is no third " + "string to disagree with either") + snapshot = power_snapshot() + checks = [t0_check, id_check, check_ci(manifest), + check_single_tenant(provisioning, manifest), check_power_policy(snapshot), check_memory_metric(stratum)] - by_name = {c["check"]: c for c in checks} + by_name = {str(c["check"]): c for c in checks} missing = [k for k in PREDICATE_KEYS if k not in by_name] if missing: raise QualificationRefused(f"the predicate did not produce {missing}") - qualified = all(c["result"] == "pass" for c in checks) return { "kind": QUALIFICATION_SCHEMA, "schema": SCHEMA_VERSION, "stratum": stratum, + "t0": t0_block, "environment_id": environment_id, - "host_fingerprint": _observed(identity, "host_fingerprint"), + "host_fingerprint": observed(identity, "host_fingerprint"), + "environment_identity_sha256": canonical_sha256(identity), "provisioning": {"sha256": sha256_file(provisioning_path)}, "environment_manifest": {"sha256": sha256_file(manifest_path)}, "qualification_tool": {"sha256": sha256_file(Path(__file__).resolve())}, + "power_snapshot": snapshot, "predicate": {name: by_name[name]["result"] for name in PREDICATE_KEYS}, "predicate_detail": {name: by_name[name]["detail"] for name in PREDICATE_KEYS}, "memory_metric": STRATUM_METRIC[stratum], - "qualified": qualified, + "qualified": all(c["result"] == "pass" for c in checks), "qualified_at": _now(), "not_a_session_certificate": ( - "this record proves the host can satisfy the predicate against the bytes named " - "above; the fitness of any particular session is proved again, per session"), + "this proves the host satisfies the predicate of the named frozen T0 against the " + "bytes named here; the fitness of any particular session is proved again"), } +def _candidate_check(binding_block: dict, candidate_path: Path) -> dict[str, object]: + raw = candidate_path.read_bytes() + digest, size = sha256_bytes(raw), len(raw) + if digest != binding_block.get("candidate_sha256") or size != binding_block.get( + "candidate_bytes"): + return check("candidate", False, + f"the executable present is {digest[:12]} / {size} B; the campaign is bound " + f"to {str(binding_block.get('candidate_sha256'))[:12]} / " + f"{binding_block.get('candidate_bytes')} B") + return check("candidate", True, f"{digest[:12]} / {size} B, as bound") + + def session_eligibility(binding_path: Path, qualification_path: Path, manifest_path: Path, + declaration_path: Path, candidate_path: Path, quiesce_result: dict[str, object] | None = None) -> dict[str, object]: binding = json.loads(binding_path.read_text(encoding="utf-8")) qualification = json.loads(qualification_path.read_text(encoding="utf-8")) manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + declaration = json.loads(declaration_path.read_text(encoding="utf-8")) stratum = str(qualification.get("stratum")) + bound = binding.get(stratum) if isinstance(binding.get(stratum), dict) else {} + identity = manifest.get("identity") if isinstance(manifest.get("identity"), dict) else {} reasons: list[str] = [] if not qualification.get("qualified"): reasons.append("the referenced qualification does not say qualified") - bound = (binding.get(stratum) or {}) if isinstance(binding.get(stratum), dict) else {} if bound.get("qualification_sha256") != sha256_file(qualification_path): reasons.append("the execution binding does not name this qualification; a qualified " - "host that is not part of this campaign may not be substituted into it") + "host outside this campaign may not be substituted into it") if bound.get("memory_metric") != qualification.get("memory_metric"): reasons.append("the binding and the qualification disagree about the memory metric") - ci = check_ci(manifest) - if ci["result"] != "pass": - reasons.append(str(ci["detail"])) - power = check_power_policy() - if power["result"] != "pass": - reasons.append(str(power["detail"])) + declaration_problems = validate_declaration(declaration) + if declaration_problems: + reasons.append("the session declaration does not validate: " + + "; ".join(declaration_problems)) - identity = manifest.get("identity") if isinstance(manifest.get("identity"), dict) else {} - if _observed(identity, "host_fingerprint") != qualification.get("host_fingerprint"): - reasons.append("the fresh manifest is not the machine this qualification describes") + ci = check_ci(manifest) + snapshot = power_snapshot() + power = check_power_policy(snapshot) + for result in (ci, power): + if result["result"] != "pass": + reasons.append(str(result["detail"])) + if snapshot != qualification.get("power_snapshot"): + reasons.append("the power state is not the one this host was qualified with") + + live_identity = canonical_sha256(identity) + if live_identity != qualification.get("environment_identity_sha256"): + reasons.append("the fresh manifest's identity block does not hash to the qualified " + "environment identity; something in the identity set moved") + + candidate = _candidate_check(bound, candidate_path) + if candidate["result"] != "pass": + reasons.append(str(candidate["detail"])) result = quiesce_result if quiesce_result is not None else quiesce() if not result.get("eligible"): reasons.append(f"quiesce: {result.get('reason')}") return { - "kind": SESSION_SCHEMA, + "kind": ELIGIBILITY_SCHEMA, "schema": SCHEMA_VERSION, "stratum": stratum, "execution_binding_sha256": sha256_file(binding_path), "qualification_sha256": sha256_file(qualification_path), "fresh_environment_manifest_sha256": sha256_file(manifest_path), - "power_policy": power, + "environment_identity_sha256": live_identity, + "session_declaration_sha256": sha256_file(declaration_path), + "power_snapshot": snapshot, + "candidate": candidate, "ci": ci, "quiesce": result, "eligible": not reasons, "reasons": reasons, "recorded_at": _now(), - "note": ("eligibility is not a measurement and not a verdict: a refusal here means the " - "session does not start, which is not INVALID, because no clock has run"), + "note": ("a refusal here means the session does not start, which is not INVALID: no " + "clock has run, so there is no evidence to damage and no retry to spend"), + } + + +def session_admissibility(binding_path: Path, qualification_path: Path, preflight_path: Path, + manifest_path: Path, candidate_path: Path, + closing_probe_path: Path) -> dict[str, object]: + """Did the attempt that ran remain the one that was authorised? + + Separate from preflight on purpose: preflight may not certify what a session + did after it started, and an attempt that drifted mid-flight has to be + caught by evidence taken after it, not before. + """ + binding = json.loads(binding_path.read_text(encoding="utf-8")) + qualification = json.loads(qualification_path.read_text(encoding="utf-8")) + preflight = json.loads(preflight_path.read_text(encoding="utf-8")) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + stratum = str(qualification.get("stratum")) + bound = binding.get(stratum) if isinstance(binding.get(stratum), dict) else {} + identity = manifest.get("identity") if isinstance(manifest.get("identity"), dict) else {} + binding_sha = sha256_file(binding_path) + reasons: list[str] = [] + + if not preflight.get("eligible"): + reasons.append("the preflight for this session did not declare it eligible") + seen = {preflight.get("execution_binding_sha256"), binding_sha} + if len(seen) != 1: + reasons.append("the preflight and this binding are two different campaign identities; " + "a changed binding is a different campaign, never a newer one") + if preflight.get("qualification_sha256") != sha256_file(qualification_path): + reasons.append("the preflight was taken against a different qualification") + + snapshot = power_snapshot() + if snapshot != preflight.get("power_snapshot"): + reasons.append("the power state changed during the session") + live_identity = canonical_sha256(identity) + if live_identity != preflight.get("environment_identity_sha256"): + reasons.append("the environment identity changed during the session") + candidate = _candidate_check(bound, candidate_path) + if candidate["result"] != "pass": + reasons.append("the candidate changed during the session: " + str(candidate["detail"])) + if not closing_probe_path.is_file(): + reasons.append("no closing noise probe evidence was supplied") + + return { + "kind": ADMISSIBILITY_SCHEMA, + "schema": SCHEMA_VERSION, + "stratum": stratum, + "execution_binding_sha256": binding_sha, + "qualification_sha256": sha256_file(qualification_path), + "preflight_sha256": sha256_file(preflight_path), + "post_environment_manifest_sha256": sha256_file(manifest_path), + "environment_identity_sha256": live_identity, + "power_snapshot": snapshot, + "candidate": candidate, + "closing_probe_sha256": (sha256_file(closing_probe_path) + if closing_probe_path.is_file() else None), + "admissible": not reasons, + "reasons": reasons, + "recorded_at": _now(), + "note": ("drift found here is an INVALID attempt under T0-6, which is not the same as a " + "session that never started"), } @@ -500,46 +653,65 @@ def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--qualify", action="store_true") parser.add_argument("--session-preflight", action="store_true") + parser.add_argument("--session-postflight", action="store_true") parser.add_argument("--selftest", action="store_true") parser.add_argument("--stratum", choices=sorted(STRATUM_METRIC)) - parser.add_argument("--environment-id") + parser.add_argument("--repo", type=Path, default=Path(__file__).resolve().parents[2]) + parser.add_argument("--t0-path") + parser.add_argument("--t0-commit") parser.add_argument("--provisioning", type=Path) parser.add_argument("--manifest", type=Path) + parser.add_argument("--declaration", type=Path) + parser.add_argument("--candidate", type=Path) parser.add_argument("--binding", type=Path) parser.add_argument("--qualification", type=Path) + parser.add_argument("--preflight", type=Path) + parser.add_argument("--closing-probe", type=Path) parser.add_argument("--emit", type=Path) args = parser.parse_args(argv) if args.selftest: print(json.dumps({"tool_sha256": sha256_file(Path(__file__).resolve()), - "strata": STRATUM_METRIC, + "strata": STRATUM_METRIC, "predicate_keys": list(PREDICATE_KEYS), "quiesce": {"window_s": QUIESCE_WINDOW_S, + "quiet_s": QUIESCE_QUIET_S, "intervals": QUIESCE_INTERVALS, "interval_s": QUIESCE_INTERVAL_S, "mean_max": QUIESCE_MEAN_MAX, - "interval_max": QUIESCE_INTERVAL_MAX}, - "predicate_keys": list(PREDICATE_KEYS)}, indent=2)) + "interval_max": QUIESCE_INTERVAL_MAX}}, indent=2)) return 0 + def require(flag: str, names: tuple[str, ...]) -> None: + absent = [n for n in names if getattr(args, n.replace("-", "_")) is None] + if absent: + parser.error(f"--{flag} requires {['--' + n for n in absent]}") + if args.qualify: - for name in ("stratum", "environment_id", "provisioning", "manifest"): - if getattr(args, name) is None: - parser.error(f"--qualify requires --{name.replace('_', '-')}") - record = qualify(args.stratum, args.environment_id, args.provisioning, args.manifest) + require("qualify", ("stratum", "t0-path", "t0-commit", "provisioning", "manifest")) + record = qualify(args.stratum, args.repo, args.t0_path, args.t0_commit, + args.provisioning, args.manifest) + ok = bool(record["qualified"]) elif args.session_preflight: - for name in ("binding", "qualification", "manifest"): - if getattr(args, name) is None: - parser.error(f"--session-preflight requires --{name}") - record = session_eligibility(args.binding, args.qualification, args.manifest) + require("session-preflight", + ("binding", "qualification", "manifest", "declaration", "candidate")) + record = session_eligibility(args.binding, args.qualification, args.manifest, + args.declaration, args.candidate) + ok = bool(record["eligible"]) + elif args.session_postflight: + require("session-postflight", + ("binding", "qualification", "preflight", "manifest", "candidate", + "closing-probe")) + record = session_admissibility(args.binding, args.qualification, args.preflight, + args.manifest, args.candidate, args.closing_probe) + ok = bool(record["admissible"]) else: - parser.error("choose --qualify, --session-preflight or --selftest") + parser.error("choose --qualify, --session-preflight, --session-postflight or --selftest") text = json.dumps(record, indent=2, sort_keys=True, ensure_ascii=False) + "\n" if args.emit: args.emit.write_text(text, encoding="utf-8") else: print(text, end="") - ok = bool(record.get("qualified") if args.qualify else record.get("eligible")) return 0 if ok else 1 diff --git a/tests/test_step7_hostqual.py b/tests/test_step7_hostqual.py index 13d2ec8d..d94b39aa 100644 --- a/tests/test_step7_hostqual.py +++ b/tests/test_step7_hostqual.py @@ -1,20 +1,26 @@ #!/usr/bin/env python3 -"""#263 step 7 — controls on host qualification and the execution binding. - - hostqual-memory-vocabulary one closed set, three files, no drift - hostqual-ci-predicate ci == false, not "the field is absent" - hostqual-provisioning-shape a declaration with a hole is not a declaration - hostqual-quiesce-arithmetic quiet passes; spike, mean, gap and rewind do not - hostqual-session-binds a qualified host outside this campaign is refused - hostqual-not-a-certificate qualification proves capability, never session fitness - execbinding-references the binding carries references, not copies - execbinding-strata two strata, two metrics, both qualified - execbinding-no-overwrite a rebuild is deliberate, never silent - tools-do-not-import-harness qualification never reaches into the frozen instrument - -Both platform branches are exercised through synthetic fixtures, so the Windows -rules are driven on Linux and the Linux rules on Windows. Where a control can -only observe the live branch it says so rather than claiming both were run. +"""#263 step 7 — controls on qualification, eligibility, admissibility and binding. + + hostqual-memory-vocabulary one closed set, three files, no drift + hostqual-t0-versioned a qualification is versioned by the T0 it claims + hostqual-ci-predicate ci == false, not "the field is absent" + hostqual-provisioning-values shape AND value; a VM that promises nothing fails + hostqual-one-environment-id one identity, not three strings that usually agree + hostqual-quiesce-window 120 s means 120 s, of which 60 s is measured + hostqual-quiesce-arithmetic quiet passes; spike, mean, gap and rewind do not + hostqual-identity-projection the whole identity set is compared, not one field + hostqual-candidate-at-start the executable present must be the one bound + hostqual-postflight drift during the session is caught after it + hostqual-one-binding two campaign identities cannot meet in one session + execbinding-proves-inputs T0, digest and manifest are proved, not trusted + execbinding-old-t0 a host qualified under another T0 cannot enter + execbinding-verify-campaign the verifier checks the campaign, not two hashes + execbinding-no-overwrite a rebuild is deliberate, never silent + provisioning-example-validates the template still fits the schema it teaches + tools-do-not-import-harness qualification never reaches into the instrument + +Git-dependent controls build a throwaway repository, so T0 and instrument +identity are proved against real objects rather than mocked strings. Failures print `FAIL[]: `; nothing stops at the first one. @@ -27,6 +33,7 @@ import contextlib import io import json +import subprocess import sys import tempfile from collections.abc import Callable @@ -40,6 +47,8 @@ import hostqual as hq # noqa: E402 import perf_baseline as pb # noqa: E402 +EXAMPLE = ROOT / "scripts" / "step7" / "examples" / "host-provisioning.example.json" + _FAILURES: list[tuple[str, str]] = [] _PASSES: list[str] = [] @@ -61,342 +70,551 @@ def guarded(check: str, control: Callable[[], None]) -> None: fail(check, f"the control raised {type(exc).__name__}: {exc}") -def manifest(ci: bool = False, fingerprint: str = "sha256:abc", - environment_id: str = "env-1") -> dict: +# --- fixtures --------------------------------------------------------------- + + +T0_FROZEN = "# T0\n\n```text\nStatus:\n FROZEN.\n collection_authorized: true\n```\n" +T0_OPEN = "# T0\n\n```text\nStatus:\n NOT_FROZEN.\n collection_authorized: false\n```\n" + +# A compliant Windows snapshot, used as a fixture on every platform so the +# Windows rules are driven on Linux too — and so these controls do not depend on +# whatever the machine running them happens to have in its power plan. +COMPLIANT_POWER = {"platform": "windows", "plan_guid": hq.WIN_ACCEPTED_PLANS[0], + "processor_min_ac": 100, "processor_max_ac": 100, + "processor_min_dc": 100, "processor_max_dc": 100} + + +@contextlib.contextmanager +def fixed_power(snapshot: dict): + original = hq.power_snapshot + hq.power_snapshot = lambda: dict(snapshot) # type: ignore[assignment] + try: + yield + finally: + hq.power_snapshot = original # type: ignore[assignment] + + +def git_repo(tmp: Path, files: dict[str, str], message: str = "fixture") -> str: + tmp.mkdir(parents=True, exist_ok=True) + def run(*args: str) -> subprocess.CompletedProcess: + return subprocess.run(["git", "-C", str(tmp), *args], capture_output=True, check=True) + if not (tmp / ".git").exists(): + run("init", "-q") + run("config", "user.email", "control@example.invalid") + run("config", "user.name", "control") + for name, text in files.items(): + path = tmp / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + run("add", "-A") + run("commit", "-q", "-m", message) + return subprocess.run(["git", "-C", str(tmp), "rev-parse", "HEAD"], + capture_output=True, text=True, check=True).stdout.strip() + + +def manifest(ci: bool = False, fingerprint: str = "sha256:abc", environment_id: str = "env-1", + kernel: str = "6.1.0") -> dict: return { "identity": { "environment_id": {"status": "observed", "value": environment_id}, "host_fingerprint": {"status": "observed", "value": fingerprint}, + "kernel": {"status": "observed", "value": kernel}, }, - "provenance": {"ci": ci, "runner_name": None}, + "provenance": {"ci": ci, "timestamp_utc": "2026-09-16T00:00:00+00:00"}, } def provisioning(**overrides: object) -> dict: doc: dict[str, object] = { - "kind": hq.PROVISIONING_SCHEMA, - "schema": 1, - "environment_id": "env-1", - "host_fingerprint": "sha256:abc", - "operator": "owner", - "recorded_at": "2026-09-16T00:00:00+00:00", - "dedicated_to_p022": True, - "no_concurrent_user_workload": True, + "kind": hq.PROVISIONING_SCHEMA, "schema": 1, + "environment_id": "env-1", "host_fingerprint": "sha256:abc", + "operator": "owner", "recorded_at": "2026-09-16T00:00:00+00:00", + "dedicated_to_p022": True, "no_concurrent_user_workload": True, "hosted_ci_runner": False, - "prohibited_background_declared_inactive": True, "virtualization": {"is_vm": False, - "fixed_vcpu": "n/a: bare metal", - "fixed_ram": "n/a: bare metal", - "live_migration_disabled": "n/a: bare metal", - "dynamic_memory_disabled": "n/a: bare metal"}, + "fixed_vcpu": "n/a: physical", + "fixed_ram": "n/a: physical", + "live_migration_disabled": "n/a: physical", + "dynamic_memory_disabled": "n/a: physical"}, } doc.update(overrides) return doc -def write(tmp: Path, name: str, doc: dict) -> Path: - path = tmp / name - path.write_text(json.dumps(doc, indent=2), encoding="utf-8") - return path +def vm_provisioning(**virt_overrides: object) -> dict: + virt = {"is_vm": True, "fixed_vcpu": True, "fixed_ram": True, + "live_migration_disabled": True, "dynamic_memory_disabled": True} + virt.update(virt_overrides) + return provisioning(virtualization=virt) + + +def declaration(**overrides: object) -> dict: + doc: dict[str, object] = { + "kind": hq.DECLARATION_SCHEMA, "schema": 1, + "no_campaign_workload": True, "no_interactive_user_workload": True, + "no_prohibited_background_job_active": True, + "operator": "owner", "recorded_at": "2026-09-16T00:00:00+00:00"} + doc.update(overrides) + return doc -def qualification(stratum: str = "linux", qualified: bool = True, **overrides) -> dict: - doc = { - "kind": "own.net/p022/host-qualification", - "schema": 1, - "stratum": stratum, - "environment_id": f"env-{stratum}", - "host_fingerprint": "sha256:abc", +def qualification(stratum: str = "linux", t0: dict | None = None, **overrides) -> dict: + doc: dict[str, object] = { + "kind": "own.net/p022/host-qualification", "schema": 1, "stratum": stratum, + "t0": t0 or {"commit": "c" * 40, "path": "t0.md", "blob_sha": "b" * 40, + "sha256": "f" * 64, "status": "FROZEN"}, + "environment_id": "env-1", "host_fingerprint": "sha256:abc", + "environment_identity_sha256": hq.canonical_sha256(manifest()["identity"]), "provisioning": {"sha256": "0" * 64}, "environment_manifest": {"sha256": "1" * 64}, "qualification_tool": {"sha256": "2" * 64}, + "power_snapshot": dict(COMPLIANT_POWER), "predicate": {k: "pass" for k in hq.PREDICATE_KEYS}, "memory_metric": hq.STRATUM_METRIC[stratum], - "qualified": qualified, - "qualified_at": "2026-09-16T00:00:00+00:00", - } + "qualified": True, "qualified_at": "2026-09-16T00:00:00+00:00"} doc.update(overrides) return doc -# --- the vocabulary --------------------------------------------------------- +def write(tmp: Path, name: str, doc: dict) -> Path: + path = tmp / name + path.write_text(json.dumps(doc, indent=2), encoding="utf-8") + return path + + +def steady(n: int = hq.QUIESCE_INTERVALS): + series = iter([(0, 0)] + [(i, i * 100) for i in range(1, n + 1)]) + return lambda: next(series, None) + + +# --- vocabulary and T0 ------------------------------------------------------- def control_memory_vocabulary() -> None: - """One closed set, declared in three files, proved identical here. - - The tools do not import the frozen instrument, so the set is written down - more than once. That is a deliberate decoupling with a control on top: a - drift between the copies fails a test rather than mislabelling a campaign. - """ - tool_set = set(hq.STRATUM_METRIC.values()) - binding_set = set(eb.STRATUM_METRIC.values()) - if tool_set != pb.MEMORY_METRICS: - fail("hostqual-memory-vocabulary", - f"hostqual declares {sorted(tool_set)}, the instrument {sorted(pb.MEMORY_METRICS)}") - return - if binding_set != pb.MEMORY_METRICS: - fail("hostqual-memory-vocabulary", - f"execbinding declares {sorted(binding_set)}, the instrument " - f"{sorted(pb.MEMORY_METRICS)}") + if set(hq.STRATUM_METRIC.values()) != pb.MEMORY_METRICS: + fail("hostqual-memory-vocabulary", "hostqual and the instrument disagree") return if hq.STRATUM_METRIC != eb.STRATUM_METRIC: - fail("hostqual-memory-vocabulary", "the two tools map strata to metrics differently") + fail("hostqual-memory-vocabulary", "the two tools map strata differently") return if hq.STRATUM_METRIC["linux"] == hq.STRATUM_METRIC["windows"]: - fail("hostqual-memory-vocabulary", "both strata map to one metric; they measure " - "different physical quantities") + fail("hostqual-memory-vocabulary", "both strata map to one metric") return ok("hostqual-memory-vocabulary", - f"linux={hq.STRATUM_METRIC['linux']}, windows={hq.STRATUM_METRIC['windows']}, " - "identical to the instrument's closed set in both tools") + "the closed set is identical in hostqual, execbinding and the instrument") + + +def control_t0_versioned() -> None: + """A qualification claims to satisfy T0-7, so it cannot float free of T0.""" + with tempfile.TemporaryDirectory() as raw: + tmp = Path(raw) + repo = tmp / "repo" + open_commit = git_repo(repo, {"t0.md": T0_OPEN}, "t0 open") + frozen_commit = git_repo(repo, {"t0.md": T0_FROZEN}, "t0 frozen") + + block, result = hq.bind_t0(repo, "t0.md", open_commit) + if result["result"] != "fail" or "NOT_FROZEN" not in str(result["detail"]): + fail("hostqual-t0-versioned", f"a NOT_FROZEN T0 was accepted: {result}") + return + block, result = hq.bind_t0(repo, "t0.md", frozen_commit) + if result["result"] != "pass": + fail("hostqual-t0-versioned", f"a FROZEN T0 was refused: {result}") + return + for field in ("commit", "blob_sha", "sha256", "path"): + if not block.get(field): + fail("hostqual-t0-versioned", f"the bound T0 block has no {field}") + return + missing_commit = hq.bind_t0(repo, "t0.md", "0" * 40)[1] + if missing_commit["result"] != "fail": + fail("hostqual-t0-versioned", "a nonexistent commit was accepted") + return + wrong_path = hq.bind_t0(repo, "nope.md", frozen_commit)[1] + if wrong_path["result"] != "fail": + fail("hostqual-t0-versioned", "a path absent at that commit was accepted") + return + ok("hostqual-t0-versioned", + "the commit must exist, the path must exist at it, the bytes are hashed from the blob, " + "and NOT_FROZEN refuses: reconnaissance cannot become qualification by reuse") -# --- the predicate ---------------------------------------------------------- +# --- declared evidence ------------------------------------------------------- def control_ci_predicate() -> None: - """`ci == false`, never "the field is absent". - - The capture tool writes `ci` as a boolean on every manifest, so a predicate - demanding its absence could not be satisfied by any real manifest — which is - exactly the ambiguity the freeze review found. - """ - cases = [("ci false", manifest(ci=False), "pass"), - ("ci true", manifest(ci=True), "fail")] - for label, doc, expected in cases: - got = hq.check_ci(doc)["result"] - if got != expected: - fail("hostqual-ci-predicate", f"{label} gave {got}, expected {expected}") - return - missing = manifest() - del missing["provenance"]["ci"] - if hq.check_ci(missing)["result"] != "fail": + if hq.check_ci(manifest(ci=False))["result"] != "pass": + fail("hostqual-ci-predicate", "ci false was refused") + return + if hq.check_ci(manifest(ci=True))["result"] != "fail": + fail("hostqual-ci-predicate", "ci true was accepted") + return + absent = manifest() + del absent["provenance"]["ci"] + if hq.check_ci(absent)["result"] != "fail": fail("hostqual-ci-predicate", "a manifest with no ci field was accepted") return - wrong_type = manifest() - wrong_type["provenance"]["ci"] = "false" - if hq.check_ci(wrong_type)["result"] != "fail": - fail("hostqual-ci-predicate", "the string 'false' was accepted as a boolean") + wrong = manifest() + wrong["provenance"]["ci"] = "false" + if hq.check_ci(wrong)["result"] != "fail": + fail("hostqual-ci-predicate", "the string 'false' passed as a boolean") return - ok("hostqual-ci-predicate", - "false passes, true fails, and an absent or non-boolean ci is refused rather than " - "read as absence-means-not-CI") + ok("hostqual-ci-predicate", "ci == false; absent or non-boolean is refused") -def control_provisioning_shape() -> None: - if validate := hq.validate_provisioning(provisioning()): - fail("hostqual-provisioning-shape", f"a complete declaration was refused: {validate}") +def control_provisioning_values() -> None: + """Shape was never the question. The values are the declaration.""" + if hq.validate_provisioning(provisioning()): + fail("hostqual-provisioning-values", "a valid physical-host declaration was refused") return - holed = provisioning() - del holed["dedicated_to_p022"] - if not hq.validate_provisioning(holed): - fail("hostqual-provisioning-shape", "a declaration with a missing key was accepted") + if hq.validate_provisioning(vm_provisioning()): + fail("hostqual-provisioning-values", "a valid VM declaration was refused") return - vague = provisioning(fixed_vcpu="unknown") - vague["virtualization"]["fixed_vcpu"] = "unknown" - if not hq.validate_provisioning(vague): - fail("hostqual-provisioning-shape", - "a bare string was accepted where a boolean or an explicit 'n/a: ' " - "is required") + + cases = [ + ("dedicated_to_p022 false", provisioning(dedicated_to_p022=False)), + ("no_concurrent_user_workload false", provisioning(no_concurrent_user_workload=False)), + ("hosted_ci_runner true", provisioning(hosted_ci_runner=True)), + ("VM fixed_vcpu false", vm_provisioning(fixed_vcpu=False)), + ("VM fixed_ram false", vm_provisioning(fixed_ram=False)), + ("VM live_migration_disabled false", vm_provisioning(live_migration_disabled=False)), + ("VM dynamic_memory_disabled false", vm_provisioning(dynamic_memory_disabled=False)), + ("VM answering n/a", vm_provisioning(fixed_vcpu="n/a: do not ask")), + ("is_vm as n/a", provisioning(virtualization={"is_vm": "n/a: unclear"})), + ] + for label, doc in cases: + if not hq.validate_provisioning(doc): + fail("hostqual-provisioning-values", f"{label} was accepted") + return + physical_hole = provisioning() + physical_hole["virtualization"]["fixed_vcpu"] = True + if not hq.validate_provisioning(physical_hole): + fail("hostqual-provisioning-values", + "a physical host answering the VM questions with bare booleans was accepted") return - if hq.check_single_tenant(provisioning(dedicated_to_p022=False), - manifest())["result"] != "fail": - fail("hostqual-provisioning-shape", "a host declared not dedicated was qualified") + ok("hostqual-provisioning-values", + "every required boolean is checked by VALUE: a VM that cannot promise fixed vCPU, fixed " + "RAM, no live migration or no dynamic memory fails, and 'n/a' is unavailable to it") + + +def control_one_environment_id() -> None: + mismatch = hq.check_single_tenant(provisioning(environment_id="env-other"), manifest()) + if mismatch["result"] != "fail": + fail("hostqual-one-environment-id", + "a declaration naming another environment qualified this one") return - if hq.check_single_tenant(provisioning(hosted_ci_runner=True), - manifest())["result"] != "fail": - fail("hostqual-provisioning-shape", "a declared hosted CI runner was qualified") + agree = hq.check_single_tenant(provisioning(), manifest()) + if agree["result"] != "pass": + fail("hostqual-one-environment-id", f"agreeing identities were refused: {agree}") return - if hq.check_single_tenant(provisioning(), manifest(fingerprint="sha256:other") - )["result"] != "fail": - fail("hostqual-provisioning-shape", - "a declaration describing a different machine than the manifest was accepted") + source = (ROOT / "scripts" / "step7" / "hostqual.py").read_text(encoding="utf-8") + if "--environment-id" in source: + fail("hostqual-one-environment-id", + "the tool still accepts an independent --environment-id; that is a third string " + "that only happens to agree while everyone behaves") return - ok("hostqual-provisioning-shape", - "complete declarations pass, a hole is refused, 'unknown' is not an n/a, and a " - "declaration that names another machine cannot qualify this one") + ok("hostqual-one-environment-id", + "the id is the manifest's observed value, the declaration must agree with it, and there " + "is no third source to disagree with either") -def control_quiesce_arithmetic() -> None: - """Driven on injected counters, so the rule is proved without waiting 120 s.""" - def counters(series: list[tuple[int, int]]): - it = iter(series) - return lambda: next(it, None) +# --- quiesce ----------------------------------------------------------------- + - quiet = [(0, 0)] + [(i, i * 100) for i in range(1, hq.QUIESCE_INTERVALS + 1)] - result = hq.quiesce(sleep=lambda _s: None, counters=counters(quiet)) - if not result["eligible"]: - fail("hostqual-quiesce-arithmetic", f"a 1% machine was refused: {result['reason']}") +def control_quiesce_window() -> None: + """120 s means 120 s. A constant that nobody waits for is documentation.""" + slept: list[float] = [] + hq.quiesce(sleep=slept.append, counters=steady()) + total = sum(slept) + if not slept or slept[0] != hq.QUIESCE_QUIET_S: + fail("hostqual-quiesce-window", + f"the quiet period was {slept[:1]}, expected a first wait of {hq.QUIESCE_QUIET_S}s") return - if len(result["samples"]) != hq.QUIESCE_INTERVALS: - fail("hostqual-quiesce-arithmetic", - f"{len(result['samples'])} samples, expected {hq.QUIESCE_INTERVALS}") + if total != hq.QUIESCE_WINDOW_S: + fail("hostqual-quiesce-window", + f"the window lasted {total}s, not {hq.QUIESCE_WINDOW_S}s; sampling the final " + "minute immediately turns the other minute into a comment") return - - spike = [(0, 0)] + [(i * 30 if i == 4 else i, i * 100) for i in - range(1, hq.QUIESCE_INTERVALS + 1)] - if hq.quiesce(sleep=lambda _s: None, counters=counters(spike))["eligible"]: - fail("hostqual-quiesce-arithmetic", "an interval above the cap was accepted") + measured = sum(slept[1:]) + if measured != hq.QUIESCE_INTERVAL_S * hq.QUIESCE_INTERVALS: + fail("hostqual-quiesce-window", f"the measured part lasted {measured}s") return + ok("hostqual-quiesce-window", + f"{hq.QUIESCE_QUIET_S}s waited quietly, then {hq.QUIESCE_INTERVALS} intervals of " + f"{hq.QUIESCE_INTERVAL_S}s = {hq.QUIESCE_WINDOW_S}s in total") - busy = [(0, 0)] + [(i * 10, i * 100) for i in range(1, hq.QUIESCE_INTERVALS + 1)] - if hq.quiesce(sleep=lambda _s: None, counters=counters(busy))["eligible"]: - fail("hostqual-quiesce-arithmetic", "a 10% mean was accepted below a 5% limit") - return - short = [(0, 0)] + [(i, i * 100) for i in range(1, 4)] - gap = hq.quiesce(sleep=lambda _s: None, counters=counters(short)) - if gap["eligible"] or "sample" not in str(gap["reason"]): - fail("hostqual-quiesce-arithmetic", f"a missing sample was not refused: {gap}") - return +def control_quiesce_arithmetic() -> None: + def counters(series): + it = iter(series) + return lambda: next(it, None) - rewind = [(0, 0), (5, 100), (1, 50)] - back = hq.quiesce(sleep=lambda _s: None, counters=counters(rewind)) - if back["eligible"]: - fail("hostqual-quiesce-arithmetic", "a counter that went backwards was accepted") + if not hq.quiesce(sleep=lambda _s: None, counters=steady())["eligible"]: + fail("hostqual-quiesce-arithmetic", "a 1% machine was refused") return + spike = [(0, 0)] + [(i * 30 if i == 4 else i, i * 100) + for i in range(1, hq.QUIESCE_INTERVALS + 1)] + busy = [(0, 0)] + [(i * 10, i * 100) for i in range(1, hq.QUIESCE_INTERVALS + 1)] + short = [(0, 0)] + [(i, i * 100) for i in range(1, 4)] + rewind = [(0, 0), (5, 100), (1, 50)] + for label, series in (("a 20% spike", spike), ("a 10% mean", busy), + ("a missing sample", short), ("a rewound counter", rewind)): + if hq.quiesce(sleep=lambda _s: None, counters=counters(series))["eligible"]: + fail("hostqual-quiesce-arithmetic", f"{label} was accepted") + return ok("hostqual-quiesce-arithmetic", - f"{hq.QUIESCE_INTERVALS} intervals of {hq.QUIESCE_INTERVAL_S}s; quiet passes, a spike " - "over 20%, a 10% mean, a missing sample and a rewound counter each refuse") + "quiet passes; a spike over 20%, a 10% mean, a missing sample and a rewound counter " + "each refuse rather than skip") + +# --- session identity -------------------------------------------------------- -# --- the two artifacts stay two --------------------------------------------- +def _session(tmp: Path, *, fresh: dict | None = None, candidate: bytes = b"candidate", + bound_candidate: bytes = b"candidate", qual: dict | None = None): + qualification_doc = qual or qualification() + qpath = write(tmp, "q.json", qualification_doc) + binding = {"linux": {"qualification_sha256": hq.sha256_file(qpath), + "memory_metric": hq.STRATUM_METRIC["linux"], + "candidate_sha256": hq.sha256_bytes(bound_candidate), + "candidate_bytes": len(bound_candidate)}} + bpath = write(tmp, "b.json", binding) + mpath = write(tmp, "m.json", fresh or manifest()) + dpath = write(tmp, "d.json", declaration()) + cpath = tmp / "cand.bin" + cpath.write_bytes(candidate) + quiet = {"eligible": True, "reason": "", "samples": [0.01], "mean": 0.01, "max": 0.01} + with fixed_power(COMPLIANT_POWER): + record = hq.session_eligibility(bpath, qpath, mpath, dpath, cpath, quiesce_result=quiet) + return record, (bpath, qpath, mpath, dpath, cpath) -def control_not_a_certificate() -> None: + +def control_identity_projection() -> None: with tempfile.TemporaryDirectory() as raw: tmp = Path(raw) - qual = write(tmp, "q.json", qualification()) - record = json.loads(qual.read_text(encoding="utf-8")) - leaked = [k for k in ("quiesce", "eligible", "cpu_samples") if k in record] - if leaked: - fail("hostqual-not-a-certificate", - f"the qualification carries session-fitness fields {leaked}; a qualification " - "that certifies quiet makes 'a fresh manifest per session' decorative") + good, _ = _session(tmp) + if not good["eligible"]: + fail("hostqual-identity-projection", f"a clean session was refused: {good['reasons']}") + return + drifted, _ = _session(tmp, fresh=manifest(kernel="6.2.0")) + if drifted["eligible"]: + fail("hostqual-identity-projection", + "a manifest whose kernel changed still preflighted; only host_fingerprint was " + "being compared and the rest of the identity set walked through") return - ok("hostqual-not-a-certificate", - "the qualification proves capability against named bytes and carries no session " - "quiesce, no eligibility and no Rust-vs-Python number") + if not any("identity" in r for r in drifted["reasons"]): + fail("hostqual-identity-projection", f"refused for the wrong reason: {drifted}") + return + ok("hostqual-identity-projection", + "the whole identity block is compared as one content-addressed projection, so a changed " + "kernel, CPU, RAM or toolchain cannot walk past a matching fingerprint") -def control_session_binds_campaign() -> None: +def control_candidate_at_start() -> None: with tempfile.TemporaryDirectory() as raw: tmp = Path(raw) - qual = write(tmp, "q.json", qualification()) - fresh = write(tmp, "m.json", manifest()) - bound = {"linux": {"qualification_sha256": hq.sha256_file(qual), - "memory_metric": hq.STRATUM_METRIC["linux"]}} - good = write(tmp, "b.json", bound) - quiet = {"eligible": True, "reason": "", "samples": [0.01], "mean": 0.01, "max": 0.01} - record = hq.session_eligibility(good, qual, fresh, quiesce_result=quiet) - for key in ("execution_binding_sha256", "qualification_sha256", - "fresh_environment_manifest_sha256"): - if not record.get(key): - fail("hostqual-session-binds", f"the session record does not carry {key}") - return - - other = write(tmp, "b2.json", {"linux": {"qualification_sha256": "9" * 64, - "memory_metric": hq.STRATUM_METRIC["linux"]}}) - substituted = hq.session_eligibility(other, qual, fresh, quiesce_result=quiet) - if substituted["eligible"]: - fail("hostqual-session-binds", - "a qualified host the binding does not name was allowed into the campaign") + swapped, _ = _session(tmp, candidate=b"a different executable") + if swapped["eligible"]: + fail("hostqual-candidate-at-start", "a substituted candidate preflighted") return - noisy = hq.session_eligibility(good, qual, fresh, - quiesce_result={"eligible": False, "reason": "loud", - "samples": [], "mean": None, "max": None}) - if noisy["eligible"]: - fail("hostqual-session-binds", "a session was eligible despite a failed quiesce") + if not any("bound to" in r for r in swapped["reasons"]): + fail("hostqual-candidate-at-start", f"refused for the wrong reason: {swapped}") return - ok("hostqual-session-binds", - "a session names binding, qualification and its fresh manifest; a qualified host outside " - "this campaign and a failed quiesce each refuse the start") + ok("hostqual-candidate-at-start", + "the executable that will run is hashed and compared to the bound candidate before the " + "clock, not assumed from the path it was found at") -def control_execbinding_references() -> None: +def control_postflight() -> None: with tempfile.TemporaryDirectory() as raw: tmp = Path(raw) - lin = write(tmp, "ql.json", qualification("linux")) - win = write(tmp, "qw.json", qualification("windows")) - cand_l = tmp / "cand.linux" - cand_l.write_bytes(b"linux candidate") - cand_w = tmp / "cand.win" - cand_w.write_bytes(b"windows candidate bytes") - t0 = tmp / "t0.md" - t0.write_text("T0", encoding="utf-8") - loads = write(tmp, "w.json", {"decisive": []}) - binding = eb.build(t0, "c0ffee", "b10b", "acce97", "104c384d", loads, - {"linux": (lin, cand_l), "windows": (win, cand_w)}) - problems = eb.validate(binding) - if problems: - fail("execbinding-references", f"a well-formed binding was refused: {problems}") + pre, (bpath, qpath, mpath, dpath, cpath) = _session(tmp) + ppath = write(tmp, "pre.json", pre) + probe = tmp / "probe.json" + probe.write_text("{}", encoding="utf-8") + + with fixed_power(COMPLIANT_POWER): + clean = hq.session_admissibility(bpath, qpath, ppath, mpath, cpath, probe) + if not clean["admissible"]: + fail("hostqual-postflight", f"a clean attempt was refused: {clean['reasons']}") return - blob = json.dumps(binding) - copied = [word for word in ("governor", "power_plan", "scaling_governor", "cpu_samples", - "provisioning", "predicate_detail") if word in blob] - if copied: - fail("execbinding-references", - f"the binding copies qualification detail {copied}; it must carry references, " - "because two copies of one fact drift") + + moved = write(tmp, "after.json", manifest(kernel="6.9.9")) + with fixed_power(COMPLIANT_POWER): + drifted = hq.session_admissibility(bpath, qpath, ppath, moved, cpath, probe) + if drifted["admissible"]: + fail("hostqual-postflight", "an environment that changed mid-session was admissible") + return + + other = tmp / "other.bin" + other.write_bytes(b"rebuilt candidate") + with fixed_power(COMPLIANT_POWER): + rebuilt = hq.session_admissibility(bpath, qpath, ppath, mpath, other, probe) + if rebuilt["admissible"]: + fail("hostqual-postflight", "a candidate rebuilt mid-session was admissible") return - if binding["linux"]["candidate_bytes"] == binding["windows"]["candidate_bytes"]: - fail("execbinding-references", "the fixture cannot tell the two candidates apart") + + with fixed_power(COMPLIANT_POWER): + missing_probe = hq.session_admissibility(bpath, qpath, ppath, mpath, cpath, + tmp / "absent.json") + if missing_probe["admissible"]: + fail("hostqual-postflight", "an attempt with no closing probe was admissible") return - ok("execbinding-references", - "the binding carries t0, instrument, workloads and one reference block per stratum, " - "and no copy of the qualification's own facts") + ok("hostqual-postflight", + "a separate post-session pass catches identity drift, candidate drift and a missing " + "closing probe; preflight may not certify what a session did after it started") -def control_execbinding_strata() -> None: +def control_one_binding() -> None: with tempfile.TemporaryDirectory() as raw: tmp = Path(raw) - cand = tmp / "c" - cand.write_bytes(b"x") - t0 = tmp / "t0.md" - t0.write_text("T0", encoding="utf-8") - loads = write(tmp, "w.json", {"decisive": []}) - lin = write(tmp, "ql.json", qualification("linux")) - win = write(tmp, "qw.json", qualification("windows")) + pre, (bpath, qpath, mpath, dpath, cpath) = _session(tmp) + ppath = write(tmp, "pre.json", pre) + probe = tmp / "probe.json" + probe.write_text("{}", encoding="utf-8") + second = write(tmp, "b2.json", {**json.loads(bpath.read_text(encoding="utf-8")), + "bound_at": "later"}) + with fixed_power(COMPLIANT_POWER): + mixed = hq.session_admissibility(second, qpath, ppath, mpath, cpath, probe) + if mixed["admissible"]: + fail("hostqual-one-binding", + "a preflight from one binding and a postflight from another were admissible; " + "a changed binding is a different campaign, never a newer one") + return + ok("hostqual-one-binding", + "two execution_binding_sha256 values cannot meet inside one session record") + + +# --- the binding ------------------------------------------------------------- + + +def _instrument_repo(tmp: Path) -> tuple[Path, str, str, str]: + """A throwaway repo carrying a T0 and the two instrument sources.""" + repo = tmp / "repo" + commit = git_repo(repo, {"t0.md": T0_FROZEN, + eb.INSTRUMENT_SOURCES[0]: "print('instrument')\n", + eb.INSTRUMENT_SOURCES[1]: '{"decisive": []}\n'}) + digest = eb.harness_digest_at(repo, commit) + return repo, commit, digest or "", "t0.md" + + +def control_execbinding_proves_inputs() -> None: + with tempfile.TemporaryDirectory() as raw: + tmp = Path(raw) + repo, commit, digest, t0_path = _instrument_repo(tmp) + t0 = eb.t0_at(repo, t0_path, commit) + lin = write(tmp, "ql.json", qualification("linux", t0=t0)) + win = write(tmp, "qw.json", qualification("windows", t0=t0)) + cand_l, cand_w = tmp / "cl", tmp / "cw" + cand_l.write_bytes(b"linux") + cand_w.write_bytes(b"windows-longer") + + binding = eb.build(repo, t0_path, commit, commit, digest, + {"linux": (lin, cand_l), "windows": (win, cand_w)}) + if eb.validate(binding): + fail("execbinding-proves-inputs", f"a good binding was refused: {eb.validate(binding)}") + return + blob = json.dumps(binding) + if any(word in blob for word in ("governor", "power_snapshot", "predicate_detail")): + fail("execbinding-proves-inputs", "the binding copies qualification detail") + return try: - eb.build(t0, "c", "b", "a", "d", loads, {"linux": (lin, cand)}) + eb.build(repo, t0_path, commit, commit, "0" * 64, + {"linux": (lin, cand_l), "windows": (win, cand_w)}) except eb.BindingRefused: pass else: - fail("execbinding-strata", "a campaign with one stratum was bound") + fail("execbinding-proves-inputs", + "a harness digest the sources do not produce was accepted as a string") return - unqualified = write(tmp, "qu.json", qualification("windows", qualified=False)) + open_commit = git_repo(repo, {"t0.md": T0_OPEN}, "reopen") try: - eb.build(t0, "c", "b", "a", "d", loads, - {"linux": (lin, cand), "windows": (unqualified, cand)}) + eb.t0_at(repo, "t0.md", open_commit) except eb.BindingRefused: pass else: - fail("execbinding-strata", "an unqualified host entered a campaign") + fail("execbinding-proves-inputs", "a NOT_FROZEN T0 was bound") return + ok("execbinding-proves-inputs", + "the harness digest is recomputed from the instrument sources at the bound commit by the " + "frozen formula, the manifest comes from the same commit's git object, and a NOT_FROZEN " + "T0 cannot be bound") - mislabelled = write(tmp, "qm.json", - qualification("windows", memory_metric=hq.MEMORY_METRIC_RESIDENT)) + +def control_execbinding_old_t0() -> None: + with tempfile.TemporaryDirectory() as raw: + tmp = Path(raw) + repo, commit, digest, t0_path = _instrument_repo(tmp) + old_t0 = eb.t0_at(repo, t0_path, commit) + newer = git_repo(repo, {"t0.md": T0_FROZEN + "\nAmended.\n"}, "t0 amended") + lin = write(tmp, "ql.json", qualification("linux", t0=old_t0)) + win = write(tmp, "qw.json", qualification("windows", t0=old_t0)) + cand = tmp / "c" + cand.write_bytes(b"x") try: - eb.build(t0, "c", "b", "a", "d", loads, - {"linux": (lin, cand), "windows": (mislabelled, cand)}) - except eb.BindingRefused: - pass + eb.build(repo, t0_path, newer, commit, digest, + {"linux": (lin, cand), "windows": (win, cand)}) + except eb.BindingRefused as exc: + if "qualified against T0" not in str(exc): + fail("execbinding-old-t0", f"refused for the wrong reason: {exc}") + return else: - fail("execbinding-strata", "a Windows stratum carrying the resident metric was bound") + fail("execbinding-old-t0", + "a host qualified under an earlier T0 entered a campaign bound to a newer one") return + ok("execbinding-old-t0", + "a qualification earned under one protocol is not evidence under another") - both_same = eb.build(t0, "c", "b", "a", "d", loads, - {"linux": (lin, cand), "windows": (win, cand)}) - both_same["windows"]["memory_metric"] = hq.MEMORY_METRIC_RESIDENT - if not eb.validate(both_same): - fail("execbinding-strata", "a binding whose strata share one metric validated") + +def control_execbinding_verify_campaign() -> None: + with tempfile.TemporaryDirectory() as raw: + tmp = Path(raw) + repo, commit, digest, t0_path = _instrument_repo(tmp) + t0 = eb.t0_at(repo, t0_path, commit) + lin = write(tmp, "ql.json", qualification("linux", t0=t0)) + win = write(tmp, "qw.json", qualification("windows", t0=t0)) + cand_l, cand_w = tmp / "cl", tmp / "cw" + cand_l.write_bytes(b"linux") + cand_w.write_bytes(b"windows-longer") + binding = eb.build(repo, t0_path, commit, commit, digest, + {"linux": (lin, cand_l), "windows": (win, cand_w)}) + bpath = write(tmp, "binding.json", binding) + quals = {"linux": lin, "windows": win} + cands = {"linux": cand_l, "windows": cand_w} + + if eb.verify(repo, bpath, quals, cands): + fail("execbinding-verify-campaign", + f"a clean campaign failed verification: {eb.verify(repo, bpath, quals, cands)}") + return + + cand_w.write_bytes(b"a replacement binary") + if not any("candidate" in p for p in eb.verify(repo, bpath, quals, cands)): + fail("execbinding-verify-campaign", "a replaced candidate was not caught") + return + cand_w.write_bytes(b"windows-longer") + + write(tmp, "qw.json", qualification("windows", t0=t0, qualified_at="changed")) + if not any("qualification" in p for p in eb.verify(repo, bpath, quals, cands)): + fail("execbinding-verify-campaign", "a changed qualification was not caught") + return + write(tmp, "qw.json", qualification("windows", t0=t0)) + + moved = json.loads(bpath.read_text(encoding="utf-8")) + moved["workloads"]["manifest_sha256"] = "9" * 64 + moved_path = write(tmp, "moved.json", moved) + if not any("workloads" in p for p in eb.verify(repo, moved_path, quals, cands)): + fail("execbinding-verify-campaign", "a changed workload manifest was not caught") + return + + retimed = json.loads(bpath.read_text(encoding="utf-8")) + retimed["t0"]["sha256"] = "7" * 64 + retimed_path = write(tmp, "retimed.json", retimed) + if not any("T0" in p for p in eb.verify(repo, retimed_path, quals, cands)): + fail("execbinding-verify-campaign", "a changed T0 was not caught") return - ok("execbinding-strata", - "both strata are required, an unqualified or mislabelled host is refused, and two " - "strata carrying one metric do not validate") + ok("execbinding-verify-campaign", + "the verifier re-proves T0, instrument digest, workload manifest, both qualifications " + "and both candidates — not two hashes with a confident docstring") def control_execbinding_no_overwrite() -> None: @@ -404,32 +622,78 @@ def control_execbinding_no_overwrite() -> None: tmp = Path(raw) existing = tmp / "binding.json" existing.write_text("{}", encoding="utf-8") - # Captured, not printed: a green run that prints a refusal teaches readers - # to ignore refusals. noise = io.StringIO() with contextlib.redirect_stderr(noise): rc = eb.main(["--emit", str(existing)]) - if "refused" not in noise.getvalue(): - fail("execbinding-no-overwrite", "the refusal was silent") - return - if rc != 2: - fail("execbinding-no-overwrite", - f"emitting over an existing binding returned {rc}, not a refusal") + if rc != 2 or "refused" not in noise.getvalue(): + fail("execbinding-no-overwrite", f"overwriting returned {rc}") return if existing.read_text(encoding="utf-8") != "{}": fail("execbinding-no-overwrite", "the existing binding was modified") return - ok("execbinding-no-overwrite", - "a rebuild before the first clock is legitimate but never silent: the emitter refuses " - "to overwrite and says so") + ok("execbinding-no-overwrite", "a rebuild before the first clock is deliberate, never silent") -def control_tools_do_not_import_harness() -> None: - """The qualification layer does not reach into the frozen instrument. +# --- the template ------------------------------------------------------------ + - Proved by AST rather than by text, because this file's own docstrings name - `perf_baseline` to say the tools do not import it. - """ +def control_provisioning_example() -> None: + if not EXAMPLE.is_file(): + fail("provisioning-example-validates", f"{EXAMPLE} is missing") + return + doc = json.loads(EXAMPLE.read_text(encoding="utf-8")) + problems = hq.validate_provisioning(doc) + if problems: + fail("provisioning-example-validates", + f"the template no longer validates under the rules it teaches: {problems}") + return + if "evidence" in EXAMPLE.parts: + fail("provisioning-example-validates", "the template lives under an evidence directory") + return + marker = json.dumps(doc) + if "EXAMPLE / NOT EVIDENCE" not in marker: + fail("provisioning-example-validates", "the template does not say it is not evidence") + return + if "n/a: " not in marker or "is_vm" not in marker: + fail("provisioning-example-validates", + "the template does not show the n/a-versus-required-boolean rule") + return + ok("provisioning-example-validates", + "the template validates under the same rules, says EXAMPLE / NOT EVIDENCE, shows the " + "physical-host form and explains the VM one, and sits outside docs/evidence/") + + +def control_power_ac_and_dc() -> None: + """Both AC and DC, so a machine cannot be compliant only while plugged in.""" + if hq.check_power_policy(COMPLIANT_POWER)["result"] != "pass": + fail("hostqual-power-ac-and-dc", "a fully compliant snapshot was refused") + return + for label, patch in (("DC minimum below 100", {"processor_min_dc": 5}), + ("DC maximum below 100", {"processor_max_dc": 50}), + ("AC minimum below 100", {"processor_min_ac": 5}), + ("an unaccepted plan", {"plan_guid": "381b4222-f694-41f0-9685-ff5bb260df2e"})): + if hq.check_power_policy({**COMPLIANT_POWER, **patch})["result"] != "fail": + fail("hostqual-power-ac-and-dc", f"{label} was accepted") + return + linux_ok = {"platform": "linux", "governors": {"cpu0": "performance", "cpu1": "performance"}, + "boost": {"mechanism": "cpufreq/boost", "value": "1"}} + if hq.check_power_policy(linux_ok)["result"] != "pass": + fail("hostqual-power-ac-and-dc", "a compliant Linux snapshot was refused") + return + mixed = {**linux_ok, "governors": {"cpu0": "performance", "cpu1": "powersave"}} + if hq.check_power_policy(mixed)["result"] != "fail": + fail("hostqual-power-ac-and-dc", "one CPU on powersave was accepted") + return + unknown_boost = {**linux_ok, "boost": {"mechanism": None, "value": None}} + if hq.check_power_policy(unknown_boost)["result"] != "fail": + fail("hostqual-power-ac-and-dc", "a host with no identifiable turbo mechanism passed") + return + ok("hostqual-power-ac-and-dc", + "Windows needs 100% on AC *and* DC and an accepted plan; Linux needs performance on every " + "CPU and a turbo mechanism that can be named and rechecked") + + +def control_tools_do_not_import_harness() -> None: for name in ("hostqual.py", "execbinding.py"): tree = ast.parse((ROOT / "scripts" / "step7" / name).read_text(encoding="utf-8")) imported: list[str] = [] @@ -443,20 +707,28 @@ def control_tools_do_not_import_harness() -> None: fail("tools-do-not-import-harness", f"{name} imports {reached}") return ok("tools-do-not-import-harness", - "neither tool imports the instrument or the capture tool; the shared vocabulary is " - "held together by a control instead of by coupling") + "neither tool imports the instrument; the digest is recomputed from git objects by the " + "frozen formula rather than by asking the thing under proof") def run() -> int: guarded("hostqual-memory-vocabulary", control_memory_vocabulary) + guarded("hostqual-t0-versioned", control_t0_versioned) guarded("hostqual-ci-predicate", control_ci_predicate) - guarded("hostqual-provisioning-shape", control_provisioning_shape) + guarded("hostqual-provisioning-values", control_provisioning_values) + guarded("hostqual-one-environment-id", control_one_environment_id) + guarded("hostqual-quiesce-window", control_quiesce_window) guarded("hostqual-quiesce-arithmetic", control_quiesce_arithmetic) - guarded("hostqual-not-a-certificate", control_not_a_certificate) - guarded("hostqual-session-binds", control_session_binds_campaign) - guarded("execbinding-references", control_execbinding_references) - guarded("execbinding-strata", control_execbinding_strata) + guarded("hostqual-identity-projection", control_identity_projection) + guarded("hostqual-candidate-at-start", control_candidate_at_start) + guarded("hostqual-postflight", control_postflight) + guarded("hostqual-one-binding", control_one_binding) + guarded("execbinding-proves-inputs", control_execbinding_proves_inputs) + guarded("execbinding-old-t0", control_execbinding_old_t0) + guarded("execbinding-verify-campaign", control_execbinding_verify_campaign) guarded("execbinding-no-overwrite", control_execbinding_no_overwrite) + guarded("provisioning-example-validates", control_provisioning_example) + guarded("hostqual-power-ac-and-dc", control_power_ac_and_dc) guarded("tools-do-not-import-harness", control_tools_do_not_import_harness) print() print(f"step 7 host qualification controls: {len(_PASSES)} passed, {len(_FAILURES)} failed") From 830c241b774fd1910604474b45e6cfc93772c17b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 17:22:29 +0500 Subject: [PATCH 03/15] fix(step7): prove the artifact before reading it, and verify the whole campaign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from the second Phase-2 review, both reproduced before repair. S-A — machine-produced artifacts were consumed as ordinary dicts. A document whose kind was "NOT A BINDING AT ALL" was accepted as the execution binding, and an arbitrary object carrying `identity` and `provenance` was accepted as an environment manifest: the probe returned an ELIGIBLE session from two handwritten files. The asymmetry was exactly backwards — the two DECLARED artifacts were validated by kind and schema, and the three machine ones were not. There is now one boundary. `load_artifact()` parses, proves the artifact by its own validator, and only then hands it over; no consumer reads a field before the type is established, and a refusal raises rather than becoming "not eligible" — an input that is not what it claims is an operator error, not a session that failed a predicate, so it exits 2. The envcapture schema string is duplicated rather than imported, with `hostqual-producer-schema` proving the copy still equals what the capture tool emits. One validator per artifact type, not five call sites: `validate_qualification` now lives in `execbinding` — the module that binds campaigns is the lower one, so `hostqual` reuses it without an import cycle. That immediately paid: the required A4 attack found that binding never checked a qualification's SCHEMA, only its kind. A wrong-schema qualification could be bound. It cannot now. S-B — `--verify` narrowed silently to whatever it was handed. Called without the strata it skipped the qualification and candidate checks and still printed "binding verified". Owner ruling applied: no partial mode under that name. The function refuses incomplete input maps — not only argparse, because the next caller may be a script — and the command line refuses before running anything. The string "binding verified" is now unreachable unless the full set was attempted and passed. P-A — the control inventory is checked by a control. The docstring list and the executed set must be the same set, because this class of defect has now been found twice and a third discovery wearing a new hat is not a surprise worth paying for. step 7 host qualification controls: 22 passed, 0 failed. Regression: envcapture 11/11, perf instrument 16/16, calibration freeze 7/7, training prereg 9/9. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh --- scripts/step7/execbinding.py | 65 +++- scripts/step7/hostqual.py | 180 ++++++++--- tests/test_step7_hostqual.py | 562 +++++++++++++++++++++++------------ 3 files changed, 572 insertions(+), 235 deletions(-) diff --git a/scripts/step7/execbinding.py b/scripts/step7/execbinding.py index 01c23ac0..828ae865 100644 --- a/scripts/step7/execbinding.py +++ b/scripts/step7/execbinding.py @@ -140,11 +140,44 @@ def t0_at(repo: Path, path: str, commit: str) -> dict[str, object]: "sha256": sha256_bytes(raw), "status": declared} +def validate_qualification(doc: object) -> list[str]: + """The one qualification validator. `hostqual` reuses this rather than keeping + a second opinion: the module that binds campaigns is the lower one, so there + is no import cycle and no drift between two copies of the same rules.""" + if not isinstance(doc, dict): + return ["the qualification is not a JSON object"] + problems = [] + if doc.get("kind") != QUALIFICATION_SCHEMA: + problems.append(f"kind is {doc.get('kind')!r}, not {QUALIFICATION_SCHEMA!r}") + if doc.get("schema") != SCHEMA_VERSION: + problems.append(f"schema is {doc.get('schema')!r}, not {SCHEMA_VERSION}") + stratum = doc.get("stratum") + if stratum not in STRATUM_METRIC: + problems.append(f"stratum is {stratum!r}") + elif doc.get("memory_metric") != STRATUM_METRIC[stratum]: + problems.append(f"memory_metric {doc.get('memory_metric')!r} does not belong to " + f"stratum {stratum!r}") + t0 = doc.get("t0") + if not isinstance(t0, dict) or not t0.get("sha256") or not t0.get("commit"): + problems.append("t0 does not name a commit and a sha256") + for key in ("environment_id", "host_fingerprint", "environment_identity_sha256"): + if not doc.get(key): + problems.append(f"{key} is missing") + if not isinstance(doc.get("power_snapshot"), dict): + problems.append("power_snapshot is missing") + if not isinstance(doc.get("qualified"), bool): + problems.append("qualified is not a boolean") + return problems + + def _stratum_block(stratum: str, qualification_path: Path, candidate_path: Path, t0_block: dict) -> dict[str, object]: doc = json.loads(qualification_path.read_text(encoding="utf-8")) - if doc.get("kind") != QUALIFICATION_SCHEMA: - raise BindingRefused(f"{qualification_path} is not a host-qualification artifact") + problems = validate_qualification(doc) + if problems: + raise BindingRefused( + f"{qualification_path} does not validate as a host qualification: " + + "; ".join(problems)) if doc.get("stratum") != stratum: raise BindingRefused( f"{qualification_path} declares stratum {doc.get('stratum')!r}, bound as {stratum!r}") @@ -245,9 +278,18 @@ def verify(repo: Path, binding_path: Path, qualifications: dict[str, Path], candidates: dict[str, Path]) -> list[str]: """Re-prove every bound component that can drift or be substituted. - A verifier whose docstring says campaign identity while it checks two hashes - is a future incident report. + There is no partial mode. A verifier that silently narrows to whatever it was + handed will eventually be called with two arguments missing, and the string + `binding verified` will be filed as evidence that nobody checked the hosts. + Incomplete inputs are refused here, not only in argparse, because the next + caller may be a script. """ + incomplete = ([f"qualification for {s}" for s in STRATA if s not in qualifications] + + [f"candidate for {s}" for s in STRATA if s not in candidates]) + if incomplete: + return [f"full verification requires both qualification and candidate inputs for " + f"linux and windows; missing {incomplete}. There is no partial verification " + f"under this name"] binding = json.loads(binding_path.read_text(encoding="utf-8")) problems = validate(binding) @@ -323,10 +365,17 @@ def main(argv: list[str] | None = None) -> int: return 0 if args.verify: - qualifications = {s: p for s, p in (("linux", args.linux), ("windows", args.windows)) - if p is not None} - candidates = {s: p for s, p in (("linux", args.linux_candidate), - ("windows", args.windows_candidate)) if p is not None} + needed = {"linux": args.linux, "windows": args.windows, + "linux-candidate": args.linux_candidate, + "windows-candidate": args.windows_candidate} + absent = sorted(k for k, v in needed.items() if v is None) + if absent: + # Refusing to run beats running half of it and printing the word + # "verified" over the half that was skipped. + parser.error(f"--verify performs the FULL campaign verification and requires " + f"{['--' + k for k in absent]}") + qualifications = {"linux": args.linux, "windows": args.windows} + candidates = {"linux": args.linux_candidate, "windows": args.windows_candidate} problems = verify(args.repo, args.verify, qualifications, candidates) for problem in problems: print(f"BINDING-DRIFT: {problem}") diff --git a/scripts/step7/hostqual.py b/scripts/step7/hostqual.py index b69fef10..9b017f7a 100644 --- a/scripts/step7/hostqual.py +++ b/scripts/step7/hostqual.py @@ -47,11 +47,17 @@ import time from pathlib import Path +import execbinding as eb # sibling tool: one validator per artifact type, not five call sites + QUALIFICATION_SCHEMA = "own.net/p022/host-qualification" ELIGIBILITY_SCHEMA = "own.net/p022/session-eligibility" ADMISSIBILITY_SCHEMA = "own.net/p022/session-admissibility" PROVISIONING_SCHEMA = "own.net/p022/host-provisioning" DECLARATION_SCHEMA = "own.net/p022/session-declaration" +# The producer's own schema string, duplicated rather than imported so this tool +# stays independent of the capture tool. `hostqual-producer-schema` proves the +# copy still equals what envcapture emits, so the decoupling cannot rot quietly. +ENVCAPTURE_SCHEMA = "p022-263a-step7-environment-identity" SCHEMA_VERSION = 1 # The closed memory vocabulary, declared rather than imported so this tool never @@ -164,6 +170,64 @@ def check(name: str, ok: bool, detail: str) -> dict[str, object]: return {"check": name, "result": "pass" if ok else "fail", "detail": detail} +# --- the boundary: parse bytes, prove the artifact, then read semantics ------ + + +def validate_manifest(doc: object) -> list[str]: + """An environment manifest is what the capture tool emitted, not any JSON + that happens to carry an `identity` key. Inferring a type from the presence + of two keys is how a hand-written file becomes campaign evidence.""" + if not isinstance(doc, dict): + return ["the environment manifest is not a JSON object"] + problems = [] + if doc.get("schema") != ENVCAPTURE_SCHEMA: + problems.append(f"schema is {doc.get('schema')!r}, not {ENVCAPTURE_SCHEMA!r}") + if not isinstance(doc.get("identity"), dict) or not doc.get("identity"): + problems.append("identity is missing or empty") + provenance = doc.get("provenance") + if not isinstance(provenance, dict): + problems.append("provenance is missing") + elif not isinstance(provenance.get("ci"), bool): + problems.append("provenance.ci is missing or not a boolean") + return problems + + +# One validator, owned by the module that binds campaigns, reused here. Two +# copies of the same rules is how a qualification passes one consumer and fails +# the next. +validate_qualification = eb.validate_qualification + + +def validate_binding(doc: object) -> list[str]: + if not isinstance(doc, dict): + return ["the execution binding is not a JSON object"] + problems = [] + if doc.get("kind") != eb.BINDING_SCHEMA: + problems.append(f"kind is {doc.get('kind')!r}, not {eb.BINDING_SCHEMA!r}") + if doc.get("schema") != SCHEMA_VERSION: + problems.append(f"schema is {doc.get('schema')!r}, not {SCHEMA_VERSION}") + return problems + eb.validate(doc) + + +def validate_eligibility(doc: object) -> list[str]: + if not isinstance(doc, dict): + return ["the preflight record is not a JSON object"] + problems = [] + if doc.get("kind") != ELIGIBILITY_SCHEMA: + problems.append(f"kind is {doc.get('kind')!r}, not {ELIGIBILITY_SCHEMA!r}") + if doc.get("schema") != SCHEMA_VERSION: + problems.append(f"schema is {doc.get('schema')!r}, not {SCHEMA_VERSION}") + for key in ("execution_binding_sha256", "qualification_sha256", + "environment_identity_sha256"): + if not doc.get(key): + problems.append(f"{key} is missing") + if not isinstance(doc.get("power_snapshot"), dict): + problems.append("power_snapshot is missing") + if not isinstance(doc.get("eligible"), bool): + problems.append("eligible is not a boolean") + return problems + + # --- T0: a qualification is versioned by the protocol it claims to satisfy --- @@ -259,6 +323,35 @@ def validate_declaration(doc: dict) -> list[str]: return problems +ARTIFACT_VALIDATORS = { + "environment manifest": validate_manifest, + "host qualification": validate_qualification, + "execution binding": validate_binding, + "session eligibility": validate_eligibility, + "host provisioning": validate_provisioning, + "session declaration": validate_declaration, +} + + +def load_artifact(path: Path, kind: str) -> dict: + """Parse, prove, then hand over. + + One validator per artifact type rather than an ad-hoc `if doc.get("kind")` at + five call sites, and no path that reads a field before the type is + established. + """ + validator = ARTIFACT_VALIDATORS[kind] + try: + doc = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + raise QualificationRefused(f"{path} is not readable JSON: {exc}") from exc + problems = validator(doc) + if problems: + raise QualificationRefused( + f"{path} does not validate as a {kind}: " + "; ".join(problems)) + return doc + + def check_single_tenant(provisioning: dict, manifest: dict) -> dict[str, object]: problems = validate_provisioning(provisioning) if problems: @@ -467,9 +560,9 @@ def qualify(stratum: str, repo: Path, t0_path: str, t0_commit: str, provisioning_path: Path, manifest_path: Path) -> dict[str, object]: if stratum not in STRATUM_METRIC: raise QualificationRefused(f"unknown stratum {stratum!r}") - provisioning = json.loads(provisioning_path.read_text(encoding="utf-8")) - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - identity = manifest.get("identity") if isinstance(manifest.get("identity"), dict) else {} + provisioning = load_artifact(provisioning_path, "host provisioning") + manifest = load_artifact(manifest_path, "environment manifest") + identity = manifest["identity"] t0_block, t0_check = bind_t0(repo, t0_path, t0_commit) environment_id = observed(identity, "environment_id") @@ -523,13 +616,13 @@ def _candidate_check(binding_block: dict, candidate_path: Path) -> dict[str, obj def session_eligibility(binding_path: Path, qualification_path: Path, manifest_path: Path, declaration_path: Path, candidate_path: Path, quiesce_result: dict[str, object] | None = None) -> dict[str, object]: - binding = json.loads(binding_path.read_text(encoding="utf-8")) - qualification = json.loads(qualification_path.read_text(encoding="utf-8")) - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - declaration = json.loads(declaration_path.read_text(encoding="utf-8")) - stratum = str(qualification.get("stratum")) - bound = binding.get(stratum) if isinstance(binding.get(stratum), dict) else {} - identity = manifest.get("identity") if isinstance(manifest.get("identity"), dict) else {} + binding = load_artifact(binding_path, "execution binding") + qualification = load_artifact(qualification_path, "host qualification") + manifest = load_artifact(manifest_path, "environment manifest") + load_artifact(declaration_path, "session declaration") + stratum = str(qualification["stratum"]) + bound = binding[stratum] + identity = manifest["identity"] reasons: list[str] = [] if not qualification.get("qualified"): @@ -540,11 +633,6 @@ def session_eligibility(binding_path: Path, qualification_path: Path, manifest_p if bound.get("memory_metric") != qualification.get("memory_metric"): reasons.append("the binding and the qualification disagree about the memory metric") - declaration_problems = validate_declaration(declaration) - if declaration_problems: - reasons.append("the session declaration does not validate: " - + "; ".join(declaration_problems)) - ci = check_ci(manifest) snapshot = power_snapshot() power = check_power_policy(snapshot) @@ -597,13 +685,13 @@ def session_admissibility(binding_path: Path, qualification_path: Path, prefligh did after it started, and an attempt that drifted mid-flight has to be caught by evidence taken after it, not before. """ - binding = json.loads(binding_path.read_text(encoding="utf-8")) - qualification = json.loads(qualification_path.read_text(encoding="utf-8")) - preflight = json.loads(preflight_path.read_text(encoding="utf-8")) - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - stratum = str(qualification.get("stratum")) - bound = binding.get(stratum) if isinstance(binding.get(stratum), dict) else {} - identity = manifest.get("identity") if isinstance(manifest.get("identity"), dict) else {} + binding = load_artifact(binding_path, "execution binding") + qualification = load_artifact(qualification_path, "host qualification") + preflight = load_artifact(preflight_path, "session eligibility") + manifest = load_artifact(manifest_path, "environment manifest") + stratum = str(qualification["stratum"]) + bound = binding[stratum] + identity = manifest["identity"] binding_sha = sha256_file(binding_path) reasons: list[str] = [] @@ -686,26 +774,34 @@ def require(flag: str, names: tuple[str, ...]) -> None: if absent: parser.error(f"--{flag} requires {['--' + n for n in absent]}") - if args.qualify: - require("qualify", ("stratum", "t0-path", "t0-commit", "provisioning", "manifest")) - record = qualify(args.stratum, args.repo, args.t0_path, args.t0_commit, - args.provisioning, args.manifest) - ok = bool(record["qualified"]) - elif args.session_preflight: - require("session-preflight", - ("binding", "qualification", "manifest", "declaration", "candidate")) - record = session_eligibility(args.binding, args.qualification, args.manifest, - args.declaration, args.candidate) - ok = bool(record["eligible"]) - elif args.session_postflight: - require("session-postflight", - ("binding", "qualification", "preflight", "manifest", "candidate", - "closing-probe")) - record = session_admissibility(args.binding, args.qualification, args.preflight, - args.manifest, args.candidate, args.closing_probe) - ok = bool(record["admissible"]) - else: - parser.error("choose --qualify, --session-preflight, --session-postflight or --selftest") + try: + if args.qualify: + require("qualify", ("stratum", "t0-path", "t0-commit", "provisioning", "manifest")) + record = qualify(args.stratum, args.repo, args.t0_path, args.t0_commit, + args.provisioning, args.manifest) + ok = bool(record["qualified"]) + elif args.session_preflight: + require("session-preflight", + ("binding", "qualification", "manifest", "declaration", "candidate")) + record = session_eligibility(args.binding, args.qualification, args.manifest, + args.declaration, args.candidate) + ok = bool(record["eligible"]) + elif args.session_postflight: + require("session-postflight", + ("binding", "qualification", "preflight", "manifest", "candidate", + "closing-probe")) + record = session_admissibility(args.binding, args.qualification, args.preflight, + args.manifest, args.candidate, args.closing_probe) + ok = bool(record["admissible"]) + else: + parser.error("choose --qualify, --session-preflight, --session-postflight " + "or --selftest") + except QualificationRefused as exc: + # An input that is not the artifact it claims to be is an operator error, + # never a session that merely failed a predicate. It gets its own exit + # code so a caller cannot read it as "measured, and not eligible". + print(f"refused: {exc}", file=sys.stderr) + return 2 text = json.dumps(record, indent=2, sort_keys=True, ensure_ascii=False) + "\n" if args.emit: diff --git a/tests/test_step7_hostqual.py b/tests/test_step7_hostqual.py index d94b39aa..a1cac1b4 100644 --- a/tests/test_step7_hostqual.py +++ b/tests/test_step7_hostqual.py @@ -2,6 +2,8 @@ """#263 step 7 — controls on qualification, eligibility, admissibility and binding. hostqual-memory-vocabulary one closed set, three files, no drift + hostqual-producer-schema the duplicated schema string still equals the producer's + hostqual-artifact-boundary every consumed artifact is proved before it is read hostqual-t0-versioned a qualification is versioned by the T0 it claims hostqual-ci-predicate ci == false, not "the field is absent" hostqual-provisioning-values shape AND value; a VM that promises nothing fails @@ -12,11 +14,14 @@ hostqual-candidate-at-start the executable present must be the one bound hostqual-postflight drift during the session is caught after it hostqual-one-binding two campaign identities cannot meet in one session + hostqual-power-ac-and-dc compliant on AC only is not a fixed environment execbinding-proves-inputs T0, digest and manifest are proved, not trusted execbinding-old-t0 a host qualified under another T0 cannot enter execbinding-verify-campaign the verifier checks the campaign, not two hashes + execbinding-verify-is-total there is no partial verification under that name execbinding-no-overwrite a rebuild is deliberate, never silent provisioning-example-validates the template still fits the schema it teaches + control-inventory-complete this list and the executed set are the same set tools-do-not-import-harness qualification never reaches into the instrument Git-dependent controls build a throwaway repository, so T0 and instrument @@ -33,6 +38,7 @@ import contextlib import io import json +import re import subprocess import sys import tempfile @@ -43,6 +49,7 @@ sys.path.insert(0, str(ROOT / "scripts")) sys.path.insert(0, str(ROOT / "scripts" / "step7")) +import envcapture as ec # noqa: E402 import execbinding as eb # noqa: E402 import hostqual as hq # noqa: E402 import perf_baseline as pb # noqa: E402 @@ -70,6 +77,15 @@ def guarded(check: str, control: Callable[[], None]) -> None: fail(check, f"the control raised {type(exc).__name__}: {exc}") +def refuses(call: Callable[[], object]) -> str | None: + """The refusal message, or None when the call went through.""" + try: + call() + except hq.QualificationRefused as exc: + return str(exc) + return None + + # --- fixtures --------------------------------------------------------------- @@ -77,12 +93,15 @@ def guarded(check: str, control: Callable[[], None]) -> None: T0_OPEN = "# T0\n\n```text\nStatus:\n NOT_FROZEN.\n collection_authorized: false\n```\n" # A compliant Windows snapshot, used as a fixture on every platform so the -# Windows rules are driven on Linux too — and so these controls do not depend on -# whatever the machine running them happens to have in its power plan. +# Windows rules are driven on Linux too, and so these controls do not depend on +# whatever the machine running them has in its power plan. COMPLIANT_POWER = {"platform": "windows", "plan_guid": hq.WIN_ACCEPTED_PLANS[0], "processor_min_ac": 100, "processor_max_ac": 100, "processor_min_dc": 100, "processor_max_dc": 100} +LINUX_CANDIDATE = b"linux candidate bytes" +WINDOWS_CANDIDATE = b"windows candidate, a different length" + @contextlib.contextmanager def fixed_power(snapshot: dict): @@ -96,8 +115,10 @@ def fixed_power(snapshot: dict): def git_repo(tmp: Path, files: dict[str, str], message: str = "fixture") -> str: tmp.mkdir(parents=True, exist_ok=True) + def run(*args: str) -> subprocess.CompletedProcess: return subprocess.run(["git", "-C", str(tmp), *args], capture_output=True, check=True) + if not (tmp / ".git").exists(): run("init", "-q") run("config", "user.email", "control@example.invalid") @@ -113,8 +134,9 @@ def run(*args: str) -> subprocess.CompletedProcess: def manifest(ci: bool = False, fingerprint: str = "sha256:abc", environment_id: str = "env-1", - kernel: str = "6.1.0") -> dict: + kernel: str = "6.1.0", schema: str | None = None) -> dict: return { + "schema": hq.ENVCAPTURE_SCHEMA if schema is None else schema, "identity": { "environment_id": {"status": "observed", "value": environment_id}, "host_fingerprint": {"status": "observed", "value": fingerprint}, @@ -158,11 +180,15 @@ def declaration(**overrides: object) -> dict: return doc +def t0_stub() -> dict: + return {"commit": "c" * 40, "path": "t0.md", "blob_sha": "b" * 40, + "sha256": "f" * 64, "status": "FROZEN"} + + def qualification(stratum: str = "linux", t0: dict | None = None, **overrides) -> dict: doc: dict[str, object] = { - "kind": "own.net/p022/host-qualification", "schema": 1, "stratum": stratum, - "t0": t0 or {"commit": "c" * 40, "path": "t0.md", "blob_sha": "b" * 40, - "sha256": "f" * 64, "status": "FROZEN"}, + "kind": hq.QUALIFICATION_SCHEMA, "schema": 1, "stratum": stratum, + "t0": t0 or t0_stub(), "environment_id": "env-1", "host_fingerprint": "sha256:abc", "environment_identity_sha256": hq.canonical_sha256(manifest()["identity"]), "provisioning": {"sha256": "0" * 64}, @@ -176,6 +202,33 @@ def qualification(stratum: str = "linux", t0: dict | None = None, **overrides) - return doc +def binding_doc(linux_qual_sha: str, windows_qual_sha: str, t0: dict | None = None, + linux_candidate: bytes = LINUX_CANDIDATE, + windows_candidate: bytes = WINDOWS_CANDIDATE) -> dict: + """A structurally complete binding, so a control that means to attack one + field is not passing because the whole document was malformed.""" + block = t0 or t0_stub() + return { + "kind": eb.BINDING_SCHEMA, "schema": 1, + "t0": block, + "instrument": {"accepted_commit": "a" * 40, "harness_digest": "d" * 64}, + "workloads": {"path": eb.WORKLOAD_MANIFEST, "manifest_sha256": "9" * 64}, + "linux": {"qualification_sha256": linux_qual_sha, "environment_id": "env-1", + "host_fingerprint": "sha256:abc", + "environment_identity_sha256": hq.canonical_sha256(manifest()["identity"]), + "candidate_sha256": hq.sha256_bytes(linux_candidate), + "candidate_bytes": len(linux_candidate), + "memory_metric": hq.STRATUM_METRIC["linux"]}, + "windows": {"qualification_sha256": windows_qual_sha, "environment_id": "env-2", + "host_fingerprint": "sha256:def", + "environment_identity_sha256": "e" * 64, + "candidate_sha256": hq.sha256_bytes(windows_candidate), + "candidate_bytes": len(windows_candidate), + "memory_metric": hq.STRATUM_METRIC["windows"]}, + "bound_at": "2026-09-16T00:00:00+00:00", + } + + def write(tmp: Path, name: str, doc: dict) -> Path: path = tmp / name path.write_text(json.dumps(doc, indent=2), encoding="utf-8") @@ -187,7 +240,22 @@ def steady(n: int = hq.QUIESCE_INTERVALS): return lambda: next(series, None) -# --- vocabulary and T0 ------------------------------------------------------- +def session_fixture(tmp: Path, *, fresh: dict | None = None, + candidate: bytes = LINUX_CANDIDATE, qual: dict | None = None): + qpath = write(tmp, "q.json", qual or qualification()) + wpath = write(tmp, "qw.json", qualification("windows")) + bpath = write(tmp, "b.json", binding_doc(hq.sha256_file(qpath), hq.sha256_file(wpath))) + mpath = write(tmp, "m.json", fresh or manifest()) + dpath = write(tmp, "d.json", declaration()) + cpath = tmp / "cand.bin" + cpath.write_bytes(candidate) + quiet = {"eligible": True, "reason": "", "samples": [0.01], "mean": 0.01, "max": 0.01} + with fixed_power(COMPLIANT_POWER): + record = hq.session_eligibility(bpath, qpath, mpath, dpath, cpath, quiesce_result=quiet) + return record, (bpath, qpath, mpath, dpath, cpath) + + +# --- vocabulary, producer schema and the boundary --------------------------- def control_memory_vocabulary() -> None: @@ -204,8 +272,103 @@ def control_memory_vocabulary() -> None: "the closed set is identical in hostqual, execbinding and the instrument") +def control_producer_schema() -> None: + """The duplicated constant is allowed; drifting from the producer is not.""" + if hq.ENVCAPTURE_SCHEMA != ec.SCHEMA: + fail("hostqual-producer-schema", + f"hostqual expects {hq.ENVCAPTURE_SCHEMA!r}, the capture tool emits {ec.SCHEMA!r}; " + "every real manifest would be refused, or worse, a stale one accepted") + return + ok("hostqual-producer-schema", + f"{hq.ENVCAPTURE_SCHEMA!r} is exactly what envcapture emits, so decoupling the tools " + "did not decouple their agreement") + + +def control_artifact_boundary() -> None: + """Every consumed artifact is proved to BE that artifact before it is read.""" + with tempfile.TemporaryDirectory() as raw: + tmp = Path(raw) + good_q = write(tmp, "q.json", qualification()) + good_w = write(tmp, "qw.json", qualification("windows")) + good_b = write(tmp, "b.json", binding_doc(hq.sha256_file(good_q), + hq.sha256_file(good_w))) + good_m = write(tmp, "m.json", manifest()) + good_d = write(tmp, "d.json", declaration()) + cand = tmp / "c.bin" + cand.write_bytes(LINUX_CANDIDATE) + quiet = {"eligible": True, "reason": "", "samples": [0.01], "mean": 0.01, "max": 0.01} + + attacks = [ + ("fake execution-binding kind", + write(tmp, "b-kind.json", {**binding_doc(hq.sha256_file(good_q), + hq.sha256_file(good_w)), + "kind": "NOT A BINDING AT ALL"}), "binding"), + ("wrong execution-binding schema", + write(tmp, "b-schema.json", {**binding_doc(hq.sha256_file(good_q), + hq.sha256_file(good_w)), + "schema": 99}), "binding"), + ("fake envcapture schema", + write(tmp, "m-kind.json", manifest(schema="totally-made-up")), "manifest"), + ("missing envcapture schema", + write(tmp, "m-none.json", {k: v for k, v in manifest().items() if k != "schema"}), + "manifest"), + ("handwritten identity/provenance only", + write(tmp, "m-hand.json", {"identity": manifest()["identity"], + "provenance": {"ci": False}}), "manifest"), + ("fake qualification kind", + write(tmp, "q-kind.json", {**qualification(), "kind": "something else"}), "qual"), + ("wrong qualification schema", + write(tmp, "q-schema.json", {**qualification(), "schema": 7}), "qual"), + ] + for label, path, slot in attacks: + binding = path if slot == "binding" else good_b + qual = path if slot == "qual" else good_q + fresh = path if slot == "manifest" else good_m + with fixed_power(COMPLIANT_POWER): + message = refuses(lambda: hq.session_eligibility( + binding, qual, fresh, good_d, cand, quiesce_result=quiet)) + if message is None: + fail("hostqual-artifact-boundary", f"{label} was consumed as a real artifact") + return + + # The old hole, reproduced exactly: a handwritten binding-like JSON plus a + # handwritten identity/provenance JSON must not produce an eligible session. + forged_b = write(tmp, "forged.json", {"kind": "NOT A BINDING AT ALL", + "linux": {"qualification_sha256": + hq.sha256_file(good_q), + "memory_metric": + hq.STRATUM_METRIC["linux"], + "candidate_sha256": + hq.sha256_bytes(LINUX_CANDIDATE), + "candidate_bytes": len(LINUX_CANDIDATE)}}) + forged_m = write(tmp, "forged-m.json", {"identity": manifest()["identity"], + "provenance": {"ci": False}}) + with fixed_power(COMPLIANT_POWER): + message = refuses(lambda: hq.session_eligibility(forged_b, good_q, forged_m, good_d, + cand, quiesce_result=quiet)) + if message is None: + fail("hostqual-artifact-boundary", + "the original hole is open: a handwritten binding and a handwritten manifest " + "produced a session") + return + + # Postflight refuses a preflight record that is not one. + pre, _ = session_fixture(tmp) + bad_pre = write(tmp, "pre-kind.json", {**pre, "kind": "not a preflight"}) + probe = tmp / "probe.json" + probe.write_text("{}", encoding="utf-8") + with fixed_power(COMPLIANT_POWER): + message = refuses(lambda: hq.session_admissibility(good_b, good_q, bad_pre, good_m, + cand, probe)) + if message is None: + fail("hostqual-artifact-boundary", "a fake preflight record was consumed") + return + ok("hostqual-artifact-boundary", + "binding, qualification, manifest and preflight are each proved by kind and schema before " + "a single field is read, and the original handwritten-JSON hole is closed") + + def control_t0_versioned() -> None: - """A qualification claims to satisfy T0-7, so it cannot float free of T0.""" with tempfile.TemporaryDirectory() as raw: tmp = Path(raw) repo = tmp / "repo" @@ -224,12 +387,10 @@ def control_t0_versioned() -> None: if not block.get(field): fail("hostqual-t0-versioned", f"the bound T0 block has no {field}") return - missing_commit = hq.bind_t0(repo, "t0.md", "0" * 40)[1] - if missing_commit["result"] != "fail": + if hq.bind_t0(repo, "t0.md", "0" * 40)[1]["result"] != "fail": fail("hostqual-t0-versioned", "a nonexistent commit was accepted") return - wrong_path = hq.bind_t0(repo, "nope.md", frozen_commit)[1] - if wrong_path["result"] != "fail": + if hq.bind_t0(repo, "nope.md", frozen_commit)[1]["result"] != "fail": fail("hostqual-t0-versioned", "a path absent at that commit was accepted") return ok("hostqual-t0-versioned", @@ -237,9 +398,6 @@ def control_t0_versioned() -> None: "and NOT_FROZEN refuses: reconnaissance cannot become qualification by reuse") -# --- declared evidence ------------------------------------------------------- - - def control_ci_predicate() -> None: if hq.check_ci(manifest(ci=False))["result"] != "pass": fail("hostqual-ci-predicate", "ci false was refused") @@ -261,14 +419,12 @@ def control_ci_predicate() -> None: def control_provisioning_values() -> None: - """Shape was never the question. The values are the declaration.""" if hq.validate_provisioning(provisioning()): fail("hostqual-provisioning-values", "a valid physical-host declaration was refused") return if hq.validate_provisioning(vm_provisioning()): fail("hostqual-provisioning-values", "a valid VM declaration was refused") return - cases = [ ("dedicated_to_p022 false", provisioning(dedicated_to_p022=False)), ("no_concurrent_user_workload false", provisioning(no_concurrent_user_workload=False)), @@ -296,49 +452,36 @@ def control_provisioning_values() -> None: def control_one_environment_id() -> None: - mismatch = hq.check_single_tenant(provisioning(environment_id="env-other"), manifest()) - if mismatch["result"] != "fail": + if hq.check_single_tenant(provisioning(environment_id="env-other"), + manifest())["result"] != "fail": fail("hostqual-one-environment-id", "a declaration naming another environment qualified this one") return - agree = hq.check_single_tenant(provisioning(), manifest()) - if agree["result"] != "pass": - fail("hostqual-one-environment-id", f"agreeing identities were refused: {agree}") + if hq.check_single_tenant(provisioning(), manifest())["result"] != "pass": + fail("hostqual-one-environment-id", "agreeing identities were refused") return source = (ROOT / "scripts" / "step7" / "hostqual.py").read_text(encoding="utf-8") if "--environment-id" in source: - fail("hostqual-one-environment-id", - "the tool still accepts an independent --environment-id; that is a third string " - "that only happens to agree while everyone behaves") + fail("hostqual-one-environment-id", "the tool still accepts an independent id argument") return ok("hostqual-one-environment-id", - "the id is the manifest's observed value, the declaration must agree with it, and there " - "is no third source to disagree with either") - - -# --- quiesce ----------------------------------------------------------------- + "the id is the manifest's observed value and the declaration must agree with it") def control_quiesce_window() -> None: - """120 s means 120 s. A constant that nobody waits for is documentation.""" slept: list[float] = [] hq.quiesce(sleep=slept.append, counters=steady()) - total = sum(slept) if not slept or slept[0] != hq.QUIESCE_QUIET_S: - fail("hostqual-quiesce-window", - f"the quiet period was {slept[:1]}, expected a first wait of {hq.QUIESCE_QUIET_S}s") + fail("hostqual-quiesce-window", f"the quiet period was {slept[:1]}") return - if total != hq.QUIESCE_WINDOW_S: - fail("hostqual-quiesce-window", - f"the window lasted {total}s, not {hq.QUIESCE_WINDOW_S}s; sampling the final " - "minute immediately turns the other minute into a comment") + if sum(slept) != hq.QUIESCE_WINDOW_S: + fail("hostqual-quiesce-window", f"the window lasted {sum(slept)}s") return - measured = sum(slept[1:]) - if measured != hq.QUIESCE_INTERVAL_S * hq.QUIESCE_INTERVALS: - fail("hostqual-quiesce-window", f"the measured part lasted {measured}s") + if sum(slept[1:]) != hq.QUIESCE_INTERVAL_S * hq.QUIESCE_INTERVALS: + fail("hostqual-quiesce-window", f"the measured part lasted {sum(slept[1:])}s") return ok("hostqual-quiesce-window", - f"{hq.QUIESCE_QUIET_S}s waited quietly, then {hq.QUIESCE_INTERVALS} intervals of " + f"{hq.QUIESCE_QUIET_S}s waited quietly, then {hq.QUIESCE_INTERVALS} x " f"{hq.QUIESCE_INTERVAL_S}s = {hq.QUIESCE_WINDOW_S}s in total") @@ -365,40 +508,16 @@ def counters(series): "each refuse rather than skip") -# --- session identity -------------------------------------------------------- - - -def _session(tmp: Path, *, fresh: dict | None = None, candidate: bytes = b"candidate", - bound_candidate: bytes = b"candidate", qual: dict | None = None): - qualification_doc = qual or qualification() - qpath = write(tmp, "q.json", qualification_doc) - binding = {"linux": {"qualification_sha256": hq.sha256_file(qpath), - "memory_metric": hq.STRATUM_METRIC["linux"], - "candidate_sha256": hq.sha256_bytes(bound_candidate), - "candidate_bytes": len(bound_candidate)}} - bpath = write(tmp, "b.json", binding) - mpath = write(tmp, "m.json", fresh or manifest()) - dpath = write(tmp, "d.json", declaration()) - cpath = tmp / "cand.bin" - cpath.write_bytes(candidate) - quiet = {"eligible": True, "reason": "", "samples": [0.01], "mean": 0.01, "max": 0.01} - with fixed_power(COMPLIANT_POWER): - record = hq.session_eligibility(bpath, qpath, mpath, dpath, cpath, quiesce_result=quiet) - return record, (bpath, qpath, mpath, dpath, cpath) - - def control_identity_projection() -> None: with tempfile.TemporaryDirectory() as raw: tmp = Path(raw) - good, _ = _session(tmp) + good, _ = session_fixture(tmp) if not good["eligible"]: fail("hostqual-identity-projection", f"a clean session was refused: {good['reasons']}") return - drifted, _ = _session(tmp, fresh=manifest(kernel="6.2.0")) + drifted, _ = session_fixture(tmp, fresh=manifest(kernel="6.2.0")) if drifted["eligible"]: - fail("hostqual-identity-projection", - "a manifest whose kernel changed still preflighted; only host_fingerprint was " - "being compared and the rest of the identity set walked through") + fail("hostqual-identity-projection", "a manifest whose kernel changed preflighted") return if not any("identity" in r for r in drifted["reasons"]): fail("hostqual-identity-projection", f"refused for the wrong reason: {drifted}") @@ -411,7 +530,7 @@ def control_identity_projection() -> None: def control_candidate_at_start() -> None: with tempfile.TemporaryDirectory() as raw: tmp = Path(raw) - swapped, _ = _session(tmp, candidate=b"a different executable") + swapped, _ = session_fixture(tmp, candidate=b"a different executable") if swapped["eligible"]: fail("hostqual-candidate-at-start", "a substituted candidate preflighted") return @@ -426,107 +545,138 @@ def control_candidate_at_start() -> None: def control_postflight() -> None: with tempfile.TemporaryDirectory() as raw: tmp = Path(raw) - pre, (bpath, qpath, mpath, dpath, cpath) = _session(tmp) + pre, (bpath, qpath, mpath, dpath, cpath) = session_fixture(tmp) ppath = write(tmp, "pre.json", pre) probe = tmp / "probe.json" probe.write_text("{}", encoding="utf-8") - with fixed_power(COMPLIANT_POWER): clean = hq.session_admissibility(bpath, qpath, ppath, mpath, cpath, probe) - if not clean["admissible"]: - fail("hostqual-postflight", f"a clean attempt was refused: {clean['reasons']}") - return - - moved = write(tmp, "after.json", manifest(kernel="6.9.9")) - with fixed_power(COMPLIANT_POWER): - drifted = hq.session_admissibility(bpath, qpath, ppath, moved, cpath, probe) - if drifted["admissible"]: - fail("hostqual-postflight", "an environment that changed mid-session was admissible") - return - - other = tmp / "other.bin" - other.write_bytes(b"rebuilt candidate") - with fixed_power(COMPLIANT_POWER): - rebuilt = hq.session_admissibility(bpath, qpath, ppath, mpath, other, probe) - if rebuilt["admissible"]: - fail("hostqual-postflight", "a candidate rebuilt mid-session was admissible") - return - - with fixed_power(COMPLIANT_POWER): - missing_probe = hq.session_admissibility(bpath, qpath, ppath, mpath, cpath, - tmp / "absent.json") - if missing_probe["admissible"]: - fail("hostqual-postflight", "an attempt with no closing probe was admissible") - return + if not clean["admissible"]: + fail("hostqual-postflight", f"a clean attempt was refused: {clean['reasons']}") + return + moved = write(tmp, "after.json", manifest(kernel="6.9.9")) + if hq.session_admissibility(bpath, qpath, ppath, moved, cpath, + probe)["admissible"]: + fail("hostqual-postflight", "an environment that changed mid-session passed") + return + other = tmp / "other.bin" + other.write_bytes(b"rebuilt candidate") + if hq.session_admissibility(bpath, qpath, ppath, mpath, other, + probe)["admissible"]: + fail("hostqual-postflight", "a candidate rebuilt mid-session passed") + return + if hq.session_admissibility(bpath, qpath, ppath, mpath, cpath, + tmp / "absent.json")["admissible"]: + fail("hostqual-postflight", "an attempt with no closing probe passed") + return + with fixed_power({**COMPLIANT_POWER, "processor_max_ac": 50}): + if hq.session_admissibility(bpath, qpath, ppath, mpath, cpath, + probe)["admissible"]: + fail("hostqual-postflight", "power that changed during the session passed") + return ok("hostqual-postflight", - "a separate post-session pass catches identity drift, candidate drift and a missing " - "closing probe; preflight may not certify what a session did after it started") + "a separate post-session pass catches identity drift, candidate drift, power drift and a " + "missing closing probe; preflight may not certify what a session did after it started") def control_one_binding() -> None: with tempfile.TemporaryDirectory() as raw: tmp = Path(raw) - pre, (bpath, qpath, mpath, dpath, cpath) = _session(tmp) + pre, (bpath, qpath, mpath, dpath, cpath) = session_fixture(tmp) ppath = write(tmp, "pre.json", pre) probe = tmp / "probe.json" probe.write_text("{}", encoding="utf-8") - second = write(tmp, "b2.json", {**json.loads(bpath.read_text(encoding="utf-8")), - "bound_at": "later"}) + original = json.loads(bpath.read_text(encoding="utf-8")) + second = write(tmp, "b2.json", {**original, "bound_at": "a later moment"}) with fixed_power(COMPLIANT_POWER): mixed = hq.session_admissibility(second, qpath, ppath, mpath, cpath, probe) if mixed["admissible"]: fail("hostqual-one-binding", - "a preflight from one binding and a postflight from another were admissible; " - "a changed binding is a different campaign, never a newer one") + "a preflight from one binding and a postflight from another were admissible") return ok("hostqual-one-binding", "two execution_binding_sha256 values cannot meet inside one session record") +def control_power_ac_and_dc() -> None: + if hq.check_power_policy(COMPLIANT_POWER)["result"] != "pass": + fail("hostqual-power-ac-and-dc", "a fully compliant snapshot was refused") + return + for label, patch in (("DC minimum below 100", {"processor_min_dc": 5}), + ("DC maximum below 100", {"processor_max_dc": 50}), + ("AC minimum below 100", {"processor_min_ac": 5}), + ("an unaccepted plan", + {"plan_guid": "381b4222-f694-41f0-9685-ff5bb260df2e"})): + if hq.check_power_policy({**COMPLIANT_POWER, **patch})["result"] != "fail": + fail("hostqual-power-ac-and-dc", f"{label} was accepted") + return + linux_ok = {"platform": "linux", "governors": {"cpu0": "performance", "cpu1": "performance"}, + "boost": {"mechanism": "cpufreq/boost", "value": "1"}} + if hq.check_power_policy(linux_ok)["result"] != "pass": + fail("hostqual-power-ac-and-dc", "a compliant Linux snapshot was refused") + return + if hq.check_power_policy({**linux_ok, + "governors": {"cpu0": "performance", + "cpu1": "powersave"}})["result"] != "fail": + fail("hostqual-power-ac-and-dc", "one CPU on powersave was accepted") + return + if hq.check_power_policy({**linux_ok, + "boost": {"mechanism": None, "value": None}})["result"] != "fail": + fail("hostqual-power-ac-and-dc", "a host with no identifiable turbo mechanism passed") + return + ok("hostqual-power-ac-and-dc", + "Windows needs 100% on AC *and* DC and an accepted plan; Linux needs performance on every " + "CPU and a turbo mechanism that can be named and rechecked") + + # --- the binding ------------------------------------------------------------- -def _instrument_repo(tmp: Path) -> tuple[Path, str, str, str]: - """A throwaway repo carrying a T0 and the two instrument sources.""" +def instrument_repo(tmp: Path) -> tuple[Path, str, str, str]: repo = tmp / "repo" commit = git_repo(repo, {"t0.md": T0_FROZEN, eb.INSTRUMENT_SOURCES[0]: "print('instrument')\n", eb.INSTRUMENT_SOURCES[1]: '{"decisive": []}\n'}) - digest = eb.harness_digest_at(repo, commit) - return repo, commit, digest or "", "t0.md" + return repo, commit, eb.harness_digest_at(repo, commit) or "", "t0.md" def control_execbinding_proves_inputs() -> None: with tempfile.TemporaryDirectory() as raw: tmp = Path(raw) - repo, commit, digest, t0_path = _instrument_repo(tmp) + repo, commit, digest, t0_path = instrument_repo(tmp) t0 = eb.t0_at(repo, t0_path, commit) lin = write(tmp, "ql.json", qualification("linux", t0=t0)) win = write(tmp, "qw.json", qualification("windows", t0=t0)) cand_l, cand_w = tmp / "cl", tmp / "cw" - cand_l.write_bytes(b"linux") - cand_w.write_bytes(b"windows-longer") + cand_l.write_bytes(LINUX_CANDIDATE) + cand_w.write_bytes(WINDOWS_CANDIDATE) binding = eb.build(repo, t0_path, commit, commit, digest, {"linux": (lin, cand_l), "windows": (win, cand_w)}) if eb.validate(binding): fail("execbinding-proves-inputs", f"a good binding was refused: {eb.validate(binding)}") return - blob = json.dumps(binding) - if any(word in blob for word in ("governor", "power_snapshot", "predicate_detail")): + if any(word in json.dumps(binding) + for word in ("governor", "power_snapshot", "predicate_detail")): fail("execbinding-proves-inputs", "the binding copies qualification detail") return - try: eb.build(repo, t0_path, commit, commit, "0" * 64, {"linux": (lin, cand_l), "windows": (win, cand_w)}) except eb.BindingRefused: pass else: - fail("execbinding-proves-inputs", - "a harness digest the sources do not produce was accepted as a string") + fail("execbinding-proves-inputs", "a digest the sources do not produce was accepted") + return + wrong_schema = write(tmp, "qbad.json", {**qualification("windows", t0=t0), "schema": 9}) + try: + eb.build(repo, t0_path, commit, commit, digest, + {"linux": (lin, cand_l), "windows": (wrong_schema, cand_w)}) + except eb.BindingRefused: + pass + else: + fail("execbinding-proves-inputs", "a qualification with the wrong schema was bound") return - open_commit = git_repo(repo, {"t0.md": T0_OPEN}, "reopen") try: eb.t0_at(repo, "t0.md", open_commit) @@ -537,14 +687,14 @@ def control_execbinding_proves_inputs() -> None: return ok("execbinding-proves-inputs", "the harness digest is recomputed from the instrument sources at the bound commit by the " - "frozen formula, the manifest comes from the same commit's git object, and a NOT_FROZEN " - "T0 cannot be bound") + "frozen formula, the manifest comes from that commit's git object, and a wrong-schema " + "qualification or a NOT_FROZEN T0 cannot be bound") def control_execbinding_old_t0() -> None: with tempfile.TemporaryDirectory() as raw: tmp = Path(raw) - repo, commit, digest, t0_path = _instrument_repo(tmp) + repo, commit, digest, t0_path = instrument_repo(tmp) old_t0 = eb.t0_at(repo, t0_path, commit) newer = git_repo(repo, {"t0.md": T0_FROZEN + "\nAmended.\n"}, "t0 amended") lin = write(tmp, "ql.json", qualification("linux", t0=old_t0)) @@ -559,8 +709,7 @@ def control_execbinding_old_t0() -> None: fail("execbinding-old-t0", f"refused for the wrong reason: {exc}") return else: - fail("execbinding-old-t0", - "a host qualified under an earlier T0 entered a campaign bound to a newer one") + fail("execbinding-old-t0", "a host qualified under an earlier T0 entered a campaign") return ok("execbinding-old-t0", "a qualification earned under one protocol is not evidence under another") @@ -569,29 +718,28 @@ def control_execbinding_old_t0() -> None: def control_execbinding_verify_campaign() -> None: with tempfile.TemporaryDirectory() as raw: tmp = Path(raw) - repo, commit, digest, t0_path = _instrument_repo(tmp) + repo, commit, digest, t0_path = instrument_repo(tmp) t0 = eb.t0_at(repo, t0_path, commit) lin = write(tmp, "ql.json", qualification("linux", t0=t0)) win = write(tmp, "qw.json", qualification("windows", t0=t0)) cand_l, cand_w = tmp / "cl", tmp / "cw" - cand_l.write_bytes(b"linux") - cand_w.write_bytes(b"windows-longer") - binding = eb.build(repo, t0_path, commit, commit, digest, - {"linux": (lin, cand_l), "windows": (win, cand_w)}) - bpath = write(tmp, "binding.json", binding) + cand_l.write_bytes(LINUX_CANDIDATE) + cand_w.write_bytes(WINDOWS_CANDIDATE) + bpath = write(tmp, "binding.json", + eb.build(repo, t0_path, commit, commit, digest, + {"linux": (lin, cand_l), "windows": (win, cand_w)})) quals = {"linux": lin, "windows": win} cands = {"linux": cand_l, "windows": cand_w} - if eb.verify(repo, bpath, quals, cands): fail("execbinding-verify-campaign", - f"a clean campaign failed verification: {eb.verify(repo, bpath, quals, cands)}") + f"a clean campaign failed: {eb.verify(repo, bpath, quals, cands)}") return cand_w.write_bytes(b"a replacement binary") if not any("candidate" in p for p in eb.verify(repo, bpath, quals, cands)): fail("execbinding-verify-campaign", "a replaced candidate was not caught") return - cand_w.write_bytes(b"windows-longer") + cand_w.write_bytes(WINDOWS_CANDIDATE) write(tmp, "qw.json", qualification("windows", t0=t0, qualified_at="changed")) if not any("qualification" in p for p in eb.verify(repo, bpath, quals, cands)): @@ -601,20 +749,66 @@ def control_execbinding_verify_campaign() -> None: moved = json.loads(bpath.read_text(encoding="utf-8")) moved["workloads"]["manifest_sha256"] = "9" * 64 - moved_path = write(tmp, "moved.json", moved) - if not any("workloads" in p for p in eb.verify(repo, moved_path, quals, cands)): + if not any("workloads" in p + for p in eb.verify(repo, write(tmp, "moved.json", moved), quals, cands)): fail("execbinding-verify-campaign", "a changed workload manifest was not caught") return - retimed = json.loads(bpath.read_text(encoding="utf-8")) retimed["t0"]["sha256"] = "7" * 64 - retimed_path = write(tmp, "retimed.json", retimed) - if not any("T0" in p for p in eb.verify(repo, retimed_path, quals, cands)): + if not any("T0" in p + for p in eb.verify(repo, write(tmp, "retimed.json", retimed), quals, cands)): fail("execbinding-verify-campaign", "a changed T0 was not caught") return ok("execbinding-verify-campaign", "the verifier re-proves T0, instrument digest, workload manifest, both qualifications " - "and both candidates — not two hashes with a confident docstring") + "and both candidates") + + +def control_execbinding_verify_is_total() -> None: + """No partial mode under this name, in the API or at the command line.""" + with tempfile.TemporaryDirectory() as raw: + tmp = Path(raw) + repo, commit, digest, t0_path = instrument_repo(tmp) + t0 = eb.t0_at(repo, t0_path, commit) + lin = write(tmp, "ql.json", qualification("linux", t0=t0)) + win = write(tmp, "qw.json", qualification("windows", t0=t0)) + cand_l, cand_w = tmp / "cl", tmp / "cw" + cand_l.write_bytes(LINUX_CANDIDATE) + cand_w.write_bytes(WINDOWS_CANDIDATE) + bpath = write(tmp, "binding.json", + eb.build(repo, t0_path, commit, commit, digest, + {"linux": (lin, cand_l), "windows": (win, cand_w)})) + + partial_calls = [ + ("no inputs at all", {}, {}), + ("only Linux inputs", {"linux": lin}, {"linux": cand_l}), + ("both qualifications, one candidate", {"linux": lin, "windows": win}, + {"linux": cand_l}), + ] + for label, quals, cands in partial_calls: + problems = eb.verify(repo, bpath, quals, cands) + if not problems: + fail("execbinding-verify-is-total", f"{label} verified clean") + return + if not any("full verification requires" in p for p in problems): + fail("execbinding-verify-is-total", + f"{label} produced findings instead of a refusal: {problems}") + return + + out = io.StringIO() + err = io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + try: + rc = eb.main(["--verify", str(bpath), "--repo", str(repo), "--linux", str(lin)]) + except SystemExit as exc: # argparse refuses before running anything + rc = int(exc.code or 0) + if rc == 0 or "binding verified" in out.getvalue(): + fail("execbinding-verify-is-total", + "the command line accepted an incomplete verify") + return + ok("execbinding-verify-is-total", + "an incomplete verify is refused by the function and by the command line; the string " + "'binding verified' cannot be produced over a skipped component") def control_execbinding_no_overwrite() -> None: @@ -634,7 +828,7 @@ def control_execbinding_no_overwrite() -> None: ok("execbinding-no-overwrite", "a rebuild before the first clock is deliberate, never silent") -# --- the template ------------------------------------------------------------ +# --- the template and this file itself --------------------------------------- def control_provisioning_example() -> None: @@ -663,34 +857,22 @@ def control_provisioning_example() -> None: "physical-host form and explains the VM one, and sits outside docs/evidence/") -def control_power_ac_and_dc() -> None: - """Both AC and DC, so a machine cannot be compliant only while plugged in.""" - if hq.check_power_policy(COMPLIANT_POWER)["result"] != "pass": - fail("hostqual-power-ac-and-dc", "a fully compliant snapshot was refused") - return - for label, patch in (("DC minimum below 100", {"processor_min_dc": 5}), - ("DC maximum below 100", {"processor_max_dc": 50}), - ("AC minimum below 100", {"processor_min_ac": 5}), - ("an unaccepted plan", {"plan_guid": "381b4222-f694-41f0-9685-ff5bb260df2e"})): - if hq.check_power_policy({**COMPLIANT_POWER, **patch})["result"] != "fail": - fail("hostqual-power-ac-and-dc", f"{label} was accepted") - return - linux_ok = {"platform": "linux", "governors": {"cpu0": "performance", "cpu1": "performance"}, - "boost": {"mechanism": "cpufreq/boost", "value": "1"}} - if hq.check_power_policy(linux_ok)["result"] != "pass": - fail("hostqual-power-ac-and-dc", "a compliant Linux snapshot was refused") - return - mixed = {**linux_ok, "governors": {"cpu0": "performance", "cpu1": "powersave"}} - if hq.check_power_policy(mixed)["result"] != "fail": - fail("hostqual-power-ac-and-dc", "one CPU on powersave was accepted") - return - unknown_boost = {**linux_ok, "boost": {"mechanism": None, "value": None}} - if hq.check_power_policy(unknown_boost)["result"] != "fail": - fail("hostqual-power-ac-and-dc", "a host with no identifiable turbo mechanism passed") +def control_inventory_complete() -> None: + """The docstring inventory and the executed set are the same set. + + This class of defect has now been found twice — a list of controls that + quietly stopped matching the controls. A control is cheaper than finding it a + third time. + """ + listed = set(re.findall(r"^ ([a-z0-9-]+) +\S", __doc__ or "", re.MULTILINE)) + executed = {name for name, _ in CONTROLS} + if listed != executed: + fail("control-inventory-complete", + f"listed but not executed: {sorted(listed - executed)}; executed but not listed: " + f"{sorted(executed - listed)}") return - ok("hostqual-power-ac-and-dc", - "Windows needs 100% on AC *and* DC and an accepted plan; Linux needs performance on every " - "CPU and a turbo mechanism that can be named and rechecked") + ok("control-inventory-complete", + f"{len(executed)} controls listed, {len(executed)} executed, same names in both") def control_tools_do_not_import_harness() -> None: @@ -707,29 +889,39 @@ def control_tools_do_not_import_harness() -> None: fail("tools-do-not-import-harness", f"{name} imports {reached}") return ok("tools-do-not-import-harness", - "neither tool imports the instrument; the digest is recomputed from git objects by the " - "frozen formula rather than by asking the thing under proof") + "neither tool imports the instrument or the capture tool; the digest is recomputed from " + "git objects and the producer's schema is held by a control instead of an import") + + +CONTROLS: list[tuple[str, Callable[[], None]]] = [ + ("hostqual-memory-vocabulary", control_memory_vocabulary), + ("hostqual-producer-schema", control_producer_schema), + ("hostqual-artifact-boundary", control_artifact_boundary), + ("hostqual-t0-versioned", control_t0_versioned), + ("hostqual-ci-predicate", control_ci_predicate), + ("hostqual-provisioning-values", control_provisioning_values), + ("hostqual-one-environment-id", control_one_environment_id), + ("hostqual-quiesce-window", control_quiesce_window), + ("hostqual-quiesce-arithmetic", control_quiesce_arithmetic), + ("hostqual-identity-projection", control_identity_projection), + ("hostqual-candidate-at-start", control_candidate_at_start), + ("hostqual-postflight", control_postflight), + ("hostqual-one-binding", control_one_binding), + ("hostqual-power-ac-and-dc", control_power_ac_and_dc), + ("execbinding-proves-inputs", control_execbinding_proves_inputs), + ("execbinding-old-t0", control_execbinding_old_t0), + ("execbinding-verify-campaign", control_execbinding_verify_campaign), + ("execbinding-verify-is-total", control_execbinding_verify_is_total), + ("execbinding-no-overwrite", control_execbinding_no_overwrite), + ("provisioning-example-validates", control_provisioning_example), + ("control-inventory-complete", control_inventory_complete), + ("tools-do-not-import-harness", control_tools_do_not_import_harness), +] def run() -> int: - guarded("hostqual-memory-vocabulary", control_memory_vocabulary) - guarded("hostqual-t0-versioned", control_t0_versioned) - guarded("hostqual-ci-predicate", control_ci_predicate) - guarded("hostqual-provisioning-values", control_provisioning_values) - guarded("hostqual-one-environment-id", control_one_environment_id) - guarded("hostqual-quiesce-window", control_quiesce_window) - guarded("hostqual-quiesce-arithmetic", control_quiesce_arithmetic) - guarded("hostqual-identity-projection", control_identity_projection) - guarded("hostqual-candidate-at-start", control_candidate_at_start) - guarded("hostqual-postflight", control_postflight) - guarded("hostqual-one-binding", control_one_binding) - guarded("execbinding-proves-inputs", control_execbinding_proves_inputs) - guarded("execbinding-old-t0", control_execbinding_old_t0) - guarded("execbinding-verify-campaign", control_execbinding_verify_campaign) - guarded("execbinding-no-overwrite", control_execbinding_no_overwrite) - guarded("provisioning-example-validates", control_provisioning_example) - guarded("hostqual-power-ac-and-dc", control_power_ac_and_dc) - guarded("tools-do-not-import-harness", control_tools_do_not_import_harness) + for name, control in CONTROLS: + guarded(name, control) print() print(f"step 7 host qualification controls: {len(_PASSES)} passed, {len(_FAILURES)} failed") return 1 if _FAILURES else 0 From d0cb8faec9a8d8961ccf32bef01bf8a8757df3d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 17:37:55 +0500 Subject: [PATCH 04/15] fix(step7): a declaration of false is evidence, not damage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S-C, found by the review of the previous repair — and introduced by it. Closing the artifact-boundary hole, I folded value judgements into the shape validator, so `dedicated_to_p022: false` was refused exactly like a forged document. The honest case that used to produce a record with `qualified: false` and `single_tenant: fail` produced nothing at all: one line on stderr and no artifact. That erases negative attempts, which is how a laboratory ends up with machines that pass on the first try because the other tries were never artifacts. The boundary now separates the two questions it had merged: artifact validity is this the artifact it claims to be — kind, schema, types, applicability shape. Malformed is refused before any record exists. predicate outcome do the declared values satisfy the predicate. Every failure reaches a real record. So the string "false" is malformed, a missing key is malformed, `is_vm` answered with "n/a" is malformed, a VM omitting a VM-only field is malformed, and a physical host answering those with bare booleans is malformed — while the boolean `false`, anywhere it is allowed, is a valid declaration that this host does not qualify. Exit codes now say which class occurred, and are written down rather than implied: 0 valid artifact, positive outcome 1 valid artifact, NEGATIVE outcome — the record exists and says why 2 malformed input or operational misuse — no record is produced The same split applies to the session declaration: an operator who truthfully records that a prohibited job is running gets `eligible: false` with the reason, not an error message and no evidence. Three controls added, driven through the command line so the exit codes are part of the proof: an honest provisioning negative, an honest VM negative and an honest session negative each leave an artifact naming the failed check, while `"false"` as a string leaves none and exits 2. step 7 host qualification controls: 24 passed, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh --- scripts/step7/hostqual.py | 85 +++++++++++++---- tests/test_step7_hostqual.py | 178 +++++++++++++++++++++++++++++------ 2 files changed, 216 insertions(+), 47 deletions(-) diff --git a/scripts/step7/hostqual.py b/scripts/step7/hostqual.py index 9b017f7a..76f8c8ee 100644 --- a/scripts/step7/hostqual.py +++ b/scripts/step7/hostqual.py @@ -14,6 +14,17 @@ utility that checks a CPU governor must not become the root of campaign identity. +Artifact validity is not predicate outcome, and the exit codes say which is +which: + + 0 a valid artifact, and the answer is yes + 1 a valid artifact, and the answer is no — the record EXISTS and says so + 2 a malformed input or an operational misuse; no record is produced + +A declaration of `false` is evidence, not damage. Only a document that is not the +artifact it claims to be — wrong kind, wrong schema, a missing key, the string +"false" where a boolean belongs — is refused before a record exists. + Two classes of evidence, kept apart because only one of them is proof: DECLARED provisioning and per-session operator facts. A guest OS @@ -273,8 +284,17 @@ def _declared_na(value: object) -> bool: def validate_provisioning(doc: dict) -> list[str]: - """Shape AND value. A structurally perfect declaration of the wrong facts is - not a valid declaration, it is a refusal written politely.""" + """SHAPE only. + + Artifact validity is not predicate outcome. `dedicated_to_p022: false` is not + a damaged document — it is a perfectly good declaration that this host does + not qualify, and it has to survive long enough to become a record. Discarding + it before the artifact exists erases the negative attempts, and a laboratory + where every machine passes first time because the other tries "were not + artifacts" is not one anybody should trust. + + So the string "false" is malformed and the boolean `false` is evidence. + """ problems: list[str] = [] if doc.get("kind") != PROVISIONING_SCHEMA: problems.append(f"kind is {doc.get('kind')!r}, not {PROVISIONING_SCHEMA!r}") @@ -283,12 +303,9 @@ def validate_provisioning(doc: dict) -> list[str]: for key in PROVISIONING_STRINGS: if not isinstance(doc.get(key), str) or not doc.get(key): problems.append(f"{key} is missing or not a non-empty string") - for key in PROVISIONING_REQUIRED_TRUE: - if doc.get(key) is not True: - problems.append(f"{key} must be declared true, got {doc.get(key, '')!r}") - for key in PROVISIONING_REQUIRED_FALSE: - if doc.get(key) is not False: - problems.append(f"{key} must be declared false, got {doc.get(key, '')!r}") + for key in PROVISIONING_REQUIRED_TRUE + PROVISIONING_REQUIRED_FALSE: + if not isinstance(doc.get(key), bool): + problems.append(f"{key} must be a boolean, got {doc.get(key, '')!r}") virt = doc.get("virtualization") if not isinstance(virt, dict): @@ -297,18 +314,37 @@ def validate_provisioning(doc: dict) -> list[str]: if not isinstance(is_vm, bool): return problems + [f"virtualization.is_vm must be a real boolean, got {is_vm!r}: " "whether this is a VM is not a question a host may decline"] + # Applicability is shape; the answers themselves are the predicate's business. for key in PROVISIONING_VM_BOOLEANS: value = virt.get(key, "") - if is_vm: - if value is not True: - problems.append(f"virtualization.{key} must be true on a VM, got {value!r}") - elif not _declared_na(value): + if is_vm and not isinstance(value, bool): + problems.append(f"virtualization.{key} must be a boolean on a VM, got {value!r}") + elif not is_vm and not _declared_na(value): problems.append(f"virtualization.{key} must be an explicit 'n/a: ' on a " f"physical host, got {value!r}") return problems +def provisioning_predicate(doc: dict) -> list[str]: + """VALUE. Every failure here becomes `qualified: false` in a real artifact.""" + failures: list[str] = [] + for key in PROVISIONING_REQUIRED_TRUE: + if doc.get(key) is not True: + failures.append(f"{key} is declared false") + for key in PROVISIONING_REQUIRED_FALSE: + if doc.get(key) is not False: + failures.append(f"{key} is declared true") + virt = doc.get("virtualization") or {} + if virt.get("is_vm") is True: + for key in PROVISIONING_VM_BOOLEANS: + if virt.get(key) is not True: + failures.append(f"virtualization.{key} is declared false; a VM that cannot " + "promise it is not a measurement host") + return failures + + def validate_declaration(doc: dict) -> list[str]: + """SHAPE only, for the same reason as the provisioning declaration.""" problems: list[str] = [] if doc.get("kind") != DECLARATION_SCHEMA: problems.append(f"kind is {doc.get('kind')!r}, not {DECLARATION_SCHEMA!r}") @@ -318,11 +354,18 @@ def validate_declaration(doc: dict) -> list[str]: if not isinstance(doc.get(key), str) or not doc.get(key): problems.append(f"{key} is missing or not a non-empty string") for key in DECLARATION_REQUIRED_TRUE: - if doc.get(key) is not True: - problems.append(f"{key} must be declared true, got {doc.get(key, '')!r}") + if not isinstance(doc.get(key), bool): + problems.append(f"{key} must be a boolean, got {doc.get(key, '')!r}") return problems +def declaration_predicate(doc: dict) -> list[str]: + """VALUE. An operator who truthfully says a workload is running gets an + `eligible: false` record, not an error message and no evidence at all.""" + return [f"{key} is declared false" for key in DECLARATION_REQUIRED_TRUE + if doc.get(key) is not True] + + ARTIFACT_VALIDATORS = { "environment manifest": validate_manifest, "host qualification": validate_qualification, @@ -353,10 +396,11 @@ def load_artifact(path: Path, kind: str) -> dict: def check_single_tenant(provisioning: dict, manifest: dict) -> dict[str, object]: - problems = validate_provisioning(provisioning) - if problems: + failures = provisioning_predicate(provisioning) + if failures: return check("single_tenant", False, - "the provisioning declaration does not validate: " + "; ".join(problems)) + "the provisioning declaration is valid evidence that this host does not " + "qualify: " + "; ".join(failures)) identity = manifest.get("identity") if isinstance(manifest.get("identity"), dict) else {} mismatch = [k for k in ("environment_id", "host_fingerprint") if observed(identity, k) != provisioning.get(k)] @@ -619,7 +663,7 @@ def session_eligibility(binding_path: Path, qualification_path: Path, manifest_p binding = load_artifact(binding_path, "execution binding") qualification = load_artifact(qualification_path, "host qualification") manifest = load_artifact(manifest_path, "environment manifest") - load_artifact(declaration_path, "session declaration") + declaration = load_artifact(declaration_path, "session declaration") stratum = str(qualification["stratum"]) bound = binding[stratum] identity = manifest["identity"] @@ -633,6 +677,11 @@ def session_eligibility(binding_path: Path, qualification_path: Path, manifest_p if bound.get("memory_metric") != qualification.get("memory_metric"): reasons.append("the binding and the qualification disagree about the memory metric") + declared = declaration_predicate(declaration) + if declared: + reasons.append("the session declaration says this moment is not measurable: " + + "; ".join(declared)) + ci = check_ci(manifest) snapshot = power_snapshot() power = check_power_policy(snapshot) diff --git a/tests/test_step7_hostqual.py b/tests/test_step7_hostqual.py index a1cac1b4..f6b52450 100644 --- a/tests/test_step7_hostqual.py +++ b/tests/test_step7_hostqual.py @@ -6,7 +6,9 @@ hostqual-artifact-boundary every consumed artifact is proved before it is read hostqual-t0-versioned a qualification is versioned by the T0 it claims hostqual-ci-predicate ci == false, not "the field is absent" - hostqual-provisioning-values shape AND value; a VM that promises nothing fails + hostqual-provisioning-shape malformed is malformed; a boolean false is not + hostqual-provisioning-predicate values are judged by the predicate, not the parser + hostqual-negative-evidence an honest no leaves a record, not a stderr line hostqual-one-environment-id one identity, not three strings that usually agree hostqual-quiesce-window 120 s means 120 s, of which 60 s is measured hostqual-quiesce-arithmetic quiet passes; spike, mean, gap and rewind do not @@ -418,37 +420,68 @@ def control_ci_predicate() -> None: ok("hostqual-ci-predicate", "ci == false; absent or non-boolean is refused") -def control_provisioning_values() -> None: - if hq.validate_provisioning(provisioning()): - fail("hostqual-provisioning-values", "a valid physical-host declaration was refused") - return - if hq.validate_provisioning(vm_provisioning()): - fail("hostqual-provisioning-values", "a valid VM declaration was refused") - return - cases = [ - ("dedicated_to_p022 false", provisioning(dedicated_to_p022=False)), - ("no_concurrent_user_workload false", provisioning(no_concurrent_user_workload=False)), - ("hosted_ci_runner true", provisioning(hosted_ci_runner=True)), - ("VM fixed_vcpu false", vm_provisioning(fixed_vcpu=False)), - ("VM fixed_ram false", vm_provisioning(fixed_ram=False)), - ("VM live_migration_disabled false", vm_provisioning(live_migration_disabled=False)), - ("VM dynamic_memory_disabled false", vm_provisioning(dynamic_memory_disabled=False)), - ("VM answering n/a", vm_provisioning(fixed_vcpu="n/a: do not ask")), - ("is_vm as n/a", provisioning(virtualization={"is_vm": "n/a: unclear"})), +def control_provisioning_shape() -> None: + """Malformed is malformed; `false` is not malformed.""" + for label, doc in (("a physical-host declaration", provisioning()), + ("a VM declaration", vm_provisioning()), + ("an honest negative", provisioning(dedicated_to_p022=False)), + ("an honest VM negative", vm_provisioning(fixed_vcpu=False))): + if hq.validate_provisioning(doc): + fail("hostqual-provisioning-shape", + f"{label} was refused as malformed: {hq.validate_provisioning(doc)}") + return + malformed = [ + ('the string "false" where a boolean belongs', provisioning(dedicated_to_p022="false")), + ("a missing required boolean", + {k: v for k, v in provisioning().items() if k != "dedicated_to_p022"}), + ("is_vm answered with n/a", provisioning(virtualization={"is_vm": "n/a: unclear"})), + ("a VM leaving a VM-only field out", + provisioning(virtualization={"is_vm": True, "fixed_vcpu": True, "fixed_ram": True, + "live_migration_disabled": True})), + ("a VM answering a VM-only field with n/a", vm_provisioning(fixed_vcpu="n/a: do not ask")), ] - for label, doc in cases: + physical_bare = provisioning() + physical_bare["virtualization"]["fixed_vcpu"] = True + malformed.append(("a physical host answering VM questions with bare booleans", physical_bare)) + for label, doc in malformed: if not hq.validate_provisioning(doc): - fail("hostqual-provisioning-values", f"{label} was accepted") + fail("hostqual-provisioning-shape", f"{label} was accepted as a valid artifact") + return + ok("hostqual-provisioning-shape", + "wrong types, missing keys and inapplicable answers are malformed; a boolean `false` is " + "not, because a declaration that this host does not qualify is still a declaration") + + +def control_provisioning_predicate() -> None: + if hq.provisioning_predicate(provisioning()) or hq.provisioning_predicate(vm_provisioning()): + fail("hostqual-provisioning-predicate", "a compliant declaration failed the predicate") + return + for label, doc in (("dedicated_to_p022 false", provisioning(dedicated_to_p022=False)), + ("no_concurrent_user_workload false", + provisioning(no_concurrent_user_workload=False)), + ("hosted_ci_runner true", provisioning(hosted_ci_runner=True)), + ("VM fixed_vcpu false", vm_provisioning(fixed_vcpu=False)), + ("VM fixed_ram false", vm_provisioning(fixed_ram=False)), + ("VM live_migration_disabled false", + vm_provisioning(live_migration_disabled=False)), + ("VM dynamic_memory_disabled false", + vm_provisioning(dynamic_memory_disabled=False))): + if not hq.provisioning_predicate(doc): + fail("hostqual-provisioning-predicate", f"{label} satisfied the predicate") + return + if hq.check_single_tenant(doc, manifest())["result"] != "fail": + fail("hostqual-provisioning-predicate", f"{label} still qualified the host") return - physical_hole = provisioning() - physical_hole["virtualization"]["fixed_vcpu"] = True - if not hq.validate_provisioning(physical_hole): - fail("hostqual-provisioning-values", - "a physical host answering the VM questions with bare booleans was accepted") + if hq.declaration_predicate(declaration()): + fail("hostqual-provisioning-predicate", "a compliant session declaration failed") return - ok("hostqual-provisioning-values", - "every required boolean is checked by VALUE: a VM that cannot promise fixed vCPU, fixed " - "RAM, no live migration or no dynamic memory fails, and 'n/a' is unavailable to it") + for key in hq.DECLARATION_REQUIRED_TRUE: + if not hq.declaration_predicate(declaration(**{key: False})): + fail("hostqual-provisioning-predicate", f"session declaration {key} false passed") + return + ok("hostqual-provisioning-predicate", + "every required value is judged by the predicate rather than by the parser, so each " + "failure can reach a record instead of a stream of errors") def control_one_environment_id() -> None: @@ -831,6 +864,91 @@ def control_execbinding_no_overwrite() -> None: # --- the template and this file itself --------------------------------------- +def control_negative_evidence() -> None: + """A refused host leaves a record saying so. Erasing negative attempts is how + a laboratory ends up with machines that pass on the first try because the + other tries were never artifacts.""" + with tempfile.TemporaryDirectory() as raw: + tmp = Path(raw) + repo, commit, _digest, t0_path = instrument_repo(tmp) + mpath = write(tmp, "m.json", manifest()) + + def qualify_cli(name: str, prov: dict) -> tuple[int, Path]: + ppath = write(tmp, f"{name}-p.json", prov) + out = tmp / f"{name}-q.json" + with fixed_power(COMPLIANT_POWER): + rc = hq.main(["--qualify", "--stratum", "linux", "--repo", str(repo), + "--t0-path", t0_path, "--t0-commit", commit, + "--provisioning", str(ppath), "--manifest", str(mpath), + "--emit", str(out)]) + return rc, out + + # N1 and N2: honest provisioning negatives + for label, prov, expect_key in ( + ("n1", provisioning(dedicated_to_p022=False), "single_tenant"), + ("n1b", provisioning(no_concurrent_user_workload=False), "single_tenant"), + ("n2", vm_provisioning(fixed_vcpu=False), "single_tenant")): + rc, out = qualify_cli(label, prov) + if not out.is_file(): + fail("hostqual-negative-evidence", + f"{label}: a valid negative declaration produced no artifact at all") + return + record = json.loads(out.read_text(encoding="utf-8")) + if record.get("qualified") is not False: + fail("hostqual-negative-evidence", f"{label}: the record does not say qualified " + f"false: {record.get('qualified')!r}") + return + if record.get("predicate", {}).get(expect_key) != "fail": + fail("hostqual-negative-evidence", + f"{label}: {expect_key} is {record.get('predicate', {}).get(expect_key)!r}, " + "so the record does not say WHY") + return + if rc != 1: + fail("hostqual-negative-evidence", + f"{label}: exit code {rc}; a valid artifact with a negative outcome is 1") + return + + # N4 and N5: malformed input produces no artifact and a different code + rc, out = qualify_cli("n4", provisioning(dedicated_to_p022="false")) + if out.is_file(): + fail("hostqual-negative-evidence", "a malformed declaration produced an artifact") + return + if rc != 2: + fail("hostqual-negative-evidence", + f"a malformed declaration exited {rc}; malformed input is 2, not 1") + return + + # N3: an honest session negative still records eligibility + qpath = write(tmp, "q.json", qualification()) + wpath = write(tmp, "qw.json", qualification("windows")) + bpath = write(tmp, "b.json", binding_doc(hq.sha256_file(qpath), hq.sha256_file(wpath))) + dpath = write(tmp, "d.json", declaration(no_prohibited_background_job_active=False)) + cpath = tmp / "cand.bin" + cpath.write_bytes(LINUX_CANDIDATE) + quiet = {"eligible": True, "reason": "", "samples": [0.01], "mean": 0.01, "max": 0.01} + with fixed_power(COMPLIANT_POWER): + record = hq.session_eligibility(bpath, qpath, mpath, dpath, cpath, + quiesce_result=quiet) + if record["eligible"]: + fail("hostqual-negative-evidence", "a declared prohibited job left the session eligible") + return + if not any("declaration" in r for r in record["reasons"]): + fail("hostqual-negative-evidence", + f"the eligibility record does not say why: {record['reasons']}") + return + malformed_d = write(tmp, "d-bad.json", declaration(no_campaign_workload="false")) + with fixed_power(COMPLIANT_POWER): + message = refuses(lambda: hq.session_eligibility(bpath, qpath, mpath, malformed_d, + cpath, quiesce_result=quiet)) + if message is None: + fail("hostqual-negative-evidence", "a malformed session declaration was consumed") + return + ok("hostqual-negative-evidence", + "an honest negative — host, VM or session — leaves a real artifact saying qualified/" + "eligible false and naming the reason, and exits 1; malformed input leaves nothing and " + "exits 2. The two classes never share a code") + + def control_provisioning_example() -> None: if not EXAMPLE.is_file(): fail("provisioning-example-validates", f"{EXAMPLE} is missing") @@ -899,7 +1017,9 @@ def control_tools_do_not_import_harness() -> None: ("hostqual-artifact-boundary", control_artifact_boundary), ("hostqual-t0-versioned", control_t0_versioned), ("hostqual-ci-predicate", control_ci_predicate), - ("hostqual-provisioning-values", control_provisioning_values), + ("hostqual-provisioning-shape", control_provisioning_shape), + ("hostqual-provisioning-predicate", control_provisioning_predicate), + ("hostqual-negative-evidence", control_negative_evidence), ("hostqual-one-environment-id", control_one_environment_id), ("hostqual-quiesce-window", control_quiesce_window), ("hostqual-quiesce-arithmetic", control_quiesce_arithmetic), From a40bdfd7df3b5ca299c536e7527189cea4d5e246 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 01:45:06 +0500 Subject: [PATCH 05/15] feat(step7): join the campaign to the freeze, and make the authority flag bite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two freeze-blockers from the full T0 review, both of the same family: a chain that reads correctly and does not actually hold. F1 — the accepted D7 payload binds the reference, the harness digest and version, and the workload manifest. It does NOT bind the execution binding: `execution_binding_sha256` is absent from `D7_PAYLOAD_BINDING_KEYS`, the string `execution_binding` does not occur in the instrument at all, and the gate only checks that the listed keys are present — an extra key is tolerated and never verified. Meanwhile this tool's own docstring promised D7 would bind it. One accepted component was advertising a join another accepted component cannot see. A campaign link now makes the join by bytes: it names the execution binding, the D7 payload's sha256, its blob at a named commit, and the attestation's sha256. Preflight and postflight both re-prove it — a link naming a different binding, a freeze edited after the campaign was linked to it, or a campaign swapped between preflight and postflight each refuse — and the link is recorded in both session records. Admissibility under T0 is the existence of the postflight record, so evidence produced outside a campaign cannot acquire one. What this deliberately does not do: make the instrument's firewall aware of the campaign. `IdentityGate` still arms from the payload and attestation alone, so a decisive clock can physically run with no binding in existence; what cannot happen is that such a run becomes admissible. Teaching the firewall would edit `perf_baseline.py`, move `measurement_harness_digest` and reopen steps 4/5/6 — a price not paid for a property obtainable by gating admissibility instead of execution. F4 — `collection_authorized` was decoration. Both T0 readers accepted FROZEN regardless of the flag, and the fixtures had quietly encoded the intended rule all along. The two fields are now read as one authority state on both paths: FROZEN+true may proceed to the remaining gates, while FROZEN+false, NOT_FROZEN+false and NOT_FROZEN+true each refuse — the third by name, because an authorisation without a fixed protocol is a contradiction and honouring the flag over the contract is how a tool starts arguing with its own rules. A fixture defect surfaced on the way: two campaigns built from identical content in the same second produced the same commit sha, so the "different campaign" attack was proving that a link equals itself. Campaigns are now distinct documents. step 7 host qualification controls: 26 passed, 0 failed. Regression: envcapture 11/11, perf instrument 16/16, calibration freeze 7/7, training prereg 9/9. No instrument change; the harness digest does not move. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh --- scripts/step7/execbinding.py | 20 ++++- scripts/step7/hostqual.py | 122 ++++++++++++++++++++++++--- tests/test_step7_hostqual.py | 158 +++++++++++++++++++++++++++++++---- 3 files changed, 271 insertions(+), 29 deletions(-) diff --git a/scripts/step7/execbinding.py b/scripts/step7/execbinding.py index 828ae865..2bf30cd6 100644 --- a/scripts/step7/execbinding.py +++ b/scripts/step7/execbinding.py @@ -129,15 +129,29 @@ def t0_at(repo: Path, path: str, commit: str) -> dict[str, object]: rc, raw = _git(repo, "cat-file", "blob", f"{commit}:{path}") if rc != 0: raise BindingRefused(f"the T0 blob at {commit}:{path} could not be read") - status = re.search(r"^\s*(NOT_FROZEN|FROZEN)\.?\s*$", raw.decode("utf-8", "replace"), - re.MULTILINE) + text = raw.decode("utf-8", "replace") + status = re.search(r"^\s*(NOT_FROZEN|FROZEN)\.?\s*$", text, re.MULTILINE) + flag = re.search(r"^\s*collection_authorized:\s*(true|false)\s*$", text, re.MULTILINE) declared = status.group(1) if status else "" + authorized = (flag.group(1) == "true") if flag else None + # One authority state read from two fields (R15); three of the four + # combinations refuse, and the same three refuse on the qualification path. + if declared != "FROZEN" and authorized is True: + raise BindingRefused( + f"T0 at {commit}:{path} declares {declared} while claiming collection_authorized: " + "true; an authorisation without a fixed protocol is a contradiction") if declared != "FROZEN": raise BindingRefused( f"T0 at {commit}:{path} declares {declared}; a campaign cannot be bound to a " "protocol whose rules may still change") + if authorized is not True: + raise BindingRefused( + f"T0 at {commit}:{path} is FROZEN but collection_authorized is {authorized!r}; the " + "freeze is the owner's authorising act and carries both, so a campaign bound " + "without it would be bound to a protocol nobody has released") return {"commit": commit, "path": path, "blob_sha": blob.decode().strip(), - "sha256": sha256_bytes(raw), "status": declared} + "sha256": sha256_bytes(raw), "status": declared, + "collection_authorized": authorized} def validate_qualification(doc: object) -> list[str]: diff --git a/scripts/step7/hostqual.py b/scripts/step7/hostqual.py index 76f8c8ee..642df46d 100644 --- a/scripts/step7/hostqual.py +++ b/scripts/step7/hostqual.py @@ -37,10 +37,10 @@ --provisioning --manifest --emit hostqual.py --session-preflight --binding --qualification \\ --manifest --declaration --candidate \\ - --emit + --campaign-link --emit hostqual.py --session-postflight --binding --qualification \\ --preflight --manifest --candidate \\ - --closing-probe --emit + --closing-probe --campaign-link --emit hostqual.py --selftest """ @@ -69,6 +69,13 @@ # stays independent of the capture tool. `hostqual-producer-schema` proves the # copy still equals what envcapture emits, so the decoupling cannot rot quietly. ENVCAPTURE_SCHEMA = "p022-263a-step7-environment-identity" +# The join the accepted D7 payload cannot make for itself: its binding keys are +# python_reference_commit, python_reference_tree, harness_digest, harness_version +# and workload_manifest_sha256 — no execution binding among them, and the gate +# tolerates an extra key without ever verifying it. This artifact ties the +# campaign to the freeze by exact bytes, and the session gates below refuse +# without it, so evidence produced outside a campaign cannot become admissible. +CAMPAIGN_LINK_SCHEMA = "own.net/p022/campaign-link" SCHEMA_VERSION = 1 # The closed memory vocabulary, declared rather than imported so this tool never @@ -266,14 +273,32 @@ def bind_t0(repo: Path, path: str, commit: str) -> tuple[dict[str, object], dict block = {"commit": commit, "path": path, "blob_sha": blob_sha.decode().strip(), "sha256": sha256_bytes(raw), "status": status["declared"], "collection_authorized": status["collection_authorized"]} - if not status["frozen"]: + # The two fields are one authority state, and three of its four combinations + # are refusals (R15). `FROZEN + false` is the one a reader assumes is fine: + # the protocol is fixed, so surely work may proceed — but the owner has not + # authorised collection, and a flag the tooling ignores is decoration. + frozen, authorized = bool(status["frozen"]), status["collection_authorized"] + if not frozen and authorized is True: + return block, check("t0", False, + f"T0 at {commit}:{path} declares {status['declared']} while claiming " + "collection_authorized: true. An authorisation without a fixed " + "protocol is a contradiction, and honouring the flag over the " + "contract is how a tool starts arguing with its own rules") + if not frozen: return block, check("t0", False, f"T0 at {commit}:{path} declares {status['declared']}. A host cannot " "be qualified against a protocol whose predicate may still change; " "work done against it stays exploratory") + if authorized is not True: + return block, check("t0", False, + f"T0 at {commit}:{path} is FROZEN but collection_authorized is " + f"{json.dumps(authorized)}. The freeze is the owner's authorising " + "act and carries both; without it no collection is authorised, " + "however qualified the host") return block, check("t0", True, - f"T0 {block['blob_sha'][:12]} at {commit} is FROZEN; " - f"collection_authorized={status['collection_authorized']}") + f"T0 {block['blob_sha'][:12]} at {commit} is FROZEN and " + "collection_authorized: true — necessary, and not sufficient: " + "qualification, binding and preflight may each still refuse") # --- the declared evidence --------------------------------------------------- @@ -366,7 +391,62 @@ def declaration_predicate(doc: dict) -> list[str]: if doc.get(key) is not True] +def validate_campaign_link(doc: object) -> list[str]: + if not isinstance(doc, dict): + return ["the campaign link is not a JSON object"] + problems = [] + if doc.get("kind") != CAMPAIGN_LINK_SCHEMA: + problems.append(f"kind is {doc.get('kind')!r}, not {CAMPAIGN_LINK_SCHEMA!r}") + if doc.get("schema") != SCHEMA_VERSION: + problems.append(f"schema is {doc.get('schema')!r}, not {SCHEMA_VERSION}") + if not doc.get("execution_binding_sha256"): + problems.append("execution_binding_sha256 is missing") + for side, keys in (("d7_payload", ("path", "sha256", "blob_sha", "commit")), + ("d7_attestation", ("path", "sha256"))): + block = doc.get(side) + if not isinstance(block, dict): + problems.append(f"{side} is missing") + continue + problems.extend(f"{side}.{k} is missing" for k in keys if not block.get(k)) + return problems + + +def check_campaign_link(link: dict, binding_path: Path, repo: Path) -> dict[str, object]: + """Does this campaign link actually join THIS binding to the freeze on disk? + + Every field is re-proved against bytes: the binding's own hash, the D7 + payload's hash and its blob at the named commit, the attestation's hash. A + link that names a different campaign — or a payload that has since changed — + fails here, before a clock exists. + """ + if link.get("execution_binding_sha256") != sha256_file(binding_path): + return check("campaign_link", False, + "the campaign link names a different execution binding; this session " + "belongs to another campaign, or to none") + for side in ("d7_payload", "d7_attestation"): + named = link[side] + path = repo / str(named["path"]) + if not path.is_file(): + return check("campaign_link", False, + f"{side} is absent at {named['path']}: the freeze the link names is " + "not on disk") + if sha256_file(path) != named["sha256"]: + return check("campaign_link", False, + f"{side} at {named['path']} does not hash to what the link names; " + "the freeze changed after the campaign was linked to it") + payload = link["d7_payload"] + rc, blob = _git(repo, "rev-parse", f"{payload['commit']}:{payload['path']}") + if rc != 0 or blob.decode().strip() != payload["blob_sha"]: + return check("campaign_link", False, + "the D7 payload blob at the named commit is not the one the link names") + return check("campaign_link", True, + f"execution binding {str(link['execution_binding_sha256'])[:12]} is joined to " + f"the D7 payload {str(payload['sha256'])[:12]} at {str(payload['commit'])[:12]} " + "and to its attestation, by exact bytes") + + ARTIFACT_VALIDATORS = { + "campaign link": validate_campaign_link, "environment manifest": validate_manifest, "host qualification": validate_qualification, "execution binding": validate_binding, @@ -659,11 +739,13 @@ def _candidate_check(binding_block: dict, candidate_path: Path) -> dict[str, obj def session_eligibility(binding_path: Path, qualification_path: Path, manifest_path: Path, declaration_path: Path, candidate_path: Path, + campaign_link_path: Path, repo: Path, quiesce_result: dict[str, object] | None = None) -> dict[str, object]: binding = load_artifact(binding_path, "execution binding") qualification = load_artifact(qualification_path, "host qualification") manifest = load_artifact(manifest_path, "environment manifest") declaration = load_artifact(declaration_path, "session declaration") + link = load_artifact(campaign_link_path, "campaign link") stratum = str(qualification["stratum"]) bound = binding[stratum] identity = manifest["identity"] @@ -677,6 +759,10 @@ def session_eligibility(binding_path: Path, qualification_path: Path, manifest_p if bound.get("memory_metric") != qualification.get("memory_metric"): reasons.append("the binding and the qualification disagree about the memory metric") + joined = check_campaign_link(link, binding_path, repo) + if joined["result"] != "pass": + reasons.append(str(joined["detail"])) + declared = declaration_predicate(declaration) if declared: reasons.append("the session declaration says this moment is not measurable: " @@ -713,6 +799,8 @@ def session_eligibility(binding_path: Path, qualification_path: Path, manifest_p "fresh_environment_manifest_sha256": sha256_file(manifest_path), "environment_identity_sha256": live_identity, "session_declaration_sha256": sha256_file(declaration_path), + "campaign_link_sha256": sha256_file(campaign_link_path), + "campaign_link": joined, "power_snapshot": snapshot, "candidate": candidate, "ci": ci, @@ -727,7 +815,8 @@ def session_eligibility(binding_path: Path, qualification_path: Path, manifest_p def session_admissibility(binding_path: Path, qualification_path: Path, preflight_path: Path, manifest_path: Path, candidate_path: Path, - closing_probe_path: Path) -> dict[str, object]: + closing_probe_path: Path, campaign_link_path: Path, + repo: Path) -> dict[str, object]: """Did the attempt that ran remain the one that was authorised? Separate from preflight on purpose: preflight may not certify what a session @@ -738,6 +827,7 @@ def session_admissibility(binding_path: Path, qualification_path: Path, prefligh qualification = load_artifact(qualification_path, "host qualification") preflight = load_artifact(preflight_path, "session eligibility") manifest = load_artifact(manifest_path, "environment manifest") + link = load_artifact(campaign_link_path, "campaign link") stratum = str(qualification["stratum"]) bound = binding[stratum] identity = manifest["identity"] @@ -752,6 +842,12 @@ def session_admissibility(binding_path: Path, qualification_path: Path, prefligh "a changed binding is a different campaign, never a newer one") if preflight.get("qualification_sha256") != sha256_file(qualification_path): reasons.append("the preflight was taken against a different qualification") + joined = check_campaign_link(link, binding_path, repo) + if joined["result"] != "pass": + reasons.append(str(joined["detail"])) + if preflight.get("campaign_link_sha256") != sha256_file(campaign_link_path): + reasons.append("the preflight and this pass name different campaign links; an " + "attempt cannot change which campaign it belongs to mid-session") snapshot = power_snapshot() if snapshot != preflight.get("power_snapshot"): @@ -772,6 +868,8 @@ def session_admissibility(binding_path: Path, qualification_path: Path, prefligh "execution_binding_sha256": binding_sha, "qualification_sha256": sha256_file(qualification_path), "preflight_sha256": sha256_file(preflight_path), + "campaign_link_sha256": sha256_file(campaign_link_path), + "campaign_link": joined, "post_environment_manifest_sha256": sha256_file(manifest_path), "environment_identity_sha256": live_identity, "power_snapshot": snapshot, @@ -804,6 +902,7 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--qualification", type=Path) parser.add_argument("--preflight", type=Path) parser.add_argument("--closing-probe", type=Path) + parser.add_argument("--campaign-link", type=Path) parser.add_argument("--emit", type=Path) args = parser.parse_args(argv) @@ -831,16 +930,19 @@ def require(flag: str, names: tuple[str, ...]) -> None: ok = bool(record["qualified"]) elif args.session_preflight: require("session-preflight", - ("binding", "qualification", "manifest", "declaration", "candidate")) + ("binding", "qualification", "manifest", "declaration", "candidate", + "campaign-link")) record = session_eligibility(args.binding, args.qualification, args.manifest, - args.declaration, args.candidate) + args.declaration, args.candidate, args.campaign_link, + args.repo) ok = bool(record["eligible"]) elif args.session_postflight: require("session-postflight", ("binding", "qualification", "preflight", "manifest", "candidate", - "closing-probe")) + "closing-probe", "campaign-link")) record = session_admissibility(args.binding, args.qualification, args.preflight, - args.manifest, args.candidate, args.closing_probe) + args.manifest, args.candidate, args.closing_probe, + args.campaign_link, args.repo) ok = bool(record["admissible"]) else: parser.error("choose --qualify, --session-preflight, --session-postflight " diff --git a/tests/test_step7_hostqual.py b/tests/test_step7_hostqual.py index f6b52450..92eb51e9 100644 --- a/tests/test_step7_hostqual.py +++ b/tests/test_step7_hostqual.py @@ -9,6 +9,8 @@ hostqual-provisioning-shape malformed is malformed; a boolean false is not hostqual-provisioning-predicate values are judged by the predicate, not the parser hostqual-negative-evidence an honest no leaves a record, not a stderr line + hostqual-campaign-link the campaign and the freeze are joined by bytes + hostqual-authority-states four states, three refusals, both T0 paths hostqual-one-environment-id one identity, not three strings that usually agree hostqual-quiesce-window 120 s means 120 s, of which 60 s is measured hostqual-quiesce-arithmetic quiet passes; spike, mean, gap and rewind do not @@ -93,6 +95,8 @@ def refuses(call: Callable[[], object]) -> str | None: T0_FROZEN = "# T0\n\n```text\nStatus:\n FROZEN.\n collection_authorized: true\n```\n" T0_OPEN = "# T0\n\n```text\nStatus:\n NOT_FROZEN.\n collection_authorized: false\n```\n" +T0_FROZEN_UNAUTHORIZED = "# T0\n\n```text\nStatus:\n FROZEN.\n collection_authorized: false\n```\n" +T0_OPEN_AUTHORIZED = "# T0\n\n```text\nStatus:\n NOT_FROZEN.\n collection_authorized: true\n```\n" # A compliant Windows snapshot, used as a fixture on every platform so the # Windows rules are driven on Linux too, and so these controls do not depend on @@ -242,8 +246,39 @@ def steady(n: int = hq.QUIESCE_INTERVALS): return lambda: next(series, None) +def campaign_fixture(tmp: Path, binding_sha: str, *, tamper: bool = False, + other_binding: bool = False): + """A repo carrying a D7 payload and attestation, and a link that joins them.""" + # Distinct campaigns must be distinct documents: identical content committed + # inside the same second yields the same commit sha, and the fixture would + # then be proving only that a link equals itself. + tag = f"camp{len(list(tmp.glob('camp*')))}" + repo = tmp / tag + commit = git_repo(repo, {"docs/evidence/d7-payload.json": + '{"kind": "d7", "campaign": "' + tag + '"}\n', + "docs/evidence/d7-attestation.json": '{"kind": "att"}\n'}, + "freeze") + payload = repo / "docs/evidence/d7-payload.json" + attestation = repo / "docs/evidence/d7-attestation.json" + blob = subprocess.run(["git", "-C", str(repo), "rev-parse", + commit + ":docs/evidence/d7-payload.json"], + capture_output=True, text=True, check=True).stdout.strip() + link = {"kind": hq.CAMPAIGN_LINK_SCHEMA, "schema": 1, + "execution_binding_sha256": "f" * 64 if other_binding else binding_sha, + "d7_payload": {"path": "docs/evidence/d7-payload.json", + "sha256": hq.sha256_file(payload), "blob_sha": blob, + "commit": commit}, + "d7_attestation": {"path": "docs/evidence/d7-attestation.json", + "sha256": hq.sha256_file(attestation)}, + "recorded_at": "2026-09-17T00:00:00+00:00"} + if tamper: # the freeze moved after the campaign was linked to it + payload.write_text('{"kind": "d7", "edited": true}\n', encoding="utf-8") + return repo, link + + def session_fixture(tmp: Path, *, fresh: dict | None = None, - candidate: bytes = LINUX_CANDIDATE, qual: dict | None = None): + candidate: bytes = LINUX_CANDIDATE, qual: dict | None = None, + tamper_freeze: bool = False, other_campaign: bool = False): qpath = write(tmp, "q.json", qual or qualification()) wpath = write(tmp, "qw.json", qualification("windows")) bpath = write(tmp, "b.json", binding_doc(hq.sha256_file(qpath), hq.sha256_file(wpath))) @@ -252,9 +287,13 @@ def session_fixture(tmp: Path, *, fresh: dict | None = None, cpath = tmp / "cand.bin" cpath.write_bytes(candidate) quiet = {"eligible": True, "reason": "", "samples": [0.01], "mean": 0.01, "max": 0.01} + repo, link = campaign_fixture(tmp, hq.sha256_file(bpath), tamper=tamper_freeze, + other_binding=other_campaign) + lpath = write(tmp, "link.json", link) with fixed_power(COMPLIANT_POWER): - record = hq.session_eligibility(bpath, qpath, mpath, dpath, cpath, quiesce_result=quiet) - return record, (bpath, qpath, mpath, dpath, cpath) + record = hq.session_eligibility(bpath, qpath, mpath, dpath, cpath, lpath, repo, + quiesce_result=quiet) + return record, (bpath, qpath, mpath, dpath, cpath, lpath, repo) # --- vocabulary, producer schema and the boundary --------------------------- @@ -299,6 +338,8 @@ def control_artifact_boundary() -> None: cand = tmp / "c.bin" cand.write_bytes(LINUX_CANDIDATE) quiet = {"eligible": True, "reason": "", "samples": [0.01], "mean": 0.01, "max": 0.01} + crepo, link_doc = campaign_fixture(tmp, hq.sha256_file(good_b)) + good_link = write(tmp, "link.json", link_doc) attacks = [ ("fake execution-binding kind", @@ -328,7 +369,8 @@ def control_artifact_boundary() -> None: fresh = path if slot == "manifest" else good_m with fixed_power(COMPLIANT_POWER): message = refuses(lambda: hq.session_eligibility( - binding, qual, fresh, good_d, cand, quiesce_result=quiet)) + binding, qual, fresh, good_d, cand, good_link, crepo, + quiesce_result=quiet)) if message is None: fail("hostqual-artifact-boundary", f"{label} was consumed as a real artifact") return @@ -347,7 +389,8 @@ def control_artifact_boundary() -> None: "provenance": {"ci": False}}) with fixed_power(COMPLIANT_POWER): message = refuses(lambda: hq.session_eligibility(forged_b, good_q, forged_m, good_d, - cand, quiesce_result=quiet)) + cand, good_link, crepo, + quiesce_result=quiet)) if message is None: fail("hostqual-artifact-boundary", "the original hole is open: a handwritten binding and a handwritten manifest " @@ -361,7 +404,7 @@ def control_artifact_boundary() -> None: probe.write_text("{}", encoding="utf-8") with fixed_power(COMPLIANT_POWER): message = refuses(lambda: hq.session_admissibility(good_b, good_q, bad_pre, good_m, - cand, probe)) + cand, probe, good_link, crepo)) if message is None: fail("hostqual-artifact-boundary", "a fake preflight record was consumed") return @@ -578,33 +621,33 @@ def control_candidate_at_start() -> None: def control_postflight() -> None: with tempfile.TemporaryDirectory() as raw: tmp = Path(raw) - pre, (bpath, qpath, mpath, dpath, cpath) = session_fixture(tmp) + pre, (bpath, qpath, mpath, dpath, cpath, lpath, crepo) = session_fixture(tmp) ppath = write(tmp, "pre.json", pre) probe = tmp / "probe.json" probe.write_text("{}", encoding="utf-8") with fixed_power(COMPLIANT_POWER): - clean = hq.session_admissibility(bpath, qpath, ppath, mpath, cpath, probe) + clean = hq.session_admissibility(bpath, qpath, ppath, mpath, cpath, probe, lpath, crepo) if not clean["admissible"]: fail("hostqual-postflight", f"a clean attempt was refused: {clean['reasons']}") return moved = write(tmp, "after.json", manifest(kernel="6.9.9")) if hq.session_admissibility(bpath, qpath, ppath, moved, cpath, - probe)["admissible"]: + probe, lpath, crepo)["admissible"]: fail("hostqual-postflight", "an environment that changed mid-session passed") return other = tmp / "other.bin" other.write_bytes(b"rebuilt candidate") if hq.session_admissibility(bpath, qpath, ppath, mpath, other, - probe)["admissible"]: + probe, lpath, crepo)["admissible"]: fail("hostqual-postflight", "a candidate rebuilt mid-session passed") return if hq.session_admissibility(bpath, qpath, ppath, mpath, cpath, - tmp / "absent.json")["admissible"]: + tmp / "absent.json", lpath, crepo)["admissible"]: fail("hostqual-postflight", "an attempt with no closing probe passed") return with fixed_power({**COMPLIANT_POWER, "processor_max_ac": 50}): if hq.session_admissibility(bpath, qpath, ppath, mpath, cpath, - probe)["admissible"]: + probe, lpath, crepo)["admissible"]: fail("hostqual-postflight", "power that changed during the session passed") return ok("hostqual-postflight", @@ -615,14 +658,14 @@ def control_postflight() -> None: def control_one_binding() -> None: with tempfile.TemporaryDirectory() as raw: tmp = Path(raw) - pre, (bpath, qpath, mpath, dpath, cpath) = session_fixture(tmp) + pre, (bpath, qpath, mpath, dpath, cpath, lpath, crepo) = session_fixture(tmp) ppath = write(tmp, "pre.json", pre) probe = tmp / "probe.json" probe.write_text("{}", encoding="utf-8") original = json.loads(bpath.read_text(encoding="utf-8")) second = write(tmp, "b2.json", {**original, "bound_at": "a later moment"}) with fixed_power(COMPLIANT_POWER): - mixed = hq.session_admissibility(second, qpath, ppath, mpath, cpath, probe) + mixed = hq.session_admissibility(second, qpath, ppath, mpath, cpath, probe, lpath, crepo) if mixed["admissible"]: fail("hostqual-one-binding", "a preflight from one binding and a postflight from another were admissible") @@ -926,8 +969,10 @@ def qualify_cli(name: str, prov: dict) -> tuple[int, Path]: cpath = tmp / "cand.bin" cpath.write_bytes(LINUX_CANDIDATE) quiet = {"eligible": True, "reason": "", "samples": [0.01], "mean": 0.01, "max": 0.01} + crepo, link_doc = campaign_fixture(tmp, hq.sha256_file(bpath)) + lpath = write(tmp, "link.json", link_doc) with fixed_power(COMPLIANT_POWER): - record = hq.session_eligibility(bpath, qpath, mpath, dpath, cpath, + record = hq.session_eligibility(bpath, qpath, mpath, dpath, cpath, lpath, crepo, quiesce_result=quiet) if record["eligible"]: fail("hostqual-negative-evidence", "a declared prohibited job left the session eligible") @@ -939,7 +984,8 @@ def qualify_cli(name: str, prov: dict) -> tuple[int, Path]: malformed_d = write(tmp, "d-bad.json", declaration(no_campaign_workload="false")) with fixed_power(COMPLIANT_POWER): message = refuses(lambda: hq.session_eligibility(bpath, qpath, mpath, malformed_d, - cpath, quiesce_result=quiet)) + cpath, lpath, crepo, + quiesce_result=quiet)) if message is None: fail("hostqual-negative-evidence", "a malformed session declaration was consumed") return @@ -949,6 +995,84 @@ def qualify_cli(name: str, prov: dict) -> tuple[int, Path]: "exits 2. The two classes never share a code") +def control_campaign_link() -> None: + """The join the D7 payload cannot make for itself, verified by bytes. + + `D7_PAYLOAD_BINDING_KEYS` carries no execution binding and the gate tolerates + an unverified extra key, so without this the campaign and the freeze are two + documents that merely hope they are about each other. + """ + with tempfile.TemporaryDirectory() as raw: + tmp = Path(raw) + clean, _ = session_fixture(tmp) + if not clean["eligible"] or clean["campaign_link"]["result"] != "pass": + fail("hostqual-campaign-link", f"a joined session was refused: {clean['reasons']}") + return + elsewhere, _ = session_fixture(tmp, other_campaign=True) + if elsewhere["eligible"] or not any("another campaign" in r for r in elsewhere["reasons"]): + fail("hostqual-campaign-link", + "a link naming a different execution binding was accepted") + return + moved, _ = session_fixture(tmp, tamper_freeze=True) + if moved["eligible"] or not any("freeze changed" in r for r in moved["reasons"]): + fail("hostqual-campaign-link", + "a D7 payload edited after the campaign was linked to it was accepted") + return + + pre, (bpath, qpath, mpath, dpath, cpath, lpath, crepo) = session_fixture(tmp) + ppath = write(tmp, "pre.json", pre) + probe = tmp / "probe.json" + probe.write_text("{}", encoding="utf-8") + other_repo, other_link = campaign_fixture(tmp, hq.sha256_file(bpath)) + olpath = write(tmp, "link2.json", other_link) + with fixed_power(COMPLIANT_POWER): + swapped = hq.session_admissibility(bpath, qpath, ppath, mpath, cpath, probe, + olpath, other_repo) + if swapped["admissible"]: + fail("hostqual-campaign-link", + "an attempt changed which campaign it belonged to between preflight and " + "postflight") + return + ok("hostqual-campaign-link", + "preflight and postflight re-prove the link by bytes: a link naming another binding, a " + "freeze edited afterwards, and a campaign swapped mid-session each refuse") + + +def control_authority_states() -> None: + """Four states, three refusals, on both paths that read T0.""" + with tempfile.TemporaryDirectory() as raw: + tmp = Path(raw) + repo = tmp / "repo" + states = {} + for name, text in (("frozen_authorized", T0_FROZEN), + ("frozen_unauthorized", T0_FROZEN_UNAUTHORIZED), + ("open_unauthorized", T0_OPEN), + ("open_authorized", T0_OPEN_AUTHORIZED)): + states[name] = git_repo(repo, {"t0.md": text}, name) + expected = {"frozen_authorized": "pass", "frozen_unauthorized": "fail", + "open_unauthorized": "fail", "open_authorized": "fail"} + for name, commit in states.items(): + got = hq.bind_t0(repo, "t0.md", commit)[1]["result"] + if got != expected[name]: + fail("hostqual-authority-states", + f"qualification path: {name} gave {got}, expected {expected[name]}") + return + refused = True + try: + eb.t0_at(repo, "t0.md", commit) + refused = False + except eb.BindingRefused: + pass + if refused == (expected[name] == "pass"): + fail("hostqual-authority-states", + f"binding path: {name} was {'refused' if refused else 'accepted'}, " + f"expected {expected[name]}") + return + ok("hostqual-authority-states", + "FROZEN+true proceeds; FROZEN+false, NOT_FROZEN+false and NOT_FROZEN+true each refuse, " + "on the qualification path and on the binding path alike") + + def control_provisioning_example() -> None: if not EXAMPLE.is_file(): fail("provisioning-example-validates", f"{EXAMPLE} is missing") @@ -1033,6 +1157,8 @@ def control_tools_do_not_import_harness() -> None: ("execbinding-verify-campaign", control_execbinding_verify_campaign), ("execbinding-verify-is-total", control_execbinding_verify_is_total), ("execbinding-no-overwrite", control_execbinding_no_overwrite), + ("hostqual-campaign-link", control_campaign_link), + ("hostqual-authority-states", control_authority_states), ("provisioning-example-validates", control_provisioning_example), ("control-inventory-complete", control_inventory_complete), ("tools-do-not-import-harness", control_tools_do_not_import_harness), From 5c9f2eecd172d4a3caf8ac1e4f2b1d3575015192 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 07:13:33 +0500 Subject: [PATCH 06/15] feat(step7): the merge order becomes a predicate on the target tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frozen T0 names an accepted harness identity that, on its own branch, does not exist yet: `104c384d01bf` arrives with the memory-semantics repair, which is a different pull request. That is fine while the merge order holds — and an order held by agreement is one stray click from becoming an archaeological artifact, with a document saying FROZEN in a tree where its referent is absent. So the order is checked instead of promised, and checked against the TARGET TREE rather than against pull-request numbers: a number proves someone pressed a button and says nothing about what the merged tree contains. The gate refuses unless, at the commit a merge would produce: - T0 declares FROZEN *and* collection_authorized: true; - the harness digest recomputed from that tree's own instrument sources, by the frozen formula, equals the one T0 names; - the policy freeze, the design constants and the training preregistration all bind that same digest — present is not the same as re-bound; - the capture, qualification and binding tools exist and actually enforce the campaign link and the authority state; - the step-7 note still revokes the automatic collection authority, so hosts plus a binding cannot again be enough to start a clock. The expected digest is read out of the frozen T0 and recomputed from the tree; a control proves no digest literal lives in the gate's own source, because a gate trusting its own constant would be checking itself. Exercised against real merges, not only fixtures. The full chain (#355 -> #356 -> #353 -> #354) is allowed. #354 alone is refused on three predicates at once — the instrument hashes to 562a7f7232da where T0 names 104c384d01bf, the bindings are stale, the machinery is absent. #355 plus #354 without the machinery is refused on one. T0-0 turns out to need no predicate of its own: #353 is an ancestor of the freeze commit, so it cannot be skipped. merge gate controls: 7 passed, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh --- scripts/step7/mergegate.py | 157 +++++++++++++++++++++ tests/test_step7_mergegate.py | 254 ++++++++++++++++++++++++++++++++++ 2 files changed, 411 insertions(+) create mode 100644 scripts/step7/mergegate.py create mode 100644 tests/test_step7_mergegate.py diff --git a/scripts/step7/mergegate.py b/scripts/step7/mergegate.py new file mode 100644 index 00000000..49620fa4 --- /dev/null +++ b/scripts/step7/mergegate.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""P-022 — the merge gate: does the world a freeze enters satisfy its references? + +The frozen T0 names things that live outside itself: an accepted harness +identity, the step-7 machinery that enforces the campaign join, the T0-0 +amendment that revoked automatic collection authority. On the branch where T0 +was frozen, some of those referents arrive through *other* pull requests. That +is fine while the merge order holds — and merge order held by agreement is one +stray click away from becoming an archaeological artifact, with a document +saying FROZEN in a tree where its referent does not exist yet. + +So the order is checked rather than promised, and it is checked against the +**target tree**, never against pull-request numbers. A PR number proves that +someone pressed a button; it proves nothing about what the merged tree contains. + +Run it on the commit a merge would produce: + + python scripts/step7/mergegate.py --repo . --commit + +Exit 0 only when every predicate holds. Any failure means the frozen contract +would become reachable from a tree that cannot satisfy it, and the merge is +refused however mergeable the forge believes it to be. + +The digest is not hard-coded here: it is read out of the frozen T0 and then +RECOMPUTED from the instrument sources in the target tree, by the frozen +formula, without importing the instrument. A gate that trusted a constant in +its own source would be checking itself. +""" + +from __future__ import annotations + +import argparse + +import json +import re +import subprocess +import sys +from pathlib import Path + +import execbinding as eb # the single implementation of the harness-digest formula + +T0_PATH = "docs/notes/p022-263-t0-protocol-freeze.md" +STEP7_NOTE = "docs/notes/p022-263a-step7-environment-capture.md" +BINDING_ARTIFACTS = ( + "docs/evidence/calibration/p022-263a-policy-freeze.json", + "docs/evidence/calibration/p022-263a-design-constants.json", + "docs/evidence/calibration/p022-263a-training-preregistration.json", +) +STEP7_TOOLS = ("scripts/step7/envcapture.py", "scripts/step7/hostqual.py", + "scripts/step7/execbinding.py") +DIGEST_RE = re.compile(r"measurement_harness_digest\s*\n?\s*([0-9a-f]{64})") + + +def blob(repo: Path, commit: str, path: str) -> bytes | None: + proc = subprocess.run(["git", "-C", str(repo), "cat-file", "blob", f"{commit}:{path}"], + capture_output=True, check=False) + return proc.stdout if proc.returncode == 0 else None + + +def check(name: str, ok: bool, detail: str) -> dict[str, object]: + return {"check": name, "result": "pass" if ok else "fail", "detail": detail} + + +def gate(repo: Path, commit: str) -> list[dict[str, object]]: + results: list[dict[str, object]] = [] + + raw_t0 = blob(repo, commit, T0_PATH) + if raw_t0 is None: + return [check("t0_present", False, f"{T0_PATH} does not exist at {commit}")] + text = raw_t0.decode("utf-8", "replace") + frozen = re.search(r"^\s*(NOT_FROZEN|FROZEN)\.?\s*$", text, re.MULTILINE) + flag = re.search(r"^\s*collection_authorized:\s*(true|false)\s*$", text, re.MULTILINE) + declared = frozen.group(1) if frozen else "" + authorized = (flag.group(1) == "true") if flag else None + results.append(check( + "t0_frozen_and_authorized", declared == "FROZEN" and authorized is True, + f"T0 declares {declared}, collection_authorized={json.dumps(authorized)}")) + + named = DIGEST_RE.search(text) + results.append(check("t0_names_a_digest", named is not None, + f"T0 names {named.group(1)[:12] if named else ''} as the accepted " + "harness identity")) + if named is None: + return results + expected = named.group(1) + + # Recomputed from the target tree's own sources, by the frozen formula. + live = eb.harness_digest_at(repo, commit) + results.append(check( + "instrument_matches_t0", live == expected, + f"the instrument at {commit[:12]} hashes to {live[:12] if live else ''}; " + f"T0 names {expected[:12]}. The frozen contract would otherwise point at an identity " + "this tree does not contain")) + + rebound = [] + for path in BINDING_ARTIFACTS: + raw = blob(repo, commit, path) + if raw is None: + rebound.append(f"{Path(path).name}: absent") + elif expected not in raw.decode("utf-8", "replace"): + rebound.append(f"{Path(path).name}: still bound to an older digest") + results.append(check( + "steps_4_5_6_rebound", not rebound, + "; ".join(rebound) if rebound else + f"the policy freeze, the design constants and the training preregistration all bind " + f"{expected[:12]}")) + + missing_tools = [t for t in STEP7_TOOLS if blob(repo, commit, t) is None] + hostqual = (blob(repo, commit, "scripts/step7/hostqual.py") or b"").decode("utf-8", "replace") + enforces_link = "CAMPAIGN_LINK_SCHEMA" in hostqual and "check_campaign_link" in hostqual + enforces_authority = "collection_authorized is" in hostqual or ( + "authorized is not True" in hostqual) + results.append(check( + "step7_machinery_present", not missing_tools and enforces_link and enforces_authority, + "; ".join(filter(None, [ + f"missing: {missing_tools}" if missing_tools else "", + "" if enforces_link else "hostqual does not enforce the campaign link", + "" if enforces_authority else "hostqual does not enforce the authority state", + ])) or "capture, qualification and binding tools are present, and the campaign link and " + "authority state are enforced")) + + note = (blob(repo, commit, STEP7_NOTE) or b"").decode("utf-8", "replace") + revoked = "AUTOMATIC AUTHORISATION OF THE FIRST STEP-7 COLLECTION IS REVOKED" in note + lingering = "SINGLE STEP-7 COLLECTION AUTHORISED automatically" in note + results.append(check( + "t0_zero_prerequisite", revoked and not lingering, + "the step-7 status block revokes the automatic collection authority" + if revoked and not lingering else + "the step-7 note still authorises the first collection automatically, so hosts plus a " + "binding would again be enough to start a clock")) + return results + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", type=Path, default=Path(__file__).resolve().parents[2]) + parser.add_argument("--commit", default="HEAD") + parser.add_argument("--json", action="store_true") + args = parser.parse_args(argv) + + results = gate(args.repo, args.commit) + failed = [r for r in results if r["result"] != "pass"] + if args.json: + print(json.dumps({"commit": args.commit, "checks": results, + "merge_allowed": not failed}, indent=2)) + else: + for r in results: + print(f"{'ok ' if r['result'] == 'pass' else 'FAIL'} [{r['check']}] {r['detail']}") + print() + print("merge gate: allowed" if not failed else + f"merge gate: REFUSED, {len(failed)} predicate(s) unsatisfied — the frozen " + "contract must not become reachable from this tree") + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_step7_mergegate.py b/tests/test_step7_mergegate.py new file mode 100644 index 00000000..9340d2e4 --- /dev/null +++ b/tests/test_step7_mergegate.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +"""P-022 — controls on the merge gate. + + mergegate-complete-tree a world that satisfies the freeze is allowed + mergegate-missing-instrument the digest T0 names must exist in the tree + mergegate-stale-bindings steps 4/5/6 must be re-bound to that digest + mergegate-missing-machinery the campaign link and authority must be enforceable + mergegate-lingering-auto the revoked automatic authority must stay revoked + mergegate-unfrozen-t0 an unfrozen or unauthorised T0 is not merged as frozen + mergegate-reads-t0-digest the expected digest comes from T0, not from a constant + +Every control builds a throwaway repository, so the gate is exercised against +real git objects rather than against a mock of the thing it exists to read. + +Failures print `FAIL[]: `; nothing stops at the first one. + +Run: python tests/test_step7_mergegate.py +""" + +from __future__ import annotations + +import contextlib +import io +import re +import subprocess +import sys +import tempfile +from collections.abc import Callable +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "scripts")) +sys.path.insert(0, str(ROOT / "scripts" / "step7")) + +import execbinding as eb # noqa: E402 +import mergegate as mg # noqa: E402 + +_FAILURES: list[tuple[str, str]] = [] +_PASSES: list[str] = [] + + +def fail(check: str, detail: str) -> None: + _FAILURES.append((check, detail)) + print(f"FAIL[{check}]: {detail}") + + +def ok(check: str, detail: str = "") -> None: + _PASSES.append(check) + print(f"ok[{check}]: {detail}" if detail else f"ok[{check}]") + + +def guarded(check: str, control: Callable[[], None]) -> None: + try: + control() + except Exception as exc: + fail(check, f"the control raised {type(exc).__name__}: {exc}") + + +HOSTQUAL_STUB = ('CAMPAIGN_LINK_SCHEMA = "own.net/p022/campaign-link"\n' + 'def check_campaign_link():\n pass\n' + 'def gate():\n if authorized is not True:\n return "refuse"\n') +STEP7_NOTE_REVOKED = ("Status:\n AUTOMATIC AUTHORISATION OF THE FIRST STEP-7 COLLECTION " + "IS REVOKED (T0-0).\n") +STEP7_NOTE_AUTO = "Status:\n SINGLE STEP-7 COLLECTION AUTHORISED automatically after that gate.\n" + + +def commit_tree(repo: Path, files: dict[str, str]) -> str: + repo.mkdir(parents=True, exist_ok=True) + + def run(*args: str) -> None: + subprocess.run(["git", "-C", str(repo), *args], capture_output=True, check=True) + + if not (repo / ".git").exists(): + run("init", "-q") + run("config", "user.email", "control@example.invalid") + run("config", "user.name", "control") + for name, text in files.items(): + path = repo / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + run("add", "-A") + run("commit", "-q", "-m", "fixture") + return subprocess.run(["git", "-C", str(repo), "rev-parse", "HEAD"], + capture_output=True, text=True, check=True).stdout.strip() + + +def world(tmp: Path, *, frozen: bool = True, authorized: bool = True, + instrument: str = "print('instrument')\n", rebound: bool = True, + machinery: bool = True, auto_authority: bool = False, + name: str = "w") -> tuple[Path, str]: + """A synthetic target tree, and the commit a merge into it would produce.""" + repo = tmp / name + # The digest T0 will name is whatever this tree's own sources hash to, so a + # fixture cannot pass by agreeing with a constant this file also wrote. + files = {mg.STEP7_TOOLS[0]: "# capture\n", + eb.INSTRUMENT_SOURCES[0]: instrument, + eb.INSTRUMENT_SOURCES[1]: '{"decisive": []}\n', + mg.STEP7_NOTE: STEP7_NOTE_AUTO if auto_authority else STEP7_NOTE_REVOKED} + if machinery: + files[mg.STEP7_TOOLS[1]] = HOSTQUAL_STUB + files[mg.STEP7_TOOLS[2]] = "# binding\n" + probe = commit_tree(repo, files) + digest = eb.harness_digest_at(repo, probe) or "" + status = "FROZEN." if frozen else "NOT_FROZEN." + flag = "true" if authorized else "false" + files[mg.T0_PATH] = (f"```text\nStatus:\n {status}\n collection_authorized: {flag}\n```\n\n" + f" measurement_harness_digest\n {digest}\n") + bound = digest if rebound else "0" * 64 + for artifact in mg.BINDING_ARTIFACTS: + files[artifact] = '{"measurement_harness_digest": "' + bound + '"}\n' + return repo, commit_tree(repo, files) + + +def verdicts(repo: Path, commit: str) -> dict[str, str]: + return {str(r["check"]): str(r["result"]) for r in mg.gate(repo, commit)} + + +def control_complete_tree() -> None: + with tempfile.TemporaryDirectory() as raw: + repo, commit = world(Path(raw)) + results = verdicts(repo, commit) + bad = sorted(k for k, v in results.items() if v != "pass") + if bad: + fail("mergegate-complete-tree", f"a satisfying world was refused on {bad}") + return + # Captured: a green run that prints a refusal teaches readers to skim past + # refusals, which is how the word stops meaning anything. + with contextlib.redirect_stdout(io.StringIO()): + rc = mg.main(["--repo", str(repo), "--commit", commit]) + if rc != 0: + fail("mergegate-complete-tree", "the CLI refused a satisfying world") + return + ok("mergegate-complete-tree", + "a tree carrying the named instrument, the re-bound artifacts, the machinery and the " + "revoked automatic authority is allowed") + + +def control_missing_instrument() -> None: + with tempfile.TemporaryDirectory() as raw: + tmp = Path(raw) + repo, commit = world(tmp) + # the instrument moves after T0 named it — the exact intermediate state a + # stray merge produces when the repair PR is not in yet + drifted = commit_tree(repo, {eb.INSTRUMENT_SOURCES[0]: "print('older instrument')\n"}) + results = verdicts(repo, drifted) + if results.get("instrument_matches_t0") != "fail": + fail("mergegate-missing-instrument", + "a tree whose instrument does not hash to the digest T0 names was allowed") + return + noise = io.StringIO() + with contextlib.redirect_stdout(noise): + rc = mg.main(["--repo", str(repo), "--commit", drifted]) + if rc == 0 or "REFUSED" not in noise.getvalue(): + fail("mergegate-missing-instrument", "the CLI allowed it") + return + ok("mergegate-missing-instrument", + "the frozen contract cannot become reachable from a tree whose instrument is not the " + "one it names") + + +def control_stale_bindings() -> None: + with tempfile.TemporaryDirectory() as raw: + repo, commit = world(Path(raw), rebound=False) + if verdicts(repo, commit).get("steps_4_5_6_rebound") != "fail": + fail("mergegate-stale-bindings", "artifacts bound to an older digest were allowed") + return + ok("mergegate-stale-bindings", + "steps 4, 5 and 6 must be re-bound to the digest T0 names, not merely present") + + +def control_missing_machinery() -> None: + with tempfile.TemporaryDirectory() as raw: + repo, commit = world(Path(raw), machinery=False) + if verdicts(repo, commit).get("step7_machinery_present") != "fail": + fail("mergegate-missing-machinery", + "a tree without the qualification and binding tools was allowed") + return + ok("mergegate-missing-machinery", + "without the tools that enforce the campaign link and the authority state, the freeze " + "would enter a world that cannot keep its promises") + + +def control_lingering_auto() -> None: + with tempfile.TemporaryDirectory() as raw: + repo, commit = world(Path(raw), auto_authority=True) + if verdicts(repo, commit).get("t0_zero_prerequisite") != "fail": + fail("mergegate-lingering-auto", + "a tree still authorising the first collection automatically was allowed") + return + ok("mergegate-lingering-auto", + "the revoked automatic authority must still be revoked in the target tree, or hosts " + "plus a binding are again enough to start a clock") + + +def control_unfrozen_t0() -> None: + with tempfile.TemporaryDirectory() as raw: + tmp = Path(raw) + for label, kwargs in (("not frozen", {"frozen": False}), + ("frozen but unauthorised", {"authorized": False}), + ("unfrozen but authorised", {"frozen": False, "authorized": True})): + repo, commit = world(tmp, name=f"w-{len(list(tmp.glob('w-*')))}", **kwargs) + if verdicts(repo, commit).get("t0_frozen_and_authorized") != "fail": + fail("mergegate-unfrozen-t0", f"{label} was treated as a freeze") + return + ok("mergegate-unfrozen-t0", + "only FROZEN together with collection_authorized: true is merged as a freeze; the other " + "three states are refused here as they are at the session gates") + + +def control_reads_t0_digest() -> None: + """The gate must not carry the expected digest in its own source.""" + source = (ROOT / "scripts" / "step7" / "mergegate.py").read_text(encoding="utf-8") + hardcoded = re.findall(r"\b[0-9a-f]{64}\b", source) + if hardcoded: + fail("mergegate-reads-t0-digest", + f"the gate carries {len(hardcoded)} literal digest(s) in its own source; it would " + "then be checking a constant it wrote rather than the contract") + return + with tempfile.TemporaryDirectory() as raw: + # two different instruments, two different digests, both accepted because + # each tree's T0 names its own + for text in ("print('one')\n", "print('a completely different instrument')\n"): + repo, commit = world(Path(raw), instrument=text, + name=f"d{abs(hash(text)) % 1000}") + if verdicts(repo, commit).get("instrument_matches_t0") != "pass": + fail("mergegate-reads-t0-digest", + "a tree whose T0 names its own instrument was refused") + return + ok("mergegate-reads-t0-digest", + "the expected digest is read out of the frozen T0 and recomputed from the target tree; " + "no digest is written into this gate's own source") + + +CONTROLS: list[tuple[str, Callable[[], None]]] = [ + ("mergegate-complete-tree", control_complete_tree), + ("mergegate-missing-instrument", control_missing_instrument), + ("mergegate-stale-bindings", control_stale_bindings), + ("mergegate-missing-machinery", control_missing_machinery), + ("mergegate-lingering-auto", control_lingering_auto), + ("mergegate-unfrozen-t0", control_unfrozen_t0), + ("mergegate-reads-t0-digest", control_reads_t0_digest), +] + + +def run() -> int: + for name, control in CONTROLS: + guarded(name, control) + print() + print(f"merge gate controls: {len(_PASSES)} passed, {len(_FAILURES)} failed") + return 1 if _FAILURES else 0 + + +if __name__ == "__main__": + sys.exit(run()) From 81b3a130018bb1ba0d504a56862f7ff93b84920c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 07:46:14 +0500 Subject: [PATCH 07/15] fix(step7): the merge gate must run the machinery, not read its name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first revision proved words. `step7_machinery_present` asked whether the two tool files existed and whether certain identifiers appeared in them, which a file containing `def check_campaign_link(): pass` satisfies — and the fixture that was supposed to exclude that world was exactly that file. A check for the name of a mechanism is not a check of the mechanism. `step7_machinery_enforces` now extracts the target tree's own hostqual and execbinding, runs them in a subprocess against a synthetic campaign, and requires all four attacks to be refused: a link naming another execution binding, a freeze edited after the link was made, FROZEN with collection_authorized false (on both readers), and a campaign swapped between preflight and postflight. A tool that accepts any of them fails the gate, and the refusal says which attack got through. `steps_4_5_6_rebound` searched each artifact's text for the digest. The right digest sitting in any field — a comment, a history entry, a field nobody binds — was read as a binding. It now walks the exact path each artifact actually binds at: measurement_harness_digest in the policy freeze, bound_measurement_harness_digest in the design constants, bindings.measurement_harness_digest in the training preregistration. Anywhere else is not a binding, and the refusal names the path and both digests. Fixtures ship the real tools; the two attack controls mutate one enforcement point each and require the gate to notice. Twelve controls, four of them new: a permissive link check is refused, an authority check that always passes is refused, a stale binding with the right digest in a decoy field is refused, and correct exact fields are allowed. Against the real merges: the full chain is allowed (rc 0); #354 without #355 and #356 is refused on three predicates (rc 1); #354 with the instrument but without the step-7 tools is refused on one (rc 1). 30d2f32 is untouched. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh --- scripts/step7/mergegate.py | 234 +++++++++++++++++++++++++++--- tests/test_step7_mergegate.py | 260 +++++++++++++++++++++++++++------- 2 files changed, 419 insertions(+), 75 deletions(-) diff --git a/scripts/step7/mergegate.py b/scripts/step7/mergegate.py index 49620fa4..fce995f6 100644 --- a/scripts/step7/mergegate.py +++ b/scripts/step7/mergegate.py @@ -35,17 +35,23 @@ import re import subprocess import sys +import tempfile from pathlib import Path import execbinding as eb # the single implementation of the harness-digest formula T0_PATH = "docs/notes/p022-263-t0-protocol-freeze.md" STEP7_NOTE = "docs/notes/p022-263a-step7-environment-capture.md" -BINDING_ARTIFACTS = ( - "docs/evidence/calibration/p022-263a-policy-freeze.json", - "docs/evidence/calibration/p022-263a-design-constants.json", - "docs/evidence/calibration/p022-263a-training-preregistration.json", -) +# Each artifact binds the digest at its own exact path. Named here so the check +# reads the binding rather than searching the file for a hopeful substring. +BINDING_ARTIFACTS = { + "docs/evidence/calibration/p022-263a-policy-freeze.json": + ("measurement_harness_digest",), + "docs/evidence/calibration/p022-263a-design-constants.json": + ("bound_measurement_harness_digest",), + "docs/evidence/calibration/p022-263a-training-preregistration.json": + ("bindings", "measurement_harness_digest"), +} STEP7_TOOLS = ("scripts/step7/envcapture.py", "scripts/step7/hostqual.py", "scripts/step7/execbinding.py") DIGEST_RE = re.compile(r"measurement_harness_digest\s*\n?\s*([0-9a-f]{64})") @@ -57,6 +63,183 @@ def blob(repo: Path, commit: str, path: str) -> bytes | None: return proc.stdout if proc.returncode == 0 else None +# The witness runs INSIDE the candidate tree's own modules, in a subprocess, so +# that importing them cannot bind to this gate's copies and a passing result +# cannot come from anything but the code being merged. +WITNESS_SOURCE = r""" +import json, sys, subprocess +from pathlib import Path +sys.path.insert(0, "scripts/step7") +import hostqual as hq +import execbinding as eb + +tmp = Path(sys.argv[1]) +POWER = {"platform": "windows", "plan_guid": hq.WIN_ACCEPTED_PLANS[0], + "processor_min_ac": 100, "processor_max_ac": 100, + "processor_min_dc": 100, "processor_max_dc": 100} +hq.power_snapshot = lambda: dict(POWER) +failures = [] + +def repo_with(name, files): + root = tmp / name + root.mkdir(parents=True, exist_ok=True) + run = lambda *a: subprocess.run(["git", "-C", str(root), *a], capture_output=True, check=True) + if not (root / ".git").exists(): + run("init", "-q"); run("config", "user.email", "w@x"); run("config", "user.name", "w") + for rel, text in files.items(): + f = root / rel; f.parent.mkdir(parents=True, exist_ok=True) + f.write_text(text, encoding="utf-8") + run("add", "-A"); run("commit", "-q", "-m", "w") + return root, subprocess.run(["git", "-C", str(root), "rev-parse", "HEAD"], + capture_output=True, text=True, check=True).stdout.strip() + +def w(name, doc): + f = tmp / name; f.write_text(json.dumps(doc), encoding="utf-8"); return f + +# --- W3/W4: the authority state must bite on both readers ------------------- +for label, status, flag, must_pass in (("FROZEN+false", "FROZEN.", "false", False), + ("NOT_FROZEN+true", "NOT_FROZEN.", "true", False), + ("FROZEN+true", "FROZEN.", "true", True)): + body = "```text\nStatus:\n %s\n collection_authorized: %s\n```\n" % (status, flag) + root, commit = repo_with("t0-" + flag + status[:4], {"t0.md": body}) + got = hq.bind_t0(root, "t0.md", commit)[1]["result"] == "pass" + if got != must_pass: + failures.append("hostqual accepted " + label if got else + "hostqual refused " + label) + try: + eb.t0_at(root, "t0.md", commit); accepted = True + except eb.BindingRefused: + accepted = False + if accepted != must_pass: + failures.append("execbinding accepted " + label if accepted else + "execbinding refused " + label) + +# --- W1/W2: the campaign link must be verified, not merely mentioned -------- +froot, fcommit = repo_with("freeze", {"d7/payload.json": '{"kind": "d7"}\n', + "d7/attestation.json": '{"kind": "att"}\n'}) +payload, att = froot / "d7/payload.json", froot / "d7/attestation.json" +blob = subprocess.run(["git", "-C", str(froot), "rev-parse", fcommit + ":d7/payload.json"], + capture_output=True, text=True, check=True).stdout.strip() +binding = w("binding.json", {"kind": "own.net/p022/execution-binding"}) +link = {"kind": hq.CAMPAIGN_LINK_SCHEMA, "schema": 1, + "execution_binding_sha256": hq.sha256_file(binding), + "d7_payload": {"path": "d7/payload.json", "sha256": hq.sha256_file(payload), + "blob_sha": blob, "commit": fcommit}, + "d7_attestation": {"path": "d7/attestation.json", "sha256": hq.sha256_file(att)}, + "recorded_at": "w"} +if hq.check_campaign_link(link, binding, froot)["result"] != "pass": + failures.append("a correct campaign link was refused") +wrong = dict(link, execution_binding_sha256="f" * 64) +if hq.check_campaign_link(wrong, binding, froot)["result"] != "fail": + failures.append("a link naming another execution binding was accepted") +payload.write_text('{"kind": "d7", "edited": true}\n', encoding="utf-8") +if hq.check_campaign_link(link, binding, froot)["result"] != "fail": + failures.append("a freeze edited after linking was accepted") + +# --- W5: a campaign cannot be swapped between preflight and postflight ------ +QUAL = {"kind": hq.QUALIFICATION_SCHEMA, "schema": 1, "stratum": "linux", + "t0": {"commit": "c" * 40, "path": "t0.md", "blob_sha": "b" * 40, + "sha256": "f" * 64, "status": "FROZEN"}, + "environment_id": "env-1", "host_fingerprint": "sha256:abc", + "provisioning": {"sha256": "0" * 64}, "environment_manifest": {"sha256": "1" * 64}, + "qualification_tool": {"sha256": "2" * 64}, "power_snapshot": dict(POWER), + "predicate": {k: "pass" for k in hq.PREDICATE_KEYS}, + "memory_metric": hq.STRATUM_METRIC["linux"], "qualified": True, "qualified_at": "w"} +MANIFEST = {"schema": hq.ENVCAPTURE_SCHEMA, + "identity": {"environment_id": {"status": "observed", "value": "env-1"}, + "host_fingerprint": {"status": "observed", "value": "sha256:abc"}}, + "provenance": {"ci": False}} +QUAL["environment_identity_sha256"] = hq.canonical_sha256(MANIFEST["identity"]) +CAND = b"candidate bytes" +cand = tmp / "cand.bin"; cand.write_bytes(CAND) +qpath = w("qual.json", QUAL) +stratum_block = lambda m, extra: {"qualification_sha256": hq.sha256_file(qpath), + "environment_id": "env-1", "host_fingerprint": "sha256:abc", + "environment_identity_sha256": QUAL["environment_identity_sha256"], + "candidate_sha256": hq.sha256_bytes(CAND), + "candidate_bytes": len(CAND), "memory_metric": m, **extra} +BINDING = {"kind": eb.BINDING_SCHEMA, "schema": 1, + "t0": {"commit": "c" * 40, "path": "t0.md", "blob_sha": "b" * 40, "sha256": "f" * 64}, + "instrument": {"accepted_commit": "a" * 40, "harness_digest": "d" * 64}, + "workloads": {"path": eb.WORKLOAD_MANIFEST, "manifest_sha256": "9" * 64}, + "linux": stratum_block(hq.STRATUM_METRIC["linux"], {}), + "windows": stratum_block(hq.STRATUM_METRIC["windows"], {})} +bpath = w("exec-binding.json", BINDING) +mpath = w("manifest.json", MANIFEST) +dpath = w("declaration.json", {"kind": hq.DECLARATION_SCHEMA, "schema": 1, + "no_campaign_workload": True, + "no_interactive_user_workload": True, + "no_prohibited_background_job_active": True, + "operator": "w", "recorded_at": "w"}) +probe = tmp / "probe.json"; probe.write_text("{}", encoding="utf-8") +quiet = {"eligible": True, "reason": "", "samples": [0.01], "mean": 0.01, "max": 0.01} + +def link_for(binding_file, root, commit_sha, payload_file, att_file, blob_sha): + return {"kind": hq.CAMPAIGN_LINK_SCHEMA, "schema": 1, + "execution_binding_sha256": hq.sha256_file(binding_file), + "d7_payload": {"path": "d7/payload.json", "sha256": hq.sha256_file(payload_file), + "blob_sha": blob_sha, "commit": commit_sha}, + "d7_attestation": {"path": "d7/attestation.json", "sha256": hq.sha256_file(att_file)}, + "recorded_at": "w"} + +r2, c2 = repo_with("freeze2", {"d7/payload.json": '{"kind": "d7", "campaign": "two"}\n', + "d7/attestation.json": '{"kind": "att"}\n'}) +p2, a2 = r2 / "d7/payload.json", r2 / "d7/attestation.json" +b2 = subprocess.run(["git", "-C", str(r2), "rev-parse", c2 + ":d7/payload.json"], + capture_output=True, text=True, check=True).stdout.strip() +r3, c3 = repo_with("freeze3", {"d7/payload.json": '{"kind": "d7", "campaign": "three"}\n', + "d7/attestation.json": '{"kind": "att"}\n'}) +p3, a3 = r3 / "d7/payload.json", r3 / "d7/attestation.json" +b3 = subprocess.run(["git", "-C", str(r3), "rev-parse", c3 + ":d7/payload.json"], + capture_output=True, text=True, check=True).stdout.strip() +l2 = w("link2.json", link_for(bpath, r2, c2, p2, a2, b2)) +l3 = w("link3.json", link_for(bpath, r3, c3, p3, a3, b3)) + +pre = hq.session_eligibility(bpath, qpath, mpath, dpath, cand, l2, r2, quiesce_result=quiet) +if not pre["eligible"]: + failures.append("a correctly linked session was refused: " + str(pre["reasons"])[:120]) +ppath = w("preflight.json", pre) +after = hq.session_admissibility(bpath, qpath, ppath, mpath, cand, probe, l3, r3) +if after["admissible"]: + failures.append("a campaign swapped between preflight and postflight was admissible") + +print(json.dumps(failures)) +""" + + +def witness(repo: Path, commit: str) -> list[str]: + """Run the target tree's own tools and require them to refuse. + + A name in a source file proves nothing: a comment, a dead function or + `def check_campaign_link(): pass` all satisfy a lexical check, and an earlier + revision of this gate was satisfied by exactly that — its own fixture shipped + the no-op and called the world compliant. So the tools are extracted from the + target tree, imported in a subprocess, and driven against attacks they are + claimed to stop. + """ + with tempfile.TemporaryDirectory() as raw: + work = Path(raw) + for tool in STEP7_TOOLS: + data = blob(repo, commit, tool) + if data is None: + return [f"{tool} is absent"] + target = work / tool + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + script = work / "witness.py" + script.write_text(WITNESS_SOURCE, encoding="utf-8") + fixtures = work / "fixtures" + fixtures.mkdir() + proc = subprocess.run([sys.executable, str(script), str(fixtures)], + capture_output=True, text=True, cwd=str(work), check=False) + if proc.returncode != 0: + tail = (proc.stderr or proc.stdout).strip().splitlines()[-1:] or [""] + return [f"the machinery could not be exercised: {tail[0][:160]}"] + try: + return list(json.loads(proc.stdout.strip().splitlines()[-1])) + except (ValueError, IndexError): + return [f"the witness produced no verdict: {proc.stdout[:120]!r}"] + def check(name: str, ok: bool, detail: str) -> dict[str, object]: return {"check": name, "result": "pass" if ok else "fail", "detail": detail} @@ -93,12 +276,25 @@ def gate(repo: Path, commit: str) -> list[dict[str, object]]: "this tree does not contain")) rebound = [] - for path in BINDING_ARTIFACTS: + for path, field in BINDING_ARTIFACTS.items(): raw = blob(repo, commit, path) if raw is None: rebound.append(f"{Path(path).name}: absent") - elif expected not in raw.decode("utf-8", "replace"): - rebound.append(f"{Path(path).name}: still bound to an older digest") + continue + try: + doc = json.loads(raw.decode("utf-8")) + except ValueError as exc: + rebound.append(f"{Path(path).name}: not readable JSON ({exc})") + continue + # The exact field, by its own path in that artifact. A substring search + # would accept the right digest sitting in any passing field while the + # real binding stayed old — which is the same defect one level down. + node: object = doc + for key in field: + node = node.get(key) if isinstance(node, dict) else None + if node != expected: + got = str(node)[:12] if node is not None else "" + rebound.append(f"{Path(path).name}: {'.'.join(field)} is {got}, not {expected[:12]}") results.append(check( "steps_4_5_6_rebound", not rebound, "; ".join(rebound) if rebound else @@ -106,18 +302,16 @@ def gate(repo: Path, commit: str) -> list[dict[str, object]]: f"{expected[:12]}")) missing_tools = [t for t in STEP7_TOOLS if blob(repo, commit, t) is None] - hostqual = (blob(repo, commit, "scripts/step7/hostqual.py") or b"").decode("utf-8", "replace") - enforces_link = "CAMPAIGN_LINK_SCHEMA" in hostqual and "check_campaign_link" in hostqual - enforces_authority = "collection_authorized is" in hostqual or ( - "authorized is not True" in hostqual) - results.append(check( - "step7_machinery_present", not missing_tools and enforces_link and enforces_authority, - "; ".join(filter(None, [ - f"missing: {missing_tools}" if missing_tools else "", - "" if enforces_link else "hostqual does not enforce the campaign link", - "" if enforces_authority else "hostqual does not enforce the authority state", - ])) or "capture, qualification and binding tools are present, and the campaign link and " - "authority state are enforced")) + if missing_tools: + results.append(check("step7_machinery_enforces", False, f"missing: {missing_tools}")) + else: + refusals = witness(repo, commit) + results.append(check( + "step7_machinery_enforces", not refusals, + "; ".join(refusals) if refusals else + "the target tree's own tools were run and refused all four attacks: a link naming " + "another binding, a freeze edited after linking, FROZEN with collection_authorized " + "false, and a campaign swapped between preflight and postflight")) note = (blob(repo, commit, STEP7_NOTE) or b"").decode("utf-8", "replace") revoked = "AUTOMATIC AUTHORISATION OF THE FIRST STEP-7 COLLECTION IS REVOKED" in note diff --git a/tests/test_step7_mergegate.py b/tests/test_step7_mergegate.py index 9340d2e4..29e3a332 100644 --- a/tests/test_step7_mergegate.py +++ b/tests/test_step7_mergegate.py @@ -1,18 +1,25 @@ #!/usr/bin/env python3 """P-022 — controls on the merge gate. - mergegate-complete-tree a world that satisfies the freeze is allowed - mergegate-missing-instrument the digest T0 names must exist in the tree - mergegate-stale-bindings steps 4/5/6 must be re-bound to that digest - mergegate-missing-machinery the campaign link and authority must be enforceable - mergegate-lingering-auto the revoked automatic authority must stay revoked - mergegate-unfrozen-t0 an unfrozen or unauthorised T0 is not merged as frozen - mergegate-reads-t0-digest the expected digest comes from T0, not from a constant - -Every control builds a throwaway repository, so the gate is exercised against -real git objects rather than against a mock of the thing it exists to read. - -Failures print `FAIL[]: `; nothing stops at the first one. + mergegate-complete-tree a world that satisfies the freeze is allowed + mergegate-missing-instrument the digest T0 names must exist in the tree + mergegate-stale-bindings steps 4/5/6 must be re-bound to that digest + mergegate-exact-binding-field the digest is read at each artifact's own path + mergegate-decoy-digest the right digest in a field nobody binds is not a binding + mergegate-noop-campaign-link a permissive link check must not satisfy the gate + mergegate-dead-authority an authority check that always passes must not satisfy it + mergegate-missing-machinery the tools must be there at all + mergegate-lingering-auto the revoked automatic authority must stay revoked + mergegate-unfrozen-t0 an unfrozen or unauthorised T0 is not merged as frozen + mergegate-reads-t0-digest the expected digest comes from T0, not from a constant + control-inventory-complete this list and the executed set are the same set + +Fixtures ship the real tools, and two controls mutate them. An earlier revision +shipped a stub whose check_campaign_link was a bare pass and called that world +compliant: the fixture demonstrated the false positive it was meant to exclude. +Checking for the name of a mechanism is not checking the mechanism. + +Failures print FAIL[]: ; nothing stops at the first one. Run: python tests/test_step7_mergegate.py """ @@ -21,6 +28,7 @@ import contextlib import io +import json import re import subprocess import sys @@ -35,6 +43,8 @@ import execbinding as eb # noqa: E402 import mergegate as mg # noqa: E402 +NL = chr(10) + _FAILURES: list[tuple[str, str]] = [] _PASSES: list[str] = [] @@ -56,12 +66,55 @@ def guarded(check: str, control: Callable[[], None]) -> None: fail(check, f"the control raised {type(exc).__name__}: {exc}") -HOSTQUAL_STUB = ('CAMPAIGN_LINK_SCHEMA = "own.net/p022/campaign-link"\n' - 'def check_campaign_link():\n pass\n' - 'def gate():\n if authorized is not True:\n return "refuse"\n') -STEP7_NOTE_REVOKED = ("Status:\n AUTOMATIC AUTHORISATION OF THE FIRST STEP-7 COLLECTION " - "IS REVOKED (T0-0).\n") -STEP7_NOTE_AUTO = "Status:\n SINGLE STEP-7 COLLECTION AUTHORISED automatically after that gate.\n" +NOTE_REVOKED = """Status: + AUTOMATIC AUTHORISATION OF THE FIRST STEP-7 COLLECTION IS REVOKED (T0-0). +""" +NOTE_AUTO = """Status: + SINGLE STEP-7 COLLECTION AUTHORISED automatically after that gate. +""" + +T0_TEMPLATE = """```text +Status: + {status} + collection_authorized: {flag} +``` + + measurement_harness_digest + {digest} +""" + +# The digest sits at a different exact path in each artifact, so a fixture that +# wrote one flat shape everywhere would be exercising a gate that does not exist. +BINDING_SHAPES = { + "docs/evidence/calibration/p022-263a-policy-freeze.json": + lambda d: {"measurement_harness_digest": d}, + "docs/evidence/calibration/p022-263a-design-constants.json": + lambda d: {"bound_measurement_harness_digest": d}, + "docs/evidence/calibration/p022-263a-training-preregistration.json": + lambda d: {"bindings": {"measurement_harness_digest": d}}, +} + +# Mutations appended to the real hostqual: a later definition wins, so each stub +# disables exactly one enforcement point and leaves the rest genuine. +NOOP_LINK = ''' + +def check_campaign_link(link, binding_path, repo): + return check("campaign_link", True, "stubbed: what a lexical gate accepted") +''' + +DEAD_AUTHORITY = ''' + +def bind_t0(repo, path, commit): + block = {"commit": commit, "path": path, "blob_sha": "b" * 40, + "sha256": "f" * 64, "status": "FROZEN", "collection_authorized": True} + return block, check("t0", True, "stubbed: always authorised") +''' + + +def real_tools() -> dict[str, str]: + """The tools as they actually are. A fixture that shipped a stub would prove + only that the gate accepts stubs.""" + return {t: (ROOT / t).read_text(encoding="utf-8") for t in mg.STEP7_TOOLS} def commit_tree(repo: Path, files: dict[str, str]) -> str: @@ -85,29 +138,37 @@ def run(*args: str) -> None: def world(tmp: Path, *, frozen: bool = True, authorized: bool = True, - instrument: str = "print('instrument')\n", rebound: bool = True, - machinery: bool = True, auto_authority: bool = False, - name: str = "w") -> tuple[Path, str]: + instrument: str = "print('instrument')" + NL, rebound: bool = True, + machinery: bool = True, auto_authority: bool = False, decoy: bool = False, + wrong_field: bool = False, mutate: str = "", name: str = "w") -> tuple[Path, str]: """A synthetic target tree, and the commit a merge into it would produce.""" repo = tmp / name - # The digest T0 will name is whatever this tree's own sources hash to, so a - # fixture cannot pass by agreeing with a constant this file also wrote. - files = {mg.STEP7_TOOLS[0]: "# capture\n", - eb.INSTRUMENT_SOURCES[0]: instrument, - eb.INSTRUMENT_SOURCES[1]: '{"decisive": []}\n', - mg.STEP7_NOTE: STEP7_NOTE_AUTO if auto_authority else STEP7_NOTE_REVOKED} + files = {eb.INSTRUMENT_SOURCES[0]: instrument, + eb.INSTRUMENT_SOURCES[1]: '{"decisive": []}' + NL, + mg.STEP7_NOTE: NOTE_AUTO if auto_authority else NOTE_REVOKED} if machinery: - files[mg.STEP7_TOOLS[1]] = HOSTQUAL_STUB - files[mg.STEP7_TOOLS[2]] = "# binding\n" + tools = real_tools() + if mutate: + tools[mg.STEP7_TOOLS[1]] = tools[mg.STEP7_TOOLS[1]] + mutate + files.update(tools) + else: + files[mg.STEP7_TOOLS[0]] = "# capture only" + NL probe = commit_tree(repo, files) + # T0 names whatever THIS tree's own sources hash to, so a fixture cannot pass + # by agreeing with a constant this file also wrote. digest = eb.harness_digest_at(repo, probe) or "" - status = "FROZEN." if frozen else "NOT_FROZEN." - flag = "true" if authorized else "false" - files[mg.T0_PATH] = (f"```text\nStatus:\n {status}\n collection_authorized: {flag}\n```\n\n" - f" measurement_harness_digest\n {digest}\n") + files[mg.T0_PATH] = T0_TEMPLATE.format( + status="FROZEN." if frozen else "NOT_FROZEN.", + flag="true" if authorized else "false", + digest=digest) bound = digest if rebound else "0" * 64 - for artifact in mg.BINDING_ARTIFACTS: - files[artifact] = '{"measurement_harness_digest": "' + bound + '"}\n' + for artifact, shape in BINDING_SHAPES.items(): + doc = shape(bound) + if decoy: # the right digest, in a field nobody binds + doc["decoy"] = digest + if wrong_field and "training" in artifact: # right digest, wrong exact path + doc = {"measurement_harness_digest": digest} + files[artifact] = json.dumps(doc, indent=2) + NL return repo, commit_tree(repo, files) @@ -115,13 +176,18 @@ def verdicts(repo: Path, commit: str) -> dict[str, str]: return {str(r["check"]): str(r["result"]) for r in mg.gate(repo, commit)} +def details(repo: Path, commit: str) -> dict[str, str]: + return {str(r["check"]): str(r["detail"]) for r in mg.gate(repo, commit)} + + def control_complete_tree() -> None: with tempfile.TemporaryDirectory() as raw: repo, commit = world(Path(raw)) - results = verdicts(repo, commit) - bad = sorted(k for k, v in results.items() if v != "pass") + bad = sorted(k for k, v in verdicts(repo, commit).items() if v != "pass") if bad: - fail("mergegate-complete-tree", f"a satisfying world was refused on {bad}") + seen = details(repo, commit) + fail("mergegate-complete-tree", + f"a satisfying world was refused on {bad}: {[seen[k][:90] for k in bad]}") return # Captured: a green run that prints a refusal teaches readers to skim past # refusals, which is how the word stops meaning anything. @@ -131,19 +197,17 @@ def control_complete_tree() -> None: fail("mergegate-complete-tree", "the CLI refused a satisfying world") return ok("mergegate-complete-tree", - "a tree carrying the named instrument, the re-bound artifacts, the machinery and the " - "revoked automatic authority is allowed") + "a tree carrying the named instrument, the exactly re-bound artifacts, the real tools " + "and the revoked automatic authority is allowed") def control_missing_instrument() -> None: with tempfile.TemporaryDirectory() as raw: - tmp = Path(raw) - repo, commit = world(tmp) + repo, commit = world(Path(raw)) # the instrument moves after T0 named it — the exact intermediate state a # stray merge produces when the repair PR is not in yet - drifted = commit_tree(repo, {eb.INSTRUMENT_SOURCES[0]: "print('older instrument')\n"}) - results = verdicts(repo, drifted) - if results.get("instrument_matches_t0") != "fail": + drifted = commit_tree(repo, {eb.INSTRUMENT_SOURCES[0]: "print('older')" + NL}) + if verdicts(repo, drifted).get("instrument_matches_t0") != "fail": fail("mergegate-missing-instrument", "a tree whose instrument does not hash to the digest T0 names was allowed") return @@ -168,10 +232,78 @@ def control_stale_bindings() -> None: "steps 4, 5 and 6 must be re-bound to the digest T0 names, not merely present") +def control_exact_binding_field() -> None: + """Each artifact binds at its own path, and only that path counts.""" + with tempfile.TemporaryDirectory() as raw: + tmp = Path(raw) + repo, commit = world(tmp, name="exact") + if verdicts(repo, commit).get("steps_4_5_6_rebound") != "pass": + fail("mergegate-exact-binding-field", + "correct exact fields were refused: " + + details(repo, commit)["steps_4_5_6_rebound"]) + return + moved_repo, moved = world(tmp, wrong_field=True, name="wrongfield") + if verdicts(moved_repo, moved).get("steps_4_5_6_rebound") != "fail": + fail("mergegate-exact-binding-field", + "the training preregistration bound at the wrong path was accepted; the gate " + "is searching the file rather than reading the binding") + return + ok("mergegate-exact-binding-field", + "policy freeze at measurement_harness_digest, design constants at " + "bound_measurement_harness_digest, training preregistration at " + "bindings.measurement_harness_digest — and nowhere else") + + +def control_decoy_digest() -> None: + with tempfile.TemporaryDirectory() as raw: + repo, commit = world(Path(raw), rebound=False, decoy=True) + if verdicts(repo, commit).get("steps_4_5_6_rebound") != "fail": + fail("mergegate-decoy-digest", + "a stale binding passed because the right digest sat in a decoy field") + return + ok("mergegate-decoy-digest", + "the right digest in a field nobody binds is not a binding, and a substring search " + "would have called it one") + + +def control_noop_campaign_link() -> None: + """The attack the previous revision of this gate could not see.""" + with tempfile.TemporaryDirectory() as raw: + repo, commit = world(Path(raw), mutate=NOOP_LINK, name="noop") + if verdicts(repo, commit).get("step7_machinery_enforces") != "fail": + fail("mergegate-noop-campaign-link", + "a permissive check_campaign_link satisfied the gate; the name of a mechanism " + "is not the mechanism") + return + detail = details(repo, commit)["step7_machinery_enforces"] + if "accepted" not in detail: + fail("mergegate-noop-campaign-link", f"refused for an unrelated reason: {detail}") + return + ok("mergegate-noop-campaign-link", + "a tree whose link check always passes is refused, and the refusal names the attack " + "that got through") + + +def control_dead_authority() -> None: + with tempfile.TemporaryDirectory() as raw: + repo, commit = world(Path(raw), mutate=DEAD_AUTHORITY, name="deadauth") + if verdicts(repo, commit).get("step7_machinery_enforces") != "fail": + fail("mergegate-dead-authority", + "a bind_t0 that always returns pass satisfied the gate") + return + detail = details(repo, commit)["step7_machinery_enforces"] + if "accepted" not in detail: + fail("mergegate-dead-authority", f"refused for an unrelated reason: {detail}") + return + ok("mergegate-dead-authority", + "a tree whose authority check always passes is refused, and the refusal says which " + "state it wrongly accepted") + + def control_missing_machinery() -> None: with tempfile.TemporaryDirectory() as raw: repo, commit = world(Path(raw), machinery=False) - if verdicts(repo, commit).get("step7_machinery_present") != "fail": + if verdicts(repo, commit).get("step7_machinery_enforces") != "fail": fail("mergegate-missing-machinery", "a tree without the qualification and binding tools was allowed") return @@ -193,12 +325,13 @@ def control_lingering_auto() -> None: def control_unfrozen_t0() -> None: + states = (("not frozen", {"frozen": False}), + ("frozen but unauthorised", {"authorized": False}), + ("unfrozen but authorised", {"frozen": False, "authorized": True})) with tempfile.TemporaryDirectory() as raw: tmp = Path(raw) - for label, kwargs in (("not frozen", {"frozen": False}), - ("frozen but unauthorised", {"authorized": False}), - ("unfrozen but authorised", {"frozen": False, "authorized": True})): - repo, commit = world(tmp, name=f"w-{len(list(tmp.glob('w-*')))}", **kwargs) + for i, (label, kwargs) in enumerate(states): + repo, commit = world(tmp, name=f"state{i}", **kwargs) if verdicts(repo, commit).get("t0_frozen_and_authorized") != "fail": fail("mergegate-unfrozen-t0", f"{label} was treated as a freeze") return @@ -210,18 +343,18 @@ def control_unfrozen_t0() -> None: def control_reads_t0_digest() -> None: """The gate must not carry the expected digest in its own source.""" source = (ROOT / "scripts" / "step7" / "mergegate.py").read_text(encoding="utf-8") - hardcoded = re.findall(r"\b[0-9a-f]{64}\b", source) + hardcoded = re.findall("(? None: "no digest is written into this gate's own source") +def control_inventory_complete() -> None: + listed = set(re.findall("^ ([a-z0-9-]+) +[^ ]", __doc__ or "", re.MULTILINE)) + executed = {name for name, _ in CONTROLS} + if listed != executed: + fail("control-inventory-complete", + f"listed but not executed: {sorted(listed - executed)}; executed but not listed: " + f"{sorted(executed - listed)}") + return + ok("control-inventory-complete", + f"{len(executed)} controls listed, {len(executed)} executed, same names in both") + + CONTROLS: list[tuple[str, Callable[[], None]]] = [ ("mergegate-complete-tree", control_complete_tree), ("mergegate-missing-instrument", control_missing_instrument), ("mergegate-stale-bindings", control_stale_bindings), + ("mergegate-exact-binding-field", control_exact_binding_field), + ("mergegate-decoy-digest", control_decoy_digest), + ("mergegate-noop-campaign-link", control_noop_campaign_link), + ("mergegate-dead-authority", control_dead_authority), ("mergegate-missing-machinery", control_missing_machinery), ("mergegate-lingering-auto", control_lingering_auto), ("mergegate-unfrozen-t0", control_unfrozen_t0), ("mergegate-reads-t0-digest", control_reads_t0_digest), + ("control-inventory-complete", control_inventory_complete), ] From 4ce410123eb46ad5be13ccc831bf017761bad49b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 08:12:31 +0500 Subject: [PATCH 08/15] fix(step7): the witness needs positive controls, or refusal proves nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two holes in the proof, not in the mechanism. The authority table exercised three of the four states. R15 defines all four, and `NOT_FROZEN+false` was the one nobody checked — so a future tree in which a reader quietly authorised it would have passed the gate. It is in the table now, and `DEAD_AUTHORITY` has a twin that breaks `execbinding.t0_at` instead of `hostqual.bind_t0`: the gate's message claims enforcement on both readers, and a control that only breaks the first left half that claim resting on nothing. Both mutations must now be caught naming all three forbidden states. The campaign-swap witness asserted only that a swapped campaign is inadmissible. A `session_admissibility` returning `admissible: False` unconditionally would have satisfied it — the witness would have read "nothing is admissible" as "the swap was caught". It now requires the unchanged campaign to survive preflight to postflight first, and requires the swapped one to be refused by a reason naming the campaign link, so a broken closing probe cannot stand in for the continuity check. Two mutations prove each half bites: removing only the preflight/current campaign-link comparison while leaving `check_campaign_link` intact, and a postflight that refuses everything. A mutation that changes no bytes now raises rather than passing as a second positive control nobody reads as one. 15 controls, 0 failed. The probes are unchanged: full chain rc 0, #354 alone rc 1 on three predicates, #354 without the tools rc 1 on one. 30d2f32 is untouched. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh --- scripts/step7/mergegate.py | 20 ++++- tests/test_step7_mergegate.py | 133 +++++++++++++++++++++++++++++----- 2 files changed, 131 insertions(+), 22 deletions(-) diff --git a/scripts/step7/mergegate.py b/scripts/step7/mergegate.py index fce995f6..db4c71ba 100644 --- a/scripts/step7/mergegate.py +++ b/scripts/step7/mergegate.py @@ -99,6 +99,7 @@ def w(name, doc): # --- W3/W4: the authority state must bite on both readers ------------------- for label, status, flag, must_pass in (("FROZEN+false", "FROZEN.", "false", False), ("NOT_FROZEN+true", "NOT_FROZEN.", "true", False), + ("NOT_FROZEN+false", "NOT_FROZEN.", "false", False), ("FROZEN+true", "FROZEN.", "true", True)): body = "```text\nStatus:\n %s\n collection_authorized: %s\n```\n" % (status, flag) root, commit = repo_with("t0-" + flag + status[:4], {"t0.md": body}) @@ -199,9 +200,20 @@ def link_for(binding_file, root, commit_sha, payload_file, att_file, blob_sha): if not pre["eligible"]: failures.append("a correctly linked session was refused: " + str(pre["reasons"])[:120]) ppath = w("preflight.json", pre) +# The positive half first. Without it a postflight hard-wired to inadmissible +# would satisfy the negative half, and this witness would read "everything is +# refused" as "the swap is refused" — a broken closing probe standing in for the +# check it was meant to prove. +kept = hq.session_admissibility(bpath, qpath, ppath, mpath, cand, probe, l2, r2) +if not kept["admissible"]: + failures.append("an unchanged campaign was inadmissible at postflight: " + + str(kept.get("reasons"))[:140]) after = hq.session_admissibility(bpath, qpath, ppath, mpath, cand, probe, l3, r3) if after["admissible"]: failures.append("a campaign swapped between preflight and postflight was admissible") +elif not any("campaign link" in str(r) for r in after.get("reasons", ())): + failures.append("the swapped campaign was refused, but by no reason naming the campaign " + "link: " + str(after.get("reasons"))[:140]) print(json.dumps(failures)) """ @@ -309,9 +321,11 @@ def gate(repo: Path, commit: str) -> list[dict[str, object]]: results.append(check( "step7_machinery_enforces", not refusals, "; ".join(refusals) if refusals else - "the target tree's own tools were run and refused all four attacks: a link naming " - "another binding, a freeze edited after linking, FROZEN with collection_authorized " - "false, and a campaign swapped between preflight and postflight")) + "the target tree's own tools were run: both readers refused all three forbidden " + "authority states and accepted FROZEN+true; a link naming another binding and a " + "freeze edited after linking were refused; an unchanged campaign survived " + "preflight to postflight and a swapped one was refused by campaign-link " + "continuity")) note = (blob(repo, commit, STEP7_NOTE) or b"").decode("utf-8", "replace") revoked = "AUTOMATIC AUTHORISATION OF THE FIRST STEP-7 COLLECTION IS REVOKED" in note diff --git a/tests/test_step7_mergegate.py b/tests/test_step7_mergegate.py index 29e3a332..2771bee5 100644 --- a/tests/test_step7_mergegate.py +++ b/tests/test_step7_mergegate.py @@ -7,17 +7,22 @@ mergegate-exact-binding-field the digest is read at each artifact's own path mergegate-decoy-digest the right digest in a field nobody binds is not a binding mergegate-noop-campaign-link a permissive link check must not satisfy the gate - mergegate-dead-authority an authority check that always passes must not satisfy it + mergegate-dead-authority hostqual authorising anything must not satisfy it + mergegate-dead-authority-eb nor must execbinding — the claim covers both readers + mergegate-dead-continuity nor must a postflight that stops comparing campaign links + mergegate-always-inadmissible nor must one that refuses every campaign mergegate-missing-machinery the tools must be there at all mergegate-lingering-auto the revoked automatic authority must stay revoked mergegate-unfrozen-t0 an unfrozen or unauthorised T0 is not merged as frozen mergegate-reads-t0-digest the expected digest comes from T0, not from a constant control-inventory-complete this list and the executed set are the same set -Fixtures ship the real tools, and two controls mutate them. An earlier revision -shipped a stub whose check_campaign_link was a bare pass and called that world -compliant: the fixture demonstrated the false positive it was meant to exclude. -Checking for the name of a mechanism is not checking the mechanism. +Fixtures ship the real tools, and four controls mutate one enforcement point +each. An earlier revision shipped a stub whose check_campaign_link was a bare +pass and called that world compliant: the fixture demonstrated the false +positive it was meant to exclude. Checking for the name of a mechanism is not +checking the mechanism, and a witness that only ever sees refusals is not +checking one either — hence a positive control beside each negative one. Failures print FAIL[]: ; nothing stops at the first one. @@ -94,8 +99,11 @@ def guarded(check: str, control: Callable[[], None]) -> None: lambda d: {"bindings": {"measurement_harness_digest": d}}, } -# Mutations appended to the real hostqual: a later definition wins, so each stub -# disables exactly one enforcement point and leaves the rest genuine. +HOSTQUAL, EXECBINDING = mg.STEP7_TOOLS[1], mg.STEP7_TOOLS[2] + +# Mutations of the real tools, one enforcement point each. Appending a definition +# is enough where a later definition wins; the continuity check lives inside a +# larger function, so that one is a targeted edit of its condition. NOOP_LINK = ''' def check_campaign_link(link, binding_path, repo): @@ -110,6 +118,33 @@ def bind_t0(repo, path, commit): return block, check("t0", True, "stubbed: always authorised") ''' +# The second reader. The gate's message claims enforcement on both, so a control +# that only breaks the first leaves half of that claim unproved. +DEAD_AUTHORITY_EB = ''' + +def t0_at(repo, path, commit): + return {"commit": commit, "path": path, "blob_sha": "b" * 40, "sha256": "f" * 64, + "status": "FROZEN", "collection_authorized": True} +''' + +# The mutation the positive postflight control exists to catch: a tool that +# refuses every campaign satisfies a negative-only witness, which would then be +# reading "nothing is admissible" as "the swap was caught". +ALWAYS_INADMISSIBLE = ''' + +def session_admissibility(*args, **kwargs): + return {"admissible": False, "reasons": ["stubbed: nothing is ever admissible"]} +''' + +CONTINUITY_CONDITION = ('preflight.get("campaign_link_sha256") != ' + "sha256_file(campaign_link_path)") + + +def dead_continuity(src: str) -> str: + """Leave check_campaign_link working and remove only the comparison of the + preflight's campaign link with this pass's.""" + return src.replace(CONTINUITY_CONDITION, "False") + def real_tools() -> dict[str, str]: """The tools as they actually are. A fixture that shipped a stub would prove @@ -140,7 +175,8 @@ def run(*args: str) -> None: def world(tmp: Path, *, frozen: bool = True, authorized: bool = True, instrument: str = "print('instrument')" + NL, rebound: bool = True, machinery: bool = True, auto_authority: bool = False, decoy: bool = False, - wrong_field: bool = False, mutate: str = "", name: str = "w") -> tuple[Path, str]: + wrong_field: bool = False, mutate: dict[str, object] | None = None, + name: str = "w") -> tuple[Path, str]: """A synthetic target tree, and the commit a merge into it would produce.""" repo = tmp / name files = {eb.INSTRUMENT_SOURCES[0]: instrument, @@ -148,8 +184,14 @@ def world(tmp: Path, *, frozen: bool = True, authorized: bool = True, mg.STEP7_NOTE: NOTE_AUTO if auto_authority else NOTE_REVOKED} if machinery: tools = real_tools() - if mutate: - tools[mg.STEP7_TOOLS[1]] = tools[mg.STEP7_TOOLS[1]] + mutate + for tool, change in (mutate or {}).items(): + before = tools[tool] + tools[tool] = change(before) if callable(change) else before + str(change) + if tools[tool] == before: + # A mutation that lands nowhere turns an attack control into a + # second positive control that nobody reads as one. + raise AssertionError(f"the mutation for {tool} changed nothing; the source it " + "targets has moved") files.update(tools) else: files[mg.STEP7_TOOLS[0]] = "# capture only" + NL @@ -269,7 +311,7 @@ def control_decoy_digest() -> None: def control_noop_campaign_link() -> None: """The attack the previous revision of this gate could not see.""" with tempfile.TemporaryDirectory() as raw: - repo, commit = world(Path(raw), mutate=NOOP_LINK, name="noop") + repo, commit = world(Path(raw), mutate={HOSTQUAL: NOOP_LINK}, name="noop") if verdicts(repo, commit).get("step7_machinery_enforces") != "fail": fail("mergegate-noop-campaign-link", "a permissive check_campaign_link satisfied the gate; the name of a mechanism " @@ -284,20 +326,70 @@ def control_noop_campaign_link() -> None: "that got through") +def _dead_authority(check_name: str, tool: str, stub: str, reader: str) -> None: + """Both readers carry the authority state machine, and the gate says so. + A control that breaks one of them leaves the other half of that claim + standing on nothing.""" + with tempfile.TemporaryDirectory() as raw: + repo, commit = world(Path(raw), mutate={tool: stub}, name="deadauth") + if verdicts(repo, commit).get("step7_machinery_enforces") != "fail": + fail(check_name, f"a {reader} authority check that always passes satisfied the gate") + return + detail = details(repo, commit)["step7_machinery_enforces"] + wrongly = [s for s in ("FROZEN+false", "NOT_FROZEN+true", "NOT_FROZEN+false") + if f"{reader} accepted {s}" in detail] + if len(wrongly) != 3: + fail(check_name, f"refused, but named {wrongly} rather than all three forbidden " + f"states: {detail}") + return + ok(check_name, + f"a tree whose {reader} authorises anything is refused, and the refusal names every " + "forbidden state it accepted: FROZEN+false, NOT_FROZEN+true, NOT_FROZEN+false") + + def control_dead_authority() -> None: + _dead_authority("mergegate-dead-authority", HOSTQUAL, DEAD_AUTHORITY, "hostqual") + + +def control_dead_authority_eb() -> None: + _dead_authority("mergegate-dead-authority-eb", EXECBINDING, DEAD_AUTHORITY_EB, "execbinding") + + +def control_dead_continuity() -> None: + """The campaign-swap witness needs its own sensitivity proof: a postflight + that refuses everything would satisfy a negative-only control.""" with tempfile.TemporaryDirectory() as raw: - repo, commit = world(Path(raw), mutate=DEAD_AUTHORITY, name="deadauth") + repo, commit = world(Path(raw), mutate={HOSTQUAL: dead_continuity}, name="deadcont") if verdicts(repo, commit).get("step7_machinery_enforces") != "fail": - fail("mergegate-dead-authority", - "a bind_t0 that always returns pass satisfied the gate") + fail("mergegate-dead-continuity", + "a postflight that no longer compares the preflight's campaign link with " + "this pass's satisfied the gate; the swap witness proves nothing") return detail = details(repo, commit)["step7_machinery_enforces"] - if "accepted" not in detail: - fail("mergegate-dead-authority", f"refused for an unrelated reason: {detail}") + if "swapped" not in detail: + fail("mergegate-dead-continuity", f"refused for an unrelated reason: {detail}") + return + ok("mergegate-dead-continuity", + "removing only the preflight/postflight campaign-link comparison, and leaving " + "check_campaign_link intact, is caught and named as the swap becoming admissible") + + +def control_always_inadmissible() -> None: + """A refusal is only evidence if acceptance was possible.""" + with tempfile.TemporaryDirectory() as raw: + repo, commit = world(Path(raw), mutate={HOSTQUAL: ALWAYS_INADMISSIBLE}, name="noadmit") + if verdicts(repo, commit).get("step7_machinery_enforces") != "fail": + fail("mergegate-always-inadmissible", + "a postflight that refuses every campaign satisfied the gate; the swap " + "witness was reading a blanket refusal as enforcement") + return + detail = details(repo, commit)["step7_machinery_enforces"] + if "unchanged campaign was inadmissible" not in detail: + fail("mergegate-always-inadmissible", f"refused for another reason: {detail}") return - ok("mergegate-dead-authority", - "a tree whose authority check always passes is refused, and the refusal says which " - "state it wrongly accepted") + ok("mergegate-always-inadmissible", + "the unchanged campaign must survive preflight to postflight, so a tool that refuses " + "everything cannot pose as one that caught the swap") def control_missing_machinery() -> None: @@ -384,6 +476,9 @@ def control_inventory_complete() -> None: ("mergegate-decoy-digest", control_decoy_digest), ("mergegate-noop-campaign-link", control_noop_campaign_link), ("mergegate-dead-authority", control_dead_authority), + ("mergegate-dead-authority-eb", control_dead_authority_eb), + ("mergegate-dead-continuity", control_dead_continuity), + ("mergegate-always-inadmissible", control_always_inadmissible), ("mergegate-missing-machinery", control_missing_machinery), ("mergegate-lingering-auto", control_lingering_auto), ("mergegate-unfrozen-t0", control_unfrozen_t0), From 96f6377f1d2484cb0f51b18f98abdc5f5a7dfe9b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 08:21:01 +0500 Subject: [PATCH 09/15] ci(step7): the merge gate becomes a check, not a script in a drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #357 built a gate that refuses the wrong merge. Nothing ran it. A weapon in a safe is not a guard at the door, so this is the wiring: a workflow that runs it against the commit a merge would produce, under two names a ruleset can require. Wiring it exposed two things the gate could not answer on its own. **Applicability.** A required check runs on every pull request in the repository. `mergegate.py` refuses a tree with no T0 in it — correctly, as a question about a contract that must be there — so as a required check today it would refuse every pull request, including this one. The wrapper settles it first: no frozen T0 at the merge commit means no merge can make the contract reachable, the check passes and says so. The moment a tree carries T0 every predicate applies in full. **Co-change.** The workflow that runs on a pull request is the one on that pull request. A branch could otherwise carry the freeze and a weakened gate together and be judged by the gate it brought with it. So the gate's own files — `mergegate.py`, this wrapper, the workflow — may not change in the same merge that introduces or changes the frozen contract. Repairing the gate on its own stays ordinary work; that distinction is a control, not a promise. The applicability decision lives in the tool that owns `T0_PATH`, never a second copy in YAML, and a control fails if the workflow ever grows one. Another fails if the contexts the workflow declares stop matching the ones a ruleset is told to require — a required check nothing reports waits forever and reads as protection. Exit codes are the step-7 three: 0 proceed or not applicable, 1 refused, 2 the question could not be asked. 11 wiring controls, 0 failed; the 15 gate controls unchanged. Both suites also run green on Linux, which is where the job will run. Against real commits: this branch's head is not applicable, the full chain is allowed, #354 alone is refused on three predicates. This is half the wiring. Until a ruleset requires these two contexts, the job reports and nothing is prevented. #354 stays a draft until it does. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh --- .github/workflows/p022-merge-gate.yml | 70 +++++++ scripts/step7/mergegate_ci.py | 125 ++++++++++++ tests/test_step7_mergegate_ci.py | 277 ++++++++++++++++++++++++++ 3 files changed, 472 insertions(+) create mode 100644 .github/workflows/p022-merge-gate.yml create mode 100644 scripts/step7/mergegate_ci.py create mode 100644 tests/test_step7_mergegate_ci.py diff --git a/.github/workflows/p022-merge-gate.yml b/.github/workflows/p022-merge-gate.yml new file mode 100644 index 00000000..db536d73 --- /dev/null +++ b/.github/workflows/p022-merge-gate.yml @@ -0,0 +1,70 @@ +name: P-022 merge gate + +# The merge order for the #263 preregistration, as a check rather than an +# agreement. `mergegate.py` is run against the commit a merge would PRODUCE — +# for a pull_request event that is `github.sha`, the merge ref, not the branch +# head — because a PR number proves someone pressed a button and says nothing +# about what the merged tree contains. +# +# Two jobs, and the gate needs the controls: a gate whose own controls are +# failing must not be the thing that says a merge is safe. A failed controls job +# leaves the gate job unreported, which a required check reads as unsatisfied. +# +# This workflow is one half of the wiring. The other half is a ruleset requiring +# these two checks; without it the job reports and nothing is prevented. The +# names below are the contexts that ruleset must name: +# +# P-022 merge gate controls +# P-022 merge gate +# +# Least privilege as elsewhere in this repository: read-only, and every +# third-party `uses:` pinned to a commit SHA with its version in a comment. + +permissions: + contents: read + +on: + pull_request: + workflow_dispatch: + +jobs: + controls: + name: P-022 merge gate controls + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.11" + - name: the gate's own controls (fixtures ship the real tools and mutate them) + run: python tests/test_step7_mergegate.py + - name: the wiring's controls (applicability and co-change) + run: python tests/test_step7_mergegate_ci.py + + gate: + name: P-022 merge gate + needs: controls + runs-on: ubuntu-latest + steps: + # Full history: the co-change rule compares the gate's own files against + # the base commit, which a shallow clone would not carry. + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.11" + - name: the gate, against the commit this merge would produce + env: + # Read through the environment rather than interpolated into the + # script body, so nothing from the event can be read as shell. + MERGE_COMMIT: ${{ github.sha }} + BASE_COMMIT: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + if [ -n "${BASE_COMMIT}" ]; then + python scripts/step7/mergegate_ci.py --repo . \ + --commit "${MERGE_COMMIT}" --base "${BASE_COMMIT}" + else + python scripts/step7/mergegate_ci.py --repo . --commit "${MERGE_COMMIT}" + fi diff --git a/scripts/step7/mergegate_ci.py b/scripts/step7/mergegate_ci.py new file mode 100644 index 00000000..4709ab13 --- /dev/null +++ b/scripts/step7/mergegate_ci.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""P-022 — the merge gate as a status check. + +`mergegate.py` answers one question: may the frozen contract become reachable +from this tree? It answers it about a tree that *has* a frozen contract. A +required status check runs on every pull request in the repository, including +the ones that have nothing to do with P-022, so two rules have to be settled +before that gate can be wired to a branch protection rule. + +**Applicability.** If the frozen T0 does not exist at the merge commit, no merge +can make it reachable and there is nothing to protect. The check passes and says +so. This is not a way around the gate: the moment a tree carries T0, every +predicate applies in full. + +**Co-change.** A required check that a pull request can rewrite is decoration. +The workflow that runs on a pull request is the one *on that pull request*, so a +branch may carry both the freeze and a weakened gate and be judged by the gate +it brought with it. So: **the gate's own files may not change in the same merge +that introduces or changes the frozen contract.** Repairing the gate is still +ordinary work; doing it in the same breath as the freeze is not. + +Neither rule is enforcement isolation. A repository administrator can turn the +protection off, and this script cannot see that. What it removes is the quiet +path — a weakened gate arriving as part of the change it was meant to judge. + +Exit codes: 0 the merge may proceed (or the gate does not apply), 1 it is +refused, 2 the question could not be asked. + + python scripts/step7/mergegate_ci.py --repo . --commit \ + --base + +`--base` is what the co-change rule compares against. Without it the rule cannot +run, and the output says that rather than implying it passed. +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import mergegate as mg # noqa: E402 + +# The gate's own surface: the predicates, this wrapper, and the workflow that +# runs them. A change to any of these changes what "the gate passed" means. +GATE_FILES = ("scripts/step7/mergegate.py", + "scripts/step7/mergegate_ci.py", + ".github/workflows/p022-merge-gate.yml") + + +def blob_sha(repo: Path, commit: str, path: str) -> str | None: + """The blob a path resolves to, or None when the path is not there.""" + proc = subprocess.run(["git", "-C", str(repo), "rev-parse", f"{commit}:{path}"], + capture_output=True, text=True, check=False) + return proc.stdout.strip() if proc.returncode == 0 else None + + +def applies(repo: Path, commit: str) -> bool: + return blob_sha(repo, commit, mg.T0_PATH) is not None + + +def co_change(repo: Path, commit: str, base: str) -> list[str]: + """Which gate files this merge changes, if it also touches the contract. + + Empty when the contract is untouched: repairing the gate is ordinary work. + """ + t0_now, t0_base = blob_sha(repo, commit, mg.T0_PATH), blob_sha(repo, base, mg.T0_PATH) + if t0_now == t0_base: + return [] + return [path for path in GATE_FILES + if blob_sha(repo, commit, path) != blob_sha(repo, base, path)] + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", type=Path, default=Path(".")) + parser.add_argument("--commit", required=True, + help="the commit a merge would produce, not the branch head") + parser.add_argument("--base", help="the base commit the co-change rule compares against") + args = parser.parse_args(argv) + + proc = subprocess.run(["git", "-C", str(args.repo), "rev-parse", f"{args.commit}^{{commit}}"], + capture_output=True, text=True, check=False) + if proc.returncode != 0: + print(f"merge gate: MISUSE — {args.commit} is not a commit in {args.repo}") + return 2 + + if not applies(args.repo, args.commit): + print(f"merge gate: not applicable — {mg.T0_PATH} does not exist at {args.commit[:12]}, " + "so no merge here can make the frozen contract reachable. Every predicate " + "applies the moment a tree carries it.") + return 0 + + if args.base is None: + print("merge gate: the co-change rule did not run — no --base was given, so this pass " + "cannot say whether the gate itself changed alongside the contract") + else: + changed = co_change(args.repo, args.commit, args.base) + if changed: + print("merge gate: REFUSED — this merge changes the frozen contract and the gate " + f"that judges it in one act: {', '.join(changed)}. Repair the gate in its own " + "merge; a guard that arrives with what it guards is not a guard.") + return 1 + print("ok [gate_unchanged] the contract moves and the gate does not; this merge is " + "judged by the gate already on the base") + + results = mg.gate(args.repo, args.commit) + for row in results: + mark = "ok " if row["result"] == "pass" else "FAIL" + print(f"{mark} [{row['check']}] {row['detail']}") + bad = [r for r in results if r["result"] != "pass"] + print() + if bad: + print(f"merge gate: REFUSED, {len(bad)} predicate(s) unsatisfied — the frozen contract " + "must not become reachable from this tree") + return 1 + print("merge gate: allowed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_step7_mergegate_ci.py b/tests/test_step7_mergegate_ci.py new file mode 100644 index 00000000..b85720b2 --- /dev/null +++ b/tests/test_step7_mergegate_ci.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +"""P-022 — controls on the wiring that makes the merge gate a status check. + + ci-not-applicable a tree without the frozen T0 passes, and says why + ci-applies-in-full a tree with it is judged by every predicate + ci-refusal-survives a refusal from the gate is still a refusal here + ci-co-change-refused the gate may not change in the merge that brings the contract + ci-gate-repair-allowed repairing the gate on its own is ordinary work + ci-no-base-is-stated without a base the co-change rule says it did not run + ci-misuse-is-two a commit that does not exist is misuse, not a verdict + ci-gate-files-exist every file the co-change rule watches is really there + ci-workflow-names the workflow names the contexts a ruleset must require + ci-workflow-derives-t0 the workflow hard-codes no path the tools already own + control-inventory this list and the executed set are the same set + +The applicability rule is the one that makes a required check possible at all: +without it the gate refuses every pull request in the repository, including the +one that adds it. The co-change rule is the one that makes it worth requiring: +a pull request runs its own copy of the workflow, so a branch could otherwise +carry the freeze and a weakened gate together and be judged by the gate it +brought with it. + +Run: python tests/test_step7_mergegate_ci.py +""" + +from __future__ import annotations + +import contextlib +import io +import sys +import tempfile +from collections.abc import Callable +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "scripts" / "step7")) +sys.path.insert(0, str(ROOT / "tests")) + +import mergegate_ci as ci # noqa: E402 +import test_step7_mergegate as mgt # noqa: E402 + +WORKFLOW = ROOT / ".github/workflows/p022-merge-gate.yml" +CONTEXTS = ("P-022 merge gate controls", "P-022 merge gate") + +_FAILURES: list[tuple[str, str]] = [] +_PASSES: list[str] = [] + + +def fail(check: str, detail: str) -> None: + _FAILURES.append((check, detail)) + print(f"FAIL[{check}]: {detail}") + + +def ok(check: str, detail: str = "") -> None: + _PASSES.append(check) + print(f"ok[{check}]: {detail}" if detail else f"ok[{check}]") + + +def guarded(check: str, control: Callable[[], None]) -> None: + try: + control() + except Exception as exc: + fail(check, f"the control raised {type(exc).__name__}: {exc}") + + +def run(*args: str) -> tuple[int, str]: + """The wrapper's exit code and everything it said, captured. A green run that + prints REFUSED teaches readers to skim past refusals.""" + out = io.StringIO() + with contextlib.redirect_stdout(out): + rc = ci.main(list(args)) + return rc, out.getvalue() + + +def wired(tmp: Path, name: str, **kwargs: object) -> tuple[Path, str, str]: + """A repository, the commit that carries T0, and the commit before it. + + `world()` builds its tree in two commits: the first carries the instrument + and the tools, the second adds T0 and the bindings. That first commit is + exactly the shape of a base branch that does not yet carry the contract. + """ + repo, head = mgt.world(tmp, name=name, **kwargs) + return repo, head, head + "^" + + +def control_not_applicable() -> None: + with tempfile.TemporaryDirectory() as raw: + repo, _, before = wired(Path(raw), "na") + rc, said = run("--repo", str(repo), "--commit", before) + if rc != 0: + fail("ci-not-applicable", f"a tree with no frozen contract was refused: {said[:160]}") + return + if "not applicable" not in said: + fail("ci-not-applicable", f"it passed without saying why: {said[:160]}") + return + ok("ci-not-applicable", + "with no T0 in the tree no merge can make the frozen contract reachable, so the check " + "passes and names the reason — otherwise it would refuse every pull request in the repo") + + +def control_applies_in_full() -> None: + with tempfile.TemporaryDirectory() as raw: + repo, head, before = wired(Path(raw), "full") + rc, said = run("--repo", str(repo), "--commit", head, "--base", before) + if rc != 0: + fail("ci-applies-in-full", f"a satisfying tree was refused: {said[:200]}") + return + missing = [c for c in ("t0_frozen_and_authorized", "instrument_matches_t0", + "steps_4_5_6_rebound", "step7_machinery_enforces") + if c not in said] + if missing: + fail("ci-applies-in-full", f"the wrapper did not report {missing}") + return + ok("ci-applies-in-full", + "the moment a tree carries T0 every predicate runs and is reported by name") + + +def control_refusal_survives() -> None: + with tempfile.TemporaryDirectory() as raw: + repo, head, before = wired(Path(raw), "stale", rebound=False) + rc, said = run("--repo", str(repo), "--commit", head, "--base", before) + if rc != 1 or "REFUSED" not in said: + fail("ci-refusal-survives", + f"a stale binding came back as rc={rc}: {said[:200]}") + return + ok("ci-refusal-survives", + "the wrapper adds rules, it does not soften the ones underneath: a stale binding is " + "still exit 1") + + +def control_co_change_refused() -> None: + with tempfile.TemporaryDirectory() as raw: + repo, head, before = wired(Path(raw), "cochange") + # the branch brings the contract and its own copy of the gate at once + attacked = mgt.commit_tree(repo, { + ci.GATE_FILES[0]: "# a gate that says yes\n", + ci.GATE_FILES[2]: "name: P-022 merge gate\n"}) + rc, said = run("--repo", str(repo), "--commit", attacked, "--base", before) + if rc != 1: + fail("ci-co-change-refused", + "a merge carrying both the freeze and a rewritten gate was allowed") + return + if ci.GATE_FILES[0] not in said: + fail("ci-co-change-refused", f"refused without naming the changed file: {said[:200]}") + return + _ = head + ok("ci-co-change-refused", + "a guard that arrives together with what it guards is not a guard; the refusal names " + "every gate file the merge rewrites") + + +def control_gate_repair_allowed() -> None: + with tempfile.TemporaryDirectory() as raw: + repo, head, _ = wired(Path(raw), "repair") + # the contract is already in the base and does not move; only the gate does + repaired = mgt.commit_tree(repo, {ci.GATE_FILES[0]: "# a repaired gate\n"}) + rc, said = run("--repo", str(repo), "--commit", repaired, "--base", head) + if rc != 0: + fail("ci-gate-repair-allowed", + f"an ordinary repair of the gate was refused: {said[:200]}") + return + if "gate_unchanged" not in said: + fail("ci-gate-repair-allowed", f"it passed without saying so: {said[:200]}") + return + ok("ci-gate-repair-allowed", + "when the contract does not move, changing the gate is ordinary work and stays " + "possible — the rule is about co-change, not about freezing the gate forever") + + +def control_no_base_is_stated() -> None: + with tempfile.TemporaryDirectory() as raw: + repo, head, _ = wired(Path(raw), "nobase") + rc, said = run("--repo", str(repo), "--commit", head) + if rc != 0: + fail("ci-no-base-is-stated", f"a satisfying tree was refused: {said[:200]}") + return + if "did not run" not in said: + fail("ci-no-base-is-stated", + f"a pass with no base did not say the co-change rule was skipped: {said[:200]}") + return + ok("ci-no-base-is-stated", + "a manual run cannot pose as a full one: without a base the wrapper says the co-change " + "rule did not run rather than implying it held") + + +def control_misuse_is_two() -> None: + with tempfile.TemporaryDirectory() as raw: + repo, _, _ = wired(Path(raw), "misuse") + rc, said = run("--repo", str(repo), "--commit", "f" * 40) + if rc != 2: + fail("ci-misuse-is-two", f"a commit that does not exist returned {rc}: {said[:160]}") + return + ok("ci-misuse-is-two", + "a question that could not be asked is exit 2, never a verdict — the same three codes " + "the step-7 tools already use") + + +def control_gate_files_exist() -> None: + missing = [p for p in ci.GATE_FILES if not (ROOT / p).exists()] + if missing: + fail("ci-gate-files-exist", + f"the co-change rule watches {missing}, which are not in this repository; a " + "renamed file would be watched by nobody") + return + ok("ci-gate-files-exist", + f"all {len(ci.GATE_FILES)} watched files exist: {', '.join(ci.GATE_FILES)}") + + +def control_workflow_names() -> None: + if not WORKFLOW.exists(): + fail("ci-workflow-names", f"{WORKFLOW.name} does not exist") + return + text = WORKFLOW.read_text(encoding="utf-8") + absent = [c for c in CONTEXTS if f"name: {c}" not in text] + if absent: + fail("ci-workflow-names", + f"the workflow declares no job named {absent}; a ruleset requiring that context " + "would wait forever on a check nothing reports") + return + if "mergegate_ci.py" not in text: + fail("ci-workflow-names", "the workflow does not run the wrapper") + return + ok("ci-workflow-names", + f"the workflow declares exactly the contexts a ruleset must require: {', '.join(CONTEXTS)}") + + +def control_workflow_derives_t0() -> None: + """The workflow must not carry a second copy of a path the tools own.""" + import mergegate as mg + text = WORKFLOW.read_text(encoding="utf-8") + if mg.T0_PATH in text: + fail("ci-workflow-derives-t0", + f"the workflow hard-codes {mg.T0_PATH}; applicability would then be decided in two " + "places that can drift apart") + return + ok("ci-workflow-derives-t0", + "applicability is decided once, in the tool that owns T0_PATH; the workflow only runs it") + + +def control_inventory() -> None: + import re + listed = set(re.findall("^ (ci-[a-z0-9-]+|control-inventory) +[^ ]", __doc__ or "", + re.MULTILINE)) + executed = {name for name, _ in CONTROLS} + if listed != executed: + fail("control-inventory", + f"listed but not executed: {sorted(listed - executed)}; executed but not listed: " + f"{sorted(executed - listed)}") + return + ok("control-inventory", f"{len(executed)} controls listed, {len(executed)} executed") + + +CONTROLS: list[tuple[str, Callable[[], None]]] = [ + ("ci-not-applicable", control_not_applicable), + ("ci-applies-in-full", control_applies_in_full), + ("ci-refusal-survives", control_refusal_survives), + ("ci-co-change-refused", control_co_change_refused), + ("ci-gate-repair-allowed", control_gate_repair_allowed), + ("ci-no-base-is-stated", control_no_base_is_stated), + ("ci-misuse-is-two", control_misuse_is_two), + ("ci-gate-files-exist", control_gate_files_exist), + ("ci-workflow-names", control_workflow_names), + ("ci-workflow-derives-t0", control_workflow_derives_t0), + ("control-inventory", control_inventory), +] + + +def main() -> int: + for name, control in CONTROLS: + guarded(name, control) + print() + print(f"merge gate wiring controls: {len(_PASSES)} passed, {len(_FAILURES)} failed") + return 1 if _FAILURES else 0 + + +if __name__ == "__main__": + sys.exit(main()) From 61cfea975ec7a0406526838f5e610ece9addaa89 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 08:32:03 +0500 Subject: [PATCH 10/15] style(step7): drop a noqa the linter does not need ruff allows a sys.path insertion before an import, so the E402 suppression was covering nothing and RUF100 said so. Caught by running the CI-pinned ruff (0.15.8) rather than the one that happened to be installed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh --- scripts/step7/mergegate_ci.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/step7/mergegate_ci.py b/scripts/step7/mergegate_ci.py index 4709ab13..022dc9c4 100644 --- a/scripts/step7/mergegate_ci.py +++ b/scripts/step7/mergegate_ci.py @@ -42,7 +42,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) -import mergegate as mg # noqa: E402 +import mergegate as mg # The gate's own surface: the predicates, this wrapper, and the workflow that # runs them. A change to any of these changes what "the gate passed" means. From 2e6683d3f3ad78ce4a4b2c697728c58fc3e1da11 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 08:47:08 +0500 Subject: [PATCH 11/15] style(step7): satisfy the linter without moving any meaning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sixteen ruff findings, all introduced by this PR and none of them caught here before, because `ruff check .` was never run against this branch. `main` is clean, so every one of them is mine. Behaviour-preserving throughout: `datetime.UTC` for the deprecated alias, list unpacking for two concatenations, three long lines wrapped, and six unpacked names the controls never read prefixed with an underscore. The one that was almost a real defect is B023: three fixture paths were closed over by a lambda inside a loop. It never bit, because the lambda is called in the same iteration that builds it — but that is a property of today's control body, not of the code, and a later `refuses(...)` that defers the call would have made every attack in that loop test the last fixture three times. The names are bound as defaults now, so the control cannot start lying quietly. step-7 qualification 26/26, envcapture 11/11, perf instrument 16/16, ruff clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh --- scripts/step7/execbinding.py | 2 +- scripts/step7/hostqual.py | 9 +++++---- tests/test_step7_hostqual.py | 21 ++++++++++++--------- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/scripts/step7/execbinding.py b/scripts/step7/execbinding.py index 2bf30cd6..160d06fc 100644 --- a/scripts/step7/execbinding.py +++ b/scripts/step7/execbinding.py @@ -88,7 +88,7 @@ def sha256_file(path: Path) -> str: def _now() -> str: - return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds") + return datetime.datetime.now(datetime.UTC).isoformat(timespec="seconds") def _git(repo: Path, *args: str) -> tuple[int, bytes]: diff --git a/scripts/step7/hostqual.py b/scripts/step7/hostqual.py index 642df46d..bdfb6cac 100644 --- a/scripts/step7/hostqual.py +++ b/scripts/step7/hostqual.py @@ -117,7 +117,7 @@ class QualificationRefused(Exception): def _now() -> str: - return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds") + return datetime.datetime.now(datetime.UTC).isoformat(timespec="seconds") def sha256_bytes(raw: bytes) -> str: @@ -334,11 +334,12 @@ def validate_provisioning(doc: dict) -> list[str]: virt = doc.get("virtualization") if not isinstance(virt, dict): - return problems + ["virtualization is missing"] + return [*problems, "virtualization is missing"] is_vm = virt.get("is_vm") if not isinstance(is_vm, bool): - return problems + [f"virtualization.is_vm must be a real boolean, got {is_vm!r}: " - "whether this is a VM is not a question a host may decline"] + return [*problems, + f"virtualization.is_vm must be a real boolean, got {is_vm!r}: " + "whether this is a VM is not a question a host may decline"] # Applicability is shape; the answers themselves are the predicate's business. for key in PROVISIONING_VM_BOOLEANS: value = virt.get(key, "") diff --git a/tests/test_step7_hostqual.py b/tests/test_step7_hostqual.py index 92eb51e9..9c161c8b 100644 --- a/tests/test_step7_hostqual.py +++ b/tests/test_step7_hostqual.py @@ -95,7 +95,8 @@ def refuses(call: Callable[[], object]) -> str | None: T0_FROZEN = "# T0\n\n```text\nStatus:\n FROZEN.\n collection_authorized: true\n```\n" T0_OPEN = "# T0\n\n```text\nStatus:\n NOT_FROZEN.\n collection_authorized: false\n```\n" -T0_FROZEN_UNAUTHORIZED = "# T0\n\n```text\nStatus:\n FROZEN.\n collection_authorized: false\n```\n" +T0_FROZEN_UNAUTHORIZED = ("# T0\n\n```text\nStatus:\n FROZEN.\n" + " collection_authorized: false\n```\n") T0_OPEN_AUTHORIZED = "# T0\n\n```text\nStatus:\n NOT_FROZEN.\n collection_authorized: true\n```\n" # A compliant Windows snapshot, used as a fixture on every platform so the @@ -201,7 +202,7 @@ def qualification(stratum: str = "linux", t0: dict | None = None, **overrides) - "environment_manifest": {"sha256": "1" * 64}, "qualification_tool": {"sha256": "2" * 64}, "power_snapshot": dict(COMPLIANT_POWER), - "predicate": {k: "pass" for k in hq.PREDICATE_KEYS}, + "predicate": dict.fromkeys(hq.PREDICATE_KEYS, "pass"), "memory_metric": hq.STRATUM_METRIC[stratum], "qualified": True, "qualified_at": "2026-09-16T00:00:00+00:00"} doc.update(overrides) @@ -368,8 +369,8 @@ def control_artifact_boundary() -> None: qual = path if slot == "qual" else good_q fresh = path if slot == "manifest" else good_m with fixed_power(COMPLIANT_POWER): - message = refuses(lambda: hq.session_eligibility( - binding, qual, fresh, good_d, cand, good_link, crepo, + message = refuses(lambda b=binding, q=qual, f=fresh: hq.session_eligibility( + b, q, f, good_d, cand, good_link, crepo, quiesce_result=quiet)) if message is None: fail("hostqual-artifact-boundary", f"{label} was consumed as a real artifact") @@ -621,7 +622,7 @@ def control_candidate_at_start() -> None: def control_postflight() -> None: with tempfile.TemporaryDirectory() as raw: tmp = Path(raw) - pre, (bpath, qpath, mpath, dpath, cpath, lpath, crepo) = session_fixture(tmp) + pre, (bpath, qpath, mpath, _dpath, cpath, lpath, crepo) = session_fixture(tmp) ppath = write(tmp, "pre.json", pre) probe = tmp / "probe.json" probe.write_text("{}", encoding="utf-8") @@ -658,14 +659,15 @@ def control_postflight() -> None: def control_one_binding() -> None: with tempfile.TemporaryDirectory() as raw: tmp = Path(raw) - pre, (bpath, qpath, mpath, dpath, cpath, lpath, crepo) = session_fixture(tmp) + pre, (bpath, qpath, mpath, _dpath, cpath, lpath, crepo) = session_fixture(tmp) ppath = write(tmp, "pre.json", pre) probe = tmp / "probe.json" probe.write_text("{}", encoding="utf-8") original = json.loads(bpath.read_text(encoding="utf-8")) second = write(tmp, "b2.json", {**original, "bound_at": "a later moment"}) with fixed_power(COMPLIANT_POWER): - mixed = hq.session_admissibility(second, qpath, ppath, mpath, cpath, probe, lpath, crepo) + mixed = hq.session_admissibility(second, qpath, ppath, mpath, cpath, + probe, lpath, crepo) if mixed["admissible"]: fail("hostqual-one-binding", "a preflight from one binding and a postflight from another were admissible") @@ -975,7 +977,8 @@ def qualify_cli(name: str, prov: dict) -> tuple[int, Path]: record = hq.session_eligibility(bpath, qpath, mpath, dpath, cpath, lpath, crepo, quiesce_result=quiet) if record["eligible"]: - fail("hostqual-negative-evidence", "a declared prohibited job left the session eligible") + fail("hostqual-negative-evidence", + "a declared prohibited job left the session eligible") return if not any("declaration" in r for r in record["reasons"]): fail("hostqual-negative-evidence", @@ -1019,7 +1022,7 @@ def control_campaign_link() -> None: "a D7 payload edited after the campaign was linked to it was accepted") return - pre, (bpath, qpath, mpath, dpath, cpath, lpath, crepo) = session_fixture(tmp) + pre, (bpath, qpath, mpath, _dpath, cpath, _lpath, _crepo) = session_fixture(tmp) ppath = write(tmp, "pre.json", pre) probe = tmp / "probe.json" probe.write_text("{}", encoding="utf-8") From 9bed20f70f4912e709f8a76713452b47fd31c937 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 08:49:26 +0500 Subject: [PATCH 12/15] style(step7): the gate's own source passes its own repository's linter Three ruff findings, all mine: an unsorted import block, one over-long line inside the witness source, and an unpacked name a control never reads. The long line is inside WITNESS_SOURCE, so wrapping it edits the script the gate runs against the target tree's tools. Hoisting the identity into a local changes nothing it does, and the controls and probes were re-run to say so rather than assumed: 15 merge gate controls pass, host qualification 26/26, and the three real merge probes still come back 0 / 1 / 1 with the same predicates. This is a lint pass only. No predicate, no attack and no message moved, and 30d2f32 is untouched. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh --- scripts/step7/mergegate.py | 7 ++++--- tests/test_step7_mergegate.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/scripts/step7/mergegate.py b/scripts/step7/mergegate.py index db4c71ba..2ef635f3 100644 --- a/scripts/step7/mergegate.py +++ b/scripts/step7/mergegate.py @@ -30,7 +30,6 @@ from __future__ import annotations import argparse - import json import re import subprocess @@ -154,9 +153,11 @@ def w(name, doc): CAND = b"candidate bytes" cand = tmp / "cand.bin"; cand.write_bytes(CAND) qpath = w("qual.json", QUAL) +IDENT = QUAL["environment_identity_sha256"] stratum_block = lambda m, extra: {"qualification_sha256": hq.sha256_file(qpath), - "environment_id": "env-1", "host_fingerprint": "sha256:abc", - "environment_identity_sha256": QUAL["environment_identity_sha256"], + "environment_id": "env-1", + "host_fingerprint": "sha256:abc", + "environment_identity_sha256": IDENT, "candidate_sha256": hq.sha256_bytes(CAND), "candidate_bytes": len(CAND), "memory_metric": m, **extra} BINDING = {"kind": eb.BINDING_SCHEMA, "schema": 1, diff --git a/tests/test_step7_mergegate.py b/tests/test_step7_mergegate.py index 2771bee5..1593235f 100644 --- a/tests/test_step7_mergegate.py +++ b/tests/test_step7_mergegate.py @@ -245,7 +245,7 @@ def control_complete_tree() -> None: def control_missing_instrument() -> None: with tempfile.TemporaryDirectory() as raw: - repo, commit = world(Path(raw)) + repo, _commit = world(Path(raw)) # the instrument moves after T0 named it — the exact intermediate state a # stray merge produces when the repair PR is not in yet drifted = commit_tree(repo, {eb.INSTRUMENT_SOURCES[0]: "print('older')" + NL}) From 1e488fdf87902f2bf169b0c7507bc1773fdb41cc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 08:54:41 +0500 Subject: [PATCH 13/15] fix(step7): an unreachable base is a question unasked, not a refusal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If `--base` named a commit the clone does not carry, `blob_sha` returned None for every gate file on that side, the co-change rule read all of them as rewritten, and the merge was REFUSED — for a fact about the checkout's depth rather than about the merge. The workflow sets fetch-depth 0, so this would not have fired today; it would have fired the first time someone ran the wrapper by hand, or the first time a shallow clone was used to save a minute, and the refusal would have been about the gate being rewritten. A false refusal teaches people that the gate is noise, which is how a required check dies. The base is now validated like the merge commit, and an unreachable one is exit 2 with a message that names the reason. A control builds exactly that case and requires 2, refusing to accept 1. 12 wiring controls, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh --- scripts/step7/mergegate_ci.py | 17 ++++++++++++++--- tests/test_step7_mergegate_ci.py | 20 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/scripts/step7/mergegate_ci.py b/scripts/step7/mergegate_ci.py index 022dc9c4..1d839e9c 100644 --- a/scripts/step7/mergegate_ci.py +++ b/scripts/step7/mergegate_ci.py @@ -62,6 +62,11 @@ def applies(repo: Path, commit: str) -> bool: return blob_sha(repo, commit, mg.T0_PATH) is not None +def is_commit(repo: Path, rev: str) -> bool: + return subprocess.run(["git", "-C", str(repo), "rev-parse", f"{rev}^{{commit}}"], + capture_output=True, check=False).returncode == 0 + + def co_change(repo: Path, commit: str, base: str) -> list[str]: """Which gate files this merge changes, if it also touches the contract. @@ -82,11 +87,17 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--base", help="the base commit the co-change rule compares against") args = parser.parse_args(argv) - proc = subprocess.run(["git", "-C", str(args.repo), "rev-parse", f"{args.commit}^{{commit}}"], - capture_output=True, text=True, check=False) - if proc.returncode != 0: + if not is_commit(args.repo, args.commit): print(f"merge gate: MISUSE — {args.commit} is not a commit in {args.repo}") return 2 + # A base that is not in the clone would make every gate file look changed, + # and the co-change rule would refuse for a reason that is about the checkout + # rather than about the merge. A question that cannot be asked is not a no. + if args.base is not None and not is_commit(args.repo, args.base): + print(f"merge gate: MISUSE — the base {args.base} is not a commit in {args.repo}; " + "the co-change rule cannot be evaluated, and a shallow checkout must not be " + "reported as a rewritten gate") + return 2 if not applies(args.repo, args.commit): print(f"merge gate: not applicable — {mg.T0_PATH} does not exist at {args.commit[:12]}, " diff --git a/tests/test_step7_mergegate_ci.py b/tests/test_step7_mergegate_ci.py index b85720b2..e3b42f03 100644 --- a/tests/test_step7_mergegate_ci.py +++ b/tests/test_step7_mergegate_ci.py @@ -8,6 +8,7 @@ ci-gate-repair-allowed repairing the gate on its own is ordinary work ci-no-base-is-stated without a base the co-change rule says it did not run ci-misuse-is-two a commit that does not exist is misuse, not a verdict + ci-absent-base-is-two nor may an unavailable base be reported as a rewritten gate ci-gate-files-exist every file the co-change rule watches is really there ci-workflow-names the workflow names the contexts a ruleset must require ci-workflow-derives-t0 the workflow hard-codes no path the tools already own @@ -195,6 +196,24 @@ def control_misuse_is_two() -> None: "the step-7 tools already use") +def control_absent_base_is_two() -> None: + """A shallow checkout must not be able to manufacture a refusal.""" + with tempfile.TemporaryDirectory() as raw: + repo, head, _ = wired(Path(raw), "absentbase") + rc, said = run("--repo", str(repo), "--commit", head, "--base", "e" * 40) + if rc == 1: + fail("ci-absent-base-is-two", + "a base that is not in the clone was reported as a rewritten gate; every gate " + "file looks changed against a base nobody has") + return + if rc != 2: + fail("ci-absent-base-is-two", f"an unavailable base returned {rc}: {said[:160]}") + return + ok("ci-absent-base-is-two", + "an unreachable base is exit 2 and says so; the co-change rule refuses to answer rather " + "than answering with the checkout's depth") + + def control_gate_files_exist() -> None: missing = [p for p in ci.GATE_FILES if not (ROOT / p).exists()] if missing: @@ -258,6 +277,7 @@ def control_inventory() -> None: ("ci-gate-repair-allowed", control_gate_repair_allowed), ("ci-no-base-is-stated", control_no_base_is_stated), ("ci-misuse-is-two", control_misuse_is_two), + ("ci-absent-base-is-two", control_absent_base_is_two), ("ci-gate-files-exist", control_gate_files_exist), ("ci-workflow-names", control_workflow_names), ("ci-workflow-derives-t0", control_workflow_derives_t0), From a49ef6cdd89cc4e77fe98e59a2e30d5ae77ee855 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 09:18:07 +0500 Subject: [PATCH 14/15] fix(tests): the wiring suite exposes run(), so the runner can run it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/run_tests.py imports every test_*.py and calls `run()` on it. The new suite called its entry point `main()`, so the runner reported it as a module with no run() and ended the whole test job — after my own controls had printed 15 green lines, which is exactly the shape of a failure that looks like someone else's. Found by reading CI rather than by running the file directly: `python tests/test_step7_mergegate_ci.py` passes either way, and that is precisely why the local run said nothing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh --- tests/test_step7_mergegate_ci.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_step7_mergegate_ci.py b/tests/test_step7_mergegate_ci.py index e3b42f03..5359366b 100644 --- a/tests/test_step7_mergegate_ci.py +++ b/tests/test_step7_mergegate_ci.py @@ -285,7 +285,9 @@ def control_inventory() -> None: ] -def main() -> int: +def run() -> int: + # `run`, not `main`: tests/run_tests.py imports every test_*.py and calls + # run() on it, and a module without one is a suite that reports nothing. for name, control in CONTROLS: guarded(name, control) print() @@ -294,4 +296,4 @@ def main() -> int: if __name__ == "__main__": - sys.exit(main()) + sys.exit(run()) From 715bbe7facd28cffbf8d559c565bc3664d6d5bcb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 09:24:35 +0500 Subject: [PATCH 15/15] fix(tests): rename the helper, not the entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit gave this module a run() for tests/run_tests.py to call — onto a name a local helper already had. The helper won, the entry point was shadowed, and eight controls started invoking themselves recursively instead of the wrapper. Caught immediately by ruff (F811) and by the suite dropping to 4 passed, 8 failed, which is what a repository's own checks are for. The helper is `invoke` now and says in its docstring why it is not `run`. 12 wiring controls, 0 failed; ruff clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh --- tests/test_step7_mergegate_ci.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/tests/test_step7_mergegate_ci.py b/tests/test_step7_mergegate_ci.py index 5359366b..60de8d8b 100644 --- a/tests/test_step7_mergegate_ci.py +++ b/tests/test_step7_mergegate_ci.py @@ -64,9 +64,12 @@ def guarded(check: str, control: Callable[[], None]) -> None: fail(check, f"the control raised {type(exc).__name__}: {exc}") -def run(*args: str) -> tuple[int, str]: +def invoke(*args: str) -> tuple[int, str]: """The wrapper's exit code and everything it said, captured. A green run that - prints REFUSED teaches readers to skim past refusals.""" + prints REFUSED teaches readers to skim past refusals. + + Not named `run`: that name belongs to this module's own entry point, which + tests/run_tests.py calls.""" out = io.StringIO() with contextlib.redirect_stdout(out): rc = ci.main(list(args)) @@ -87,7 +90,7 @@ def wired(tmp: Path, name: str, **kwargs: object) -> tuple[Path, str, str]: def control_not_applicable() -> None: with tempfile.TemporaryDirectory() as raw: repo, _, before = wired(Path(raw), "na") - rc, said = run("--repo", str(repo), "--commit", before) + rc, said = invoke("--repo", str(repo), "--commit", before) if rc != 0: fail("ci-not-applicable", f"a tree with no frozen contract was refused: {said[:160]}") return @@ -102,7 +105,7 @@ def control_not_applicable() -> None: def control_applies_in_full() -> None: with tempfile.TemporaryDirectory() as raw: repo, head, before = wired(Path(raw), "full") - rc, said = run("--repo", str(repo), "--commit", head, "--base", before) + rc, said = invoke("--repo", str(repo), "--commit", head, "--base", before) if rc != 0: fail("ci-applies-in-full", f"a satisfying tree was refused: {said[:200]}") return @@ -119,7 +122,7 @@ def control_applies_in_full() -> None: def control_refusal_survives() -> None: with tempfile.TemporaryDirectory() as raw: repo, head, before = wired(Path(raw), "stale", rebound=False) - rc, said = run("--repo", str(repo), "--commit", head, "--base", before) + rc, said = invoke("--repo", str(repo), "--commit", head, "--base", before) if rc != 1 or "REFUSED" not in said: fail("ci-refusal-survives", f"a stale binding came back as rc={rc}: {said[:200]}") @@ -136,7 +139,7 @@ def control_co_change_refused() -> None: attacked = mgt.commit_tree(repo, { ci.GATE_FILES[0]: "# a gate that says yes\n", ci.GATE_FILES[2]: "name: P-022 merge gate\n"}) - rc, said = run("--repo", str(repo), "--commit", attacked, "--base", before) + rc, said = invoke("--repo", str(repo), "--commit", attacked, "--base", before) if rc != 1: fail("ci-co-change-refused", "a merge carrying both the freeze and a rewritten gate was allowed") @@ -155,7 +158,7 @@ def control_gate_repair_allowed() -> None: repo, head, _ = wired(Path(raw), "repair") # the contract is already in the base and does not move; only the gate does repaired = mgt.commit_tree(repo, {ci.GATE_FILES[0]: "# a repaired gate\n"}) - rc, said = run("--repo", str(repo), "--commit", repaired, "--base", head) + rc, said = invoke("--repo", str(repo), "--commit", repaired, "--base", head) if rc != 0: fail("ci-gate-repair-allowed", f"an ordinary repair of the gate was refused: {said[:200]}") @@ -171,7 +174,7 @@ def control_gate_repair_allowed() -> None: def control_no_base_is_stated() -> None: with tempfile.TemporaryDirectory() as raw: repo, head, _ = wired(Path(raw), "nobase") - rc, said = run("--repo", str(repo), "--commit", head) + rc, said = invoke("--repo", str(repo), "--commit", head) if rc != 0: fail("ci-no-base-is-stated", f"a satisfying tree was refused: {said[:200]}") return @@ -187,7 +190,7 @@ def control_no_base_is_stated() -> None: def control_misuse_is_two() -> None: with tempfile.TemporaryDirectory() as raw: repo, _, _ = wired(Path(raw), "misuse") - rc, said = run("--repo", str(repo), "--commit", "f" * 40) + rc, said = invoke("--repo", str(repo), "--commit", "f" * 40) if rc != 2: fail("ci-misuse-is-two", f"a commit that does not exist returned {rc}: {said[:160]}") return @@ -200,7 +203,7 @@ def control_absent_base_is_two() -> None: """A shallow checkout must not be able to manufacture a refusal.""" with tempfile.TemporaryDirectory() as raw: repo, head, _ = wired(Path(raw), "absentbase") - rc, said = run("--repo", str(repo), "--commit", head, "--base", "e" * 40) + rc, said = invoke("--repo", str(repo), "--commit", head, "--base", "e" * 40) if rc == 1: fail("ci-absent-base-is-two", "a base that is not in the clone was reported as a rewritten gate; every gate "