Skip to content

fix(guardrails): block-hook-bypass.sh fails CLOSED on a Windows stdin read stall, blocking valid tool calls with 'hook stdin is not valid JSON' #3507

Description

@kyle-sexton

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

  1. 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.
  2. Keep rc 1 (genuinely empty stdin) as the existing skip.
  3. Reserve a hard block for the case where stdin parsed successfully and the guard actually matched a bypass form.
  4. Emit telemetry on the degraded path so silent fail-open is still observable.

Acceptance criteria

  • A stalled or incomplete stdin read allows the tool call and logs a notice; it never blocks.
  • A genuine bypass form is still blocked when the payload parses.
  • A regression test covers a truncated/slow stdin payload and asserts exit 0.
  • The same audit is applied to every guardrails hook that calls hook::buffer_stdin — the fail-open/fail-closed decision should be consistent across the set, not per-file.

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)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    agent-readyFully specified and briefed; eligible for autonomous pickup from the frontier.priority: highSignificant impact, or blocks an imminent release; staff this cycle.work-class: scopedA briefed fix or small feature; blast radius bounded by the brief, tests exist.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions