Round 2, from two independent cold reviews. The headline defect was found by
the test the reviews prompted rather than by the reviews themselves:
exec 0<"$sin" 2>/dev/null
reads as "suppress errors from this redirection". It is not. `exec` with
redirections and no command applies them to the shell PERMANENTLY, so this sent
each hook's ENTIRE stderr to /dev/null. Every husky guard's user-facing output
is stderr — the H6 "push to main BLOCKED" message, the BOM guard's remediation
text, the missing-trailer message — so the guards went on blocking correctly
while telling the human nothing about why. Invisible to the original A/B, which
compared only stdout and exit status.
The finding that exposed it was ranked Low ("the A/B ignores stderr").
Also fixed, each with an executed proof:
- The fd restore sat inside the same conditional as the replay, and the replay
read the PATH, so an unlinked-but-open scratch file meant a swallowed `deny`
at exit 0. Restore is now unconditional and the replay reads through a held
fd, which survives unlink.
- A SIGTERMed hook lost output it had already printed (52 bytes -> 0). Bash runs
no EXIT trap for an untrapped fatal signal; INT/TERM/HUP are trapped now.
- The classifier misreported an uppercase decision as prose, read the bare word
additionalContext in free text as a decision, and let a non-zero exit ERASE a
decision the hook really printed.
- The session id reached the log FILENAME unscrubbed. The first fix reused the
record scrubber, which deliberately keeps `/` and `.`, so `../../escaped` went
through untouched: passing a value through a scrub is not the same as passing
it through the right scrub.
- Preamble line 1 could abort a `set -e` hook, turning a fail-open guard
fail-closed; and a missing sink printed `command not found` to stderr.
The big one is test quality. `test_instrumentation_changes_NOTHING_the_harness_
can_see` was 160/165 empty-vs-empty and 165/165 exit-0-vs-exit-0, so deleting
the whole replay left it green for four hooks, two of which issue `deny`. Three
places claimed the matrix reached every deciding branch, including the decision
record. It now constructs the missing cases — a foreign worktree marker, a BOM
`.cs` in an *ersatztv*-named repo, stubbed memory_pressure, a real bare remote
for the three pre-push guards, a stub Gitea for prepush-donewhen — and
test_the_AB_is_not_VACUOUS_for_any_hook fails unless each hook has a case where
the control side actually says something.
Running pytest also wrote 115 synthetic fires into the production log, so the
report described the test suite while looking like a record of real sessions.
scripts/tests/conftest.py isolates every test; verified live, a full suite run
now leaves the real log untouched.
Refs: #776
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
434 lines
22 KiB
Bash
Executable File
434 lines
22 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
|
|
|
|
printf '%s\n' "$line" >> "$file" 2>/dev/null || 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
|
|
# `[A-Za-z]` then lowercased, not `[a-z]`: a hook printing `"permissionDecision":"Deny"` fell
|
|
# through the lowercase-only class and was recorded as `output` — a real decision filed as
|
|
# unclassified prose.
|
|
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)
|
|
d=$(printf '%s' "$d" | tr 'A-Z' 'a-z')
|
|
# `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 non-JSON block channel. No hook here uses it today; it is classified
|
|
# anyway so that a hook which starts using it is not silently recorded as a no-op.
|
|
if [ "$code" = "2" ] && [ -z "$d" ]; then d="deny-exit2"; fi
|
|
[ -z "$d" ] && d="no-op"
|
|
|
|
# A non-zero status NO LONGER OVERWRITES a parsed decision. It used to, so a hook that printed a
|
|
# real `deny` and then exited 1 was recorded as `error` — the status erasing the decision, when
|
|
# the status already has its own field in the record. `error` now means only "it failed and said
|
|
# nothing classifiable".
|
|
if [ "$code" != "0" ] && [ "$code" != "2" ]; then
|
|
case "$d" in
|
|
no-op|output) d="error" ;;
|
|
*) d="${d}+error" ;;
|
|
esac
|
|
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. Trapping the signals
|
|
# makes bash run the handler and then the EXIT trap, which replays. Every `capture`-mode hook
|
|
# today prints and exits immediately, so the window is sub-millisecond — but the constraint is
|
|
# invisible to a future editor, so it is enforced here rather than relied upon.
|
|
trap 'etv_hook_fire_end "$?"' EXIT INT TERM HUP
|
|
return 0
|
|
}
|
|
|
|
etv_hook_fire_end() {
|
|
local code="${1:-0}" out=""
|
|
|
|
# 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
|
|
# Read through the FD, not the path — see the `exec 4<` comment in begin. `cat <&4` also avoids
|
|
# the other trap: `out=$(cat ...)` strips trailing newlines, so replaying through a variable
|
|
# delivers the hook's JSON one byte short of what it wrote, which every JSON parser downstream
|
|
# accepts without complaint. Pinned by `test_stdout_is_replayed_BYTE_EXACT`.
|
|
out=$(cat <&4 2>/dev/null || true) # for CLASSIFICATION; losing a trailing newline is harmless
|
|
exec 4<&- 2>/dev/null || true
|
|
# Replay the exact bytes from the file if the name still resolves, else from what the fd gave
|
|
# us. The first path is byte-exact; the second is the rescue.
|
|
if [ -r "${ETV_HOOK_FIRE_STDOUT_TMP:-}" ] && [ -s "${ETV_HOOK_FIRE_STDOUT_TMP:-}" ]; then
|
|
cat "$ETV_HOOK_FIRE_STDOUT_TMP" 2>/dev/null || true
|
|
elif [ -n "$out" ]; then
|
|
printf '%s' "$out"
|
|
fi
|
|
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
|
|
|
|
# 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"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------------------------
|
|
# 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
|