Summary
hook::buffer_stdin (lib/hook-utils.sh) reads the hook payload with
IFS= read -r -d '' -t "$read_timeout" input. On a pipe — which is exactly how Claude Code
delivers hook stdin — bash's read -d '' consumes the stream one byte at a time. Measured
throughput on Git Bash / Windows is roughly 32 KB/s, so the 2-second bound
(CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2) is a throughput ceiling of ~64 KB, not
the stall detector it was written to be.
Past that ceiling the read returns a truncated payload, hook::buffer_stdin returns rc 2, and
every fail-closed guard that calls it exits 2 — the legitimate write is blocked.
Reproduction
Measured on this machine (Git Bash, bash 5.3, Windows 11), timing hook::buffer_stdin on a pipe
versus a file redirect:
| payload |
file redirect |
pipe |
| 1 KB |
176 ms |
221 ms |
| 10 KB |
240 ms |
461 ms |
| 50 KB |
428 ms |
1564 ms (78% of the bound) |
| 100 KB |
666 ms |
rc 2 — BLOCKED |
| 200 KB |
1396 ms |
rc 2 — BLOCKED |
End-to-end against the real hook — a benign Write payload (no hardcoded paths anywhere in the
content) piped into plugins/guardrails/hooks/hardcoded-path-check.sh with CLAUDE_PROJECT_DIR
set:
payload=20504 rc=0 elapsed_ms=1810
payload=50939 rc=0 elapsed_ms=2857
payload=101664 rc=2 elapsed_ms=2853 BLOCKED: hook stdin timed out before a complete JSON payload arrived.
payload=203113 rc=2 elapsed_ms=3006 BLOCKED: hook stdin timed out before a complete JSON payload arrived.
The block is entirely a function of payload size. Nothing in the content is a violation.
Impact
Fail-closed callers — a legitimate write is blocked. Seven guardrails hooks map rc 2 to
exit 2: hardcoded-path-check, secret-pattern-detection, block-no-verify,
block-dangerous-git, block-hook-bypass, block-noncanonical-commit,
block-convention-violation. Observed in the field: a full-file Write of an 844-line (~50 KB)
document was repeatedly blocked, forcing the author to write the file in five chunks.
Fail-open callers — the hook silently does not run. Every other hook::buffer_stdin caller
uses || exit 0, so on a large payload the formatter / audit / advisory hook is skipped with no
diagnostic at all.
A 50 KB source file JSON-escapes to well over 50 KB of payload, so the practical threshold sits
somewhere in the 50–100 KB range and moves with machine load — which is why the 50 KB field
report and the 100 KB reproduction here are the same defect.
Root cause
The bound was designed to survive a Win32 pipe late-EOF stall (the writer sends a complete
payload but never closes the pipe). That is a stall condition. Bounding total elapsed time also
bounds throughput, and byte-at-a-time reading makes the throughput term dominate long before any
stall would.
For reference, Claude Code's own default timeout for a command hook is 600 seconds
(https://code.claude.com/docs/en/hooks), so the 2-second stdin bound is not tracking any harness
limit.
Proposed fix
Read in chunks with read -N, which bash satisfies with block reads instead of byte-at-a-time,
and use the read's own return code to distinguish EOF (rc 1) from timeout (rc > 128) rather than
inferring it from elapsed-time arithmetic:
IFS= read -r -t "$read_timeout" -N 65536 chunk
Measured on the same payloads: 20 ms for 50 KB and 85 ms for 200 KB, versus 2114 ms and
6782 ms today — a ~100x improvement.
This changes the timeout from a total bound to a per-chunk idle bound, which is the semantics
the stall detector actually wants: a read that is making progress is never killed, and a pipe that
goes silent for stdin_read_timeout seconds still fails closed with rc 2.
The fail-closed posture must not weaken. The existing jq-completeness backstop stays (a
complete payload on a pipe that never EOFs still succeeds — that is the Win32 case the function
exists for), and the PR must carry a test asserting a stalled pipe still yields rc 2.
Secondary, per the original report: guardrails does not declare stdin_read_timeout in its
userConfig, while actionlint and claude-ops do. Declaring it gives consumers the same knob.
Scope
lib/hook-utils.sh is synced into 14 carrying plugins by scripts/sync-hook-utils.sh, and CI's
--check / --check-bump gates plus the changelog-parity gate require every carrying plugin to
bump its version and add a changelog entry.
Summary
hook::buffer_stdin(lib/hook-utils.sh) reads the hook payload withIFS= read -r -d '' -t "$read_timeout" input. On a pipe — which is exactly how Claude Codedelivers hook stdin — bash's
read -d ''consumes the stream one byte at a time. Measuredthroughput on Git Bash / Windows is roughly 32 KB/s, so the 2-second bound
(
CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2) is a throughput ceiling of ~64 KB, notthe stall detector it was written to be.
Past that ceiling the read returns a truncated payload,
hook::buffer_stdinreturns rc 2, andevery fail-closed guard that calls it exits 2 — the legitimate write is blocked.
Reproduction
Measured on this machine (Git Bash, bash 5.3, Windows 11), timing
hook::buffer_stdinon a pipeversus a file redirect:
End-to-end against the real hook — a benign
Writepayload (no hardcoded paths anywhere in thecontent) piped into
plugins/guardrails/hooks/hardcoded-path-check.shwithCLAUDE_PROJECT_DIRset:
The block is entirely a function of payload size. Nothing in the content is a violation.
Impact
Fail-closed callers — a legitimate write is blocked. Seven guardrails hooks map rc 2 to
exit 2:hardcoded-path-check,secret-pattern-detection,block-no-verify,block-dangerous-git,block-hook-bypass,block-noncanonical-commit,block-convention-violation. Observed in the field: a full-fileWriteof an 844-line (~50 KB)document was repeatedly blocked, forcing the author to write the file in five chunks.
Fail-open callers — the hook silently does not run. Every other
hook::buffer_stdincalleruses
|| exit 0, so on a large payload the formatter / audit / advisory hook is skipped with nodiagnostic at all.
A 50 KB source file JSON-escapes to well over 50 KB of payload, so the practical threshold sits
somewhere in the 50–100 KB range and moves with machine load — which is why the 50 KB field
report and the 100 KB reproduction here are the same defect.
Root cause
The bound was designed to survive a Win32 pipe late-EOF stall (the writer sends a complete
payload but never closes the pipe). That is a stall condition. Bounding total elapsed time also
bounds throughput, and byte-at-a-time reading makes the throughput term dominate long before any
stall would.
For reference, Claude Code's own default timeout for a
commandhook is 600 seconds(https://code.claude.com/docs/en/hooks), so the 2-second stdin bound is not tracking any harness
limit.
Proposed fix
Read in chunks with
read -N, which bash satisfies with block reads instead of byte-at-a-time,and use the read's own return code to distinguish EOF (rc 1) from timeout (rc > 128) rather than
inferring it from elapsed-time arithmetic:
Measured on the same payloads: 20 ms for 50 KB and 85 ms for 200 KB, versus 2114 ms and
6782 ms today — a ~100x improvement.
This changes the timeout from a total bound to a per-chunk idle bound, which is the semantics
the stall detector actually wants: a read that is making progress is never killed, and a pipe that
goes silent for
stdin_read_timeoutseconds still fails closed with rc 2.The fail-closed posture must not weaken. The existing jq-completeness backstop stays (a
complete payload on a pipe that never EOFs still succeeds — that is the Win32 case the function
exists for), and the PR must carry a test asserting a stalled pipe still yields rc 2.
Secondary, per the original report:
guardrailsdoes not declarestdin_read_timeoutin itsuserConfig, whileactionlintandclaude-opsdo. Declaring it gives consumers the same knob.Scope
lib/hook-utils.shis synced into 14 carrying plugins byscripts/sync-hook-utils.sh, and CI's--check/--check-bumpgates plus the changelog-parity gate require every carrying plugin tobump its version and add a changelog entry.