feat(claude-ops): surface silent hook launch failures via a decoupled Stop audit - #2580
Conversation
…pled Stop audit (#2577) A hook that fails to launch is a non-blocking error: the guarded tool call proceeds as if approved, and the only durable trace is a hook_non_blocking_error transcript attachment nobody reads. The #1416 incident class proved an in-plugin detector is no shelter — disk-hygiene's own Stop monitor shared its guard's registration form and died the same launch death on all 23 of its runs (163 unsurfaced failures on the incident host, 22 of them after the fix was on disk, in a session still running stale pre-fix hook config). hook-failure-audit is the decoupled detector: registered here on Stop in shell form, bounded transcript-tail read, structural attachment-type matching (never substring), once per session per distinct failing hook with marker loss degrading toward re-warning, systemMessage naming the failing hooks and the stale-session restart remedy, and the standard telemetry envelope with hook-name-only subjects. Red-first: hook-failure-audit.test.sh failed 20/24 assertions against the pre-fix tree (script absent), passes 28/28 with the hook in place. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Aac8xjCjMxFsXGHCXKHY4W
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
|
Claude finished @kyle-sexton's task in 4m 11s —— View job Claude Code Review
Scope note: This repo carries a FindingsNone met the high-signal bar (would a careful senior reviewer block or flag it). Areas specifically checked and found sound:
One pre-existing, documented tradeoff worth naming for completeness rather than as a defect: Not independently verified: the PR's Red-first test run (PASS=28/FAIL=0), |
|
Claude finished @kyle-sexton's task in 2m 0s —— View job Security Review —
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1c4294b314
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Last security-reviewed head: |
|
Claude has reviewed this PR 3 times. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
Live end-to-end proof against the real incident transcriptRan the new hook exactly as Claude Code would launch it, with $ printf '{"session_id":"live-proof","transcript_path":".../ac1c95e3-....jsonl","hook_event_name":"Stop"}' \
| bash plugins/claude-ops/hooks/hook-failure-audit.sh
{"systemMessage":"claude-ops: 81 hook failure record(s) in this session's transcript were never surfaced:
PreToolUse:Bash (66x; last stderr: Failed with non-blocking status code: <3>WSL (373226 - Relay) ERROR:
CreateProcessCommon:818: execvpe(/bin/bash) failed: No such file or directory);
PreToolUse:PowerShell (1x; ...); Stop (14x; ...).
A hook that fails to launch enforces nothing — the tool calls it guards proceed as if approved (fail-open).
If a plugin update changed hook config on disk mid-session, this session still runs the config it loaded
at startup — restart the session to load the fix."}
$ rc=081 rather than the session's full 140+: the 2 MB tail cap bounds the window by design — earlier records aged out of it, exactly the bounded-cost tradeoff |
|
Claude finished @kyle-sexton's task in 2m 35s —— View job Code Review —
|
…udit (#2577) Review findings from #2580 (chatgpt-codex-connector P1/P2, both confirmed): P1 — the registration invokes the script directly, but the blob was committed 100644: on a POSIX checkout every Stop exec fails with 126, making the silent-failure detector itself a silent failure. Now 100755, and the contract test executes the script the way the registration does (no bash prefix) so the shape is pinned cross-platform; the hygiene lane's shebang-executable gate caught the same defect in CI. P2 — dedup keyed on hookName alone collapsed distinct registrations sharing an event:matcher (several PreToolUse:Bash guards exist in this marketplace): registration B's first failure hid behind registration A's earlier warning. Identity is now (hookName, command); the systemMessage carries the registered command so the operator can tell which guard is dead; telemetry subjects stay hookName-only. Red-first: the new shared-hookName test case failed against the previous commit (silent when it must re-warn), passes now — 30/30. Fixing it also surfaced that this host's jq emits CRLF, which had salted marker fingerprints with carriage returns; both sides of the comparison now normalize (rtrimstr on read, tr -d on write). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Aac8xjCjMxFsXGHCXKHY4W
|
Claude finished @kyle-sexton's task in 1m 32s —— View job Claude Code Review
Scope note: Security is covered by a separate Verified: both prior findings are correctly fixed
Checked and sound
No new findings on this diff. Nothing to flag as blocking; no inline comments needed since there's nothing new to anchor. Not independently verified: I don't have Bash execution approval in this lane, so I read the diff/files directly rather than running Branch: |
|
Claude finished @kyle-sexton's task in 1m 33s —— View job Security Review —
|
Problem
Claude Code records a hook that fails to launch only as a
hook_non_blocking_errortranscript attachment — the guarded tool call proceeds as if approved, and nobody is told. The #1416 → #2570 → #2572 → #2571 chain fixed the disk-hygiene instances and gated the source shape, but the fleet's only silent-failure detector (disk-hygiene'sguard_launch_monitor.py) lives inside the plugin it watches and launches through the same registration form it watches. Full-fleet transcript mining on the incident host (97 transcript files, all projects) shows what that coupling costs:PreToolUse:Bash—destructive_guard.pyexecvpe(/bin/bash) failed(WSL relay)PreToolUse:PowerShell—destructive_guard.pyStop—guard_launch_monitor.py(the detector itself)And the stale-session window no source-side gate can reach: hook config loads at session start, so a session running when the #2570 fix landed on disk (2026-08-13T21:19:56Z) kept executing the dead exec-form config — 22 further failures after the fix shipped, latest 2026-08-14T03:53Z, guard and detector both dead, zero operator-visible signal.
Fix
hook-failure-audit.sh, an eighthclaude-ops*-audithook, registered onStop:"${CLAUDE_PLUGIN_ROOT}"/hooks/*.shthroughout — alive during the entire incident, including the stale-session window. A defect that kills a watched plugin's launch path cannot take this detector with it.Stopcadence (once per turn, perguard_launch_monitor.py's ADR 0004 / D-12 rationale), transcript-tail read capped at 2 MB with the truncated first line dropped — O(cap), not O(session length)..type == "attachment"and.attachment.type == "hook_non_blocking_error". Ahook_successwhose stdout quotes an error, and a message record quoting a failure record as a string — both false-positive shapes hit while mining the incident transcripts — cannot fire it (both pinned in the contract test).${CLAUDE_PLUGIN_DATA}degrades toward re-warning, never toward silence.systemMessagenames the failing hooks, counts, a stderr snippet, the fail-open consequence, and the restart-to-reload remedy for the stale-session case; telemetry envelope carries privacy-safe subjects (hook names only).hook_failure_audit_enabled.Overlap with
guard_launch_monitor.pyis deliberate: that monitor keeps its guard-specific semantics; a destructive-guard failure may warn twice. Its own doc block names over-warning as the safe direction for this class.Red-first evidence
hook-failure-audit.test.shwas written and run before the hook existed:With the hook in place:
PASS=28 FAIL=0. The core fixture is a structural copy of a real incident attachment record (WSL-relay stderr, sessionac1c95e3).Gates
hook-failure-audit.test.sh— PASS=28 FAIL=0shellcheckboth new files — cleancheck-hook-exec-form.sh— clean (the new registration is shell form)check-changelog-parity.sh --check / --check-bump origin/main / --check-order— clean (0.31.14 → 0.32.0)check-silent-skips.sh,check-hook-userconfig-argv.sh,check-manifest-duplicate-keys.py,check-cross-plugin-source-drift.sh— cleancheck-shell-portability.sh origin/main— cleantyposon all touched files — cleanRelated
Closes #2577
🤖 Generated with Claude Code
https://claude.ai/code/session_01Aac8xjCjMxFsXGHCXKHY4W