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/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 new file mode 100644 index 00000000..160d06fc --- /dev/null +++ b/scripts/step7/execbinding.py @@ -0,0 +1,429 @@ +#!/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 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: + 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 + +import argparse +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") + +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") + + +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 sha256_bytes(path.read_bytes()) + + +def _now() -> str: + return datetime.datetime.now(datetime.UTC).isoformat(timespec="seconds") + + +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") + 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, + "collection_authorized": authorized} + + +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")) + 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}") + if not doc.get("qualified"): + 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}: 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"), + "environment_identity_sha256": doc.get("environment_identity_sha256"), + "candidate_sha256": sha256_bytes(raw), + "candidate_bytes": len(raw), + "memory_metric": doc.get("memory_metric"), + } + + +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": t0_block, + "instrument": {"accepted_commit": instrument_commit, "harness_digest": harness_digest}, + # 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], t0_block) + 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", "path", "blob_sha", "sha256")), + ("instrument", ("accepted_commit", "harness_digest")), + ("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)) + 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") + 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(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. + + 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) + + 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 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]}") + 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 + + +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("--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) + 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, "instrument_sources": INSTRUMENT_SOURCES, + "required_stratum_keys": list(REQUIRED_STRATUM_KEYS)}, indent=2)) + return 0 + + if args.verify: + 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}") + 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(): + 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 + 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.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) + 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..bdfb6cac --- /dev/null +++ b/scripts/step7/hostqual.py @@ -0,0 +1,967 @@ +#!/usr/bin/env python3 +"""P-022 / #263 — step 7: host qualification, session eligibility, admissibility. + +Three questions, three artifacts, never merged: + + 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? + +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. + +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 + cannot establish them. Content-addressed, shape- and + value-checked, and never called machine proof. + MACHINE-OBSERVED what a checker asserts here and now. + +Usage: + hostqual.py --qualify --stratum linux --t0-path

