"""Every hook reports that it fired, and the reporting changes nothing else (ersatztv#776). Two separable claims, and conflating them is how instrumentation ships as a regression: 1. COVERAGE — every hook script records its own execution. The population is DERIVED from the tracked `.claude/hooks/*.sh`, never listed, per `testing.guard-derives-population-from-source`, so a hook staged tomorrow is uninstrumented-and-red rather than silently unobserved. From the git index rather than a filesystem walk since ersatztv#806 — an untracked file on one machine is not part of the repo, and a guard whose population differs per checkout is one nobody trusts. 2. TRANSPARENCY — the wrapper is invisible to the harness. It slurps stdin and replays it, and it diverts stdout and replays it, which puts it directly in the path of the most load-bearing guards in this repo. If it drops a byte of a `deny` payload or swallows an exit code, it disables a guard while reporting that the guard fired. That is strictly worse than the blindness #776 set out to fix, so the differential test below drives EVERY hook, with and without the instrumentation, over a payload matrix and demands byte-equal stdout and equal exit status. The mutation proof for claim 1 is the contrapositive form allowed by `testing.guard-ships-with-mutation-proof` for a checker-guard: the defect the guard exists to catch (an uninstrumented hook) is introduced into an isolated copy of the guarded artifact, and the named check must go red. See `test_a_hook_that_LOSES_its_instrumentation_is_DETECTED`. Claim 2's proof is not a mutation of an assertion but a real A/B over the real scripts, which is the only thing that can establish it — a unit test of the wrapper in isolation would pass on a wrapper that is fine alone and lethal in context. """ from __future__ import annotations import os import pty import re import shlex import shutil import subprocess import sys from pathlib import Path import pytest from scripts.tests.hook_fire_isolation import ( ENV_VAR, SHARED_LOG_DIRS, isolation_violation, resolved_log_dir, ) from scripts.tests.tracked_files import tracked_paths REPO_ROOT = Path(__file__).resolve().parents[2] # Directory + patterns resolved against the GIT INDEX, not `Path.glob` (ersatztv#806) — see # `scripts/tests/tracked_files.py`. `HOOKS_DIR` survives only for error messages. HOOKS = (".claude/hooks", ("*.sh",)) HUSKY = (".husky", ("*",)) HOOKS_DIR = REPO_ROOT / ".claude" / "hooks" SINK = REPO_ROOT / "scripts" / "hook-fire-log.sh" # CAPTURED AT IMPORT TIME ON PURPOSE — this is a specimen, not an oversight. # # `{**os.environ}` at module level is the shape #785 shipped: a suite that snapshots the environment # once, at collection, and hands that stale mapping to every subprocess it launches. Because the # autouse isolation fixture runs at test SETUP, such a snapshot PREDATES it. Before isolation was # installed at `pytest_configure` (pre-collection), every hook driven from that snapshot wrote to # `$HOME/.cache/ersatztv/hook-fire/` — 58 records per run, measured, with every assertion green, # since the fire-log library is fail-open by design. # # `test_the_suite_does_not_write_to_the_PRODUCTION_log` asserts this specimen is now SAFE, which is # the cross-suite claim that test has always made in its docstring and never checked in its body. _IMPORT_TIME_ENV = {**os.environ} # The lines a hook must carry. `_BEGINS` is matched structurally, so a comment reflow around the # begin call does not redden the suite. The SINK PREAMBLE is matched as a frozen blob instead — # byte-identity against the two constants below — because every lexical rule tried over it was # defeated by a shape it did not anticipate (ersatztv#891). `_SOURCES_SINK` predates that and is now # subsumed: byte-identity necessarily matches the sourcing line, so that pattern can no longer decide # anything alone. It is kept for the diagnostic it produces, not for a verdict. _SOURCES_SINK = re.compile(r'^\[ -r "\$ETV_HOOK_FIRE_LIB" \] && \. "\$ETV_HOOK_FIRE_LIB" \|\| true$', re.M) # THE TWO LINES, byte for byte. Named once so the checker, the mutation proofs and the hooks all mean # the same string; a deliberate change to the preamble edits these and the suite tells you which # hooks disagree. CANONICAL_SINK_ASSIGNMENT = ( 'ETV_HOOK_FIRE_LIB="' '$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)' '/scripts/hook-fire-log.sh" || true' ) CANONICAL_SINK_SOURCE = '[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true' _BEGINS = re.compile(r"^etv_hook_fire_begin (\S+) .*\|\| true$", re.M) def hook_scripts() -> list[Path]: """THE POPULATION, from the GIT INDEX. Never a list, and never the filesystem (ersatztv#806). The filesystem is not an authoritative source: an untracked `.sh` dropped in `.claude/hooks/` — a scratch copy, a half-written hook — would enter this population and be demanded to carry instrumentation, reddening the suite on that checkout while CI, which never sees the file, stayed green. That is #778's third shape, and a guard that fails everywhere except where it runs trains its readers to ignore it. """ return tracked_paths(*HOOKS) def expected_mode(name: str) -> str: """DERIVED from the wiring, not listed. A hook registered in `.claude/settings.json` is a Claude hook: it always exits 0 and decides by PRINTING JSON, so its stdout must be captured or the decision is unobservable. A hook registered in `.husky/*` is a git hook: it decides by exit status and its stdout is progress text a human watches live, so capturing it would hold the output back until the end. Reading this from the settings files rather than a hardcoded set means a hook that changes wiring gets the right expectation automatically — and, unlike a list, it cannot be quietly edited to match a mistake. """ if name in (REPO_ROOT / ".claude" / "settings.json").read_text(): return "capture" for husky in tracked_paths(*HUSKY): if name in husky.read_text(): return "stream" return "capture" def instrumentation_faults(text: str, name: str) -> list[str]: """The check itself, factored out so a mutation can be fed to the same code the suite runs.""" faults = [] if not _SOURCES_SINK.search(text): faults.append(f"{name}: does not source scripts/hook-fire-log.sh") # The sink PATH, not just the sourcing line. Repointing `ETV_HOOK_FIRE_LIB` at /dev/null leaves # the `[ -r ] && .` line untouched and disables all reporting silently — a mutation the first # version of this checker passed clean. # # EVERY assignment, and EXACTLY ONE of them. Reading only the first is a hole, and it is # MEASURED: a hook carrying the canonical self-located line and then a SECOND # `ETV_HOOK_FIRE_LIB=${SOME_OTHER_VAR:-…}/scripts/hook-fire-log.sh` passed this checker clean on # all 13 hooks, and the shell would source the LAST value. The sourcing line reads the variable's # final value, so the checker must judge every writer of it — checking the first is checking the # one the defect does not use. # THE AUTHORITY IS BYTE-IDENTITY, not a pattern — and that is a withdrawal, recorded rather than # quietly performed. Three successive lexical rules over this line each fell: `${VAR:-}` # satisfied a containment test; then backticks and `$((…))`; then `$(printenv VAR)`, `$1`, `$?`, # an INDENTED or `export`ed reassignment further down, and `$'…'` quoting that makes the required # token literal rather than expanded. Every fix admitted the next shape, which is this repo's own # signal to stop: a guard was WITHDRAWN from THIS FILE after four iterations of pattern-matching # shell source (`docs/guard-inventory.md`). A shell assignment cannot be recognised by regex, so # this stops trying to recognise one and pins the property that is actually true and actually # wanted — every hook carries THE SAME LINE. # # Comment lines are excluded, which needs no parsing: a line whose first non-blank character is # `#` is a comment in every shell. `pretooluse-merge-consent.sh` legitimately discusses this # variable in prose. Every other line mentioning it must be one of the two canonical lines, in # order. A heredoc carrying the token would fault; no hook has one, and refusing is the safe # direction — this arm's errors are refusals, never acceptances. # `split("\n")`, not `splitlines()`: the latter also breaks on \x0b, \x0c, \x1c-\x1e, \x85 and the # Unicode separators, none of which ends a line for the shell. The divergence runs BOTH ways — # a canonical assignment followed by `\x0c` and garbage on one physical line parses clean under # `splitlines()` and faults under `split("\n")`, so it is an acceptance hole, not only a # stricter refusal. No tracked hook changes its `mentions` either way (measured, 13 of 13); the # point is that the checker should split lines the way the shell does. mentions = [line for line in text.split("\n") if "ETV_HOOK_FIRE_LIB" in line and not line.lstrip().startswith("#")] if mentions != [CANONICAL_SINK_ASSIGNMENT, CANONICAL_SINK_SOURCE]: faults.append( f"{name}: the sink preamble is not the canonical two lines. Every hook carries them " f"byte-identically, so anything else — a second or indented or exported assignment, a " f"trailing comment, a rewritten expansion — is a divergence, not a variant. got={mentions}" ) # THE ARMS OVER THE SINK PREAMBLE no longer decide anything — the `libs` arms below AND # `_SOURCES_SINK` above — because byte-identity already refuses every line they catch. They # survive to say WHY a line is wrong, since "not byte-identical" is true but useless for the # failure that recurs. # # ENUMERATED, not counted: a count here has been wrong twice. The arms judging OTHER THINGS are # NOT subsumed — `ETV_HOOK_FIRE_DISABLE`, and the begin call's PRESENCE, name, mode and stdin # ordering. Every one of them is now pinned by a test asserting ITS OWN fault message, so this # list no longer has to be believed — delete an arm and the suite reddens. It did have to be # believed: PRESENCE and the no-mode-token arm were unsubsumed AND unpinned, because # `…_LOSES_its_instrumentation_…` looks like their proof and is not (it asserts only that the # fault list is NON-EMPTY, and a stripped hook trips four arms, so removing one leaves three # answering). Disarm an arm here with `pass`. `if False:` is safe on an arm with no `elif` after # it — `mutation_manifest.py` disarms byte-identity that way every run — but on the two begin-call # arms below it falls through to a `.group(1)` on None and reds with an AttributeError: a red for # the wrong reason that reads like a proof. A # hook carrying both canonical lines plus `ETV_HOOK_FIRE_DISABLE=1` trips exactly one fault, and # it is not byte-identity's, so a reader who sorts those into "already covered" deletes a live # detector. libs = re.findall(r"^ETV_HOOK_FIRE_LIB=(.*)$", text, re.M) if not libs: faults.append(f"{name}: no ETV_HOOK_FIRE_LIB assignment") elif len(libs) > 1: faults.append( f"{name}: {len(libs)} ETV_HOOK_FIRE_LIB assignments; the sourcing line takes the LAST, so " f"only one may exist: {libs}" ) for value in libs: if "/scripts/hook-fire-log.sh" not in value: faults.append(f"{name}: ETV_HOOK_FIRE_LIB does not point at the shared sink: {value}") # The ROOT the path is resolved FROM, not only the leaf it ends in. The arm above is # satisfied by `${CLAUDE_PROJECT_DIR:-}/scripts/hook-fire-log.sh` — the form all # thirteen hooks carried before ersatztv#891 — and this line is `. `-SOURCED, so an # environment variable chose which tree's CODE ran inside the hook, before stdin was read and # before its decision helper existed. Measured on the merge-consent gate: a decoy tree's copy # printed an `allow` and exited 0, granting the merge 500 lines above the checks. # `process.hook-resolves-inputs-from-repo-root`. # # NARROW BY CONSTRUCTION — only these assignments are judged, never every mention of the # variable in a hook. `pretooluse-bom-guard.sh` reads it to locate the tree a commit ACTS ON: # a SUBJECT the caller supplies, not the AUTHORITY that decides, and binding that one to # `$repo_root` would break it. Classify a path by how it is consumed, not by what it is # called. if "CLAUDE_PROJECT_DIR" in value: faults.append( f"{name}: resolves the SOURCED sink from $CLAUDE_PROJECT_DIR, so an environment " f"variable picks which tree's code runs inside this hook: {value}" ) # A POSITIVE requirement, not a ban on one variable name — banning `CLAUDE_PROJECT_DIR` alone # waves through the next `${SOME_OTHER_DIR:-…}` to be invented, and "could not tell where # this resolves from" must fail rather than pass quietly. if "${BASH_SOURCE[0]}" not in value: faults.append(f"{name}: the sink path is not self-located from ${{BASH_SOURCE[0]}}: {value}") # ...and MENTIONING `${BASH_SOURCE[0]}` is not the same as being DECIDED by it. The gap is # MEASURED: `${ETV_HOOKS_ROOT:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../..")}` # — literally "the next `${SOME_OTHER_DIR:-…}`" the comment above says it stops — passed on # all 13 hooks, because the self-location sitting in the `:-` FALLBACK satisfied a # containment test while the environment still won whenever the variable was set. A # containment test cannot see which branch runs. # # So the value must contain NO expansion at all beyond `${BASH_SOURCE[0]}` itself and the # `$(…)` command substitution the canonical line is MADE of. Four spellings can let something # outside this file choose the tree, and all four fail: `${…}`, a bare `$NAME`, an arithmetic # `$((…))`, and a BACKTICK substitution — the last two # slip past a `${…}`/`$NAME` test when `${BASH_SOURCE[0]}` also appears # (`` `cat /tmp/root` `` names a tree while containing no `$` at all). Neither has any use in # this line, so refusing them costs nothing and "could not tell where this resolves from" # fails rather than passing quietly. residue = value.replace("${BASH_SOURCE[0]}", "") stray = re.search(r"\$\{|\$\(\(|\$[A-Za-z_]|`", residue) if stray: faults.append( f"{name}: the sink path expands {stray.group(0)!r} besides ${{BASH_SOURCE[0]}}, so " f"something outside this hook can still choose the tree whose code it sources: {value}" ) # A hook that disables its own reporting reads as instrumented and reports nothing. if re.search(r"^\s*(export\s+)?ETV_HOOK_FIRE_DISABLE=1", text, re.M): faults.append(f"{name}: sets ETV_HOOK_FIRE_DISABLE=1, so it never reports") m = _BEGINS.search(text) if not m: faults.append(f"{name}: never calls etv_hook_fire_begin") elif m.group(1) != name: faults.append(f"{name}: calls etv_hook_fire_begin for {m.group(1)!r}, not for itself") else: # MODE. Flipping a Claude hook from `capture` to `stream` stops its stdout being read, so # every decision it makes is recorded as `pass`/`blocked` from the exit status it never # uses — the log stays full and becomes wrong. Nothing else in the suite notices. mode = re.search(rf"^etv_hook_fire_begin {re.escape(name)} \S+ (\S+)", text, re.M) want = expected_mode(name) if not mode: faults.append(f"{name}: etv_hook_fire_begin names no stdout mode") elif mode.group(1) != want: faults.append(f"{name}: begins in {mode.group(1)!r} mode but its wiring implies {want!r}") # Order is the whole point: 8 of the hooks slurp stdin with `input=$(cat)`, and a begin # placed after that read would find the pipe already drained — recording a fire with no # tool name, and replaying nothing into a hook that has already consumed its payload. stdin_read = text.find("$(cat)") if 0 <= stdin_read < m.start(): faults.append(f"{name}: reads stdin before etv_hook_fire_begin") return faults def strip_instrumentation(text: str) -> str: """Reconstruct the pre-#776 script. Used BOTH as the mutation and as the A/B control. The blank line that separates the preamble from the code above it is dropped too. Leaving it made the reconstruction differ from the original by one blank line in all 13 hooks — harmless behaviourally, but the A/B control is only trustworthy insofar as it IS the original, and "differs only in ways I judged harmless" is a claim, not a property. `test_the_stripper_removes_EXACTLY_the_preamble_and_nothing_else` turns it into a property, checked structurally — every removed line is a preamble line and nothing is added — rather than against a historical copy. """ out, skipping = [], False for line in text.splitlines(keepends=True): if line.startswith("# ersatztv#776 —"): skipping = True if out and out[-1].strip() == "": out.pop() continue if skipping: if line.startswith( ("#", "ETV_HOOK_FIRE_LIB=", "[ -r ", "type etv_hook_fire_begin", "etv_hook_fire_begin ") ): continue skipping = False out.append(line) return "".join(out) # ------------------------------------------------------------------------------------------------ # ANTI-VACUITY FIRST — a glob that stopped matching would make every assertion below iterate over # nothing and report full coverage. That is the characteristic failure of a completeness check, and # this repo has shipped it (#631, #751), so it is asserted before anything depends on it. # ------------------------------------------------------------------------------------------------ def test_the_population_is_not_empty(): hooks = hook_scripts() assert len(hooks) >= 10, ( f"only found {len(hooks)} hook scripts tracked under {HOOKS_DIR} — the derivation has " "stopped matching, " "so every coverage assertion in this file is vacuous." ) assert SINK.exists(), "the shared sink is missing; the instrumentation cannot work" assert os.access(SINK, os.X_OK), "the sink is not executable, so its report side cannot be run" def test_the_stripper_removes_EXACTLY_the_preamble_and_nothing_else(): """The A/B control must be the hook minus the instrumentation — no more, no less. Comparing `strip_instrumentation(hook)` against the hook at the merge base asserts two unrelated things at once: that the stripper is exact, AND that no hook was edited on the branch for any other reason. The second is not a property worth pinning — it went red the moment `pretooluse-bom-guard.sh` had a real defect fixed (`xxd`, absent on the CI runner, made it fail open) — and coupling them means a legitimate change reads as a broken control. The property that actually protects the differential is structural: every line the stripper removes is a preamble line, and it adds nothing. Checked without reference to history, so it holds on any branch and on any platform. """ import difflib allowed = ( "# ersatztv#776", "# git hook:", "# Claude hook:", "ETV_HOOK_FIRE_LIB=", '[ -r "$ETV_HOOK_FIRE_LIB"', "type etv_hook_fire_begin", "etv_hook_fire_begin ", ) for hook in hook_scripts(): cur = hook.read_text().splitlines(keepends=True) stripped = strip_instrumentation("".join(cur)).splitlines(keepends=True) for line in difflib.Differ().compare(cur, stripped): if line.startswith("+ "): raise AssertionError(f"{hook.name}: the stripper ADDED a line: {line[2:]!r}") if line.startswith("- "): body = line[2:] assert body.strip() == "" or body.startswith(allowed), ( f"{hook.name}: the stripper removed a NON-preamble line, so the A/B control is " f"not this hook: {body!r}" ) def test_the_stripper_actually_strips(): """The A/B control and the mutation are the same function. If it were a no-op, the differential test would compare each hook against itself and pass on a wrapper that breaks everything.""" for hook in hook_scripts(): text = hook.read_text() stripped = strip_instrumentation(text) assert stripped != text, f"{hook.name}: strip_instrumentation() removed nothing" assert "etv_hook_fire_begin" not in stripped, f"{hook.name}: begin survived the strip" assert not instrumentation_faults(text, hook.stem), f"{hook.name} is not instrumented" # ------------------------------------------------------------------------------------------------ # CLAIM 1 — COVERAGE # ------------------------------------------------------------------------------------------------ def test_every_hook_reports_that_it_fired(): faults = [] for hook in hook_scripts(): faults += instrumentation_faults(hook.read_text(), hook.stem) assert not faults, ( "these hooks do not report their own execution:\n " + "\n ".join(faults) + "\n\nAdd the three-line preamble after the `set -` line and BEFORE any stdin read. A hook " "that does not report is one whose firing we can only infer, which is ersatztv#776." ) def test_a_hook_that_LOSES_its_instrumentation_is_DETECTED(): """THE MUTATION PROOF (contrapositive form, for a checker-guard). Introduce the defect this guard exists to catch — an uninstrumented hook — into an isolated copy of each hook in turn, and the check must report it. Every hook, not a sample: the interesting loss is whichever file someone actually edits. """ for hook in hook_scripts(): mutated = strip_instrumentation(hook.read_text()) faults = instrumentation_faults(mutated, hook.stem) assert faults, ( f"stripping the instrumentation from {hook.name} left the check GREEN. The check is " "not load-bearing — it would not notice a hook silently losing its reporting." ) @pytest.mark.parametrize("hook", hook_scripts(), ids=lambda h: h.stem) def test_a_hook_that_DISABLES_its_own_reporting_is_DETECTED(hook): """An arm byte-identity does NOT subsume, and it had no proof until #891 said so. A hook can carry both canonical lines and still report nothing by setting `ETV_HOOK_FIRE_DISABLE` — it reads as fully instrumented and is silent, which is #776's defect wearing the fix's clothes. Byte-identity cannot see it (the preamble is untouched), so this arm decides alone. """ mutated = hook.read_text() + "\nexport ETV_HOOK_FIRE_DISABLE=1\n" faults = instrumentation_faults(mutated, hook.stem) assert any("never reports" in f for f in faults), ( f"{hook.name} disabled its own reporting and the check stayed green. faults={faults}" ) @pytest.mark.parametrize("hook", hook_scripts(), ids=lambda h: h.stem) def test_a_hook_that_BEGINS_UNDER_ANOTHER_HOOKS_NAME_is_DETECTED(hook): """Another unsubsumed arm. A copy-paste that keeps the donor's name logs every fire under the wrong hook: the report shows the donor firing twice and this one never, which is precisely the NEVER-FIRED row #776 exists to make trustworthy.""" mutated = re.sub( rf"^etv_hook_fire_begin {re.escape(hook.stem)} ", "etv_hook_fire_begin some-other-hook ", hook.read_text(), count=1, flags=re.M, ) faults = instrumentation_faults(mutated, hook.stem) assert any("not for itself" in f for f in faults), ( f"{hook.name} reported under another hook's name and the check stayed green. faults={faults}" ) @pytest.mark.parametrize("hook", hook_scripts(), ids=lambda h: h.stem) def test_a_hook_that_LOSES_its_begin_CALL_is_DETECTED(hook): """PINS the begin-call PRESENCE arm, which was unsubsumed AND unpinned until ersatztv#891. `…_LOSES_its_instrumentation_…` above looks like this arm's proof and is not: it strips the whole preamble, which trips FOUR arms on every hook, and it asserts only that the fault list is non-empty — so deleting this arm leaves it green with three arms still answering. An arm nothing reddens for is one a reader can delete on the strength of a prose list, which is how the enumeration beside the arms became load-bearing. This asserts the arm's OWN message instead. """ mutated = re.sub(r"^etv_hook_fire_begin .*\n", "", hook.read_text(), count=1, flags=re.M) faults = instrumentation_faults(mutated, hook.stem) assert any("never calls etv_hook_fire_begin" in f for f in faults), ( f"{hook.name} lost its begin call and the check stayed green, so nothing would notice this " f"arm being deleted. faults={faults}" ) @pytest.mark.parametrize("hook", hook_scripts(), ids=lambda h: h.stem) def test_a_begin_call_NAMING_NO_STDOUT_MODE_is_DETECTED(hook): """PINS the arm beside the one above, found unpinned by the same sweep rather than by a report. The MODE MISMATCH arm has a proof; the arm that fires when there is no mode token to compare had none, and the two fail differently — a mismatch is a wrong mode, this is no mode at all. The mutation is a DOUBLED SPACE, so the token count changes without the line looking edited; that is the shape a reflow or a careless sed produces, and it silently disables the mode check below it. """ mutated = re.sub(rf"^(etv_hook_fire_begin {re.escape(hook.stem)}) ", r"\1 ", hook.read_text(), count=1, flags=re.M) faults = instrumentation_faults(mutated, hook.stem) assert any("names no stdout mode" in f for f in faults), ( f"{hook.name}'s begin call lost its stdout mode token and the check stayed green. faults={faults}" ) @pytest.mark.parametrize("hook", hook_scripts(), ids=lambda h: h.stem) def test_a_hook_that_BEGINS_IN_THE_WRONG_STDOUT_MODE_is_DETECTED(hook): """And another. Flipping a Claude hook to `stream` stops its stdout being read, so every decision is recorded from an exit status it never uses — the log stays full and becomes wrong, which is worse than empty. The expectation is DERIVED from the wiring, so the mutation is "the other mode" rather than a fixed word.""" want = expected_mode(hook.stem) other = "stream" if want == "capture" else "capture" mutated = re.sub( rf"^(etv_hook_fire_begin {re.escape(hook.stem)} \S+ ){re.escape(want)}", rf"\g<1>{other}", hook.read_text(), count=1, flags=re.M, ) assert mutated != hook.read_text(), f"could not flip {hook.name}'s stdout mode; the begin line moved" faults = instrumentation_faults(mutated, hook.stem) assert any("but its wiring implies" in f for f in faults), ( f"{hook.name} begins in the wrong stdout mode and the check stayed green. faults={faults}" ) def test_begin_placed_AFTER_the_stdin_read_is_DETECTED(): """The subtler mutation: present but too late. Ordering is the property that makes it work.""" late = 'set -euo pipefail\ninput=$(cat)\nETV_HOOK_FIRE_LIB="x"\n[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true\netv_hook_fire_begin demo "" capture || true\n' # noqa: E501 - kept on one line so the fixture is greppable against the payload the hook receives faults = instrumentation_faults(late, "demo") assert any("reads stdin before" in f for f in faults), ( "a begin call placed after `input=$(cat)` was accepted. It would record a fire with no " f"payload and replay nothing. faults={faults}" ) # ------------------------------------------------------------------------------------------------ # CLAIM 1b — THE SOURCED SINK IS THE ONE IN *THIS* TREE (ersatztv#858, #891) # # `instrumentation_faults` reads the assignment; these run it. The static arm can only see the shape # of a line, and a shape is a claim about behaviour until something executes it — so each hook is # driven with `$CLAUDE_PROJECT_DIR` naming a tree whose `scripts/hook-fire-log.sh` announces itself, # and must not source it. Every hook, from the derived population: the interesting one is whichever # file someone edits next. # # WHY THIS IS REACHABLE WITHOUT AN ATTACKER, since the obvious objection is that the same variable # also names the hook binary in `.claude/settings.json`. That objection holds for the Claude hooks # and not for the husky ones: `.husky/pre-push` invokes `./.claude/hooks/…`, a RELATIVE path from # the pushed tree, wholly independent of the variable. A push from one worktree while the # environment names another then sources the other tree's code into a gate that can block or allow # the push — ordinary in a repo that runs several worktrees at once, and it fails by returning a # confident wrong answer rather than visibly. # ------------------------------------------------------------------------------------------------ DECOY_SINK_MARKER = "DECOY-SINK-IN-AN-ENV-VAR-NAMED-TREE" # Prints and EXITS. A sourced file is code at the hook's own top level, so `exit` ends the hook — # which is precisely the authority the measured merge-consent bypass used, reduced to a marker. DECOY_SINK_BODY = f"printf '%s\\n' '{DECOY_SINK_MARKER}'\nexit 0\n" def _sink_decoy(tmp_path: Path) -> Path: """A tree whose only content is a `scripts/hook-fire-log.sh` that announces it ran.""" project = tmp_path / "decoy" (project / "scripts").mkdir(parents=True) (project / "scripts" / "hook-fire-log.sh").write_text(DECOY_SINK_BODY) return project def _drive_with_project_dir(hook: Path, sandbox, project: Path, tmp_path: Path) -> bytes: """Run `hook` with `$CLAUDE_PROJECT_DIR` naming `project`. Returns stdout+stderr together. Both channels, because the question is only whether the decoy's code ran at all; which stream it reached is the hook's stdout mode, not the property under test. The cwd is a scratch directory that is deliberately NOT a git repository, so every hook takes an early exit instead of inspecting a real tree. Their deciding branches are covered by the A/B matrix below; this test needs them to reach their PREAMBLE and nothing more. """ _root, base_env = sandbox env = dict(base_env) env["CLAUDE_PROJECT_DIR"] = str(project) env["TMPDIR"] = str(tmp_path / "tmp") Path(env["TMPDIR"]).mkdir(parents=True, exist_ok=True) cwd = tmp_path / "cwd" cwd.mkdir(parents=True, exist_ok=True) p = subprocess.run(["bash", str(hook)], input=b"", capture_output=True, cwd=str(cwd), env=env, timeout=90) return p.stdout + p.stderr @pytest.mark.parametrize("hook", hook_scripts(), ids=lambda h: h.stem) def test_CLAUDE_PROJECT_DIR_cannot_choose_the_SOURCED_sink(hook, sandbox, tmp_path): """The defect ersatztv#891 swept, executed against every hook rather than argued about. `ETV_HOOK_FIRE_LIB` was `${CLAUDE_PROJECT_DIR:-}/scripts/hook-fire-log.sh` and is `. `-SOURCED at the top of the hook — so it is not a log DESTINATION, it is CODE, executed before stdin is read and before the hook can decide anything. Measured on `pretooluse-merge-consent.sh` before #858: a decoy printing an `allow` and exiting 0 granted the merge outright. """ project = _sink_decoy(tmp_path) seen = _drive_with_project_dir(hook, sandbox, project, tmp_path) assert DECOY_SINK_MARKER.encode() not in seen, ( f"{hook.name} sourced a hook-fire-log.sh chosen by $CLAUDE_PROJECT_DIR — another tree's code " f"ran inside this hook before it read stdin: {seen[:300]!r}" ) @pytest.mark.parametrize("hook", hook_scripts(), ids=lambda h: h.stem) def test_the_sink_decoy_IS_sourced_when_it_genuinely_is_the_repo_root(hook, sandbox, tmp_path): """NEGATIVE CONTROL for the test above, and it is not optional. That test asserts an ABSENCE, which is also exactly what an inert decoy produces — mis-copied, unreadable, or a fixture that quietly stopped being built. A green there cannot on its own distinguish "the environment variable was ignored" from "nothing was ever there to source". So the SAME decoy is run again with the hook COPIED INTO it, making the decoy genuinely `$repo_root`. Now the marker MUST appear. That is what makes the absence above evidence. """ project = _sink_decoy(tmp_path) (project / ".claude" / "hooks").mkdir(parents=True) copied = project / ".claude" / "hooks" / hook.name shutil.copy2(hook, copied) seen = _drive_with_project_dir(copied, sandbox, project, tmp_path) assert DECOY_SINK_MARKER.encode() in seen, ( f"the decoy sink was inert even as {hook.name}'s own $repo_root, so its sibling test proves " f"nothing: {seen[:300]!r}" ) def husky_launched_hooks() -> list[Path]: """DERIVED from the `.husky/*` wiring, never listed. These are the hooks a DIFFERENT launcher starts — the whole reason the two roots can disagree in ordinary use.""" wiring = "\n".join(h.read_text() for h in tracked_paths(*HUSKY)) return [h for h in hook_scripts() if h.name in wiring] def test_the_husky_launched_population_is_not_empty(): """ANTI-VACUITY for the pair below. `@parametrize` over an empty list does not fail. Measured: pytest's default `empty_parameter_set_mark` collects one placeholder reported as SKIPPED, which in a full run is a single `s` among the dots — not a false green, but indistinguishable from coverage unless someone reads the skip list. That is the characteristic failure of a derived population that stops matching, and this file has already shipped it once (#631, #751). """ found = husky_launched_hooks() assert found, ( f"no tracked hook under {HOOKS_DIR} is named by any file in .husky/, so the husky-launch " "pair below is parametrized over nothing and asserts nothing. The wiring derivation has " "stopped matching." ) def _pushed_tree(tmp_path: Path, hook: Path, sink_body: str) -> Path: """A repo-SHAPED scratch tree holding this hook and a `scripts/hook-fire-log.sh` of `sink_body`. A relative launch from REPO_ROOT itself is wrong twice over, and both were measured: `prepush-rebase-check.sh` reaches `git fetch origin main` on an empty ref list, so every suite run made a NETWORK CALL and rewrote `FETCH_HEAD` in the very checkout under test — the one ref this repo's process treats as the baseline — and `decisions-guard.sh` ran the full decisions validator over the live tree. A unit test must not move the state its own repository is judged against. Running from a scratch tree also makes the pair a true differential: both halves use the same layout and the same launch, and differ only in which sink the pushed tree holds. """ root = tmp_path / f"pushed-{hook.stem}" (root / ".claude" / "hooks").mkdir(parents=True, exist_ok=True) (root / "scripts").mkdir(parents=True, exist_ok=True) (root / "scripts" / "hook-fire-log.sh").write_text(sink_body) shutil.copy2(hook, root / ".claude" / "hooks" / hook.name) return root def _relative_launch(hook: Path, sandbox, pushed: Path, decoy: Path, tmp_path: Path): """Start `hook` exactly as husky does — `./.claude/hooks/.sh`, relative to the pushed tree — while `$CLAUDE_PROJECT_DIR` names a different tree entirely.""" _root, base_env = sandbox env = dict(base_env) env["CLAUDE_PROJECT_DIR"] = str(decoy) env["TMPDIR"] = str(tmp_path / f"tmp-{hook.stem}") Path(env["TMPDIR"]).mkdir(parents=True, exist_ok=True) p = subprocess.run( ["bash", f"./.claude/hooks/{hook.name}"], input=b"", capture_output=True, cwd=str(pushed), env=env, timeout=90, ) return p.returncode, p.stdout + p.stderr @pytest.mark.parametrize("hook", husky_launched_hooks(), ids=lambda h: h.stem) def test_the_HUSKY_RELATIVE_launch_resolves_to_the_pushed_tree(hook, sandbox, tmp_path): """THE REACHABLE CASE, in the construct it actually occurs in. The sibling tests launch each hook by ABSOLUTE path, which is how the Claude harness starts them — and there `$CLAUDE_PROJECT_DIR` names the same tree by construction, so binding to `$repo_root` is consistency rather than repair. Husky is what makes the two roots genuinely diverge: `.husky/pre-push` runs `./.claude/hooks/…`, a RELATIVE path from the pushed tree, wholly independent of the variable. Self-location under a relative `${BASH_SOURCE[0]}` resolves through the CWD, so it is a different resolution to exercise, not the same one at another spelling. Scenario: a push from one worktree while the environment names another. Ordinary here. The hook must source the PUSHED tree's sink, not the one the variable points at. """ pushed = _pushed_tree(tmp_path, hook, SINK.read_text()) decoy = _sink_decoy(tmp_path) rc, seen = _relative_launch(hook, sandbox, pushed, decoy, tmp_path) assert rc != 127, f"the relative launch did not find the hook (exit 127): {seen[:200]!r}" assert DECOY_SINK_MARKER.encode() not in seen, ( f"{hook.name}, launched the way husky launches it, sourced a hook-fire-log.sh chosen by " f"$CLAUDE_PROJECT_DIR: {seen[:300]!r}" ) @pytest.mark.parametrize("hook", husky_launched_hooks(), ids=lambda h: h.stem) def test_the_HUSKY_RELATIVE_launch_DOES_source_its_own_trees_sink(hook, sandbox, tmp_path): """NEGATIVE CONTROL for the test above, in the same construct. Its sibling asserts an ABSENCE under a relative launch, and a relative launch has an extra way to produce one: resolve to nothing at all. So the same relative spelling is run against a pushed tree whose OWN sink is the announcing one, where the marker MUST appear. That separates "the variable was ignored" from "the self-location silently resolved nowhere". The two arms differ in exactly one thing — which sink the pushed tree holds — and `$CLAUDE_PROJECT_DIR` names the unrelated decoy in both. """ pushed = _pushed_tree(tmp_path, hook, DECOY_SINK_BODY) decoy = _sink_decoy(tmp_path) _rc, seen = _relative_launch(hook, sandbox, pushed, decoy, tmp_path) assert DECOY_SINK_MARKER.encode() in seen, ( f"the relative launch of {hook.name} sourced nothing even when its own tree held the decoy, " f"so its sibling's absence proves nothing: {seen[:300]!r}" ) def test_an_ENV_VAR_resolved_sink_path_is_DETECTED(): """THE MUTATION PROOF for the `CLAUDE_PROJECT_DIR` DIAGNOSTIC arm — not for byte-identity, which has its own in `test_a_LATER_reassignment_the_regex_cannot_see_is_DETECTED`. Reintroduce the pre-#891 line into each real hook in turn and the checker must report it. Built by editing the hook's OWN assignment rather than from a hand-written fixture, so the check is measured against the shape the repo actually shipped (`verify against the REAL predecessor`). """ predecessor = ( 'ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-' '$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}' '/scripts/hook-fire-log.sh" || true' ) current = ( 'ETV_HOOK_FIRE_LIB="' '$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)' '/scripts/hook-fire-log.sh" || true' ) for hook in hook_scripts(): text = hook.read_text() assert current in text, ( f"{hook.name} no longer carries the self-located line this mutation reverts, so the " "proof below would mutate nothing. All hooks must resolve the sink identically." ) mutated = text.replace(current, predecessor) faults = instrumentation_faults(mutated, hook.stem) # THE ARM'S OWN PHRASE, never the variable name. The byte-identity fault echoes the # offending line verbatim, so a substring assertion on `CLAUDE_PROJECT_DIR` is satisfied by # the ECHO alone — measured: with the dedicated arm deleted, this test still passed. # Asserting that a MESSAGE mentions the defect is not asserting the DETECTOR ran. assert any("resolves the SOURCED sink from" in f for f in faults), ( f"restoring the env-var-first sink path in {hook.name} did not trip the arm that names " f"it, so nothing stops it being reintroduced one hook at a time. faults={faults}" ) @pytest.mark.parametrize("hook", hook_scripts(), ids=lambda h: h.stem) def test_a_LATER_reassignment_the_regex_cannot_see_is_DETECTED(hook): """THE MUTATION PROOF for the byte-identity arm — the shape only IT catches. The diagnostic arms below it read `^ETV_HOOK_FIRE_LIB=(.*)$`, anchored at column zero, so an INDENTED reassignment inside an `if`, or an `export`ed one, is invisible to them while bash executes it and the `. `-source line takes the LAST value. Both were MEASURED passing a checker that had just been "fixed" to read every assignment — which is what retired the pattern approach in favour of pinning the two canonical lines. Both spellings are asserted, because they fail the regex for different reasons (leading whitespace, and a leading keyword) and a proof of one is not a proof of the other. """ text = hook.read_text() assert CANONICAL_SINK_ASSIGNMENT in text, f"{hook.name} does not carry the canonical line" steered = '"$ETV_HOOKS_ROOT/scripts/hook-fire-log.sh"' for label, later in ( ("indented, inside an if", f'\nif [ -n "${{ETV_HOOKS_ROOT:-}}" ]; then\n ETV_HOOK_FIRE_LIB={steered}\nfi'), ("exported", f"\nexport ETV_HOOK_FIRE_LIB={steered}"), ): mutated = text.replace(CANONICAL_SINK_ASSIGNMENT, CANONICAL_SINK_ASSIGNMENT + later) faults = instrumentation_faults(mutated, hook.stem) assert any("canonical two lines" in f for f in faults), ( f"{hook.name}: a {label} reassignment of the SOURCED sink passed clean, so an " f"environment variable still picks whose code runs inside this hook. faults={faults}" ) @pytest.mark.parametrize("hook", hook_scripts(), ids=lambda h: h.stem) def test_the_NEXT_env_var_to_be_invented_is_DETECTED(hook): """THE MUTATION PROOF for the no-other-expansion arm, and the reason that arm exists. Banning `CLAUDE_PROJECT_DIR` by name only moves the defect to the next name. This is that shape, and it was MEASURED passing on all 13 hooks against the arm that merely required `${BASH_SOURCE[0]}` to APPEAR: the self-location sits in the `:-` fallback, so the containment test is satisfied while the environment still decides whenever `$ETV_HOOKS_ROOT` is set. Parametrized over the whole population rather than one specimen, because the interesting hook is whichever one someone edits — and a single-specimen proof of a per-hook checker is a claim about one file dressed as a claim about the population. """ successor = ( 'ETV_HOOK_FIRE_LIB="${ETV_HOOKS_ROOT:-' '$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}' '/scripts/hook-fire-log.sh" || true' ) current = ( 'ETV_HOOK_FIRE_LIB="' '$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)' '/scripts/hook-fire-log.sh" || true' ) text = hook.read_text() assert current in text, f"{hook.name} no longer carries the line this mutation replaces" faults = instrumentation_faults(text.replace(current, successor), hook.stem) # Its own phrase too — prudence here rather than repair: `besides` never appears in the # byte-identity fault, so this assertion was already non-vacuous. Pinned anyway, because an arm # added later can de-pin a shared substring silently. assert any("expands" in f and "besides" in f for f in faults), ( f"a sink path steered by a NEW environment variable passed clean on {hook.name}. The arm is " f"a containment test, not a resolution test, and the ban is worth only the one name in it. " f"faults={faults}" ) # ------------------------------------------------------------------------------------------------ # CLAIM 2 — TRANSPARENCY. The wrapper must be invisible to the harness. # ------------------------------------------------------------------------------------------------ # A BROAD, CHEAP matrix. It reaches the deciding branch of only a few hooks; the ones it cannot # reach with a bare payload get a constructed positive case in `positive_cases()` below, and # `test_the_AB_is_not_VACUOUS_for_any_hook` refuses to let any hook rely on this matrix alone. # # MEASURED, not assumed: this matrix # alone produced empty-vs-empty comparisons for 8 of 13 hooks and a zero exit status for all 13, so # deleting the entire stdout replay left the differential test green for four hooks — two of which # issue `deny`. An A/B over silent allows proves transparency on the one path where there is nothing # to be transparent about. PAYLOADS = { "bash-deny": '{"session_id":"s","hook_event_name":"PreToolUse","tool_name":"Bash","cwd":"%(cwd)s","tool_input":{"command":"ETV_UPDATE_GOLDENS=1 dotnet test"}}', # noqa: E501 - kept on one line so the fixture is greppable against the payload the hook receives "bash-allow": '{"session_id":"s","hook_event_name":"PreToolUse","tool_name":"Bash","cwd":"%(cwd)s","tool_input":{"command":"ls -la"}}', # noqa: E501 - kept on one line so the fixture is greppable against the payload the hook receives "git-commit": '{"session_id":"s","hook_event_name":"PreToolUse","tool_name":"Bash","cwd":"%(cwd)s","tool_input":{"command":"git commit -m x"}}', # noqa: E501 - kept on one line so the fixture is greppable against the payload the hook receives "nav-deny": '{"session_id":"s","hook_event_name":"PreToolUse","tool_name":"mcp__x__navigate","cwd":"%(cwd)s","tool_input":{"url":"http://h/iptv/channels.m3u"}}', "agent-no-model": '{"session_id":"s","hook_event_name":"PreToolUse","tool_name":"Agent","cwd":"%(cwd)s","tool_input":{"description":"d","prompt":"p"}}', # noqa: E501 - kept on one line so the fixture is greppable against the payload the hook receives "agent-model": '{"session_id":"s","hook_event_name":"PreToolUse","tool_name":"Agent","cwd":"%(cwd)s","tool_input":{"description":"d","prompt":"p","model":"sonnet"}}', # noqa: E501 - kept on one line so the fixture is greppable against the payload the hook receives "merge": '{"session_id":"s","hook_event_name":"PreToolUse","tool_name":"mcp__gitea__pull_request_write","cwd":"%(cwd)s","tool_input":{"method":"merge","owner":"timothy","repo":"ersatztv","pull_number":1}}', # noqa: E501 - kept on one line so the fixture is greppable against the payload the hook receives "worktree-add": '{"session_id":"s","hook_event_name":"PostToolUse","tool_name":"Bash","cwd":"%(cwd)s","tool_input":{"command":"git worktree add /tmp/nope-%(nonce)s HEAD"}}', # noqa: E501 - kept on one line so the fixture is greppable against the payload the hook receives "ui-edit": '{"session_id":"s","hook_event_name":"PreToolUse","tool_name":"Edit","cwd":"%(cwd)s","tool_input":{"file_path":"web/src/screens/Channels.tsx"}}', # noqa: E501 - kept on one line so the fixture is greppable against the payload the hook receives "empty": "", "garbage": "not json at all", } @pytest.fixture(scope="module") def sandbox(tmp_path_factory): """A controlled environment so the A/B pair cannot differ for reasons unrelated to the wrapper. `memory_pressure` is stubbed because `pretooluse-agent-ram.sh` reads live free RAM: a real reading that crossed the 10% or 20% threshold between the two runs would make the A and B halves disagree for a reason that has nothing to do with instrumentation, and a differential test that flakes is one that gets rerun until green. """ root = tmp_path_factory.mktemp("hookfire") bindir = root / "bin" bindir.mkdir() stub = bindir / "memory_pressure" stub.write_text("#!/bin/sh\necho 'System-wide memory free percentage: 42%'\n") stub.chmod(0o755) env = dict(os.environ) env["PATH"] = f"{bindir}:{env['PATH']}" env["ETV_HOOK_FIRE_LOG_DIR"] = str(root / "log") env["CLAUDE_PROJECT_DIR"] = str(REPO_ROOT) # No creds: `pretooluse-merge-consent.sh` must take its documented "cannot derive state" path # rather than making live Gitea calls from a unit test. for k in ("ETV_GITEA_TOKEN", "ETV_GITEA_BASICAUTH"): env.pop(k, None) return root, env def _run(script: Path, payload: str, env: dict, cwd: Path, arg: str | None = None): cmd = ["bash", str(script)] + ([arg] if arg else []) p = subprocess.run(cmd, input=payload.encode(), capture_output=True, cwd=str(cwd), env=env, timeout=60) return p.returncode, p.stdout def _git(cwd: Path, *args: str) -> None: subprocess.run( ["git", *args], cwd=str(cwd), check=True, capture_output=True, env={ **os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@e", "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@e", }, ) @pytest.fixture(scope="module") def positives(sandbox, tmp_path_factory): """CONSTRUCTED cases that drive hooks the bare payload matrix cannot reach to a DECIDING branch. Each entry is (case-name, payload, arg, env, cwd). Building real git repositories is the point: the hooks these cover — the BOM guard, the worktree-ownership guard, the three pre-push guards — all decide by inspecting a repository, so a payload alone can only ever reach their early exit. """ _root, base_env = sandbox w = tmp_path_factory.mktemp("positives") cases: dict[str, list[tuple]] = {} def pay(**kw) -> str: import json as _json return _json.dumps({"session_id": "me", "hook_event_name": "PreToolUse", "tool_name": "Bash", **kw}) # --- worktree-guard: a marker naming ANOTHER session, on a `git commit` ------------------- wt = w / "sibling" wt.mkdir() _git(wt, "init", "-q", ".") (wt / ".claude-worktree-owner").write_text("SOME-OTHER-SESSION\n") cases["pretooluse-worktree-guard"] = [ ( "foreign-marker-deny", pay(cwd=str(wt), tool_input={"command": "git commit -m x"}), None, base_env, wt, ) ] # --- bom-guard: repo path must match `*ersatztv*`, with a staged BOM-carrying .cs ---------- br = w / "ersatztv-scratch" br.mkdir() _git(br, "init", "-q", ".") (br / "Bad.cs").write_bytes(b"\xef\xbb\xbfclass A {}\n") _git(br, "add", "Bad.cs") cases["pretooluse-bom-guard"] = [ ( "staged-bom-deny", pay(cwd=str(br), tool_input={"command": "git commit -m x"}), None, base_env, br, ) ] # --- agent-ram: both thresholds, via a stubbed `memory_pressure` --------------------------- ram_cases = [] for pct, label in ((5, "deny"), (15, "ask")): bindir = w / f"bin{pct}" bindir.mkdir() stub = bindir / "memory_pressure" stub.write_text(f"#!/bin/sh\necho 'System-wide memory free percentage: {pct}%'\n") stub.chmod(0o755) env = dict(base_env) env["PATH"] = f"{bindir}:{os.environ['PATH']}" ram_cases.append( ( f"free-{pct}pct-{label}", pay(tool_name="Agent", cwd=str(w), tool_input={"prompt": "p", "model": "sonnet"}), None, env, w, ) ) cases["pretooluse-agent-ram"] = ram_cases # --- decisions-guard: a validator that prints and blocks, and one that prints and passes --- dg = w / "dg" (dg / "scripts").mkdir(parents=True) _git(dg, "init", "-q", ".") dg_cases = [] for rc, label in ((1, "block"), (0, "pass")): d = w / f"dg{rc}" (d / "scripts").mkdir(parents=True) _git(d, "init", "-q", ".") (d / "scripts" / "decisions_validate.py").write_text( f"import sys\nprint('VALIDATOR SAID SOMETHING')\nsys.exit({rc})\n" ) dg_cases.append((f"validator-{label}", "", None, base_env, d)) cases["decisions-guard"] = dg_cases # --- pre-push guards: a real remote, so `git fetch origin main` resolves ------------------- bare = w / "remote.git" _git(w, "init", "-q", "--bare", "--initial-branch=main", str(bare)) def clone(name: str) -> Path: p = w / name subprocess.run(["git", "clone", "-q", str(bare), str(p)], check=True, capture_output=True) _git(p, "config", "user.email", "t@e") _git(p, "config", "user.name", "t") return p seed = clone("seed") (seed / "file.txt").write_text("one\n") _git(seed, "add", "-A") _git(seed, "commit", "-qm", "seed") _git(seed, "push", "-q", "origin", "main") # rebase-check: local HEAD is an ANCESTOR of a since-advanced origin/main -> blocks. behind = clone("behind") _git(behind, "checkout", "-qb", "feat") ahead = clone("ahead") (ahead / "file.txt").write_text("two\n") _git(ahead, "add", "-A") _git(ahead, "commit", "-qm", "advance main") _git(ahead, "push", "-q", "origin", "main") head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=str(behind), capture_output=True, text=True).stdout.strip() cases["prepush-rebase-check"] = [ ( "behind-origin-main-blocks", f"refs/heads/feat {head} refs/heads/feat {'0' * 40}\n", None, base_env, behind, ) ] # clean-worktree-check: a file both MODIFIED in the tree and present in the pushed set. dirty = clone("dirty") (dirty / "file.txt").write_text("committed change\n") _git(dirty, "add", "-A") _git(dirty, "commit", "-qm", "change file") (dirty / "file.txt").write_text("uncommitted change\n") dhead = subprocess.run(["git", "rev-parse", "HEAD"], cwd=str(dirty), capture_output=True, text=True).stdout.strip() cases["prepush-clean-worktree-check"] = [ ( "dirty-file-in-pushed-set-blocks", f"refs/heads/main {dhead} refs/heads/main {'0' * 40}\n", None, base_env, dirty, ) ] # --- prepush-donewhen: a push to main closing an issue with an unticked Done-when box ------- # # This one needs a Gitea, so it gets a stub HTTP server rather than an exemption. Without it the # hook's only reachable path in a test is its no-creds fail-open, and the guard that blocks # pushes to `main` would be the one hook whose transparency nothing checks — precisely the # "covered everywhere except where it matters" shape this repo keeps paying for. import http.server import json as _json import threading body = _json.dumps({"body": "## Done-when\n\n- [ ] not finished\n- [x] finished\n"}).encode() class _Stub(http.server.BaseHTTPRequestHandler): def do_GET(self): self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def log_message(self, *a): # keep pytest output clean pass srv = http.server.HTTPServer(("127.0.0.1", 0), _Stub) threading.Thread(target=srv.serve_forever, daemon=True).start() dw = clone("donewhen") (dw / "src.cs").write_text("class A {}\n") # NOT docs-only, so the exemption does not apply _git(dw, "add", "-A") _git(dw, "commit", "-qm", "feat: thing\n\nfixes #999") dw_head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=str(dw), capture_output=True, text=True).stdout.strip() dw_prev = subprocess.run(["git", "rev-parse", "HEAD~1"], cwd=str(dw), capture_output=True, text=True).stdout.strip() dw_env = dict(base_env) dw_env["ETV_GITEA_URL"] = f"http://127.0.0.1:{srv.server_address[1]}" dw_env["ETV_GITEA_BASICAUTH"] = "stub:stub" cases["prepush-donewhen"] = [ ( "unticked-donewhen-blocks-push-to-main", f"refs/heads/main {dw_head} refs/heads/main {dw_prev}\n", None, dw_env, dw, ) ] return cases def _ab_cases(hook: Path, sandbox, positives) -> list[tuple]: """(label, payload, arg, env, cwd) for the generic matrix PLUS this hook's constructed cases.""" root, env = sandbox cases: list[tuple] = [] args = [None, "start", "finish"] if hook.stem == "design-sync-reminder" else [None] for name, template in PAYLOADS.items(): for arg in args: cases.append( ( f"matrix:{name}:{arg}", template % {"cwd": str(root), "nonce": name}, arg, env, root, ) ) for label, payload, arg, penv, cwd in positives.get(hook.stem, []): cases.append((f"positive:{label}", payload, arg, penv, cwd)) return cases def _ab_run(hook: Path, sandbox, case, tag_root: str): """Run the control and the instrumented hook for one case. Returns both (rc, stdout, stderr).""" root = sandbox[0] control = root / f"control-{hook.name}" if not control.exists(): control.write_text(strip_instrumentation(hook.read_text())) control.chmod(0o755) _label, payload, arg, env, cwd = case # A SEPARATE TMPDIR PER SIDE, not per pair. `design-sync-reminder.sh` throttles itself with a # marker file under $TMPDIR, so a shared one let the control fire and then silently suppress the # instrumented run — the A/B reported a swallowed decision that was really the hook's own # one-shot behaviour working correctly. Sharing state between the two halves of a differential # test makes it measure the state, not the difference. def side(t: str) -> dict: e = dict(env) e["TMPDIR"] = str(root / f"tmp-{tag_root}-{t}") Path(e["TMPDIR"]).mkdir(parents=True, exist_ok=True) return e def one(script: Path, t: str): cmd = ["bash", str(script)] + ([arg] if arg else []) p = subprocess.run(cmd, input=payload.encode(), capture_output=True, cwd=str(cwd), env=side(t), timeout=90) return p.returncode, p.stdout, p.stderr return one(control, "control"), one(hook, "instrumented") def _normalise_stderr(err: bytes) -> bytes: """Erase the two things that MUST differ between the control and the instrumented hook. bash prefixes its own diagnostics with `$0` and a line number. The control is a copy at a different path, and the instrumentation adds four lines to the top, so *every* bash-emitted message differs in exactly those two fields for reasons that have nothing to do with transparency. Only the script path and `line N:` are normalised — narrowly, so that a genuine difference in the message body still fails. """ # The two fields are normalised TOGETHER, as one anchored prefix, not independently. A global # `line \d+:` substitution also rewrites application text that happens to contain that phrase, # and was DEMONSTRATED collapsing two genuinely different diagnostics ("highest # private fd line 0" vs "line 4") into one — hiding exactly the kind of fd-state difference this # comparison exists to catch. return re.sub(rb"(?m)^[^\s:]*/[^\s:]*\.sh: line \d+:", b"