ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_hook_parity.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""Guard hook-to-Just parity and the immutable pre-commit owner transport."""
5
6from __future__ import annotations
7
8import hashlib
9import os
10import shutil
11import signal
12import subprocess
13import sys
14import tempfile
15import time
16from contextlib import suppress
17from pathlib import Path
18
19sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
20
21from scripts.checks import hook_parity_mutations as mutations
22from scripts.checks import hook_transport_support as transport
23from scripts.checks.hook_git_policy_selftest import run_hostile_owner_cases as _hostile
24from scripts.checks.hook_runtime_selftest import (
25 default_signal_test_command,
26 run_runtime_selftests,
27)
28from scripts.dev.git_environment import sanitized_git_environment, trusted_git_executable
29
30REPO_ROOT = Path(__file__).resolve().parents[2]
31POLICY_ROOT = Path(os.environ.get("RA8_HOOK_POLICY_ROOT", REPO_ROOT)).resolve()
32HOOKS_JUST = POLICY_ROOT / "just" / "hooks.just"
33PRE_COMMIT = POLICY_ROOT / "scripts" / "git" / "pre-commit"
34PRE_PUSH = POLICY_ROOT / "scripts" / "git" / "pre-push"
35HOOK_LAUNCHER_FILE = POLICY_ROOT / "scripts" / "git" / "hook-launcher"
36HOOK_INSTALLER = POLICY_ROOT / "scripts" / "git" / "install-hooks.sh"
37PROOF_WRITER = POLICY_ROOT / "scripts" / "git" / "write-proof.py"
38CI_SCRIPT = POLICY_ROOT / "scripts" / "ci.sh"
39CI_GATES_DIR = POLICY_ROOT / "scripts" / "ci" / "gates"
40ROOT_JUSTFILE = POLICY_ROOT / "justfile"
41ROOT_CMAKE = POLICY_ROOT / "CMakeLists.txt"
42RUN_JUST = POLICY_ROOT / "scripts" / "dev" / "run_just.sh"
43CANDIDATE_CHECKER = POLICY_ROOT / "scripts" / "checks" / "check_hook_parity.py"
44MUTATION_HELPER = POLICY_ROOT / "scripts" / "checks" / "hook_parity_mutations.py"
45TRUSTED_CHECKER = Path(__file__).resolve()
46TRUSTED_RUNTIME = REPO_ROOT / "scripts" / "checks" / "hook_runtime_selftest.py"
47TRUSTED_MUTATIONS = REPO_ROOT / "scripts" / "checks" / "hook_parity_mutations.py"
48# The validator boundary is every module the validator EXECUTES. When this
49# logic lived in two files both were pinned; the split to five left two
50# unpinned, and a candidate that gutted
51# hook_git_policy_selftest.run_hostile_owner_cases -- deleting every hostile
52# HOME and hostile PATH proof -- was approved by the immutable HEAD validator.
53# From the following commit onward those proofs would never run again.
54#
55# scripts/dev/git_environment.py is deliberately NOT here: run_bootstrap_
56# validator_case rewrites it inside its fixture to emulate an older HEAD, so
57# byte equality is the wrong instrument for that file.
58CANDIDATE_BOUNDARY_MODULES = (
59 "scripts/checks/hook_transport_support.py",
60 "scripts/checks/hook_git_policy_selftest.py",
61)
62ABORTED = 3
63EXPECTED_POLICY_FAILURE = 42
64
65PRE_COMMIT_GATES = (
66 "ascii",
67 "copyright",
68 "since",
69 "format",
70 "pre-commit-checks",
71 "shebangs",
72 "entry-points",
73 "annotations",
74 "doc-attachment",
75 "toolchain-parity",
76 "lint-py-shell",
77 "lint-just",
78 "cite-check",
79 "hil-eil-parity",
80 "roadmap-stats",
81 "sbom",
82 "soup-upstream",
83)
84
85STAGED_CHECKS = (
86 "check_mcdc_block.py --staged",
87 "check_new_compound_has_mcdc.py --staged",
88 "check_obsolete_standards.py --staged",
89)
90
91JUST_EXECUTABLE = '"{{ just_executable() }}"'
92HOOK_LAUNCHER = "scripts/dev/run_just.sh"
93PRE_COMMIT_SHA256 = "d5cba09dfbdb9b03f3d94cd3fea59e4ca98c626c6edd85d499171c8b812222d5"
94INSTALLED_LAUNCHER_SHA256 = "1ad13a9da6b76e6f8449ace4df6a535d2972d1062654899b353ccd1d4a863b08"
95HOOK_INSTALLER_SHA256 = "18850cb6b3c06c2c1794b6f60103cd9acb584bf7f8745f1c877ff4f838119e86"
96PROOF_WRITER_SHA256 = "09ec423b2f922c03f83504f92786fe018255ccefc31c0ef7c30bb53bb5ff5406"
97BOOTSTRAP_ORDER = (
98 "capture_source",
99 "source_metadata",
100 "resolve_owner_tools",
101 "prepare_private_repository",
102 "write_candidate_tree",
103 "checkout_candidate_tree",
104 "verify_candidate_tree",
105 "prepare_head_control_plane",
106 "make_policy_tools",
107 "run_head_validator",
108 "run_snapshot_policy",
109 "make_owner_proof",
110 "verify_completion_proof",
111 "verify_source_unchanged",
112)
113
114
115class ParityError(RuntimeError):
116 """A hook-parity self-test found a safety regression."""
117
118
119def _fail(message: str) -> None:
120 """Raise one self-test failure without embedding messages in exceptions."""
121 raise ParityError(message)
122
123
124def _recipe(text: str, name: str) -> str:
125 """Return one top-level Just recipe, including its indented body."""
126 lines = text.splitlines()
127 start = next((i for i, line in enumerate(lines) if line.startswith(f"{name}")), -1)
128 if start < 0 or not lines[start].endswith(":"):
129 return ""
130 end = start + 1
131 while end < len(lines):
132 line = lines[end]
133 if line and not line.startswith((" ", "\t")) and not line.startswith("#"):
134 break
135 end += 1
136 return "\n".join(lines[start:end])
137
138
139def _active_lines(recipe: str) -> tuple[str, ...]:
140 """Return non-comment shell lines from a recipe."""
141 return tuple(
142 line.strip()
143 for line in recipe.splitlines()[1:]
144 if line.strip() and not line.lstrip().startswith("#")
145 )
146
147
148def _digest(path: Path) -> str:
149 """Return the SHA-256 digest of one exact policy surface."""
150 return hashlib.sha256(path.read_bytes()).hexdigest()
151
152
153def _check_candidate_control_plane(
154 checker: bytes, runtime: bytes, mutation_helper: bytes, run_just: str
155) -> list[str]:
156 """Reject candidate attempts to replace the immutable validator boundary."""
157 failures: list[str] = []
158 if checker != TRUSTED_CHECKER.read_bytes():
159 failures.append("candidate hook validator differs from immutable HEAD")
160 if runtime != TRUSTED_RUNTIME.read_bytes():
161 failures.append("candidate hook runtime validator differs from immutable HEAD")
162 if mutation_helper != TRUSTED_MUTATIONS.read_bytes():
163 failures.append("candidate hook mutation helper differs from immutable HEAD")
164 for relative in CANDIDATE_BOUNDARY_MODULES:
165 candidate = POLICY_ROOT / relative
166 if not candidate.is_file():
167 failures.append(f"candidate {relative} is absent from the validator boundary")
168 elif candidate.read_bytes() != (REPO_ROOT / relative).read_bytes():
169 failures.append(f"candidate {relative} differs from immutable HEAD")
170 proof_names = ("RA8_STAGED_HOOK_PROOF", "RA8_STAGED_GATE_PROOF")
171 if any(name in run_just for name in proof_names):
172 failures.append("candidate run_just.sh branches on an owner proof capability")
173 return failures
174
175
176def _check_installation_surfaces() -> list[str]:
177 """Pin the stable common-dir installer, launcher, and proof helper."""
178 failures: list[str] = []
179 failures.extend(
180 _check_candidate_control_plane(
181 CANDIDATE_CHECKER.read_bytes(),
182 (POLICY_ROOT / "scripts/checks/hook_runtime_selftest.py").read_bytes(),
183 MUTATION_HELPER.read_bytes(),
184 RUN_JUST.read_text(encoding="utf-8"),
185 )
186 )
187 exact = (
188 (HOOK_LAUNCHER_FILE, INSTALLED_LAUNCHER_SHA256, "installed launcher"),
189 (HOOK_INSTALLER, HOOK_INSTALLER_SHA256, "hook installer"),
190 (PROOF_WRITER, PROOF_WRITER_SHA256, "atomic proof writer"),
191 )
192 failures.extend(
193 f"{label} differs from its exact audited digest"
194 for path, expected, label in exact
195 if _digest(path) != expected
196 )
197 launcher = HOOK_LAUNCHER_FILE.read_text(encoding="utf-8")
198 installer = HOOK_INSTALLER.read_text(encoding="utf-8")
199 root_just = ROOT_JUSTFILE.read_text(encoding="utf-8")
200 cmake = ROOT_CMAKE.read_text(encoding="utf-8")
201 required_launcher = (
202 "#!/bin/bash -p",
203 "PATH=/usr/bin:/bin:/usr/sbin:/sbin",
204 "system_git=/usr/bin/git",
205 'hook_args=("$@")',
206 '"$system_git" -C "$root" cat-file blob "$blob"',
207 '"$system_git" -C "$root" hash-object --no-filters "$owner"',
208 # The dispatch must still be an exec, not a fork-and-wait: pinning only
209 # the argv line would accept a launcher that keeps running as the parent.
210 "exec env -u BASH_ENV -u ENV -u PYTHONHOME -u PYTHONPATH",
211 '"$bash_bin" -p "$owner" "${hook_args[@]}"',
212 )
213 if any(token not in launcher for token in required_launcher):
214 failures.append("installed launcher lost HEAD, argv, or signal ownership")
215 required_installer = (
216 "PATH=/usr/bin:/bin:/usr/sbin:/sbin",
217 "TRUSTED_GIT=/usr/bin/git",
218 "--git-common-dir",
219 'cat-file blob "$blob"',
220 "refusing unmanaged",
221 )
222 if any(token not in installer for token in required_installer):
223 failures.append("hook installer lost common-dir or unmanaged-path safety")
224 if root_just.count("/bin/bash -p scripts/git/install-hooks.sh") != 1:
225 failures.append("root Justfile must expose exactly one explicit hook installer")
226 if "core.hooksPath" in cmake or "install-hooks.sh" in cmake:
227 failures.append("CMake must not mutate Git hook configuration")
228 return failures
229
230
231def _check_wrappers(pre_commit: str, pre_push: str) -> list[str]:
232 """Check that executable hook files remain transport-only wrappers."""
233 failures: list[str] = []
234 push_command = pre_push.replace("\\\n", " ")
235 digest = hashlib.sha256(pre_commit.encode("utf-8")).hexdigest()
236 if digest != PRE_COMMIT_SHA256:
237 failures.append("pre-commit owner hook differs from its exact audited digest")
238 if '"$root/scripts/ci.sh" --staged-hook' in pre_commit:
239 failures.append("pre-commit still enters live ci.sh before snapshotting")
240 main_start = pre_commit.rfind("main() {")
241 main_body = pre_commit[main_start:] if main_start >= 0 else ""
242 positions = tuple(main_body.find(name) for name in BOOTSTRAP_ORDER)
243 if -1 in positions or positions != tuple(sorted(positions)):
244 failures.append("pre-commit owner hook lost snapshot-first execution order")
245 active = _active_lines("owner:\n" + pre_commit)
246 if any(line.startswith(("source ", ". ")) for line in active) or "scripts/ci/lib" in pre_commit:
247 failures.append("pre-commit owner hook imports live repository control code")
248 if HOOK_LAUNCHER not in push_command or 'git_hooks::pre-push "$@"' not in push_command:
249 failures.append("pre-push wrapper does not forward argv to git_hooks::pre-push")
250 if pre_push.count(HOOK_LAUNCHER) != 1:
251 failures.append("pre-push wrapper contains policy beyond one Just dispatch")
252 for label, command in (("pre-push", push_command),):
253 if (
254 '--justfile "$root/justfile"' not in command
255 or '--working-directory "$root"' not in command
256 ):
257 failures.append(f"{label} wrapper does not anchor the launcher at the repository root")
258 return failures
259
260
261def _check_pre_commit_flow(recipe: str, active: tuple[str, ...]) -> list[str]:
262 """Check candidate dispatch after immutable HEAD validation."""
263 failures: list[str] = []
264 forbidden = ("RA8_STAGED_HOOK_PROOF", "RA8_STAGED_GATE_PROOF", "write-proof.py")
265 if any(token in recipe for token in forbidden):
266 failures.append("candidate pre-commit policy can access owner proof capability")
267 if 'local gate="$1"' not in recipe:
268 failures.append("pre-commit gate declaration is not isolated")
269 if f'{JUST_EXECUTABLE} quality::local::gate "$gate"' not in recipe:
270 failures.append("pre-commit lost its direct registered-gate dispatch")
271 if any(line == "exit 0" for line in active):
272 failures.append("pre-commit contains an early-success exit")
273 return failures
274
275
276def _check_pre_commit(hooks: str) -> list[str]:
277 """Check staged semantics and the base hook's still-valid gate coverage."""
278 failures: list[str] = []
279 recipe = _recipe(hooks, "pre-commit")
280 active = _active_lines(recipe)
281 if not recipe:
282 return ["hooks.just has no pre-commit recipe"]
283 if recipe.count("#!/bin/bash -p") != 1:
284 failures.append("pre-commit recipe lost its exact privileged Bash owner")
285 snapshot_requirements = (
286 '[[ "${RA8_STAGED_HOOK_SNAPSHOT:-0}" == "1" ]]',
287 "git diff --quiet --no-ext-diff",
288 "git ls-files --others --exclude-standard",
289 )
290 failures.extend(
291 f"pre-commit lost staged-snapshot assertion: {requirement}"
292 for requirement in snapshot_requirements
293 if requirement not in recipe
294 )
295 gate_positions = tuple(recipe.find(f"\n {gate}\n") for gate in PRE_COMMIT_GATES)
296 if -1 in gate_positions:
297 failures.append("pre-commit lost a registered gate")
298 elif gate_positions != tuple(sorted(gate_positions)):
299 failures.append("pre-commit registered gates were reordered")
300 loop = ' for gate in "${gates[@]}"; do\n run_gate "$gate"\n done'
301 if loop not in recipe:
302 failures.append("pre-commit gate loop is dead, wrapped, or reordered")
303 if f'{JUST_EXECUTABLE} quality::local::gate "$gate"' not in recipe:
304 failures.append("pre-commit lost registered gate dispatch")
305 failures.extend(
306 f"pre-commit lost index-sensitive check: {check}"
307 for check in STAGED_CHECKS
308 if check not in recipe
309 )
310 if "git diff --cached --name-only --diff-filter=ACMR -z" not in recipe:
311 failures.append("pre-commit C trigger is not derived from the staged index")
312 failures.extend(
313 f"pre-commit lost staged-C {gate} trigger"
314 for gate in ("tidy", "cppcheck")
315 if f"run_gate {gate}" not in recipe
316 )
317 failures.extend(_check_pre_commit_flow(recipe, active))
318 prohibited = (
319 "just ci",
320 f"{JUST_EXECUTABLE} ci",
321 "quality::run",
322 "checks::devcontainer",
323 )
324 if any(any(token in line for token in prohibited) for line in active):
325 failures.append("pre-commit invokes full/push-only CI")
326 if any(line.startswith("just ") for line in active):
327 failures.append("pre-commit uses PATH lookup instead of just_executable()")
328 return failures
329
330
331# Every token the snapshot bootstrap must still contain, hoisted out of
332# _check_snapshot_dispatch so that function stays inside the NASA Power of 10
333# Rule 4 length cap as the boundary grows.
334BOOTSTRAP_REQUIREMENTS = (
335 'SOURCE_INDEX="$(active_index_path "$SOURCE_ROOT")"',
336 "install_strict_git_environment",
337 "validate_inherited_alternates",
338 "init_private_repository",
339 "GIT_CONFIG_GLOBAL=/dev/null",
340 "GIT_CONFIG_SYSTEM=/dev/null",
341 "GIT_CONFIG_KEY_0=core.hooksPath",
342 "GIT_CONFIG_KEY_1=core.fsmonitor",
343 "GIT_CONFIG_KEY_2=core.attributesFile",
344 'GIT_INDEX_FILE="$COPIED_INDEX"',
345 'GIT_OBJECT_DIRECTORY="$CAPTURE_OBJECTS"',
346 "start_new_session=True",
347 "preexec_fn=reset_child_signals",
348 "wait_policy_ready",
349 "resolve_owner_tools",
350 # The supervisor interpreters are fixed absolute paths and may never be
351 # resolved out of the mutable source tree. This replaces a .venv PATH
352 # marker that can no longer fire, because the candidate policy is
353 # deliberately permitted to use that same ignored .venv.
354 "OWNER_PYTHON=/usr/bin/python3",
355 "OWNER_BASH=/bin/bash",
356 '"$SOURCE_ROOT/"*) die "owner Just resolves through the mutable source tree" ;;',
357 '[[ "$OWNER_PYTHON" == /* && "$OWNER_BASH" == /* && "$OWNER_JUST" == /* ]]',
358 "RA8_OWNER_PATH=/usr/bin:/bin:/usr/sbin:/sbin",
359 'PATH="$RA8_OWNER_PATH"',
360 '"$account_home/.local/bin/just"',
361 "make_policy_tools",
362 "prepare_head_control_plane",
363 "run_head_validator",
364 "verify_bootstrap_policy_population",
365 "head_supports_attribute_validation",
366 'RA8_HOOK_POLICY_ROOT="$SNAPSHOT_DIR"',
367 '"$OWNER_JUST"',
368 '--shell "$OWNER_BASH" --clear-shell-args --shell-arg -puc',
369 "activate_owner_signal_forwarding",
370 "drain_group(proc",
371 "signal.SIGKILL",
372 "wait_group_empty",
373 'OLDPWD="$SNAPSHOT_DIR"',
374 "verify_source_unchanged",
375 'wait "$pid"',
376 "make_owner_proof",
377 "verify_completion_proof",
378)
379
380
381def _check_snapshot_dispatch(ci_script: str, pre_commit: str) -> list[str]:
382 """Pin immutable validation before trusted direct candidate dispatch."""
383 failures: list[str] = []
384 bootstrap_requirements = BOOTSTRAP_REQUIREMENTS
385 failures.extend(
386 f"pre-commit bootstrap lost exact behavior: {token}"
387 for token in bootstrap_requirements
388 if token not in pre_commit
389 )
390 if "--staged-hook" in ci_script or "selftest-staged-runner" in ci_script:
391 failures.append("ci.sh retains an alternate live staged-hook front door")
392 if "set -euo pipefail\nexit 0\n" in ci_script:
393 failures.append("ci.sh contains an early-success exit before gate dispatch")
394 if ci_script.count('run_gate_capture "$gate"') != 1:
395 failures.append("ci.sh lost its single registered-gate dispatch")
396 if any(token in ci_script for token in ("RA8_STAGED_GATE_PROOF", "write_staged_gate_proof")):
397 failures.append("candidate ci.sh retains a forgeable proof capability")
398 return failures
399
400
401def _check_pre_push(hooks: str) -> list[str]:
402 """Check LFS, pushed-commit policy, and the one full-CI invocation."""
403 failures: list[str] = []
404 recipe = _recipe(hooks, "pre-push remote url")
405 if not recipe:
406 return ["hooks.just has no pre-push recipe"]
407 required = (
408 "git lfs pre-push",
409 "/bin/bash -p scripts/git/commit-msg --selftest",
410 "COMMIT_IDENTITY=",
411 '/bin/bash -p scripts/git/commit-msg "$message"',
412 "git rev-list",
413 "SKIP_CI_PUSH",
414 "--working-directory",
415 "ci_rc=$?",
416 '[[ "$ci_rc" -eq 3 ]]',
417 )
418 failures.extend(
419 f"pre-push lost required behavior: {token}" for token in required if token not in recipe
420 )
421 if recipe.count("#!/bin/bash -p") != 1:
422 failures.append("pre-push recipe lost its exact privileged Bash owner")
423 if "checks::devcontainer" in recipe or "quality::fast" in recipe:
424 failures.append("pre-push runs a partial suite instead of root `just ci`")
425 if "mapfile" in recipe or "readarray" in recipe:
426 failures.append("pre-push uses an array builtin absent from macOS Bash 3.2")
427 ci_command = [
428 line for line in _active_lines(recipe) if line.startswith(f"{JUST_EXECUTABLE} --justfile")
429 ]
430 if len(ci_command) != 1 or " ci" not in recipe:
431 failures.append("pre-push must invoke root `just ci` exactly once")
432 if any(line.startswith("just ") for line in _active_lines(recipe)):
433 failures.append("pre-push uses PATH lookup instead of just_executable()")
434 return failures
435
436
437def _source_reaches_runtime_proof(text: str) -> bool:
438 """Let Bash parse/source a gate fragment and require post-source proof."""
439 payload = f"{text}\nprintf 'RA8-SOURCE-PROOF\\n'\n"
440 command = "set -euo pipefail; source /dev/stdin"
441 result = subprocess.run( # noqa: S603 -- fixed shell parses private policy text
442 ["/bin/bash", "--noprofile", "--norc", "-p", "-c", command],
443 input=payload,
444 capture_output=True,
445 text=True,
446 check=False,
447 )
448 return result.returncode == 0 and result.stdout.endswith("RA8-SOURCE-PROOF\n")
449
450
451def _check_gate_sources(gate_sources: tuple[str, ...]) -> list[str]:
452 """Runtime-prove every sourced gate fragment returns to ci.sh dispatch."""
453 failures: list[str] = []
454 for number, text in enumerate(gate_sources, start=1):
455 if not _source_reaches_runtime_proof(text):
456 failures.append(f"gate source {number} bypasses its post-source runtime proof")
457 return failures
458
459
460def validate(
461 pre_commit: str,
462 pre_push: str,
463 hooks: str,
464 ci_script: str,
465 gate_sources: tuple[str, ...],
466) -> list[str]:
467 """Return every hook parity failure found in the supplied texts."""
468 failures = _check_installation_surfaces()
469 failures.extend(_check_wrappers(pre_commit, pre_push))
470 failures.extend(_check_snapshot_dispatch(ci_script, pre_commit))
471 failures.extend(_check_gate_sources(gate_sources))
472 if 'set working-directory := ".."' not in hooks:
473 failures.append("hooks.just does not anchor recipes at the repository root")
474 failures.extend(_check_pre_commit(hooks))
475 failures.extend(_check_pre_push(hooks))
476 return failures
477
478
479def _live_texts() -> tuple[str, str, str, str, tuple[str, ...]]:
480 """Read every executable hook-policy surface."""
481 gate_sources = tuple(
482 path.read_text(encoding="utf-8") for path in sorted(CI_GATES_DIR.glob("*.sh"))
483 )
484 return (
485 PRE_COMMIT.read_text(encoding="utf-8"),
486 PRE_PUSH.read_text(encoding="utf-8"),
487 HOOKS_JUST.read_text(encoding="utf-8"),
488 CI_SCRIPT.read_text(encoding="utf-8"),
489 gate_sources,
490 )
491
492
493def _structural_selftest(texts: tuple[str, str, str, str, tuple[str, ...]]) -> None:
494 """Prove all named control-flow and nonce regressions are rejected."""
495 pre_commit, pre_push, hooks, ci_script, gate_sources = texts
496 if validate(*texts):
497 _fail("live hook policy was rejected by its own baseline")
498 for number, case in enumerate(
499 mutations.mutation_cases(pre_commit, hooks, ci_script, gate_sources), start=1
500 ):
501 mutated_pre_commit, mutated_hooks, mutated_ci, mutated_gates = case
502 if not validate(mutated_pre_commit, pre_push, mutated_hooks, mutated_ci, mutated_gates):
503 _fail(f"control-flow mutation {number} escaped")
504 rejected_sources = (
505 " exit 0\n",
506 " return 0\n",
507 "{ exit 0; }\n",
508 "{ return 0; }\n",
509 "if true; then exit 0; fi\n",
510 "if true; then return 0; fi\n",
511 )
512 accepted_sources = (
513 "# exit 0\ngate_fixture() { :; }\n",
514 "( exit 0 )\ngate_fixture() { :; }\n",
515 "( return 0 )\ngate_fixture() { :; }\n",
516 )
517 if any(not _check_gate_sources((source,)) for source in rejected_sources):
518 _fail("active sourced-gate exit mutation escaped Bash runtime proof")
519 if any(_check_gate_sources((source,)) for source in accepted_sources):
520 _fail("comment or non-bypassing subshell was misclassified as active exit")
521 checker = TRUSTED_CHECKER.read_bytes()
522 runtime = TRUSTED_RUNTIME.read_bytes()
523 mutation_helper = TRUSTED_MUTATIONS.read_bytes()
524 run_just = RUN_JUST.read_text(encoding="utf-8")
525 control_mutations = (
526 (checker + b"\n# candidate mutation\n", runtime, mutation_helper, run_just),
527 (checker, runtime + b"\n# candidate mutation\n", mutation_helper, run_just),
528 (checker, runtime, mutation_helper + b"\n# candidate mutation\n", run_just),
529 (
530 checker,
531 runtime,
532 mutation_helper,
533 "if [[ -n ${RA8_STAGED_HOOK_PROOF-} ]]; then exit 0; fi\n",
534 ),
535 )
536 if any(not _check_candidate_control_plane(*mutation) for mutation in control_mutations):
537 _fail("candidate validator or run_just proof mutation escaped")
538
539
540def _git(
541 root: Path,
542 *args: str,
543 env: dict[str, str] | None = None,
544 input_data: bytes | None = None,
545) -> bytes:
546 """Run one checked Git command in an isolated synthetic repository."""
547 proc = subprocess.run( # noqa: S603 -- fixed fixture command
548 [trusted_git_executable(), "-C", str(root), *args],
549 env=sanitized_git_environment() if env is None else env,
550 input=input_data,
551 capture_output=True,
552 check=False,
553 )
554 if proc.returncode:
555 _fail(proc.stderr.decode(errors="replace").strip())
556 return proc.stdout
557
558
559def _write_fixture_file(path: Path, text: str, *, executable: bool = False) -> None:
560 """Write one synthetic fixture file and optionally make it executable."""
561 path.parent.mkdir(parents=True, exist_ok=True)
562 path.write_text(text, encoding="utf-8")
563 if executable:
564 path.chmod(0o755)
565
566
567POLICY_FIXTURE_FILES = ( # noqa: SIM905 -- compact fixed census stays below size cap
568 "CMakeLists.txt justfile just/hooks.just scripts/checks/check_hook_parity.py "
569 "scripts/checks/hook_git_policy_selftest.py scripts/checks/hook_runtime_selftest.py "
570 "scripts/checks/hook_parity_mutations.py "
571 "scripts/checks/hook_transport_support.py "
572 "scripts/ci.sh scripts/dev/git_environment.py "
573 "scripts/dev/run_just.sh scripts/git/hook-launcher scripts/git/install-hooks.sh "
574 "scripts/git/pre-commit scripts/git/pre-push scripts/git/write-proof.py"
575).split()
576
577
578def _make_transport_fixture(root: Path, staged: str, worktree: str) -> None:
579 """Create a candidate index whose policy mode differs from its worktree."""
580 _git(root, "init", "--quiet")
581 _git(root, "config", "user.email", "selftest@invalid")
582 _git(root, "config", "user.name", "selftest")
583 for relative in POLICY_FIXTURE_FILES:
584 source = REPO_ROOT / relative
585 destination = root / relative
586 destination.parent.mkdir(parents=True, exist_ok=True)
587 shutil.copy2(source, destination)
588 for source in sorted((REPO_ROOT / "scripts/ci/gates").glob("*.sh")):
589 destination = root / "scripts/ci/gates" / source.name
590 destination.parent.mkdir(parents=True, exist_ok=True)
591 shutil.copy2(source, destination)
592 transport.write_transport_justfiles(root)
593 _write_fixture_file(root / "policy-mode", "success\n")
594 _write_fixture_file(root / ".gitignore", ".venv/\nignored-dir/*\n")
595 for index in range(6):
596 _write_fixture_file(root / f"policy-attributes/{index}/.gitattributes", "* text\n")
597 for index in range(26):
598 _write_fixture_file(root / f"policy-ignores/{index}/.gitignore", "scratch\n")
599 _write_fixture_file(root / "delete-me", "delete\n")
600 _write_fixture_file(root / "resurrect-me", "original\n")
601 _write_fixture_file(root / "mode.sh", "#!/usr/bin/env bash\n")
602 _write_fixture_file(root / "link-target", "target\n")
603 _write_fixture_file(root / "conflict.txt", "base\n")
604 _write_fixture_file(root / "ignored-dir/tracked.txt", "tracked despite ignore\n")
605 _git(root, "add", ".")
606 _git(root, "add", "-f", "ignored-dir/tracked.txt")
607 _git(root, "commit", "--quiet", "-m", "fixture")
608 _write_fixture_file(root / "policy-mode", f"{staged}\n")
609 _git(root, "add", "policy-mode")
610 _write_fixture_file(root / "policy-mode", f"{worktree}\n")
611
612
613def _source_state(root: Path, index: Path | None = None) -> tuple[str, tuple[tuple[str, str], ...]]:
614 """Hash the source index and every loose/packed object-store file."""
615 index_path = index or (root / ".git/index")
616 index_digest = hashlib.sha256(index_path.read_bytes()).hexdigest()
617 objects = root / ".git/objects"
618 object_state = tuple(
619 (path.relative_to(objects).as_posix(), hashlib.sha256(path.read_bytes()).hexdigest())
620 for path in sorted(objects.rglob("*"))
621 if path.is_file()
622 )
623 return index_digest, object_state
624
625
626def _run_owner(
627 root: Path, temp_root: Path, extra_env: dict[str, str] | None = None
628) -> subprocess.CompletedProcess[str]:
629 """Run the audited owner hook against one synthetic active index."""
630 environment = os.environ.copy() if extra_env is None else extra_env.copy()
631 environment["TMPDIR"] = str(temp_root)
632 return subprocess.run( # noqa: S603 -- audited hook path
633 ["/bin/bash", "-p", str(PRE_COMMIT)],
634 cwd=root,
635 env=environment,
636 capture_output=True,
637 text=True,
638 check=False,
639 timeout=20,
640 )
641
642
643def _transport_case(base: Path, name: str, staged: str, worktree: str, expected: int) -> None:
644 """Prove the hook rules only on candidate-index policy bytes."""
645 root = base / name
646 temp_root = base / f"{name}-tmp"
647 root.mkdir()
648 temp_root.mkdir()
649 _make_transport_fixture(root, staged, worktree)
650 marker = base / f"{name}.venv"
651 transport.install_venv_wrappers(root, marker)
652 before = _source_state(root)
653 environment = transport.transport_environment(base, root)
654 inherited = environment["PATH"]
655 environment.update(
656 PATH=f"{root / '.venv/bin'}:{inherited}",
657 RA8_SELFTEST_VENV=str(marker),
658 )
659 result = _run_owner(
660 root,
661 temp_root,
662 environment,
663 )
664 if result.returncode != expected:
665 _fail(f"{name}: expected {expected}, got {result.returncode}: {result.stderr}")
666 if tuple(temp_root.iterdir()):
667 _fail(f"{name}: snapshot residue remained")
668 if _source_state(root) != before:
669 _fail(f"{name}: source index or object store changed")
670 if marker.exists():
671 _fail(f"{name}: an ignored source .venv wrapper became a trusted owner tool")
672
673
674def _wait_for_path(path: Path, process: subprocess.Popen[str]) -> None:
675 """Wait briefly for the staged fixture child to report readiness."""
676 deadline = time.monotonic() + 10
677 while time.monotonic() < deadline:
678 if path.exists():
679 return
680 if process.poll() is not None:
681 _fail(f"signal fixture exited before ready: {process.returncode}")
682 time.sleep(0.02)
683 _fail("signal fixture did not become ready")
684
685
686def _kill_ready_group(ready: Path) -> None:
687 """Kill one synthetic policy group recorded by its supervisor."""
688 try:
689 pgid = int(ready.read_text(encoding="ascii").strip())
690 os.killpg(pgid, signal.SIGKILL)
691 except (OSError, ValueError):
692 return
693
694
695def _force_fixture_cleanup(process: subprocess.Popen[str], temp_root: Path) -> None:
696 """Kill both synthetic owner and policy groups after a test timeout."""
697 for ready in tuple(temp_root.rglob("policy-ready")):
698 _kill_ready_group(ready)
699 if process.poll() is None:
700 with suppress(ProcessLookupError):
701 os.killpg(process.pid, signal.SIGKILL)
702 with suppress(subprocess.TimeoutExpired):
703 process.wait(timeout=5)
704
705
706def _signal_case(base: Path, sig: signal.Signals) -> None:
707 """Signal only the owner PID and prove its policy group is reaped."""
708 root = base / f"signal-{sig.name.lower()}"
709 temp_root = base / f"signal-{sig.name.lower()}-tmp"
710 ready = base / f"{sig.name}.ready"
711 continued = base / f"{sig.name}.continued"
712 root.mkdir()
713 temp_root.mkdir()
714 _make_transport_fixture(root, "hang", "success")
715 environment = transport.transport_environment(base, root)
716 environment.update(
717 RA8_SELFTEST_VENV=str(base / "signal.venv"),
718 TMPDIR=str(temp_root),
719 RA8_SELFTEST_READY=str(ready),
720 RA8_SELFTEST_CONTINUED=str(continued),
721 )
722 process = subprocess.Popen( # noqa: S603 -- audited hook path
723 default_signal_test_command("/bin/bash", "-p", str(PRE_COMMIT)),
724 cwd=root,
725 env=environment,
726 stdout=subprocess.PIPE,
727 stderr=subprocess.PIPE,
728 text=True,
729 start_new_session=True,
730 )
731 try:
732 _wait_for_path(ready, process)
733 os.kill(process.pid, sig)
734 _stdout, stderr = process.communicate(
735 timeout=75, # WSL teardown can drain after the 60-second hostile child exits.
736 )
737 finally:
738 _force_fixture_cleanup(process, temp_root)
739 if process.returncode != ABORTED or "ABORTED" not in stderr:
740 _fail(f"{sig.name}: owner did not report UNKNOWN: {process.returncode}")
741 if continued.exists() or tuple(temp_root.iterdir()):
742 _fail(f"{sig.name}: child continued or snapshot residue remained")
743
744
745def _shape_case(base: Path) -> None:
746 """Prove candidate add/delete/mode/link/ignore semantics and spaces."""
747 root, temp_root = base / "shape", base / "shape-tmp"
748 root.mkdir()
749 temp_root.mkdir()
750 _make_transport_fixture(root, "inspect", "failure")
751 _write_fixture_file(root / "path with spaces/added.txt", "added\n")
752 _git(root, "add", "path with spaces/added.txt")
753 _git(root, "rm", "delete-me", "resurrect-me")
754 _write_fixture_file(root / "resurrect-me", "worktree resurrection\n")
755 (root / "mode.sh").chmod(0o755)
756 _git(root, "add", "mode.sh")
757 (root / "alias").symlink_to("link-target")
758 _git(root, "add", "alias")
759 _write_fixture_file(root / "untracked.txt", "exclude me\n")
760 before = _source_state(root)
761 environment = transport.transport_environment(base, root)
762 environment["RA8_SELFTEST_VENV"] = str(base / "shape.venv")
763 result = _run_owner(root, temp_root, environment)
764 if result.returncode or _source_state(root) != before or tuple(temp_root.iterdir()):
765 _fail(f"candidate-shape fidelity failed: {result.returncode}: {result.stderr}")
766
767
768def _custom_index_case(base: Path) -> None:
769 """Prove hostile hook routing selects an inherited index with spaces."""
770 root, temp_root = base / "custom-index", base / "custom-index-tmp"
771 root.mkdir()
772 temp_root.mkdir()
773 _make_transport_fixture(root, "failure", "success")
774 custom = root / ".git/custom index"
775 shutil.copy2(root / ".git/index", custom)
776 _git(root, "reset", "--mixed", "HEAD")
777 before_custom = _source_state(root, custom)
778 before_default = _source_state(root)
779 environment = {
780 "GIT_DIR": str(root / ".git"),
781 "GIT_WORK_TREE": str(root),
782 "GIT_INDEX_FILE": ".git/custom index",
783 "GIT_PREFIX": "hostile/",
784 }
785 environment.update(transport.transport_environment(base, root))
786 environment["GIT_INDEX_FILE"] = ".git/custom index"
787 environment["RA8_SELFTEST_VENV"] = str(base / "custom.venv")
788 result = _run_owner(root, temp_root, environment)
789 if result.returncode != EXPECTED_POLICY_FAILURE:
790 _fail(f"custom index was not authoritative: {result.returncode}: {result.stderr}")
791 if _source_state(root, custom) != before_custom or _source_state(root) != before_default:
792 _fail("custom/default index or source objects changed")
793
794
795def _conflicted_index_case(base: Path) -> None:
796 """Prove an unmerged active index fails closed without residue."""
797 root, temp_root = base / "conflict", base / "conflict-tmp"
798 root.mkdir()
799 temp_root.mkdir()
800 _make_transport_fixture(root, "success", "success")
801 base_blob = os.fsdecode(_git(root, "rev-parse", "HEAD:conflict.txt")).strip()
802 _write_fixture_file(root / "ours", "ours\n")
803 _write_fixture_file(root / "theirs", "theirs\n")
804 ours = os.fsdecode(_git(root, "hash-object", "-w", "ours")).strip()
805 theirs = os.fsdecode(_git(root, "hash-object", "-w", "theirs")).strip()
806 index_info = (
807 f"100644 {base_blob} 1\tconflict.txt\n"
808 f"100644 {ours} 2\tconflict.txt\n"
809 f"100644 {theirs} 3\tconflict.txt\n"
810 )
811 _git(root, "update-index", "--index-info", input_data=index_info.encode("ascii"))
812 before = _source_state(root)
813 environment = transport.transport_environment(base, root)
814 environment["RA8_SELFTEST_VENV"] = str(base / "conflict.venv")
815 result = _run_owner(root, temp_root, environment)
816 if result.returncode == 0 or _source_state(root) != before or tuple(temp_root.iterdir()):
817 _fail("conflicted active index did not fail closed")
818
819
820def _transport_selftest() -> None:
821 """Exercise staged-vs-worktree selection, exact shape, and signals."""
822 with tempfile.TemporaryDirectory(prefix="ra8-hook-parity-") as temporary:
823 base = Path(temporary)
824 _transport_case(base, "staged-wins", "success", "failure", 0)
825 _transport_case(base, "failure-wins", "failure", "success", 42)
826 _shape_case(base)
827 _custom_index_case(base)
828 _conflicted_index_case(base)
829 transport.run_bootstrap_validator_case(
830 base,
831 (
832 _make_transport_fixture,
833 _git,
834 _source_state,
835 _run_owner,
836 _fail,
837 ),
838 )
839 _hostile(
840 base,
841 (
842 _make_transport_fixture,
843 _git,
844 _source_state,
845 _run_owner,
846 transport.transport_environment,
847 ),
848 )
849 _signal_case(base, signal.SIGTERM)
850 _signal_case(base, signal.SIGINT)
851
852
853def candidate_selftest() -> int:
854 """Run immutable structural mutation proofs against one candidate root."""
855 try:
856 _structural_selftest(_live_texts())
857 except (OSError, ParityError, subprocess.TimeoutExpired) as exc:
858 print(f"check_hook_parity.py: candidate selftest failed: {exc}", file=sys.stderr)
859 return 1
860 print("check_hook_parity.py: candidate selftest passed")
861 return 0
862
863
864def selftest() -> int:
865 """Prove structural guards and the owner transport against regressions."""
866 try:
867 _structural_selftest(_live_texts())
868 _transport_selftest()
869 run_runtime_selftests()
870 except (OSError, ParityError, subprocess.TimeoutExpired) as exc:
871 print(f"check_hook_parity.py: selftest failed: {exc}", file=sys.stderr)
872 return 1
873 print("check_hook_parity.py: selftest passed")
874 return 0
875
876
877def main() -> int:
878 """Run the self-test or validate the live hook files."""
879 if sys.argv[1:] == ["--selftest"]:
880 return selftest()
881 if sys.argv[1:] == ["--candidate-selftest"]:
882 return candidate_selftest()
883 if sys.argv[1:]:
884 print("usage: check_hook_parity.py [--selftest|--candidate-selftest]", file=sys.stderr)
885 return 2
886 failures = validate(*_live_texts())
887 for failure in failures:
888 print(f"check_hook_parity.py: {failure}", file=sys.stderr)
889 if failures:
890 return 1
891 print("check_hook_parity.py: hook wrappers and Just policy are in parity")
892 return 0
893
894
895if __name__ == "__main__":
896 raise SystemExit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298