--t0-commit \\ + --provisioning --manifest --emit + hostqual.py --session-preflight --binding --qualification \\ + --manifest --declaration --candidate \\ + --campaign-link --emit + hostqual.py --session-postflight --binding --qualification \\ + --preflight --manifest --candidate \\ + --closing-probe --campaign-link --emit + 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 + +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" +# 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 +# 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 = ("t0", "environment_identity", "ci", "single_tenant", "power_policy", + "required_memory_metric") + +# 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 # 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_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") + +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.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 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 an identity-bearing value becomes mojibake that moves with + the ambient code page. + """ + 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 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 --- + + +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"]} + # 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 and " + "collection_authorized: true — necessary, and not sufficient: " + "qualification, binding and preflight may each still refuse") + + +# --- 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 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}") + 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_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): + 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"] + # Applicability is shape; the answers themselves are the predicate's business. + for key in PROVISIONING_VM_BOOLEANS: + value = virt.get(key, "") + 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}") + 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 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] + + +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, + "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]: + failures = provisioning_predicate(provisioning) + if failures: + return check("single_tenant", False, + "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)] + 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 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: + entry = identity.get(field) + if isinstance(entry, dict) and entry.get("status") == "observed": + return entry.get("value") + return None + + +# --- the machine-observed predicate ----------------------------------------- + + +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 = 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, + "no turbo/boost mechanism could be identified, so its state cannot be " + "recorded or rechecked through the session") + return check("power_policy", True, + f"governor=performance on all {len(governors)} CPUs; " + f"{boost['mechanism']}={boost['value']}") + + +def check_memory_metric(stratum: str) -> dict[str, object]: + expected = STRATUM_METRIC[stratum] + if stratum == "linux": + available, mechanism = hasattr(os, "wait4"), "posix os.wait4 (ru_maxrss)" + else: + 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 " + "does not provide") + return check("required_memory_metric", True, f"{expected} via {mechanism}") + + +# --- quiesce ----------------------------------------------------------------- + + +def _cpu_counters() -> tuple[int, int] | None: + 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 + 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) + return total - (fields[3] + fields[4]), total # idle + iowait + + +def quiesce(sleep=time.sleep, counters=_cpu_counters, + intervals: int = QUIESCE_INTERVALS) -> dict[str, object]: + """120 s without campaign workload, of which the final 60 s is measured. + + 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", + "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", + "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})", + "quiet_seconds": QUIESCE_QUIET_S, "samples": samples, + "mean": None, "max": None} + samples.append(d_busy / d_total) + 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 {**result, "eligible": False, + "reason": f"mean utilisation {mean:.4f} is not below {QUIESCE_MEAN_MAX}"} + if worst > QUIESCE_INTERVAL_MAX: + return {**result, "eligible": False, + "reason": f"an interval reached {worst:.4f}, above {QUIESCE_INTERVAL_MAX}"} + return {**result, "eligible": True, "reason": ""} + + +# --- the three records ------------------------------------------------------- + + +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 = 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") + 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 = {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}") + return { + "kind": QUALIFICATION_SCHEMA, + "schema": SCHEMA_VERSION, + "stratum": stratum, + "t0": t0_block, + "environment_id": environment_id, + "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": all(c["result"] == "pass" for c in checks), + "qualified_at": _now(), + "not_a_session_certificate": ( + "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, + 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"] + reasons: list[str] = [] + + if not qualification.get("qualified"): + reasons.append("the referenced qualification does not say qualified") + if bound.get("qualification_sha256") != sha256_file(qualification_path): + reasons.append("the execution binding does not name this qualification; a qualified " + "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") + + 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: " + + "; ".join(declared)) + + 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": 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), + "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, + "quiesce": result, + "eligible": not reasons, + "reasons": reasons, + "recorded_at": _now(), + "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, 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 + did after it started, and an attempt that drifted mid-flight has to be + caught by evidence taken after it, not before. + """ + 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") + link = load_artifact(campaign_link_path, "campaign link") + stratum = str(qualification["stratum"]) + bound = binding[stratum] + identity = manifest["identity"] + 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") + 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"): + 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), + "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, + "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"), + } + + +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("--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("--campaign-link", 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, "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}}, 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]}") + + 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", + "campaign-link")) + record = session_eligibility(args.binding, args.qualification, args.manifest, + 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", "campaign-link")) + record = session_admissibility(args.binding, args.qualification, args.preflight, + 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 " + "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: + args.emit.write_text(text, encoding="utf-8") + else: + print(text, end="") + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/step7/mergegate.py b/scripts/step7/mergegate.py new file mode 100644 index 00000000..2ef635f3 --- /dev/null +++ b/scripts/step7/mergegate.py @@ -0,0 +1,366 @@ +#!/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 +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" +# 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})") + + +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 + + +# 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), + ("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}) + 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) +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": IDENT, + "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) +# 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)) +""" + + +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} + + +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, field in BINDING_ARTIFACTS.items(): + raw = blob(repo, commit, path) + if raw is None: + rebound.append(f"{Path(path).name}: absent") + 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 + 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] + 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: 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 + 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/scripts/step7/mergegate_ci.py b/scripts/step7/mergegate_ci.py new file mode 100644 index 00000000..1d839e9c --- /dev/null +++ b/scripts/step7/mergegate_ci.py @@ -0,0 +1,136 @@ +#!/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 + +# 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 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. + + 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) + + 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]}, " + "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_hostqual.py b/tests/test_step7_hostqual.py new file mode 100644 index 00000000..9c161c8b --- /dev/null +++ b/tests/test_step7_hostqual.py @@ -0,0 +1,1180 @@ +#!/usr/bin/env python3 +"""#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-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 + 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 + 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 +identity are proved against real objects rather than mocked strings. + +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 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 envcapture as ec # noqa: E402 +import execbinding as eb # noqa: E402 +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] = [] + + +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 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 --------------------------------------------------------------- + + +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 +# 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): + 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", 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}, + "kernel": {"status": "observed", "value": kernel}, + }, + "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, + "hosted_ci_runner": False, + "virtualization": {"is_vm": False, + "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 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 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": 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}, + "environment_manifest": {"sha256": "1" * 64}, + "qualification_tool": {"sha256": "2" * 64}, + "power_snapshot": dict(COMPLIANT_POWER), + "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) + 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") + 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) + + +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, + 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))) + 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} + 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, lpath, repo, + quiesce_result=quiet) + return record, (bpath, qpath, mpath, dpath, cpath, lpath, repo) + + +# --- vocabulary, producer schema and the boundary --------------------------- + + +def control_memory_vocabulary() -> None: + 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 differently") + return + if hq.STRATUM_METRIC["linux"] == hq.STRATUM_METRIC["windows"]: + fail("hostqual-memory-vocabulary", "both strata map to one metric") + return + ok("hostqual-memory-vocabulary", + "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} + crepo, link_doc = campaign_fixture(tmp, hq.sha256_file(good_b)) + good_link = write(tmp, "link.json", link_doc) + + 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 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") + 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, 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 " + "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, good_link, crepo)) + 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: + 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 + if hq.bind_t0(repo, "t0.md", "0" * 40)[1]["result"] != "fail": + fail("hostqual-t0-versioned", "a nonexistent commit was accepted") + return + 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", + "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") + + +def control_ci_predicate() -> None: + 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 = 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", "ci == false; absent or non-boolean is refused") + + +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")), + ] + 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-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 + if hq.declaration_predicate(declaration()): + fail("hostqual-provisioning-predicate", "a compliant session declaration failed") + return + 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: + 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 + 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 id argument") + return + ok("hostqual-one-environment-id", + "the id is the manifest's observed value and the declaration must agree with it") + + +def control_quiesce_window() -> None: + slept: list[float] = [] + hq.quiesce(sleep=slept.append, counters=steady()) + if not slept or slept[0] != hq.QUIESCE_QUIET_S: + fail("hostqual-quiesce-window", f"the quiet period was {slept[:1]}") + return + if sum(slept) != hq.QUIESCE_WINDOW_S: + fail("hostqual-quiesce-window", f"the window lasted {sum(slept)}s") + return + 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} x " + f"{hq.QUIESCE_INTERVAL_S}s = {hq.QUIESCE_WINDOW_S}s in total") + + +def control_quiesce_arithmetic() -> None: + def counters(series): + it = iter(series) + return lambda: next(it, None) + + 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", + "quiet passes; a spike over 20%, a 10% mean, a missing sample and a rewound counter " + "each refuse rather than skip") + + +def control_identity_projection() -> None: + with tempfile.TemporaryDirectory() as raw: + tmp = Path(raw) + good, _ = session_fixture(tmp) + if not good["eligible"]: + fail("hostqual-identity-projection", f"a clean session was refused: {good['reasons']}") + return + drifted, _ = session_fixture(tmp, fresh=manifest(kernel="6.2.0")) + if drifted["eligible"]: + 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}") + 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_candidate_at_start() -> None: + with tempfile.TemporaryDirectory() as raw: + tmp = Path(raw) + swapped, _ = session_fixture(tmp, candidate=b"a different executable") + if swapped["eligible"]: + fail("hostqual-candidate-at-start", "a substituted candidate preflighted") + return + 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-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_postflight() -> None: + with tempfile.TemporaryDirectory() as raw: + tmp = Path(raw) + 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, 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, 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, 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", 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, lpath, crepo)["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, 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, 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) + if mixed["admissible"]: + fail("hostqual-one-binding", + "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]: + repo = tmp / "repo" + commit = git_repo(repo, {"t0.md": T0_FROZEN, + eb.INSTRUMENT_SOURCES[0]: "print('instrument')\n", + eb.INSTRUMENT_SOURCES[1]: '{"decisive": []}\n'}) + 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) + 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) + + 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 + 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 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) + except eb.BindingRefused: + pass + else: + 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 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) + 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(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-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") + + +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(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: {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(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)): + 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 + 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 + 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") + + +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: + with tempfile.TemporaryDirectory() as raw: + tmp = Path(raw) + existing = tmp / "binding.json" + existing.write_text("{}", encoding="utf-8") + noise = io.StringIO() + with contextlib.redirect_stderr(noise): + rc = eb.main(["--emit", str(existing)]) + 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 deliberate, never silent") + + +# --- 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} + 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, lpath, crepo, + 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, lpath, crepo, + 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_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") + 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_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("control-inventory-complete", + f"{len(executed)} controls listed, {len(executed)} executed, same names in both") + + +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] = [] + 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 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-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), + ("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), + ("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), +] + + +def run() -> int: + 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 + + +if __name__ == "__main__": + sys.exit(run()) diff --git a/tests/test_step7_mergegate.py b/tests/test_step7_mergegate.py new file mode 100644 index 00000000..1593235f --- /dev/null +++ b/tests/test_step7_mergegate.py @@ -0,0 +1,499 @@ +#!/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-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 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 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. + +Run: python tests/test_step7_mergegate.py +""" + +from __future__ import annotations + +import contextlib +import io +import json +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 + +NL = chr(10) + +_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}") + + +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}}, +} + +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): + 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") +''' + +# 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 + 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: + 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')" + NL, rebound: bool = True, + machinery: bool = True, auto_authority: bool = False, decoy: bool = False, + 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, + eb.INSTRUMENT_SOURCES[1]: '{"decisive": []}' + NL, + mg.STEP7_NOTE: NOTE_AUTO if auto_authority else NOTE_REVOKED} + if machinery: + tools = real_tools() + 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 + 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 "" + 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, 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) + + +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)) + bad = sorted(k for k, v in verdicts(repo, commit).items() if v != "pass") + if 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. + 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 exactly re-bound artifacts, the real tools " + "and the revoked automatic authority is allowed") + + +def control_missing_instrument() -> None: + with tempfile.TemporaryDirectory() as 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}) + 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 + 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_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={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 " + "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 _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={HOSTQUAL: dead_continuity}, name="deadcont") + if verdicts(repo, commit).get("step7_machinery_enforces") != "fail": + 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 "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-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: + with tempfile.TemporaryDirectory() as raw: + repo, commit = world(Path(raw), machinery=False) + if verdicts(repo, commit).get("step7_machinery_enforces") != "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: + 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 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 + 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("(? 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-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), + ("mergegate-reads-t0-digest", control_reads_t0_digest), + ("control-inventory-complete", control_inventory_complete), +] + + +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()) diff --git a/tests/test_step7_mergegate_ci.py b/tests/test_step7_mergegate_ci.py new file mode 100644 index 00000000..60de8d8b --- /dev/null +++ b/tests/test_step7_mergegate_ci.py @@ -0,0 +1,302 @@ +#!/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-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 + 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 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. + + 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)) + 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 = 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 + 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 = 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 + 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 = 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]}") + 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 = 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") + 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 = 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]}") + 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 = invoke("--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 = 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 + 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_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 = 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 " + "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: + 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-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), + ("control-inventory", control_inventory), +] + + +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() + print(f"merge gate wiring controls: {len(_PASSES)} passed, {len(_FAILURES)} failed") + return 1 if _FAILURES else 0 + + +if __name__ == "__main__": + sys.exit(run())