Round 3, from two independent re-reviews of the round-2 fix commit. The fix for the lost-output-on-SIGTERM defect introduced a worse one than the defect. The signal handler ended in `exit "$?"`, and `$?` at signal time is the status of whatever command was running — normally 0 for a guard still gathering evidence. Measured on the real guard, `prepush-donewhen.sh` SIGTERMed mid-curl with an unticked Done-when box: uninstrumented rc=-15 git ABORTS the push instrumented rc=0 git PUSHES to main That converts a killed guard into consent, in the permissive direction, in the hook that protects `main`. The handler now flushes, restores the default disposition and re-raises the signal, so the process dies the way it would have died. test_output_survives_a_SIGTERM could not see it: it asserts stdout content, never reads the return code, and drives a capture-mode hook so no exit-code guard is ever signalled. Also from this round: - The flush ran TWICE on the signal path (signal handler, then EXIT trap), writing two contradictory exit records for one invocation — a real decision then a phantom no-op — so the report mis-histogrammed the very case the trap was added for. It is idempotent now. - `exec 4<&- 2>/dev/null`: the round-1 blocker pattern again, eight lines below the comment forbidding it. Rather than fix the third instance and wait for a fourth, test_no_exec_in_the_sink_carries_a_STDERR_REDIRECT now enforces it. - The log write leaked `Operation not permitted` to stderr on an unwritable log, because `>> "$file" 2>/dev/null` opens the file BEFORE redirecting stderr. The fail-open test used a path that dies at mkdir and never reached this. - Classification: an unrecognised value is no longer lowercased into a valid decision, exit 2 now dominates a printed `allow` (it is the harness's block channel, and reporting a permit for a refused call is the one direction this log must never be wrong in), and a non-zero exit no longer annotates a real decision with an invented `+error`. - Classification and replay read the same bytes; they disagreed when a hook used fd 4, so the harness saw `deny` while the log recorded `no-op`. - The stderr normaliser rewrote `line N:` globally, collapsing genuinely different diagnostics; it is anchored to the script-path prefix now. - docs 5.4 claimed the two zero rows were "trigger did not fire", justified by unit tests that invoke the scripts directly and therefore prove nothing about harness dispatch. It now separates what is established from what is not, and stops contradicting its own decision record. Refs: #776 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
482 lines
25 KiB
Bash
Executable File
482 lines
25 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Hook fire log — the WRITE side and the READ side, deliberately in ONE file (ersatztv#776).
|
|
#
|
|
# WHY THIS EXISTS. In this harness version only `Stop` hooks emit a structured transcript record
|
|
# (`stop_hook_summary`/`hookInfos`). `PreToolUse` and `PostToolUse` hooks leave no durable execution
|
|
# trace — which is every guard this repo actually relies on. #773 §5.4 could therefore only count
|
|
# *filename mentions in settings dumps*, i.e. inference. A guard that is neither proven nor
|
|
# observable is an assertion about the past, and a silently broken hook looks identical to a working
|
|
# one indefinitely. #756's standing lesson: make the system REPORT it rather than infer it.
|
|
#
|
|
# WHY ONE FILE. The reader and the writer share the record format. Two files means the format can
|
|
# drift and the report can quietly describe a shape nothing writes any more — the same argument that
|
|
# put `scripts/ci-step-ran.sh` in one script instead of an inline workflow body (#756).
|
|
#
|
|
# WHY IT CAPTURES STDOUT RATHER THAN BEING TOLD THE DECISION. Every hook here exits 0 always; the
|
|
# decision is communicated by *printing* `hookSpecificOutput.permissionDecision` (PreToolUse) or
|
|
# `decision` (Stop). `pretooluse-merge-consent.sh` alone reaches that print from ~40 call sites via
|
|
# its `decide` helper. Asking each site to also set a variable would (a) be 40 edits in the most
|
|
# load-bearing guard in the repo and (b) record what the author *meant*, which is the inference this
|
|
# issue exists to abolish. Capturing the bytes the hook actually emits records what the HARNESS
|
|
# sees. It cannot drift from the decision because it IS the decision.
|
|
#
|
|
# WHY IT CAPTURES STDIN. 8 of the 9 hooks already open with `input=$(cat)` — a full blocking slurp —
|
|
# so reading stdin once here and replaying it via `exec 0<` is not a new risk, it is the read they
|
|
# already perform, moved earlier. It buys the tool name and session id for the log, and it makes
|
|
# `pretooluse-agent-ram.sh` (which reads no stdin at all today) observable on the same terms as the
|
|
# rest instead of being a hole in the table.
|
|
#
|
|
# FAIL-OPEN, DELIBERATELY AND IN THIS DIRECTION ONLY. This file is observability, not a guard. If
|
|
# anything here fails — no temp dir, unwritable log, missing `date` — the hook must behave EXACTLY as
|
|
# it did before instrumentation. A logging bug that denies a merge, or that swallows a guard's deny
|
|
# JSON, would be far worse than the blindness it is fixing. Every function returns 0, the stdout
|
|
# replay is the first act of the exit path, and the original exit code is re-raised explicitly.
|
|
#
|
|
# USAGE (write side) — the first two lines of every hook, before anything reads stdin:
|
|
# . "${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)}/scripts/hook-fire-log.sh"
|
|
# etv_hook_fire_begin <hook-name> [label]
|
|
#
|
|
# USAGE (read side):
|
|
# scripts/hook-fire-log.sh report # this session (or every session, see below)
|
|
# scripts/hook-fire-log.sh report --all # every session in the log dir
|
|
# scripts/hook-fire-log.sh report --json
|
|
# scripts/hook-fire-log.sh path # where the current session logs
|
|
#
|
|
# `scripts/tests/test_hook_fire_log.py` is the guard: it derives the hook population from the
|
|
# filesystem (never a list — `testing.guard-derives-population-from-source`) and fails if any hook
|
|
# is uninstrumented, and it proves stdin, stdout and the exit code survive the wrapper.
|
|
|
|
# ---------------------------------------------------------------------------------------------
|
|
# Shared: where the log lives, and how a record is written
|
|
# ---------------------------------------------------------------------------------------------
|
|
|
|
etv_hook_fire_log_dir() {
|
|
printf '%s' "${ETV_HOOK_FIRE_LOG_DIR:-${HOME:-/tmp}/.cache/ersatztv/hook-fire}"
|
|
}
|
|
|
|
etv_hook_fire_session() {
|
|
# The harness does not export a session id to hooks in this version, so the payload's
|
|
# `.session_id` is the real source and the env var is only a fallback. Recorded rather than
|
|
# assumed: several hooks already read `.session_id` from stdin for exactly this reason.
|
|
printf '%s' "${ETV_HOOK_FIRE_SESSION:-${CLAUDE_SESSION_ID:-unknown-session}}"
|
|
}
|
|
|
|
etv_hook_fire_log_file() {
|
|
# SCRUBBED, like every other use of this value. The session id is extracted from the payload by a
|
|
# `sed` that only excludes `"`, so a `/` or `..` in it would otherwise steer the write outside the
|
|
# log dir. Harness-generated UUIDs make that unreachable today, but a file whose stated thesis is
|
|
# "restrict the value space so there is no escaping bug to have" should not exempt the one use
|
|
# that becomes a path.
|
|
printf '%s/%s.jsonl' "$(etv_hook_fire_log_dir)" "$(etv_hook_fire_scrub_component "$(etv_hook_fire_session)")"
|
|
}
|
|
|
|
# Sanitise a value to a safe JSON scalar charset. This is why no `jq` is needed on the write side:
|
|
# with the value space restricted there is nothing to escape, so there is no escaping bug to have.
|
|
# A quote, backslash or newline in a hook name or tool name is not a case worth supporting — it is a
|
|
# case worth flattening, loudly, to `_`.
|
|
etv_hook_fire_scrub() {
|
|
printf '%s' "${1:-}" | tr -c 'A-Za-z0-9._/:+@=-' '_' | cut -c1-200
|
|
}
|
|
|
|
# A STRICTER scrub for the one value that becomes a PATH. The record scrub above deliberately keeps
|
|
# `/` and `.` — tool names like `mcp__gitea__x` and event paths read better with them — but those
|
|
# are exactly the two characters that turn a session id into `../../escaped`. Passing a value
|
|
# through a scrub is not the same as passing it through the RIGHT scrub, and the first version of
|
|
# this fix reused the record scrubber and left the traversal wide open while reading as fixed.
|
|
etv_hook_fire_scrub_component() {
|
|
printf '%s' "${1:-}" | tr -c 'A-Za-z0-9_-' '_' | cut -c1-120
|
|
}
|
|
|
|
# Append one record. Never fails; never writes a partial line (built whole, appended once).
|
|
etv_hook_fire_record() {
|
|
[ "${ETV_HOOK_FIRE_DISABLE:-0}" = "1" ] && return 0
|
|
|
|
local file line
|
|
file="${ETV_HOOK_FIRE_LOG_FILE:-$(etv_hook_fire_log_file)}"
|
|
mkdir -p "$(dirname "$file")" 2>/dev/null || return 0
|
|
|
|
line=$(printf '{"ts":"%s","session":"%s","pid":"%s","hook":"%s","label":"%s","event":"%s","tool":"%s","phase":"%s","code":"%s","decision":"%s"}' \
|
|
"$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || printf 'unknown')" \
|
|
"$(etv_hook_fire_scrub "$(etv_hook_fire_session)")" \
|
|
"$(etv_hook_fire_scrub "${ETV_HOOK_FIRE_PID:-$$}")" \
|
|
"$(etv_hook_fire_scrub "${1:-}")" \
|
|
"$(etv_hook_fire_scrub "${2:-}")" \
|
|
"$(etv_hook_fire_scrub "${3:-}")" \
|
|
"$(etv_hook_fire_scrub "${4:-}")" \
|
|
"$(etv_hook_fire_scrub "${5:-}")" \
|
|
"$(etv_hook_fire_scrub "${6:-}")" \
|
|
"$(etv_hook_fire_scrub "${7:-}")" 2>/dev/null) || return 0
|
|
|
|
# ORDER MATTERS: `2>` BEFORE `>>`. Redirections are applied left to right, so
|
|
# `printf ... >> "$file" 2>/dev/null` opens the file FIRST and bash reports a failure to open it
|
|
# on the stderr still in force — the hook prints `Operation not permitted` at the harness. An
|
|
# existing-but-unwritable log file is the reachable case; the fail-open test missed it by using a
|
|
# path that dies at `mkdir` instead. Redirecting stderr first covers the open failure too.
|
|
printf '%s\n' "$line" 2>/dev/null >> "$file" || true
|
|
return 0
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------------------------
|
|
# Write side: what a hook calls
|
|
# ---------------------------------------------------------------------------------------------
|
|
|
|
# Classify what the hook printed. The harness reads these two shapes and nothing else:
|
|
# PreToolUse : {"hookSpecificOutput":{"permissionDecision":"allow|deny|ask", ...}}
|
|
# Stop : {"decision":"block", "reason": ...}
|
|
# Anything else printed on stdout is surfaced to the user but decides nothing.
|
|
etv_hook_fire_classify() {
|
|
local out="${1:-}" code="${2:-0}" mode="${3:-capture}" d=""
|
|
|
|
# A `stream`-mode hook is a git hook: git reads its exit code and nothing else, so that is the
|
|
# whole of its decision. Reporting `no-op` here because no JSON was captured would be an
|
|
# inference, and inference is what this file exists to replace.
|
|
if [ "$mode" = "stream" ]; then
|
|
if [ "$code" = "0" ]; then printf 'pass'; else printf 'blocked'; fi
|
|
return 0
|
|
fi
|
|
|
|
if [ -n "$out" ]; then
|
|
d=$(printf '%s' "$out" | sed -n 's/.*"permissionDecision"[[:space:]]*:[[:space:]]*"\([A-Za-z-]*\)".*/\1/p' | head -n1)
|
|
[ -z "$d" ] && d=$(printf '%s' "$out" | sed -n 's/.*"decision"[[:space:]]*:[[:space:]]*"\([A-Za-z-]*\)".*/\1/p' | head -n1)
|
|
|
|
# A NON-CANONICAL VALUE IS RECORDED AS SUCH, not normalised into a valid one. An earlier version
|
|
# lowercased whatever it found, so `"permissionDecision":"Deny"` was filed as a clean `deny` —
|
|
# manufacturing a decision the harness may never have honoured. The documented values are
|
|
# lowercase; anything else is a hook bug, and the log should say so rather than launder it.
|
|
case "$d" in
|
|
allow|deny|ask|block|"") ;;
|
|
*) d="unrecognized" ;;
|
|
esac
|
|
# `additionalContext` with no decision (design-sync-reminder's `start` path) informs, it does
|
|
# not gate. Naming it distinctly keeps it out of the allow/deny counts. Matched WITH its quotes
|
|
# and colon — a bare substring test also fired on human prose that merely used the word.
|
|
if [ -z "$d" ] && printf '%s' "$out" | grep -q '"additionalContext"[[:space:]]*:'; then d="context"; fi
|
|
[ -z "$d" ] && d="output"
|
|
fi
|
|
|
|
# Exit 2 is the harness's block channel and it DOMINATES: the call is blocked whatever the JSON
|
|
# said. Recording a printed `allow` here would report a permit for an invocation that was refused,
|
|
# which is the one direction a log of security decisions must never be wrong in.
|
|
if [ "$code" = "2" ]; then printf 'deny-exit2'; return 0; fi
|
|
|
|
[ -z "$d" ] && d="no-op"
|
|
|
|
# A non-zero status does NOT annotate a parsed decision. It used to overwrite one (`deny` + exit 1
|
|
# recorded as `error`), and the first correction over-swung the other way and appended `+error` —
|
|
# inventing a composite state the harness does not report. The status already has its own field in
|
|
# the record, so the decision field states the decision and nothing else. `error` means only "it
|
|
# failed and said nothing classifiable".
|
|
if [ "$code" != "0" ] && [ "$d" = "no-op" ]; then d="error"; fi
|
|
|
|
printf '%s' "$d"
|
|
}
|
|
|
|
# etv_hook_fire_begin <hook-name> [label] [stdout-mode]
|
|
#
|
|
# `stdout-mode` is `capture` (default) or `stream`, and the split is not a tuning knob — the two
|
|
# kinds of hook in this repo decide by different channels:
|
|
#
|
|
# Claude Code hooks (pretooluse-*, posttooluse-*, design-sync-reminder) always exit 0 and
|
|
# communicate by PRINTING JSON. Their decision is only observable by capturing stdout, and their
|
|
# output is a single line consumed by the harness after exit, so buffering costs nothing.
|
|
#
|
|
# Git hooks (prepush-*, decisions-guard) decide by EXIT CODE, and their stdout is progress text a
|
|
# human is watching in real time. Capturing it would hold a slow pre-push hook's output back until
|
|
# the end, turning a working progress display into an apparent hang. They pass `stream`, and their
|
|
# decision is read from the exit code, which is what git reads too.
|
|
etv_hook_fire_begin() {
|
|
ETV_HOOK_FIRE_NAME="${1:-unknown-hook}"
|
|
ETV_HOOK_FIRE_LABEL="${2:-}"
|
|
ETV_HOOK_FIRE_MODE="${3:-capture}"
|
|
ETV_HOOK_FIRE_PID="$$"
|
|
ETV_HOOK_FIRE_STDIN_TMP=""
|
|
ETV_HOOK_FIRE_STDOUT_TMP=""
|
|
|
|
[ "${ETV_HOOK_FIRE_DISABLE:-0}" = "1" ] && return 0
|
|
|
|
ETV_HOOK_FIRE_LOG_FILE="$(etv_hook_fire_log_file)"
|
|
mkdir -p "$(dirname "$ETV_HOOK_FIRE_LOG_FILE")" 2>/dev/null || { ETV_HOOK_FIRE_DISABLE=1; return 0; }
|
|
|
|
# --- stdin: slurp, replay, and read the payload's identifying fields ------------------------
|
|
#
|
|
# NEVER on a terminal. A `git commit` run interactively hands its hooks a TTY on fd 0, and `cat`
|
|
# would block there forever — instrumentation hanging the commit it was added to observe. Claude
|
|
# Code always writes the JSON payload and closes the pipe, which is why the 8 hooks that already
|
|
# open with `input=$(cat)` are safe today; that guarantee does not extend to the git hooks, so the
|
|
# capture is conditioned on stdin not being a tty rather than on which hook is calling.
|
|
local sin payload="" event="" tool="" sess=""
|
|
if [ ! -t 0 ]; then
|
|
sin=$(mktemp "${TMPDIR:-/tmp}/etv-hook-stdin.XXXXXX" 2>/dev/null) || sin=""
|
|
else
|
|
sin=""
|
|
fi
|
|
if [ -n "$sin" ]; then
|
|
ETV_HOOK_FIRE_STDIN_TMP="$sin"
|
|
cat > "$sin" 2>/dev/null || true
|
|
# Replay: even a partial capture is closer to the truth than the drained pipe the hook would
|
|
# otherwise inherit.
|
|
#
|
|
# NO `2>/dev/null` ON THIS LINE, EVER. `exec` with redirections and no command applies them to
|
|
# the shell PERMANENTLY, so `exec 0<"$sin" 2>/dev/null` does not suppress errors from this one
|
|
# redirection — it sends the HOOK'S ENTIRE STDERR to /dev/null for the rest of its life. That
|
|
# silenced every husky hook's user-facing output, which is stderr: the H6 "push to main BLOCKED"
|
|
# message, the BOM guard's remediation text, `husky - commit message missing Co-Authored-By`.
|
|
# The guards still blocked, and the human was told nothing about why.
|
|
# Readability is tested instead of relying on redirection-failure suppression.
|
|
if [ -r "$sin" ]; then exec 0<"$sin" || true; fi
|
|
payload=$(head -c 65536 "$sin" 2>/dev/null || true)
|
|
fi
|
|
|
|
if [ -n "$payload" ]; then
|
|
event=$(printf '%s' "$payload" | sed -n 's/.*"hook_event_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
|
|
tool=$(printf '%s' "$payload" | sed -n 's/.*"tool_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
|
|
sess=$(printf '%s' "$payload" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
|
|
fi
|
|
|
|
# The payload's session id is authoritative when the env var is absent, which it is in this
|
|
# harness version. Re-point the log file at it before the first record is written.
|
|
if [ -n "$sess" ] && [ -z "${CLAUDE_SESSION_ID:-}" ] && [ -z "${ETV_HOOK_FIRE_SESSION:-}" ]; then
|
|
ETV_HOOK_FIRE_SESSION="$sess"
|
|
ETV_HOOK_FIRE_LOG_FILE="$(etv_hook_fire_log_file)"
|
|
mkdir -p "$(dirname "$ETV_HOOK_FIRE_LOG_FILE")" 2>/dev/null || true
|
|
fi
|
|
|
|
ETV_HOOK_FIRE_EVENT="$event"
|
|
ETV_HOOK_FIRE_TOOL="$tool"
|
|
|
|
etv_hook_fire_record "$ETV_HOOK_FIRE_NAME" "$ETV_HOOK_FIRE_LABEL" "$event" "$tool" "fire" "" ""
|
|
|
|
# --- stdout: divert, so the exit path can read the decision the hook emitted ----------------
|
|
local sout=""
|
|
if [ "$ETV_HOOK_FIRE_MODE" = "capture" ]; then
|
|
sout=$(mktemp "${TMPDIR:-/tmp}/etv-hook-stdout.XXXXXX" 2>/dev/null) || sout=""
|
|
fi
|
|
if [ -n "$sout" ]; then
|
|
ETV_HOOK_FIRE_STDOUT_TMP="$sout"
|
|
exec 3>&1 || { ETV_HOOK_FIRE_STDOUT_TMP=""; rm -f "$sout" 2>/dev/null; return 0; }
|
|
exec 1>"$sout" || { exec 1>&3 3>&-; ETV_HOOK_FIRE_STDOUT_TMP=""; rm -f "$sout" 2>/dev/null; return 0; }
|
|
# A READ FD held open on the same file, so the replay does not depend on the PATH still
|
|
# resolving at exit. Replaying with `cat "$path"` loses everything if anything unlinks the file
|
|
# mid-run — a $TMPDIR reaper, a hook that clears its own scratch — because the write fd stays
|
|
# valid on the unlinked inode while the name is gone: the hook prints its `deny` into a file
|
|
# nothing can reopen. An fd survives unlink, which turns that from silent guard-disarming into
|
|
# a non-event. Pinned by `test_output_SURVIVES_a_vanished_stdout_tempfile`.
|
|
exec 4<"$sout" || true
|
|
fi
|
|
|
|
# SIGNALS, not just EXIT. Bash runs no EXIT trap for an untrapped fatal signal, so a hook killed
|
|
# by the harness timeout would have its already-printed decision discarded along with the temp
|
|
# file: measured at 52 bytes delivered before instrumentation and 0 after.
|
|
#
|
|
# THE SIGNAL HANDLER MUST NOT `exit`, AND THIS IS THE IMPORTANT PART. A handler that ends in
|
|
# `exit "$?"` re-raises whatever status the interrupted command happened to have — normally 0 for
|
|
# a guard still gathering evidence. Measured on `prepush-donewhen.sh` mid-`curl`, with an unticked
|
|
# Done-when box: uninstrumented, SIGTERM kills it and git ABORTS the push; with an `exit`-based
|
|
# handler the hook returned 0 and git PUSHED TO MAIN. That converts a signal into consent, in the
|
|
# permissive direction, in the guard that protects `main` — worse than the lost log line it was
|
|
# added to fix. So the handler flushes, then restores the default disposition and re-raises the
|
|
# signal, letting the process die the way it would have died. Pinned by
|
|
# `test_a_SIGNALLED_stream_hook_still_FAILS`.
|
|
trap 'etv_hook_fire_signal INT' INT
|
|
trap 'etv_hook_fire_signal TERM' TERM
|
|
trap 'etv_hook_fire_signal HUP' HUP
|
|
trap 'etv_hook_fire_end "$?"' EXIT
|
|
return 0
|
|
}
|
|
|
|
# Flush and record, WITHOUT deciding how the process ends. Both the EXIT trap and the signal handler
|
|
# call this; `ETV_HOOK_FIRE_FLUSHED` makes it idempotent, because on the signal path bash runs the
|
|
# signal handler AND then the EXIT trap. Without the guard that wrote two contradictory `exit`
|
|
# records for one invocation — a real `deny` followed by a phantom `no-op` — so the report
|
|
# mis-histogrammed exactly the case the signal trap was added for.
|
|
etv_hook_fire_flush() {
|
|
local code="${1:-0}" out=""
|
|
[ "${ETV_HOOK_FIRE_FLUSHED:-0}" = "1" ] && return 0
|
|
ETV_HOOK_FIRE_FLUSHED=1
|
|
|
|
# RESTORE FIRST, UNCONDITIONALLY — then replay if there is anything to replay.
|
|
#
|
|
# These were one conditional, `[ -n "$TMP" ] && [ -f "$TMP" ]`, and that coupling was the bug:
|
|
# `-f` asks "is there output to replay", but the fd restore must happen whenever the redirect was
|
|
# ESTABLISHED, which is a different fact. Any route that makes `-f` false while the redirect is
|
|
# live — the temp file unlinked by a $TMPDIR reaper mid-run (the fd stays valid, so the hook
|
|
# writes happily into an unlinked inode), a $TMPDIR where mktemp yields a non-regular file — left
|
|
# fd 1 still pointing at the temp target with the restore skipped, so nothing later could rescue
|
|
# the bytes. A guard's `deny` was silently discarded and the hook still exited 0.
|
|
#
|
|
# Split, the worst case degrades from "the guard is disarmed" to "the log is short", which is the
|
|
# correct failure direction for observability. Pinned by
|
|
# `test_output_SURVIVES_a_vanished_stdout_tempfile`.
|
|
if [ -n "${ETV_HOOK_FIRE_STDOUT_TMP:-}" ]; then
|
|
# Same rule as the stdin replay: a trailing `2>/dev/null` here would permanently silence stderr
|
|
# rather than suppress an error from this redirection. fd 3 is known open on this path.
|
|
exec 1>&3 3>&- || true
|
|
fi
|
|
if [ -n "${ETV_HOOK_FIRE_STDOUT_TMP:-}" ]; then
|
|
# ONE read, used for BOTH the replay and the classification, so the two cannot disagree. They
|
|
# did: the classifier read fd 4 while the replay preferred the file, so a hook that used fd 4
|
|
# itself replayed its `deny` correctly to the harness and filed it in the log as `no-op` — the
|
|
# log quietly contradicting the decision it exists to record.
|
|
#
|
|
# The `printf X` / `%X` dance preserves trailing newlines, which `$(...)` strips. Without it the
|
|
# rescue path delivered 52 bytes where the hook wrote 53, and every JSON parser downstream
|
|
# accepts the short form without complaint. Pinned by `test_stdout_is_replayed_BYTE_EXACT`.
|
|
#
|
|
# NO `2>/dev/null` ON THE `exec` — see the stdin comment. An earlier version of THIS line had
|
|
# it, eight lines below the comment forbidding it, which is why the rule is now restated at
|
|
# every `exec` rather than once.
|
|
if [ -r "${ETV_HOOK_FIRE_STDOUT_TMP:-}" ]; then
|
|
out=$( { cat "$ETV_HOOK_FIRE_STDOUT_TMP" 2>/dev/null; printf 'X'; } )
|
|
else
|
|
out=$( { cat <&4 2>/dev/null; printf 'X'; } ) # the file was unlinked; the fd still reads it
|
|
fi
|
|
out="${out%X}"
|
|
exec 4<&- || true
|
|
[ -n "$out" ] && printf '%s' "$out"
|
|
fi
|
|
|
|
etv_hook_fire_record \
|
|
"${ETV_HOOK_FIRE_NAME:-unknown-hook}" "${ETV_HOOK_FIRE_LABEL:-}" \
|
|
"${ETV_HOOK_FIRE_EVENT:-}" "${ETV_HOOK_FIRE_TOOL:-}" \
|
|
"exit" "$code" "$(etv_hook_fire_classify "$out" "$code" "${ETV_HOOK_FIRE_MODE:-capture}")" || true
|
|
|
|
rm -f "${ETV_HOOK_FIRE_STDIN_TMP:-}" "${ETV_HOOK_FIRE_STDOUT_TMP:-}" 2>/dev/null || true
|
|
ETV_HOOK_FIRE_STDOUT_TMP=""
|
|
return 0
|
|
}
|
|
|
|
etv_hook_fire_end() {
|
|
local code="${1:-0}"
|
|
etv_hook_fire_flush "$code"
|
|
# Re-raise the hook's own status explicitly rather than relying on the trap preserving it. Bash
|
|
# does not re-enter an EXIT trap, so this is not recursive.
|
|
exit "$code"
|
|
}
|
|
|
|
# The signal path. Flush, then DIE THE WAY THE PROCESS WOULD HAVE DIED: restore the default
|
|
# disposition and re-raise, so the parent still sees "killed by signal N" rather than a status this
|
|
# handler invented. `git` reads that distinction, and reporting 0 here let a push through.
|
|
etv_hook_fire_signal() {
|
|
local sig="${1:-TERM}"
|
|
etv_hook_fire_flush "$?"
|
|
trap - INT TERM HUP EXIT
|
|
kill -s "$sig" "$$"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------------------------
|
|
# Read side: the report
|
|
# ---------------------------------------------------------------------------------------------
|
|
|
|
# The hook population, DERIVED from the filesystem, never listed
|
|
# (`testing.guard-derives-population-from-source`). A hook added tomorrow appears in the report as
|
|
# NEVER-FIRED the moment it exists, rather than being invisible because nobody updated an array.
|
|
etv_hook_fire_population() {
|
|
local root="${1:-}" f
|
|
[ -d "$root/.claude/hooks" ] || return 0
|
|
for f in "$root"/.claude/hooks/*.sh; do
|
|
[ -f "$f" ] || continue
|
|
basename "$f" .sh
|
|
done
|
|
}
|
|
|
|
etv_hook_fire_repo_root() {
|
|
if [ -n "${CLAUDE_PROJECT_DIR:-}" ] && [ -d "$CLAUDE_PROJECT_DIR/.claude/hooks" ]; then
|
|
printf '%s' "$CLAUDE_PROJECT_DIR"
|
|
return 0
|
|
fi
|
|
( cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." >/dev/null 2>&1 && pwd )
|
|
}
|
|
|
|
etv_hook_fire_report() {
|
|
local all=0 as_json=0 dir root files hook
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--all) all=1 ;;
|
|
--json) as_json=1 ;;
|
|
--dir) shift; ETV_HOOK_FIRE_LOG_DIR="${1:-}" ;;
|
|
--session) shift; ETV_HOOK_FIRE_SESSION="${1:-}" ;;
|
|
*) printf 'hook-fire-log: unknown report option %s\n' "$1" >&2; return 2 ;;
|
|
esac
|
|
shift
|
|
done
|
|
|
|
dir="$(etv_hook_fire_log_dir)"
|
|
root="$(etv_hook_fire_repo_root)"
|
|
|
|
if [ "$all" = "1" ]; then
|
|
files=$(find "$dir" -name '*.jsonl' -type f 2>/dev/null | sort)
|
|
else
|
|
files="$(etv_hook_fire_log_file)"
|
|
[ -f "$files" ] || files=""
|
|
fi
|
|
|
|
if [ -z "$files" ]; then
|
|
printf 'hook-fire-log: no records under %s%s\n' "$dir" \
|
|
"$([ "$all" = "1" ] || printf ' for session %s (try --all)' "$(etv_hook_fire_session)")" >&2
|
|
fi
|
|
|
|
# ANTI-VACUITY. A report over an empty population would print a clean table of nothing and read
|
|
# as "all hooks accounted for" — the exact failure this file exists to end.
|
|
local pop
|
|
pop=$(etv_hook_fire_population "$root")
|
|
if [ -z "$pop" ]; then
|
|
printf 'hook-fire-log: found NO hook scripts under %s/.claude/hooks — refusing to report, because a report over an empty population reads as full coverage.\n' "$root" >&2
|
|
return 2
|
|
fi
|
|
|
|
local total_fires=0 rows=""
|
|
while IFS= read -r hook; do
|
|
[ -n "$hook" ] || continue
|
|
local fires decisions
|
|
fires=0
|
|
decisions=""
|
|
if [ -n "$files" ]; then
|
|
fires=$(cat $files 2>/dev/null | grep -c "\"hook\":\"$hook\",.*\"phase\":\"fire\"" || true)
|
|
decisions=$(cat $files 2>/dev/null \
|
|
| grep "\"hook\":\"$hook\",.*\"phase\":\"exit\"" \
|
|
| sed -n 's/.*"decision":"\([^"]*\)".*/\1/p' \
|
|
| sort | uniq -c | sort -rn \
|
|
| awk '{printf "%s=%s ", $2, $1}')
|
|
fi
|
|
[ -z "$fires" ] && fires=0
|
|
total_fires=$((total_fires + fires))
|
|
if [ "$as_json" = "1" ]; then
|
|
rows="${rows}{\"hook\":\"$hook\",\"fires\":$fires,\"decisions\":\"$(printf '%s' "$decisions" | tr -d '"')\"}\n"
|
|
else
|
|
rows="$(printf '%s%-34s %6s %s\n' "$rows" "$hook" "$fires" "${decisions:-—}")"$'\n'
|
|
fi
|
|
done <<EOF
|
|
$pop
|
|
EOF
|
|
|
|
if [ "$as_json" = "1" ]; then
|
|
printf '{"log_dir":"%s","total_fires":%s,"hooks":[%s]}\n' "$dir" "$total_fires" \
|
|
"$(printf '%b' "$rows" | sed '/^$/d' | paste -sd, -)"
|
|
return 0
|
|
fi
|
|
|
|
printf 'Hook fire log — %s\n' "$dir"
|
|
printf '%-34s %6s %s\n' 'HOOK' 'FIRES' 'DECISIONS'
|
|
printf '%s' "$rows"
|
|
printf '\n%s hook scripts on disk, %s recorded fires.\n' "$(printf '%s\n' "$pop" | wc -l | tr -d ' ')" "$total_fires"
|
|
printf 'A hook showing 0 has NOT been observed firing. That is a finding to investigate (broken\nwiring vs genuinely never matched in this window), not a blank to ignore.\n'
|
|
return 0
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------------------------
|
|
# CLI (only when executed, never when sourced)
|
|
# ---------------------------------------------------------------------------------------------
|
|
|
|
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
|
|
set -uo pipefail
|
|
case "${1:-report}" in
|
|
report) shift 2>/dev/null || true; etv_hook_fire_report "$@" ;;
|
|
path) etv_hook_fire_log_file; printf '\n' ;;
|
|
record) shift; etv_hook_fire_record "$@" ;;
|
|
*)
|
|
printf 'usage: %s [report [--all|--json|--dir D|--session S] | path]\n' "$0" >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
fi
|