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
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
558 lines
30 KiB
Bash
Executable File
558 lines
30 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=""
|
|
# BYTE-ORIENTED, not locale-aware. One 0xE9 byte in a `permissionDecisionReason` made `sed` print
|
|
# `illegal byte sequence` to the harness AND fail to extract, filing a real `deny` as `output`.
|
|
#
|
|
# `local -x`, NOT `local`. A plain `local` sets a shell variable without the export attribute, so
|
|
# the child `sed`/`tr` never sees it — the fix was INERT and read as applied. It looked correct
|
|
# only because this author's shell sets `LANG` alone; with an inherited `LC_CTYPE` (macOS
|
|
# Terminal, ssh `SendEnv LC_*`, sudo `env_keep`) `LC_CTYPE` outranks the exported `LANG=C` and the
|
|
# symptom returns in full.
|
|
local -x LC_ALL=C LANG=C
|
|
|
|
# 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
|
|
# `[^"]*`, not `[A-Za-z-]*`: a restricted class means an odd value fails to EXTRACT and is
|
|
# filed as generic `output`, so "every non-canonical value is recorded as unrecognized" was
|
|
# true only for values the class happened to admit. Extract anything, then judge it below.
|
|
d=$(printf '%s' "$out" | sed -n 's/.*"permissionDecision"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
|
|
# A PRESENT-but-empty value is not the same as an absent key, and treating them alike let
|
|
# `{"permissionDecision":""}` fall through to `output` — a malformed decision laundered into
|
|
# "the hook just printed something".
|
|
if [ -z "$d" ] && printf '%s' "$out" | grep -q '"permissionDecision"[[:space:]]*:'; then
|
|
d="unrecognized"
|
|
fi
|
|
[ -z "$d" ] && d=$(printf '%s' "$out" | sed -n 's/.*"decision"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\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".
|
|
# `output` too, not just `no-op`: a hook that printed a diagnostic and then FAILED was being
|
|
# filed as `output`, so the report showed `output=1` and the failure vanished from the histogram.
|
|
# A parsed decision is still left alone — the status has its own field.
|
|
case "$code:$d" in 0:*) ;; *:no-op|*:output) d="error" ;; esac
|
|
|
|
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=""
|
|
# RESET, never merely default. Inherited from the environment (exported by a parent, or a second
|
|
# `begin` in one shell) a stale `1` made the first flush return immediately: stdout stayed
|
|
# redirected and no exit record was ever written.
|
|
ETV_HOOK_FIRE_FLUSHED=0
|
|
|
|
[ "${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 2>/dev/null > "$sin" || 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=1
|
|
fi
|
|
|
|
if [ -n "$payload" ]; then
|
|
# Read the fields from the FILE, byte-oriented, with no size cap. These were extracted from a
|
|
# `head -c 65536` prefix, so a payload whose `tool_response` pushed `session_id` past 64 KB
|
|
# filed its records under `unknown-session` with empty event and tool — and a report keyed on
|
|
# the real session then showed those fires as NEVER HAVING HAPPENED. A truncating read is a
|
|
# sampling error, and this one manufactured exactly the vacuity #776 exists to abolish.
|
|
local -x LC_ALL=C LANG=C
|
|
# FIRST occurrence, via `grep -o`. A `sed` substitution with a leading `.*` is GREEDY, and
|
|
# payloads are one long line, so it selected the LAST match: a nested
|
|
# `{"session_id":"...","tool_name":"..."}` inside a `tool_response` outranked the top-level one
|
|
# and the whole invocation filed under the wrong session. Removing the 64 KB cap is what armed
|
|
# it — the cap had been accidentally protecting the right answer, which is the kind of load a
|
|
# bound can be silently carrying.
|
|
#
|
|
# The scan is bounded again at 256 KB, but now the bound is safe rather than load-bearing:
|
|
# identity fields are at the head of the payload, and first-match means a later duplicate cannot
|
|
# win. Unbounded cost 0.5s per scan on a 20 MB payload, three scans per fire.
|
|
# A BOUNDED FAST PATH WITH AN UNBOUNDED FALLBACK. A plain cap is a truncating read, and a
|
|
# truncating read is a sampling error: a payload whose `tool_response` pushes `session_id` past
|
|
# the cap returns nothing, the record files under `unknown-session`, and that fire reads as
|
|
# NEVER HAVING HAPPENED — the false vacuity this whole change exists to remove, reintroduced by
|
|
# the bound added to make it fast. So the cap is an optimisation only: if the prefix yields
|
|
# nothing, the whole payload is scanned. Identity fields sit at the head in practice, so the
|
|
# fallback is rare; correctness no longer depends on that being true.
|
|
#
|
|
# `etv_hook_fire__field`, not `_etv_field`: a function defined inside another is still GLOBAL in
|
|
# bash, so a short generic name leaks into the hook's namespace and can collide with something
|
|
# the hook defines. It is unset after use.
|
|
etv_hook_fire__field() {
|
|
local v
|
|
v=$(head -c 262144 "$sin" 2>/dev/null \
|
|
| grep -o "\"$1\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" 2>/dev/null \
|
|
| head -n1 | sed 's/.*:[[:space:]]*"//; s/"$//' 2>/dev/null)
|
|
if [ -z "$v" ]; then
|
|
v=$(grep -o "\"$1\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$sin" 2>/dev/null \
|
|
| head -n1 | sed 's/.*:[[:space:]]*"//; s/"$//' 2>/dev/null)
|
|
fi
|
|
printf '%s' "$v"
|
|
}
|
|
event=$(etv_hook_fire__field hook_event_name)
|
|
tool=$(etv_hook_fire__field tool_name)
|
|
sess=$(etv_hook_fire__field session_id)
|
|
unset -f etv_hook_fire__field 2>/dev/null || true
|
|
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 ARE DELIBERATELY NOT TRAPPED — a withdrawal, recorded because the next reader will
|
|
# otherwise re-add this. A `trap ... TERM` was added so a hook killed by the harness timeout would
|
|
# not lose output it had already printed (measured then at 52 bytes before, 0 after). It produced
|
|
# three defects in three rounds and cost more than it bought:
|
|
#
|
|
# 1. the handler ended in `exit "$?"`, so a SIGTERMed `prepush-donewhen.sh` reported 0 and git
|
|
# PUSHED TO MAIN — a signal became consent;
|
|
# 2. `local sig=...` clobbered `$?`, so a killed guard was RECORDED as having passed;
|
|
# 3. and the one that settles it: bash does not run a trap until the current foreground command
|
|
# finishes, so a hook mid-`curl` took 30s to die where it had taken 1s. A TERM-then-KILL
|
|
# supervisor therefore gets no flush AT ALL, plus a 29s stall — strictly worse than the
|
|
# untrapped behaviour on the very path the trap existed for.
|
|
#
|
|
# WHAT IS LOST, enumerated rather than waved past: a hook killed by a signal loses its `exit`
|
|
# record, loses stdout it had already written, and leaks its two temp files. The first is a real
|
|
# gap in the log and is why `report` counts `fire` records, not `exit` records. The second is
|
|
# near-unreachable — every capture-mode hook prints its decision and exits immediately, a
|
|
# sub-millisecond window. The third is bounded by $TMPDIR cleanup.
|
|
#
|
|
# The invariant that replaces the rescue is stronger and is what the tests now assert: under a
|
|
# signal an instrumented hook behaves EXACTLY as an uninstrumented one.
|
|
trap 'etv_hook_fire_end "$?"' EXIT
|
|
return 0
|
|
}
|
|
|
|
# Flush and record, WITHOUT deciding how the process ends. `ETV_HOOK_FIRE_FLUSHED` keeps it
|
|
# idempotent. That guard is retained after the signal traps were withdrawn, because it is cheap and
|
|
# because the failure it prevents — two contradictory `exit` records for one invocation, a real
|
|
# `deny` followed by a phantom `no-op` — is silent, and re-entrancy would return the moment anyone
|
|
# adds a second caller.
|
|
etv_hook_fire_flush() {
|
|
local code="${1:-0}" out=""
|
|
# The `tr -d '\000'` below is a child process too, and it sat outside every locale declaration:
|
|
# `tr: Illegal byte sequence` reached the harness and truncated the classification copy.
|
|
local -x LC_ALL=C LANG=C
|
|
[ "${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
|
|
# Common path: stream the FILE straight through. A shell variable cannot hold a NUL byte, so
|
|
# replaying via `$(...)` silently drops them and warns on stderr; `cat` is byte-exact for any
|
|
# content. Classification reads the SAME file, so the two cannot disagree — reading them from
|
|
# different sources is what made the harness see `deny` while the log recorded `no-op`.
|
|
cat "$ETV_HOOK_FIRE_STDOUT_TMP" 2>/dev/null || true
|
|
# `tr -d '\000'` before the substitution: bash cannot hold a NUL in a variable and warns
|
|
# about it ON STDERR, which the harness sees — an instrumentation message leaking into a
|
|
# guard's output channel. Classification does not care about NULs; the replay above is
|
|
# byte-exact regardless, because it streams the file rather than a variable.
|
|
out=$( { head -c 65536 "$ETV_HOOK_FIRE_STDOUT_TMP" 2>/dev/null | tr -d '\000'; printf 'X'; } )
|
|
out="${out%X}"
|
|
else
|
|
# Rescue path: the name is gone but the fd still reads the unlinked inode. This one goes
|
|
# through a variable and is therefore NUL-lossy — stated rather than hidden, because the
|
|
# alternative is losing the output entirely.
|
|
out=$( { cat 2>/dev/null <&4 | tr -d '\000'; printf 'X'; } )
|
|
out="${out%X}"
|
|
[ -n "$out" ] && printf '%s' "$out"
|
|
fi
|
|
exec 4<&- || true
|
|
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"
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------------------------
|
|
# 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
|