Summary
guardrails hook scripts have no abort boundary. Every explicit exit in cli-flag-verify.sh and block-windows-drive-tmp.sh is 0 or 2, yet both were recorded exiting 1 with no stderr in a live session. For block-windows-drive-tmp.sh — a PreToolUse blocking guard — exit 1 is a non-blocking error: it enforced nothing, and nobody was told.
Evidence
Reported by claude-ops' unsurfaced-hook-failure detector at session stop:
2 hook failure record(s) in this session's transcript were never surfaced:
PostToolUse:Write [Verifying CLI flags...] (1x; completed non-zero exit; exit 1; last stderr: (none))
PreToolUse:Bash [Checking for Windows drive-root /tmp writes...] (1x; completed non-zero exit; exit 1; last stderr: (none))
Both hooks are plugins/guardrails/hooks/, registered in that plugin's hooks.json. Installed version 0.29.22.
Neither script can produce exit 1 from its own logic. Every explicit exit:
cli-flag-verify.sh: exit 0 (x7), and a final `exit 0` at line 418
block-windows-drive-tmp.sh: exit 0 (x5), exit 2 (x4), and a final `exit 0` at line 329
Both use set -uo pipefail — no set -e — so an internal command failure does not itself terminate the script.
Reproduction attempted, not achieved
I could not reproduce exit 1 on demand. Both hooks behaved correctly across the payloads I tried (own script file, a markdown file, a nonexistent path, an empty file_path, a malformed payload with no tool_input, and empty stdin; for the Bash guard also a benign command, a /tmp write, and a D:/tmp write):
cli-flag-verify.sh all payloads -> rc=0, no stderr
block-windows-drive-tmp.sh benign -> rc=0
/tmp write -> rc=2 with the correct 385-byte diagnostic
D:/tmp write -> rc=2 with the correct 385-byte diagnostic
malformed -> rc=0
So the trigger is intermittent and most likely environmental. This host shows severe process-creation cost (0.3–0.9 s per spawn; see #3508), and a spawn-time failure is a plausible source of a generic exit 1 with no script output. That does not make the report less actionable — see below.
Why this is a defect regardless of the trigger
The root cause of the exit code may be environmental, but the silence is a design gap in the hooks. A blocking guard has three possible outcomes and currently conflates two of them:
| outcome |
intended signal |
what actually happened |
| allow |
exit 0 |
exit 0 |
| block |
exit 2 + stderr |
exit 2 + stderr |
| could not run |
(nothing defined) |
exit 1, no stderr, enforcement silently skipped |
There is no trap on ERR or EXIT, so any abort between source hook-utils.sh (line 32/36) and the final exit 0 exits with whatever status the shell last had and writes nothing. From the harness's point of view that is indistinguishable from an environmental kill, and PreToolUse treats it as non-blocking and proceeds.
This is the same failure class ADR 0004 documents for killed hooks, and the same one disk-hygiene's destructive_guard.py closes explicitly with a fail-closed boundary that converts any unexpected exception into a deliberate exit 2 plus a diagnostic. The guardrails hooks have no equivalent.
Suggested fix
For every guardrails hook, and especially the PreToolUse blocking ones:
- Add an
EXIT trap that fires when the script exits with a status it did not choose, and have it emit a one-line diagnostic to stderr naming the hook and the status. A guard that could not run should say so.
- Decide the policy for that case deliberately per hook — fail-open with a visible diagnostic, or fail-closed — rather than inheriting "exit 1, silent, proceed" by accident. Note that exit 1 is the one status that guarantees the operator learns nothing.
- Consider whether
source hook-utils.sh failing should be fatal-and-loud rather than falling through into undefined hook:: calls.
A shared helper in hook-utils.sh would cover the whole hook set at once, which matters given 17 PreToolUse and 22 PostToolUse registrations.
Verification expected of a fix
- A test that forces an abort mid-hook (for example by making a sourced helper fail) and asserts the hook exits with its declared policy status and writes a diagnostic naming itself.
- Confirmation that
claude-ops' unsurfaced-failure detector reports zero records for these hooks over a session that includes the forced-abort case.
Related
Summary
guardrailshook scripts have no abort boundary. Every explicit exit incli-flag-verify.shandblock-windows-drive-tmp.shis0or2, yet both were recorded exiting 1 with no stderr in a live session. Forblock-windows-drive-tmp.sh— a PreToolUse blocking guard — exit 1 is a non-blocking error: it enforced nothing, and nobody was told.Evidence
Reported by
claude-ops' unsurfaced-hook-failure detector at session stop:Both hooks are
plugins/guardrails/hooks/, registered in that plugin'shooks.json. Installed version 0.29.22.Neither script can produce exit 1 from its own logic. Every explicit exit:
Both use
set -uo pipefail— noset -e— so an internal command failure does not itself terminate the script.Reproduction attempted, not achieved
I could not reproduce exit 1 on demand. Both hooks behaved correctly across the payloads I tried (own script file, a markdown file, a nonexistent path, an empty
file_path, a malformed payload with notool_input, and empty stdin; for the Bash guard also a benign command, a/tmpwrite, and aD:/tmpwrite):So the trigger is intermittent and most likely environmental. This host shows severe process-creation cost (0.3–0.9 s per spawn; see #3508), and a spawn-time failure is a plausible source of a generic exit 1 with no script output. That does not make the report less actionable — see below.
Why this is a defect regardless of the trigger
The root cause of the exit code may be environmental, but the silence is a design gap in the hooks. A blocking guard has three possible outcomes and currently conflates two of them:
There is no
traponERRorEXIT, so any abort betweensource hook-utils.sh(line 32/36) and the finalexit 0exits with whatever status the shell last had and writes nothing. From the harness's point of view that is indistinguishable from an environmental kill, and PreToolUse treats it as non-blocking and proceeds.This is the same failure class ADR 0004 documents for killed hooks, and the same one disk-hygiene's
destructive_guard.pycloses explicitly with a fail-closed boundary that converts any unexpected exception into a deliberate exit 2 plus a diagnostic. The guardrails hooks have no equivalent.Suggested fix
For every guardrails hook, and especially the PreToolUse blocking ones:
EXITtrap that fires when the script exits with a status it did not choose, and have it emit a one-line diagnostic to stderr naming the hook and the status. A guard that could not run should say so.source hook-utils.shfailing should be fatal-and-loud rather than falling through into undefinedhook::calls.A shared helper in
hook-utils.shwould cover the whole hook set at once, which matters given 17 PreToolUse and 22 PostToolUse registrations.Verification expected of a fix
claude-ops' unsurfaced-failure detector reports zero records for these hooks over a session that includes the forced-abort case.Related
block-hook-bypass.shfailing closed on a stdin stall: the opposite failure mode (loud false block) in the same hook set, and further evidence that abort handling is unowned across guardrails.docs/adr/0004-rightsize-instruction-surfaces-by-incumbent-first-arbitration.md— a killed PreToolUse hook yields nopermissionDecision, so the tool call proceeds unguarded.