Skip to content

fix(hook-utils): stdin bound is a ~64 KB throughput ceiling, so large writes are blocked (and advisory hooks silently skipped) #1563

Description

@kyle-sexton

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.

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.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions