Summary
hooks/block-hook-bypass.sh blocked a well-formed, non-bypassing Bash tool call on Windows with:
PreToolUse:Bash hook error:
[.../guardrails/0.29.24/hooks/block-hook-bypass.sh]: BLOCKED: hook stdin is not valid JSON.
The command was a plain read with no redirect, no heredoc, and no interpreter:
jq -r '{installMethod, autoUpdates, numStartups}' "$HOME/.claude.json"
Nothing in it is a hook-bypass form. The block came from stdin buffering, not from the guard's own matching.
Environment
- Windows 11 Pro 26200, Git Bash (MSYS)
- Claude Code 2.1.251, native install
- guardrails 0.29.24
Root cause (from reading the shipped source)
hook::buffer_stdin in hooks/hook-utils.sh reads fd0 in timed slices and treats a bounded stall as fatal. block-hook-bypass.sh lines 77-89:
# hook::buffer_stdin encapsulates the Win32-pipe-safe bounded fd0 read. rc 1
# (empty stdin) skips like the empty-COMMAND guard below; rc 2 (read timed out
# ...) stop. buffer_stdin already printed the BLOCKED reason to stderr.
INPUT=$(hook::buffer_stdin) || {
The loop already acknowledges the Win32 hazard it is compensating for (hook-utils.sh ~line 894):
# ... but stop immediately if what we already hold is a whole JSON
# document. That is the Win32 late-EOF case — the payload arrived, the
# pipe just never closed ...
So the design anticipates a Windows pipe that delivers the payload but never signals EOF. The gap is the case where the payload itself arrives late or partially within the slice bound: hook::json_complete then returns false, the bound expires, and the hook exits non-zero — which Claude Code interprets as a block.
Why this is the wrong failure direction
This hook fails closed. Its sibling failure path deliberately fails open — line 91-95:
# jq is required to parse the tool payload. hook::require_jq fails OPEN
A missing jq lets the call through; a slow pipe does not. Those should agree. A guard that cannot read its own input has learned nothing about the command, so it has no basis to block it. Failing closed here converts a transport flake into a false positive on arbitrary user commands.
This is made much worse by the machine's hook latency: on this host every recorded PreToolUse:Bash duration is >= 19s and 1,778 hook runs hit their timeout over 9 days (tracked separately). A stdin slice bound that is adequate on Linux is routinely exceeded here.
Impact
Arbitrary, entirely legitimate Bash commands are randomly blocked. The message names JSON validity, which points the user at their own command rather than at the hook, so it is hard to diagnose. It is also non-deterministic — re-running the same command usually succeeds.
Proposed fix
- Fail open on stdin-read failure.
rc 2 (bounded read stalled/incomplete) should exit 0 with a stderr notice, matching hook::require_jq's documented fail-open posture, rather than exiting non-zero.
- Keep
rc 1 (genuinely empty stdin) as the existing skip.
- Reserve a hard block for the case where stdin parsed successfully and the guard actually matched a bypass form.
- Emit telemetry on the degraded path so silent fail-open is still observable.
Acceptance criteria
Sources
- Shipped source:
guardrails/0.29.24/hooks/block-hook-bypass.sh (lines 77-95), hooks/hook-utils.sh (hook::buffer_stdin, ~lines 858-905)
- https://code.claude.com/docs/en/hooks (PreToolUse exit-code semantics: non-zero blocks the call)
Summary
hooks/block-hook-bypass.shblocked a well-formed, non-bypassing Bash tool call on Windows with:The command was a plain read with no redirect, no heredoc, and no interpreter:
Nothing in it is a hook-bypass form. The block came from stdin buffering, not from the guard's own matching.
Environment
Root cause (from reading the shipped source)
hook::buffer_stdininhooks/hook-utils.shreads fd0 in timed slices and treats a bounded stall as fatal.block-hook-bypass.shlines 77-89:The loop already acknowledges the Win32 hazard it is compensating for (
hook-utils.sh~line 894):So the design anticipates a Windows pipe that delivers the payload but never signals EOF. The gap is the case where the payload itself arrives late or partially within the slice bound:
hook::json_completethen returns false, the bound expires, and the hook exits non-zero — which Claude Code interprets as a block.Why this is the wrong failure direction
This hook fails closed. Its sibling failure path deliberately fails open — line 91-95:
# jq is required to parse the tool payload. hook::require_jq fails OPENA missing
jqlets the call through; a slow pipe does not. Those should agree. A guard that cannot read its own input has learned nothing about the command, so it has no basis to block it. Failing closed here converts a transport flake into a false positive on arbitrary user commands.This is made much worse by the machine's hook latency: on this host every recorded
PreToolUse:Bashduration is >= 19s and 1,778 hook runs hit their timeout over 9 days (tracked separately). A stdin slice bound that is adequate on Linux is routinely exceeded here.Impact
Arbitrary, entirely legitimate Bash commands are randomly blocked. The message names JSON validity, which points the user at their own command rather than at the hook, so it is hard to diagnose. It is also non-deterministic — re-running the same command usually succeeds.
Proposed fix
rc 2(bounded read stalled/incomplete) shouldexit 0with a stderr notice, matchinghook::require_jq's documented fail-open posture, rather than exiting non-zero.rc 1(genuinely empty stdin) as the existing skip.Acceptance criteria
hook::buffer_stdin— the fail-open/fail-closed decision should be consistent across the set, not per-file.Sources
guardrails/0.29.24/hooks/block-hook-bypass.sh(lines 77-95),hooks/hook-utils.sh(hook::buffer_stdin, ~lines 858-905)