Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
20 KiB
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_pressurestubbed 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 soprepush-donewhenis 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.