Files
ersatztv/docs/decisions/records/testing/hook-reports-its-own-execution.md
T
timothyandtimothy 499dd348ab
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 25s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m37s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m16s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m19s
feat(776): every hook reports that it fired, and the report is measured (#795)
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-14 19:11:05 +00:00

20 KiB
Raw Blame History

key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
key title status since supersedes superseded-by rule signals mechanics
testing.hook-reports-its-own-execution 2026-08-14 — every hook records that it fired and what it decided, through one shared sink (#776) active 2026-08-14 none none Every script in `.claude/hooks/` sources `scripts/hook-fire-log.sh` and calls `etv_hook_fire_begin <its-own-name> <label> <capture|stream>` as its FIRST act, before anything reads stdin. Two records are appended per invocation — a `fire` record on entry and an `exit` record carrying the exit status and the decision — to a session-scoped JSONL log. THE DECISION IS READ FROM WHAT THE HOOK ACTUALLY EMITTED, never declared by the hook author: Claude Code hooks (`capture` mode) always exit 0 and communicate by PRINTING JSON, so their stdout is diverted and replayed, and the recorded decision is parsed from those bytes; git hooks (`stream` mode) decide by EXIT CODE and their stdout is live progress text a human is watching, so it is not diverted and the decision is the status. That split is not a tuning knob — capturing a slow pre-push hook's output would hold it back until the end and read as a hang, and inferring a git hook's decision from absent JSON would put the report back into the guessing business this record exists to end. The population is DERIVED from `.claude/hooks/*.sh` by `scripts/tests/test_hook_fire_log.py`, so a new hook is uninstrumented-and-red rather than silently unobserved, and the report lists every hook that EXISTS rather than every hook that appears in the log — a report built from the log alone can only show hooks that fired, which makes the never-fired hook, the one finding worth having, invisible. THE INSTRUMENTATION MUST BE INVISIBLE TO THE HARNESS, and this is the load-bearing half: it sits in the stdin and stdout path of the most authoritative guards in the repo, so a differential test drives EVERY hook with and without it over a payload matrix and demands byte-equal stdout and equal exit status. It fails OPEN in exactly one direction — if the log cannot be written the hook behaves exactly as before — because observability that breaks a guard is worse than the blindness it replaces. Two mechanical traps are pinned by tests rather than left to care: stdout must be replayed from the FILE, since `out=$(cat f)` strips trailing newlines and delivers a guard's JSON one byte short with no parser anywhere to complain; and stdin must never be slurped when it is a TTY, because an interactive `git commit` hands its hooks a terminal and `cat` would block forever, hanging the commit the instrumentation was added to observe. hook fire log · did this hook actually fire · PreToolUse leaves no transcript record · make the system report it rather than infer it · capture vs stream mode · byte-equal stdout differential · fail-open observability · never slurp a tty · derived hook population · never-fired row · paths: `scripts/hook-fire-log.sh`, `scripts/tests/test_hook_fire_log.py`, `docs/guard-inventory.md`, `docs/defect-shapes-773.md` · issues: #776, #773, #775, #774, #756, #751, #719, #685, #631 Read side: `scripts/hook-fire-log.sh report [--all|--json|--dir D|--session S]`. Logs default to `$HOME/.cache/ersatztv/hook-fire/<session-id>.jsonl`, outside the repo, overridable with `ETV_HOOK_FIRE_LOG_DIR`; `ETV_HOOK_FIRE_DISABLE=1` turns recording off without editing a hook. The session id comes from the payload's `.session_id`, because this harness version does not export one to hooks. Records carry no tool arguments — only hook, label, event, tool name, phase, status and decision — so the log is not a second copy of the transcript.

The blind spot this closes. In this harness version only Stop hooks emit a structured transcript record. PreToolUse and PostToolUse hooks — which is every guard this repo actually relies on: merge consent, worktree ownership, BOM, agent model and RAM, the golden-file ban — leave no durable execution trace at all. When #773 tried to measure whether they fire, the only available signal was filename mentions in settings dumps and prior audits, which is not evidence of execution. So the guards carrying the most authority were the ones whose execution could only be inferred, and a silently broken hook would have looked identical to a working one indefinitely.

That is Family B (the check never ran) applied to the hook layer itself, and this project has already paid for it twice one layer down: #751's guard that reported success in 6 seconds having run nothing, and #756's fail-open twin that 30 green tests could not see. #756's standing lesson is the whole of this record — make the system report it rather than infer it.

Why the decision is parsed from the emitted bytes rather than declared. The obvious design has each decision site announce itself. pretooluse-merge-consent.sh alone reaches its output through a decide helper from roughly forty call sites, so that design is forty edits in the most load-bearing guard in the repo, and it records what the author meant — which is the inference being abolished, reintroduced one layer up. Capturing what the hook printed records what the harness sees. It cannot drift from the decision because it is the decision, and it needed no edit to any decision site.

The risk this takes on, stated plainly. Reading stdin before the hook and diverting its stdout puts this code directly in the path of every guard. Get it wrong and a deny is swallowed while the log cheerfully reports the guard firing — strictly worse than the blindness it replaces, and undetectable from the outside for the same reason the original gap was. So the proof obligation is not a unit test of the sink: a wrapper can be perfect alone and lethal in context. test_instrumentation_changes_NOTHING_the_harness_can_see runs every hook in both forms and compares stdout bytes, stderr bytes and exit status.

A differential test is only as good as the cases it drives, and this one had to be measured rather than asserted. A hook that emits nothing and exits 0 makes the comparison ("", 0) == ("", 0), which passes on a wrapper that discards everything. Measured on the first version of the matrix: 160 of 165 comparisons were empty-against-empty and 165 of 165 compared exit 0 against exit 0, so deleting the entire replay left the test green for four hooks, two of which issue deny. The three places that claimed the matrix "reaches each hook's deciding branch" — including this record — were simply wrong. The structure that replaces the claim:

  • a broad, cheap payload matrix, which reaches the deciding branch of a few hooks;
  • positives(), which CONSTRUCTS the rest — a sibling worktree carrying a foreign owner marker, an *ersatztv*-named repo with a staged BOM .cs, memory_pressure stubbed at 5% and 15%, a real bare remote so the three pre-push guards can be driven to a genuine block, and a stub Gitea so prepush-donewhen is exercised rather than exempted;
  • test_the_AB_is_not_VACUOUS_for_any_hook, which fails unless each hook has at least one case where the control side emits output or exits non-zero.

One hook is exempt and the exemption is stated rather than assumed: posttooluse-worktree-marker.sh emits nothing and exits 0 by design — its entire output is a marker file — so its transparency is asserted on that file instead.

Five defects this shipped with, all found by cold review or by the tests it prompted. Recorded because each was invisible to every other check, and because the ranking is instructive: the worst one was surfaced by a finding ranked Low.

Defect Why nothing else would catch it Pinned by
exec 0<"$sin" 2>/dev/null sent the hook's entire stderr to /dev/null for its whole life — exec with no command redirects the shell permanently. Every husky guard's user-facing output is stderr, so guards blocked while explaining nothing the A/B compared only stdout and exit status test_stderr_is_NOT_silenced
The fd restore lived inside the same conditional as the replay, and the replay read the PATH — so an unlinked-but-open scratch file meant a swallowed deny and an exit 0 needs mktemp stubbed or the file removed mid-run test_output_SURVIVES_a_vanished_stdout_tempfile
sed/tr in the classifier are locale-aware, so one 0xE9 byte in a permissionDecisionReason printed illegal byte sequence AT THE HARNESS and filed a real deny as output no test put a non-UTF-8 byte in a decision LC_ALL=C, plus the stderr comparison in the A/B
Payload fields were read from a head -c 65536 prefix, so a large tool_response pushed session_id past the cap and those fires filed under unknown-session — reading as NEVER FIRED in the report a truncating read is a sampling error, and no fixture was over 64 KB fields now read from the file, unbounded
out=$(cat f) strips trailing newlines, so a hook's JSON reached the harness one byte short every JSON parser accepts it; only a byte comparison objects test_stdout_is_replayed_BYTE_EXACT
An unconditional stdin slurp blocks forever on a TTY, hanging every interactive git commit the pipe-fed tests cannot reach a tty; needs a real pty test_a_TTY_stdin_is_not_slurped

One mechanism was WITHDRAWN rather than fixed, and it is recorded here because the next reader will otherwise re-add it. A trap … INT TERM HUP was added so a hook killed by the harness timeout would not lose output it had already printed. It produced a defect in each of three consecutive rounds — a handler ending in exit "$?" that turned a signal into consent and let a push reach main; a local sig=… that clobbered $? so a killed guard was recorded as having passed; and a second signal mid-flush that discarded the output entirely.

A correction, because the first version of this record justified the withdrawal with a number that is wrong. It claimed, as measured fact, that trapping made a hook take 30 s to die where it had taken 1 s. That came from a review finding accepted without re-measuring, and the A/B refutes it: signalling the pid gives control 4.01 s and trapped 4.12 s; signalling the process group gives control 0.002 s and trapped 0.054 s. The stall is bash deferring a fatal signal until its foreground command finishes, and it happens identically without any trap — the control stalls too. The trap cost ~0.1 s, not 29 s. Recorded rather than quietly fixed, because a decision record carrying a fabricated measurement is worse than one carrying none: it reads as checked and talks the next reader out of checking.

A second correction, in the same paragraph as the first. The replacement rationale asserted that the round-3 sink "returned rc 0 on SIGINT where the uninstrumented control returns 2". That is inverted, and it was again taken from a review summary without re-measuring — the identical mistake, one round later, in the artifact written to correct it. The measured 12-cell matrix ({group, pid} × {TERM, INT, HUP} × {control, round-3 trapped, HEAD}):

TERM INT HUP
group kill — control / trapped (6f006a1b7) / HEAD 15 / 15 / 15 2 / 2 / 2 1 / 1 / 1
pid kill — control / trapped (6f006a1b7) / HEAD 15 / 15 / 15 0 / 2 / 0 1 / 1 / 1

Reproduce: spawn a stream-mode hook that prints a decision then sleeps, in its own session; signal 1 s in with os.killpg or os.kill; read the wait status. Independently re-measured, five repeats per cell, every cell unanimous, stdout byte-identical in all runs — and the "would not have caught round 3" claim carries its own positive control: patching the trapped handler's re-raise back to exit 0 turns the same test red on all three signals.

So the trapped sink returned 2 where the control returns 0 — a real transparency violation, but in the restrictive direction, which also makes "the consent bug survived" the wrong characterisation. HEAD matches the control in all twelve cells. And the claim that the exit-status assertion "catches the SIGINT case" is false as written: the test signals the process group, and in every group cell the trapped sink is indistinguishable, so it would not have caught round 3 at all. The test asserts the transparency of the current code, which is what it is for; it is not evidence that the trap was bad.

What actually justifies the withdrawal, with the two false reasons removed. Three defects in three rounds, each real and measured at the time — a handler that turned a signal into consent and let a push reach main, a local that clobbered the status so a killed guard was recorded as passing, and a nested signal that discarded the output. One measured transparency violation that survived the fixes (pid + SIGINT, above). And a rescue window that is sub-millisecond, because every capture-mode hook prints its decision and exits immediately. Machinery needing a signal-to-status map, an idempotent flush, correct re-raise semantics and BASHPID handling, to rescue that window, is not worth its failure modes.

What is lost by withdrawing it, enumerated rather than waved past, because process.enumerate-workaround-behaviors-before-deleting exists for precisely this move:

Behaviour the trap provided Status now
A killed hook's exit record LOST. This is why report counts fire records, not exit records — a hook that fired and was killed still shows as having fired
Stdout a killed hook had already printed LOST, and near-unreachable: every capture-mode hook prints its decision and exits immediately, a sub-millisecond window
Cleanup of the two temp files on a kill LOST, bounded by $TMPDIR cleanup
Idempotent flush (ETV_HOOK_FIRE_FLUSHED) RETAINED — cheap, and the failure it prevents is silent

The invariant that replaces the rescue is asserted directly: under a signal, an instrumented hook is indistinguishable from an uninstrumented one — compared on exit status, stdout, stderr and time to die. It signals the process group, not the pid, and that is not a detail: bash defers a fatal signal until its foreground command finishes, so signalling the pid alone makes both sides take the full sleep and the timing assertion distinguishes nothing.

Its limit, stated because the alternative is a coverage claim that is not true: the group-kill arrangement makes the test blind to the round-3 trapped sink, which passes every assertion there. The divergence that sink actually had is a pid-kill one (the table above). This test pins the transparency of the code that ships; it is not a proof that any earlier version was worse.

A SECOND withdrawal, on the same reasoning. A test_no_redirection_in_the_sink_PRECEDES_its_stderr_redirect generalised the exec … 2>/dev/null rule to any command by pattern-matching shell source. It went through four iterations, each fixing a false positive or a false negative the last had introduced — >/dev/null 2>&1 flagged wrongly, then operators preceded by a digit (exec 0<) missed entirely, then multi-command lines flagged wrongly — and cold review then constructed more of both ([[ "$x" < "$y" ]] 2>/dev/null, a line continuation, a > inside a quoted string). It is deleted rather than patched a fifth time, following the precedent in docs/guard-inventory.md: a weak detector is itself the symptom-keyed mistake, and another regex patch adds confidence without soundness. The narrower test_no_exec_in_the_sink_carries_a_STDERR_REDIRECT is retained — it is a sound match on a fixed keyword and covers the dangerous subset, which is every case that has actually occurred.

One transparency limit, measured and stated rather than papered over. On a group kill with a live foreground child, bash prints its own job-control notice (Terminated: 15 sleep 5) that an uninstrumented hook does not. Isolated: sourcing the sink alone does not produce it, and a no-op EXIT trap does not either — doing ANY work at exit keeps the shell alive the ~50 ms bash needs to print, and doing work at exit is the sink's entire purpose. The A/B exempts exactly that notice and nothing else.

The exemption is doing real work and the honest reading is uncomfortable, so it is stated plainly: one observed form of the notice is …/scripts/hook-fire-log.sh: line 109: <pid> Hangup: 1 sleep 25 — bash attributing its own message to whichever line of the sink was executing, which means the instrumentation's path appears on a guard's stderr where an uninstrumented hook emits nothing. That is a genuine, if small, leak, and the exemption hides it. It is accepted rather than fixed because it cannot be suppressed while the sink does any work at exit, and it costs one line in a run that is already being killed.

CI found what six review rounds on one platform could not, and it was a live guard defect. The non-vacuity assertion demands that pretooluse-bom-guard.sh reach a deny. On the Linux runner it could not, because the guard detects a BOM with xxd -p — and xxd ships with vim and is absent there, so the command substitution yielded empty, never matched, and the guard passed every BOM silently. It has been fail-open on any host without vim for its whole life. od -A n -t x1 -N 3 is POSIX, byte-identical on both platforms, and is what it uses now.

Two things follow. The defect was invisible to every round of review because all of them ran on macOS, where xxd exists — the environment was a sampled population too, and the only reason it surfaced is that a test asserted the guard must actually decide something. And two of the three CI failures were my tests being platform-specific (a bare Terminated job notice where macOS prints Terminated: 15 sleep 5; locales that do not exist on the runner, so bash warned and the assertion measured the missing locale rather than the fix). A test suite is code, and it inherits the same blind spot as the code it checks.

The generalisable lesson is about the fixing, not the bugs. This took three review rounds, and every round found that the previous round's fix had introduced something beside its target. The signal fix is the sharpest case: it corrected a lost log line and, in doing so, turned a killed main-protection guard into a rubber stamp. The rule this repo already has — process.consistency-fix-new-code-scrutiny, a fix is new code and gets reviewed as new code — applies with full force to instrumentation, because the fix and the thing it instruments occupy the same execution path.

Two of those fixes read as done and were not, which is the more useful pattern to recognise. The path-traversal hardening reused the record scrubber, which deliberately permits / and ., so ../../escaped passed through untouched: passing a value through a scrub is not the same as passing it through the right scrub. And exec … 2>/dev/null was fixed twice and reintroduced a third time, eight lines below the comment forbidding it — which is why test_no_exec_in_the_sink_carries_a_STDERR_REDIRECT now enforces the rule instead of restating it. Enumerating instances loses to removing the class, and the third instance is what proves it.

One measurement note, since it cost a round of confusion: a shared TMPDIR between the two halves of the differential let the control's one-shot throttle marker suppress the instrumented run, so the test reported a swallowed decision that never happened. A differential whose halves share mutable state stops measuring the difference and measures the state.

What the first measurement immediately produced, which is the argument for the whole thing. A live run recorded pretooluse-agent-model firing on an Agent dispatch and deciding no-op where ask was expected. Under the old regime that would have been an unanswerable suspicion. Instead the question was settled by dumping the real payload: the dispatch carried model: haiku, so no-op was correct and there was no bug. Note what did not happen — no argument from the hook's source about what it "must" do, which this repo's record says has been wrong repeatedly. One printed line settled it in one run.

The limit, stated rather than implied. A zero in the report means not observed in this window, which is two different findings wearing one number: a hook that is broken, and a hook whose trigger genuinely did not occur. The report says so in its own footer rather than presenting a zero as a verdict. pretooluse-nav-guard and pretooluse-merge-consent are the standing examples — neither can be exercised without either a browser session or a real merge, so their zeroes are expected and distinguishing them stays a human judgement.