fix(hook-utils): read hook stdin in chunks so a large payload is not blocked - #1587
Conversation
…blocked hook::buffer_stdin read the payload with `read -d ''`, which consumes a pipe one byte at a time (~32 KB/s on Git Bash). The stdin_read_timeout bound was therefore a ~64 KB THROUGHPUT ceiling, not the stall detector it was written to be: past it the read returned a truncated payload and rc 2, and the seven fail-closed guardrails guards mapped that to exit 2 — blocking a legitimate write whose content was never scanned. Every other caller uses `|| exit 0`, so on a large payload those hooks silently did not run at all. Reproduced end-to-end: a benign Write payload (nothing in the content is a violation) piped into hardcoded-path-check.sh — 50 KB passes at 2857 ms, 100 KB and 200 KB both exit 2 with the BLOCKED stdin diagnostic. In isolation the read itself takes 1564 ms at 50 KB (78% of the bound) and times out past 100 KB. Read in 64 KB chunks with `read -N`, which bash satisfies with block reads, and take the stall verdict from read's own exit status (EOF returns 1, an exceeded -t returns >128) instead of inferring it from elapsed-time arithmetic — which drops two awk subprocesses per hook invocation. The bound becomes an IDLE bound armed per chunk: a read still making progress is never cut off, a pipe silent for stdin_read_timeout seconds still fails closed. Claude Code's own default command hook timeout is 600 s (https://code.claude.com/docs/en/hooks), so the bound was not tracking a harness limit. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. After the fix all four e2e payloads up to 200 KB return rc 0. The fail-closed posture is unchanged and now tested three ways: a stalled pipe still yields rc 2 (existing Test 18 plus the new large-payload and late-EOF cases in lib/hook-utils.test.sh), a payload containing a violation is still blocked, and a violation at the very END of a 200 KB payload is now CAUGHT rather than swept into a content-blind block. The jq completeness check stays as the backstop so the Win32 late-EOF case — a complete payload on a pipe that never closes — still succeeds rather than blocking. Also declares stdin_read_timeout in guardrails' userConfig. Its hooks already read CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT through the shared lib but never declared the option, so consumers had no supported way to set it; actionlint and claude-ops set the precedent. The effective default with nothing configured remains the shell-level `:-2` fallback in hook-utils.sh. Synced to all 14 carrying plugins via scripts/sync-hook-utils.sh, each bumped with a matching changelog entry per the coupled sync/changelog-parity gates. Closes #1563 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 6m 25s —— View job PR Review: fix(hook-utils): read hook stdin in chunks so a large payload is not blockedTasks
SummarySolid fix for a real production defect. The root cause diagnosis is correct ( No blocking findings. Three observations below. 🟡 Nit — jq is now unconditionally called on every invocation; the PR description understates thisFile: if command -v jq >/dev/null 2>&1; then
jq -e . >/dev/null 2>&1 <<<"$input" || jq_rc=$?
fiIn the old code, the jq call sat inside Performance: the old code spawned zero post-read subprocesses on a fast, clean-EOF read (awk only fired when both the read timed out AND jq had already returned non-zero, so on the happy path the cost was the single Behavior: a corrupt or truncated payload arriving on a clean-closing pipe (EOF, not stall) now returns 1 (skip) instead of being passed through to the caller, which is arguably better but is a silent change the PR describes as "Left as-is" — that "left as-is" appears to refer only to keeping the return code at 1 (not bumping it to 2), but the gating of jq validation changed. Worth a comment or a note in the changelog so the intent is clear. 🟡 Nit — empty stalled pipe returns 1 (skip) rather than 2 (block); not explicitly called outFile: [[ -n "$input" ]] || return 1If the pipe stalls before delivering any data, 🟡 Nit —
|
|
Claude finished @kyle-sexton's task in 4m 54s —— View job Security Review
Scope: No critical or important security issues found. One suggestion-level finding below. SUGGESTION —
|
| Surface | Assessment |
|---|---|
read -t "$read_timeout" — shell expansion into bash built-in flag |
Safe: bash's read does not evaluate the timeout value as a shell expression |
jq -e . >/dev/null 2>&1 <<<"$input" — herestring from accumulated stdin |
Safe: herestring is not subject to word-splitting or glob expansion; jq parses JSON, does not execute it |
input+="$chunk" — unbounded accumulation |
Acceptable: producer is Claude Code (trusted); per-chunk idle timeout limits stall time, not payload size. No practical DoS path that wouldn't also require compromising the producer. |
((read_rc > 128)) arithmetic on $? |
Safe: $? is always 0–255 |
printf '%s' "$input" return path |
Safe: no command substitution on caller side |
Bash 4.x: read -N -t may return rc=1 (not >128) on timeout |
Not a regression — the old code's EPOCHREALTIME-based heuristic was already unavailable on bash 4.x (bash 5.0+ feature). Behavior on bash 4.x is unchanged. |
min: 1 in plugin.json userConfig |
Consistent with repo convention (actionlint, claude-ops use the same field name). Not a JSON Schema minimum — this is the marketplace's own schema dialect. |
jq always-runs (new behavior vs. old gating on read_status != 0) |
Strictly better: a clean EOF with malformed JSON now returns 1 (skip) instead of passing through to callers |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3c7354e525
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…uly idle Addresses both review findings on #1587, plus the portability lint. P1 — Bash 3.2. The previous commit used `read -N` unconditionally. That option is Bash 4.1+, and nine plugin READMEs document Bash 3.2+ support (macOS system bash). On 3.2 the invalid option would leave the payload empty, buffer_stdin would return 1, and every synced hook would silently exit 0 — disabling the whole fleet, guardrails included. The claim in the previous commit that the library already required 4.0+ was WRONG: hook::normalize_path's `${var^}` case operators sit behind an OSTYPE msys/cygwin branch that never executes on macOS. The `-N` availability guard now matches the existing in-repo idiom in plugins/context-guard/scripts/statusline-tee.sh, falling back to the delimiter read inside the same loop, so 3.2 keeps the new progress semantics. P2 — the bound was not actually idle. `read -t` is a deadline for the WHOLE requested read, not an inactivity timer, so a producer making steady progress but slower than one chunk per window still tripped it. Verified against the previous commit: a payload trickled one character per 100 ms against a 300 ms timeout returned rc 2 even though the pipe was never idle, and even when the producer then closed cleanly with complete JSON. `read` assigns whatever it received even on timeout, so a timed-out read that returned bytes is now treated as progress — the partial chunk is kept and a fresh window armed. Only a window that delivers nothing at all is a stall. Consequence, stated in the comment: a producer trickling indefinitely is never cut off here; the harness's 600 s command-hook cap is the outer bound. The guard predicate is split into hook::read_supports_nchars purely so the pre-4.1 path stays reachable in tests — BASH_VERSINFO is readonly and cannot be shadowed, so the first attempt at a 3.2 test was silently vacuous. It is not a consumer seam; nothing reads it from the environment. Tests (lib/hook-utils.test.sh, now 103 passing): - 18d: a steady trickle far slower than one chunk per window returns rc 0 with the whole payload — the idle-bound regression test. - 18d': that same trickle then going SILENT mid-payload still returns rc 2, so re-arming on progress did not become "never time out". - 18e: asserts the guard override really flips the branch (so the cases below are not vacuous), then that the pre-4.1 delimiter read buffers a 128 KB payload and still fails closed on a stalled pipe. Fail-closed re-verified end-to-end after the change: a violation still blocks, a violation at the end of a 200 KB payload still blocks, and a stalled pipe still exits 2. Note the stall must exceed one full window before EOF; a truncated payload that goes quiet and then closes reaches the pre-existing EOF branch (rc 1, skip), which this change does not alter. Also annotates two pre-existing shell-portability false positives that only surfaced because this change put all 14 synced copies into the changed set: a bash glob bracket class `[\<\>]` and a literal `a\b` path fixture, neither a GNU regex construct. Marked with the gate's own `portability-ok:` hatch rather than touching the synced linter. Refs #1563 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 33f95e0266
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
# Conflicts: # plugins/markdown-format/.claude-plugin/plugin.json # plugins/markdown-format/CHANGELOG.md
|
Claude encountered an error after 1m 57s —— View job PR Review — Updated Code (post
|
|
Claude encountered an error after 2m 22s —— View job Security Review: fix(hook-utils): read hook stdin in chunks so a large payload is not blockedTasks
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 52650f41ea
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…complete Third review finding on #1587, and a real one: the re-arm-on-progress fix made the Win32 late-EOF case cost TWO idle windows instead of one. The producer sends a complete payload and holds the pipe open; the first read times out, returns that payload as a partial chunk, and the loop — seeing bytes, therefore progress — armed another full window waiting for an EOF that is never coming. The loop now checks whether what it already holds parses as whole JSON before re-arming, and stops if it does. One window is the floor here: until a window expires, a held-open pipe is indistinguishable from a slow producer. Measured on this host at a 0.4 s bound, three runs each: 694 ms average with the early stop, 1074 ms without. The new case in lib/hook-utils.test.sh asserts this by COMPARISON rather than against a wall-clock constant — it times the real function and a variant with the completeness predicate overridden to always fail (reproducing the pre-fix behavior) back to back on the same host, so runner load cancels out and there is no absolute threshold to tune. It also fails loudly if the timing harness returns no measurement on a host that has EPOCHREALTIME, because the first two attempts at this test were silently vacuous: one bracketed the whole pipeline and measured the producer's sleep, the next lost its measurement to nested-quoting breakage and passed via the not-timed branch. hook::json_complete returns non-zero when jq is absent or broken, so the loop keeps reading rather than guessing, and the caller's existing fail-open handling for absent jq is untouched. Refs #1563 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
…raight to read Fourth review finding on #1587, and reproducing it turned up something worse than reported. Reported: `stdin_read_timeout` is consumer-configurable and reaches `read -t` directly, so a fractional value on a Bash release without fractional timeouts is rejected. Confirmed, and the failure mode is a SILENT DISABLE, not a degraded one: `read` rejects a bad spec with rc 1, the loop reads rc 1 as EOF, the payload comes back empty, every caller takes its skip branch, and a usage error is printed to stderr on every single hook invocation. Measured on this host, `abc` and `-1` both behave exactly that way. Found while reproducing: `stdin_read_timeout=0` made the loop SPIN. `read -t 0` returns success having consumed nothing, so the chunked loop `continue`d forever — the probe script hung outright. The manifest declares `min: 1`, but nothing validates the environment mirror the code actually reads, so that was reachable. Both are now resolved by hook::resolve_read_timeout, which falls back to the documented default of 2. Acceptance is decided by PROBING the running shell (`read -t "$t" </dev/null` prints to stderr only when the spec is bad) rather than by a Bash version table: the upstream changelog does not date the introduction of fractional timeouts, so a version check here would have been a guess, and asking the shell is exact. The probe is skipped for the default, which is known-good everywhere, so the common path adds nothing. Loop termination is now also a structural property rather than a consequence of that validation staying correct: a successful read that consumed nothing breaks instead of continuing. Tests: five unusable values (`abc`, `0`, `-1`, `1e3`, empty) must each fall back, still deliver the payload, and emit NOTHING on stderr — plus a positive case proving a valid non-default value is still honored rather than collapsed to the default. The `0` case doubles as the loop-termination test: before the guard it hung, so a regression shows up as the suite never finishing. Refs #1563 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 316ae52b2e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…d-stdin # Conflicts: # plugins/markdown-format/CHANGELOG.md # plugins/typos-format/.claude-plugin/plugin.json # plugins/typos-format/CHANGELOG.md
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
|
Claude encountered an error after 1s —— View job I'll analyze this and get back to you. |
…one bound Fifth review finding on #1587. `read -t` reports only that its window expired, never WHEN inside it the last byte arrived. Armed as a single window, a producer that emits bytes early and then goes quiet is not declared stalled until the SECOND window expires — so a 2 s idle bound could take nearly 4 s to fail closed, and calling it an idle bound overstated what the code did. Rather than re-word the claim a third time, make it true: the bound is now read in HOOK_STDIN_READ_SLICES (4) slices, and a stall requires that many CONSECUTIVE slices with no bytes. Any byte at all resets the count — that reset, not the read's exit status, is what makes this an inactivity timer. Worst-case overshoot drops from 100% of the bound to 25%, and the residual quarter is now stated plainly in the function comment, the guardrails README, and the guardrails changelog rather than glossed as "idle". Slice acceptance is probed exactly like the timeout itself, so a shell whose `read -t` rejects the fractional slice degrades to a count of 1 — precisely today's unsliced behavior — instead of failing. Same reasoning as the previous commit: the upstream changelog does not date fractional-timeout support, so asking the running shell beats a version table. Measured back to back on this host at a 1.2 s bound: a partial-then-silent producer is declared stalled at 2012 ms sliced vs 2728 ms unsliced; the late-EOF case settles at 719 ms vs 1909 ms. Both new assertions are comparisons against a forced-unsliced variant rather than wall-clock constants, so runner load cancels out, and both are preceded by a precondition check that the override actually engages. That check earned its keep immediately: it caught a missing `;` in the override string that would otherwise have made the new cases pass while testing the unmodified function — the third vacuous-test near-miss on this branch. Existing behavior is unchanged everywhere else: 112/112 in lib/hook-utils.test.sh (52 s), 72/72 hardcoded-path-check, 203/203 block-hook-bypass, 75/75 typos-format, and the end-to-end fail-closed triple (violation blocked, violation at the end of a 200 KB payload blocked, stalled pipe blocked) all still hold. Refs #1563 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
|
Claude encountered an error after 1s —— View job I'll analyze this and get back to you. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ef12f55e7a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Two more review findings on #1587, and chasing the first one uncovered a hang that this PR would otherwise have shipped. REPORTED: a late-EOF payload whose length is an exact multiple of 65536 completes on a read that returns rc 0, so it never reached the with-bytes completeness check and waited out the whole idle bound instead of a slice. The check now also runs on the FIRST empty slice of a quiet period — the buffer cannot grow while nothing is arriving, so once per quiet period is both sufficient and the most this can cost. Checking there rather than on the rc-0 path keeps jq off the hot path: a large payload costs one check when the producer first pauses, not one per 64 KB chunk. A first attempt that checked on EVERY empty slice made the sliced stall SLOWER than the unsliced one (3182 ms vs 2834 ms) — four jq spawns cost more than slicing saved — which the existing comparison test caught. FOUND WHILE REPRODUCING IT: hook::json_complete hung outright on a 65536-byte buffer. `jq -e . <<< "$buf"` delivers the here-string through a pipe that bash fills ITSELF before exec'ing jq, so a payload at or above the pipe capacity — 65536 bytes here, exactly one read chunk — blocks the shell forever. Traced it to the exact call; 65536 hung indefinitely, 65000 returned immediately. Every such call now goes through `printf | jq`, where a separate writer process makes the deadlock impossible: hook::json_complete, buffer_stdin's final check, hook::jq_field, and claude-ops' skill-usage-expansion-audit. That last one is load-bearing for this PR specifically. hook::jq_field takes the WHOLE buffered payload and is called by most hooks in the fleet; a bounded stdin read used to reject anything at that size before it could reach the here-string. Removing the throughput ceiling is exactly what makes those payloads reachable, so this PR had to fix the deadlock it exposed. hook::json_complete also gained an O(1) structural pre-filter (a complete payload ends in `}`) so the jq spawn is skipped for every mid-payload buffer. A false negative there costs only the early break, never correctness. ALSO REPORTED: actionlint and claude-ops already exposed stdin_read_timeout, and their READMEs, manifest descriptions, and setup skill still described it as a total read deadline "before failing open". It is an inactivity deadline now, and a producer that keeps emitting is bounded by Claude Code's hook timeout rather than by this value — materially misleading for anyone reading that contract. All four surfaces updated, with changelog entries under the versions this PR already bumps. Tests: the chunk-boundary case asserts a 65536-byte held-open payload costs no more than a non-boundary one, preceded by an assertion that the fixtures are EXACTLY 65536 and 65000 bytes. That precondition earned its keep — the first generator produced 59578 bytes because it stripped newlines after truncating, so the case was comparing two non-boundary payloads. 114/114 in lib/hook-utils.test.sh (66 s); 72/72, 42/42, 112/112, 28/28, 41/41, 10/10 across the affected hook suites; end-to-end fail-closed triple still holds. Refs #1563 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d-stdin # Conflicts: # plugins/markdown-format/CHANGELOG.md
|
Claude finished @kyle-sexton's task in 7m 23s —— View job PR Review (post-merge, HEAD
|
|
Claude finished @kyle-sexton's task in 3m 34s —— View job Security Review: fix(hook-utils): read hook stdin in chunks so a large payload is not blockedTasks
Scope reviewed: No security issues found. The full analysis is below. Surfaces reviewed
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0cad9102ea
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
🤖 Claimed by autonomous babysit lane (fable-autopilot). Expires 2026-07-26T22:26:52Z. Will release or renew. |
…ead -t Seventh review finding on #1587. Slicing the idle bound into quarters needs a fractional `read -t`, and hook::resolve_read_slice already falls back to a single window where the running shell rejects one — Bash 3.2, the macOS system shell, which these hooks explicitly support. The function comment said so; the three plugin READMEs and the two changelog entries that document the option did not, and stated the quarter-bound as unconditional. There, a producer that emits bytes and then goes silent can still take up to two intervals to be declared stalled, so a 2 s bound can take nearly 4 s. Every user-facing surface now names that condition explicitly rather than claiming the tighter figure everywhere: guardrails, actionlint, and claude-ops READMEs, and the actionlint and claude-ops changelog entries. Documentation only — no behavior change. Preserving four-slice behavior on shells without fractional timeouts is not possible with `read -t` alone, which is why this is qualified rather than fixed. Refs #1563 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 7s —— View job PR Review (HEAD
|
|
Claude finished @kyle-sexton's task in 3m 35s —— View job Security Review: fix(hook-utils): read hook stdin in chunks so a large payload is not blockedTasks
Scope reviewed: No security issues found. Full analysis below. Surfaces reviewed
|
| Scenario | Outcome |
|---|---|
STDIN_READ_TIMEOUT=0 to spin the loop |
hook::resolve_read_timeout detects ^0+(\.0+)?$, falls back to 2. Loop normal. |
STDIN_READ_TIMEOUT=abc to produce empty payload |
Probe emits stderr; [[ -n "$probe" ]] fires; fallback to 2. Loop normal. |
Payload ending in } but invalid JSON, then stall |
hook::json_complete pre-filter fires but jq fails → loop continues; stall accumulates; stalled=1; final jq fails → return 2 (BLOCKED). |
| Exactly 65536-byte complete payload, pipe held open | First empty slice: idle_slices==0, hook::json_complete succeeds → break. Returns 0 with payload intact. |
| jq absent, partial payload then stall | jq_rc stays 0; falls through to printf '%s' "$input". Fail-open without jq — pre-existing behavior, unchanged. |
Unexpected read_rc value (1–128, not EOF-1 or timeout->128) |
Falls through to final break; stalled=0; jq validates; incomplete payload → return 1 (skip). Consistent with truncated-clean-EOF case, which PR explicitly marks out of scope. |
The stall verdict comes cleanly from read's exit status (> 128) rather than elapsed-time arithmetic, removing the EPOCHREALTIME/awk heuristic that could be bypassed on hosts without EPOCHREALTIME.
skill-usage-expansion-audit.sh change
plugins/claude-ops/hooks/skill-usage-expansion-audit.sh L44–49
printf '%s' "$INPUT" | jq -r '...' — $INPUT is data to jq; the filter is a hardcoded literal. printf '%s' treats $INPUT as an argument, not the format string. No injection surface. Pure security improvement over the here-string.
guardrails plugin.json — new stdin_read_timeout userConfig entry
"min": 1 is a UI constraint in the marketplace schema dialect; it is not enforced at the shell level. The shell-level enforcement is now handled by hook::resolve_read_timeout, which validates and falls back unconditionally regardless of how CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT is set. The "default": 2 matches the shell-level :-2 fallback. No security gap.
Summary
This PR closes the previous security review's SUGGESTION (unvalidated STDIN_READ_TIMEOUT silently disabling fail-closed guards) via hook::resolve_read_timeout. All data-to-jq paths now use printf | jq (separate writer process), eliminating the pipe-capacity deadlock class entirely. The fail-closed posture for stalled pipes is preserved and verified from three angles. No new injection surfaces, trust-boundary violations, or authorization gaps introduced.
|
🤖 Claim released — PR merged by another lane while my claim was active. No conflict occurred. |
`claude-ops` released 0.21.6 on main (the chunked hook-stdin fix, #1587) while this branch was holding 0.21.6 for its own `date -d` annotation entry. The branch entry moves to 0.21.7 and the manifest follows; main's 0.21.6 section is kept verbatim. Main's #1587 also rewrote `lib/hook-utils.sh` around the `[\<\>]` bash glob that tripped this gate's `\<`/`\>` tokens, so `--all` drops from 56 hits to 25 across the synced plugin copies. That is a corpus change, not a scanner change: this branch's scanner over the PRE-merge corpus reports byte-identical findings to the scanner it replaced, all 56 of them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016E9qM8CanWf8KkFGcmg4jo
…rd (#1692) ## Summary - **Root cause**: GNU realpath under Git Bash resolves symlinks but leaves Windows 8.3 short names (`KYLESE~1`) unexpanded, so `hook::physical_path` handed the `CLAUDE_PROJECT_DIR` membership comparison in `hook::read_file_path` a short-form path that can never prefix-match the long-form project root. An **in-project** file whose `file_path` arrived in short form — the shape Claude Code's own scratchpad paths take — was silently skipped: no lint, no notice, no telemetry. 8.3 generation is per-volume (`fsutil 8dot3name`), which is why the defect reproduces on generating volumes (this machine's `C:`) and is invisible on non-generating ones (`D:`). - **Fix (shared lib, per the issue's preference)**: new `hook::expand_8dot3` in `lib/hook-utils.sh`, applied on `hook::physical_path`'s success path. Gated on `OSTYPE` `msys*|cygwin*|win32` (same host gate as `hook::normalize_path`), so POSIX hosts are bit-identical with zero extra spawns. On Windows it compares `cygpath -m` (form conversion only) against `cygpath -l -m` (long names via Win32) and replaces the path **only when the two differ** — a legitimate long name containing `~` passes through byte-for-byte, and a genuinely out-of-project file is still skipped (the deliberate defense-in-depth scoping is preserved). Running only on the resolver's success path preserves the documented unchanged-return signature that markdown-format's fail-closed checks rely on. Fail-open when cygpath is absent (it ships with Git Bash, the documented Windows bash), degrading to the pre-fix comparison. - **Scope**: canonical source edited, `scripts/sync-hook-utils.sh` run — all 14 carrying plugins synced byte-identical, version-bumped, and changelogged. `bash-format` and `markdown-format` consume the fixed lib via the sync; `actionlint`'s 0.6.0 (#1133) local opt-out is untouched and its changelog records why. The volume-scoped 8.3 contract is documented at the function, in the regression test's skip reason, and in bash-format's README/setup-SKILL scope sections (the only docs that state the guard's contract). - **Regression test** (`lib/hook-utils.test.sh` Test 12b): drives `hook::read_file_path` with a short-form in-project file (accepted + original spelling emitted), a short-form project dir, a short-form **out-of-project** file (still rejected), and a literal-tilde long name (untouched). Skips with a visible, volume-scoped reason on non-Windows hosts and on volumes that do not generate short names — the skip message states it is absence of coverage, not a pass. ## Test plan All commands run from the worktree on Windows 11 / Git Bash, where `C:` generates short names (fix exercised for real, not just skipped): - `bash lib/hook-utils.test.sh` — all 4 new Test 12b cases pass (`short-form in-project file accepted`, `short-form project dir admits in-project file`, `short-form out-of-project file still rejected`, `literal-tilde long name accepted`). Full suite `PASS=115 FAIL=3`; the 3 failures are pre-existing load-sensitive `buffer_stdin` timing tests — a pristine `origin/main` copy run side-by-side on the same loaded machine fails 5 of the same class (`PASS=109 FAIL=5`), and this diff does not touch `buffer_stdin`. - `bash scripts/check-shell-portability.sh origin/main` (CI form) — `No unexcused GNU-only constructs in 16 shell file(s).` (A full `--all` sweep has one pre-existing finding in untouched `plugins/repo-hygiene/.../batch-common.test.sh`.) - `bash scripts/sync-hook-utils.sh --check` — `All 14 plugin copies match lib/hook-utils.sh.` - `bash scripts/sync-hook-utils.sh --check-bump origin/main` — `Lib changed vs origin/main and every carrying plugin bumped its version.` - `bash scripts/check-changelog-parity.sh --check-bump origin/main` — pass. - `bash scripts/check-contract-slice-prune.sh --check-diff origin/main` — pass. - `bash scripts/check-skill-portability.sh origin/main` — pass. - `bash scripts/check-silent-skips.sh` — pass. - `npx markdownlint-cli2 <16 changed .md files>` — 0 errors. - `shellcheck --rcfile=.shellcheckrc lib/hook-utils.sh lib/hook-utils.test.sh` — clean (two justified `disable` directives for the SC2030/SC2031 false-positive pair created by Test 11's deliberate subshell-local OSTYPE override). - Empirical root-cause confirmation: `realpath "C:\Users\KYLESE~1\...\CLAUDE.md"` returns the short form unchanged; `cygpath -l -m` expands it; `cygpath -l -m` on a stale/nonexistent short path passes it through unchanged (fail-open). - Two independent adversarial verifier subagents (correctness/regression lens; portability/sync lens) were dispatched with the author's rationale withheld; their findings and any resulting fixes are recorded in this PR's comments/commits. ## Related - Closes #1636 - `plugins/actionlint/CHANGELOG.md` 0.6.0 (#1133) — same defect fixed locally; this PR fixes the shared lib that change deliberately left untouched - #1587 — prior shared-lib change; this PR follows its sync + 14-plugin bump/changelog pattern 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_013E5dps5keCfRvYCicNYH6r Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ng guards (#2123) No linked issue ## Summary Two BLOCKING PreToolUse guards — `secret-pattern-detection` and `hardcoded-path-check` — returned **no verdict at all** for a Write/Edit payload of **65536–65663 bytes inclusive**. Not slow: deadlocked. A live-shape AWS access-key id inside such a payload produced nothing; the same token in a small payload exits 2. Both hooks are registered at `timeout: 60`, so the harness cancels the guard and the verdict is lost — a fail-open reachable by any agent that controls the size of what it writes. Same class as #1587, which fixed `hook-utils.sh`'s JSON path and stopped at that one call site. This PR sweeps the class instead of patching only the two reported files. ## The defect Bash delivers a here-string by filling a pipe **itself**, before the reader is `exec`'d, and it appends a newline. A payload in that band puts the write 1–128 bytes past the 65536-byte pipe capacity and bash blocks forever. At ≥129 bytes over, bash spills to a temp file and it works again — so the window is **closed on both sides**, which is exactly why no ordinary size ever caught it. Reproduced standalone, outside the plugin (`bash 5.3.15(1)`, MINGW64): ``` C=$(head -c 65600 /dev/zero | tr '\0' b) timeout 15 bash -c 'grep -qE "Users" <<<"$1"' _ "$C"; echo $? # 124 (hung) # 65535 -> 1 65536 -> 124 65600 -> 124 65663 -> 124 65664 -> 1 ``` The `while … done <<<"$var"` shape hangs identically (rc 124 at 65536 / 65600 / 65663), which is what pulled the command-scanning guards into scope. ## Boundary measurements, through the real hooks Payload piped to the hook on stdin — never `<<<`, which would hang the measurement itself. `rc 124` = killed at the bound, i.e. never answered. All numbers from this Windows host (Git Bash + Defender), under concurrent agent load. `secret-pattern-detection`, exact content bytes, AWS access-key id at the tail: | content bytes | case | BEFORE rc | BEFORE secs | AFTER rc | AFTER secs | | --- | --- | --- | --- | --- | --- | | 65535 | clean | 0 | 10 | 0 | 19 | | 65535 | AWS key | 2 | 43 | 2 | 63 | | 65536 | clean | **124** | 202 (bound) | **0** | 14 | | 65536 | AWS key | **124** | 204 (bound) | **2** | 51 | | 65600 | clean | **124** | 151 (bound) | **0** | 12 | | 65600 | AWS key | **124** | 151 (bound) | **2** | 67 | | 65663 | clean | **124** | 156 (bound) | **0** | 24 | | 65663 | AWS key | **124** | 155 (bound) | **2** | 41 | | 65664 | clean | 0 | 20 | 0 | 19 | | 65664 | AWS key | 2 | 44 | 2 | 59 | The BEFORE hangs were taken at a **200-second** bound first, then 150 — well past the legitimate slow path (41–67 s) — so these are deadlocks, not slowness. `hardcoded-path-check`, clean payloads. The pre-filter gate runs on **every** write, so the clean column is the stronger claim: nothing in the window got a verdict, violating or not. | content bytes | BEFORE rc | BEFORE secs | AFTER rc | | --- | --- | --- | --- | | 65535 | 0 | 34 | 0 | | 65536 | **124** | 152 (bound) | **0** | | 65600 | **124** | 151 (bound) | **0** | | 65663 | **124** | 207 (bound) | **0** | | 65664 | 0 | 44 | 0 | ## Why not `printf … | grep -q` — the pipefail inversion is real `hardcoded-path-patterns.sh:73-75` carried a comment *justifying* the here-string, and the justification was half right. Measured under `set -o pipefail`, with the match on line 1 so `grep -q` can exit before the writer finishes (a single-line payload never reproduces this — grep must read it all, so `printf` never gets SIGPIPE'd): | shape | `set +o pipefail` | `set -o pipefail` | | --- | --- | --- | | `grep -qE pat <<<"$C"` | 0 | 0 — but deadlocks in the window | | `printf … \| grep -qE pat` | 0 | **141** ← inversion | | `printf … \| grep -E pat >/dev/null` | 0 | 0 | | `grep -qE pat < <(printf …)` | 0 | **0** ← chosen | Both hooks run under `set -uo pipefail` (`set -e` is off), so the inversion is live — and it is worse than a wrong status, because both gate sites are written `if ! grep -q …`: > `grep -q` matches → exits 0 → SIGPIPEs `printf` → `pipefail` reports **141** → `if ! 141` is > **true** → the gate early-returns **clean**. A fail-open on the very payload that contained the > secret. **Chosen idiom: process substitution.** It keeps the writer *outside* the pipeline, so `pipefail` can never see its SIGPIPE, while preserving the `-q` early exit the gate exists for — and it never blocks. Verified at all six sizes in both the match and no-match directions. Two shapes, chosen by whether the reader drains its input. The pattern lib previously **contradicted `hook-utils.sh` inside the same plugin** — it told readers to PREFER a here-string over `printf | grep`, while `hook-utils.sh` told them a whole payload must never go through `<<<`. The lib now states the same rule and cites it: - reader drains (`jq`, `grep` without `-q`) → `printf … | reader` - reader may exit early (`grep -q`) → `reader < <(printf …)` For `while … done` loops the substitution is `< <(printf '%s\n' …)`. The `\n` is mandatory and makes it byte-identical to the here-string it replaces (`<<<` appends a newline unconditionally), so no loop can drop its final line. ## Repo-wide sweep of `<<<` — every site, with a verdict 876 occurrences total; 329 outside `*.test.sh`. Fix criterion: **the string can reach 65536–65663 bytes from an agent- or attacker-controlled source, AND a hang loses a security verdict.** ### Fixed (18 sites) | site | input | why | | --- | --- | --- | | `guardrails/lib/path-detection/hardcoded-path-patterns.sh:76,83` | whole Write/Edit payload | reported; blocking | | `guardrails/hooks/secret-pattern-detection.sh:158,207` | whole Write/Edit payload | reported; blocking | | `guardrails/hooks/hardcoded-path-check.sh:219` | `$VIOLATIONS` | **not in the original report.** `$VIOLATIONS` embeds each MATCHED LINE verbatim, and the lib's `head -3` bounds the line COUNT, not bytes — so one 65KB minified line carrying a hardcoded path makes it payload-sized. It deadlocks on the **blocked** path, after the stderr message but before `exit 2`. Measured separately below | | `guardrails/hooks/block-convention-violation.sh:132,158` | `$cmd` (Bash/PowerShell command) | blocking guard; loop shape hangs identically | | `guardrails/hooks/block-hook-bypass.sh:248,497,566` | `$cmd`, `$NORMALIZED_SEGMENTS` (derived from `$COMMAND`) | blocking guard | | `guardrails/hooks/flag-commit-pr-skill-bypass.sh:229` | `$cmd` | same command stripper | | `guardrails/lib/powershell/ps-command.sh:143,671` | `$cmd`, `$norm` | shared lib behind the blocking PowerShell guards | | `guardrails/hooks/workflow-resilience-check.sh:72,79` | `$SCRIPT` (inline Workflow script, or a `scriptPath` file read) | advisory, so no verdict is lost — but a hang wedges the Workflow call until the harness cancels | | `source-control/hooks/pr-body-linkage-gate.sh:194` | `$text` (PR body) | **blocking** gate. GitHub caps a PR body at exactly **65536 characters** — the documented maximum lands inside the hang window | | `source-control/hooks/pr-linkage-validator.sh:76,113` | `$body` | same input, same cap | `source-control` is deliberately in scope rather than left as a half-fix; it costs the second plugin bump in this PR. ### Judged safe — no fix, with reason | site | reason | | --- | --- | | `guardrails/lib/verification/verify-cli-flag.sh:159,161` (`$HELP_OUTPUT`) | local CLI `--help` output; not attacker-influenced. A **latent hang**, not a security hole — noted, not fixed | | `guardrails/hooks/cli-flag-verify.sh:188,337` (`read -ra` on `$seg`/`$chainstr`) | advisory PostToolUse; a single ≥64KB fragment is possible in principle, but no verdict is at stake. Latent hang, noted | | `guardrails/hooks/flag-commit-pr-skill-bypass.sh:196` (`$keys`), `skill-reference-verify.sh:160` (`$declared`) | jq-derived plugin/settings key names; bounded by manifest size, not by any payload | | `guardrails/hooks/block-no-verify.sh:102` | a `userConfig` option value (administrator-provided scalar) | | `biome-format:211,237,251`, `ruff-format:250,275` (`$OUTPUT`) | formatter/linter output. Same mechanism, **different consequence class** — no security verdict at stake, only a wedged formatter. Recommended follow-up, kept out to keep this PR reviewable | | `claude-ops-paths.sh:19,68`, `worktree-create.sh:373,412`, `babysit-readiness-gate.sh:286,293`, `check-plugin-manifest-presence.sh:75` | `IFS=… read -ra` splits of a path or a short CSV; cannot approach 64KB | | `source-control/skills/pull-request/scripts/fetch-annotations.sh:177` (`$FILTERED`) | jq-filtered CI annotation records in a skill script, not a hook gate; no blocking verdict | | `claude-config/skills/audit/scripts/*`, `claude-ops/skills/lanes/scripts/*`, `work-items` adapters | skill-invoked scripts over jq-bounded JSON, not a hook payload; no blocking verdict | | ~547 sites in `*.test.sh` | fixed small fixtures authored in-repo; reported as one class | `lib/hook-utils.sh` itself was already clean (#1587), and the stdin→`CONTENT` path in both hooks is `printf '%s' "$INPUT" | jq -r` throughout — verified, because otherwise the payload would have hung upstream and this fix would have changed nothing. ### The `$VIOLATIONS` site, measured A BEFORE run cannot reach line 219 through the hook (the gate deadlocks first), so it is measured directly, and its reachability is confirmed on the patched hook: ```text $VIOLATIONS as hpp::scan_text builds it: the label, then "<lineno>:<line>" with the matched line embedded VERBATIM, then the block terminator. bytes = 65628 PRE-FIX grep -E 'detected:$' <<<"$VIOLATIONS" -> rc=124 (deadlock, 60s bound) POST-FIX grep -E 'detected:$' < <(printf '%s' …) -> rc=0 (4s under load) ``` ## Tests Neither suite had a **single** payload-size case before this (`grep -n '65536\|head -c'` returned nothing). Added to **both** `secret-pattern-detection.test.sh` and `hardcoded-path-check.test.sh`: - clean payloads at **65535, 65536, 65600, 65663, 65664** — the window and both shoulders; - a **real detectable secret / hardcoded path inside the window** (65536 and 65600) that must exit 2; - the empty-content case, pinning that `printf '%s' ""` (zero bytes) matches the old `<<<""` (one empty line) in outcome; - a stderr assertion that the `grep -q` early-exit leaks no `Broken pipe` noise onto the hook's user-facing channel. Every case is bounded by `timeout 150` so a regression **fails loudly instead of hanging CI**, and asserts the **exact** expected code — `124` is reported as its own named failure. A "non-zero means blocked" assertion would have accepted the hang and would not have caught this defect. The payload is piped, never fed to the hook with `<<<`, which would hang the test itself at exactly these sizes. In the path suite the detect payload separates the filler from the home path with a **space**: the slash-rooted macOS/Linux bodies require a left boundary, so a path glued straight onto filler bytes legitimately does not match — and the "must block" case would have passed for the wrong reason. That was caught by a measurement script that omitted the space and returned 0 where 2 was expected. ## Known follow-up (not fixed here) On this Windows host the **patched** detect path measured **41–67 s** against the `timeout: 60` registration in `hooks.json`. So on Git Bash under Defender a large payload can still lose its verdict to the harness — now by slowness rather than deadlock. The cost is process spawns, not matching: on a hit, itemization runs 12 patterns × 5 processes. That is a separate defect with a separate fix (batch the itemization), deliberately out of scope here, and stated rather than left for the next auditor to "discover" as a half-fix. ## Verification - `shellcheck -x` clean on all 17 changed files. - `shfmt -d` clean on every changed file. The pre-existing drift in the two `.test.sh` files is unchanged by this PR — confirmed byte-identical at `origin/main` — and does not touch the added block. - `scripts/check-shell-portability.sh --paths …` — no unexcused GNU-only constructs in 27 shell files. - `scripts/check-changelog-parity.sh --check-bump origin/main` — passes. - `scripts/sync-hook-utils.sh --check` and `--check-bump origin/main` — pass. `lib/hook-utils.sh` is left **byte-identical to `main`** on purpose. Its guidance was already correct (only its upper bound was imprecise), and the sync gate requires a version bump plus a changelog entry for **all fourteen** other plugins carrying the shared lib in exchange for a comment-only edit — churn that would bury a security fix. The contradiction is resolved on the guardrails side, which is where the wrong advice lived. - `markdownlint-cli2` — 0 issues. - `secret-pattern-detection.test.sh` — **PASS=52 FAIL=0** - `hardcoded-path-check.test.sh` — **PASS=94 FAIL=0** **Version bumps are patch, deliberately.** A reviewer may reach for minor on "payloads that were allowed are now blocked". Nothing legitimate becomes refused that the guard did not already intend to refuse — the fix restores the documented contract rather than widening it, which matches this repo's practice (`source-control` 0.49.3 shipped a behavior-changing `exec-bit-check` fix at patch level; the `guardrails` 0.21.0 minor was called out specifically for an *acceptance* change that could refuse previously-allowed legitimate work). ## Rider The README hook table listed all six guards registered under the `Bash|PowerShell` matcher as `PreToolUse · Bash`; no row named PowerShell at all. Verified row-by-row against `hooks.json` (6 rows, 6 hooks, exact match) and corrected. ## Related - Refs #1587 — fixed this same deadlock class in `hook-utils.sh`'s JSON path and stopped at that one call site; this PR sweeps the rest. - Refs #2007 — prior `guardrails` fix in the same review stream. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

Closes #1563
Summary
hook::buffer_stdinread the hook payload withIFS= read -r -d '' -t "$read_timeout". On a pipe— which is exactly how Claude Code delivers hook stdin — bash consumes
read -d ''one byte at atime, measured at roughly 32 KB/s on Git Bash. The
stdin_read_timeoutbound (default 2s) wastherefore a ~64 KB throughput ceiling, not the stall detector it was written to be.
Past that ceiling the read returned a truncated payload and rc 2:
hardcoded-path-check,secret-pattern-detection,block-no-verify,block-dangerous-git,block-hook-bypass,block-noncanonical-commit,block-convention-violation— map rc 2 toexit 2, so a legitimate large write was blocked,with its content never scanned. Reported from the field: a full-file
Writeof an 844-line(~50 KB) document blocked repeatedly, forcing the author to write it in five chunks.
|| exit 0, so on a large payload the formatter / audit / advisoryhook silently did not run at all, with no diagnostic.
Reproduction (before the fix)
hook::buffer_stdinin isolation, file redirect vs. pipe:End-to-end, a benign
Writepayload (nothing in the content is a violation) piped into the realplugins/guardrails/hooks/hardcoded-path-check.shwithCLAUDE_PROJECT_DIRset:The block is purely a function of payload size. A 50 KB source file JSON-escapes to well over 50 KB
of payload, which is why the 50 KB field report and this 100 KB reproduction are the same defect —
the threshold sits in the 50–100 KB range and moves with machine load.
Fix
Two changes together, because either alone leaves the bound meaning the wrong thing.
1. Chunk the read.
read -N 65536lets bash satisfy the read in blocks rather thanbyte-at-a-time. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to
~85 ms. All four end-to-end payloads up to 200 KB now return rc 0.
2. Measure inactivity, not the read.
read -tis a deadline for the whole requested read, notan inactivity timer, so a producer making steady progress but slower than one chunk per window would
still trip it.
readassigns whatever it received even when it times out, so any byte counts asprogress: the partial chunk is kept and the read continues. Only the absence of bytes for a whole
stdin_read_timeoutis a stall.2b. Read the bound in four slices.
read -treports only that its window expired, never wheninside it the last byte arrived — so armed as one window, a stall is declared anywhere between one
and two bounds after the pipe actually went quiet. Slicing caps that overshoot at a quarter-bound.
That residual quarter is the honest limit of the mechanism, and it errs toward waiting, never toward
declaring a live producer dead. Measured back to back at a 1.2 s bound: a partial-then-silent producer
is declared stalled at 2012 ms sliced vs 2728 ms unsliced. A shell whose
read -trejects thefractional slice degrades to a count of 1 — exactly the unsliced behavior — rather than failing.
3. Stop reading once the buffer is already whole. Otherwise the Win32 late-EOF case — payload
complete, pipe simply never closed — waits out the rest of the bound for an EOF that is not coming.
The loop now checks completeness before continuing. Some wait is the floor, since a held-open pipe is
indistinguishable from a slow producer until a window expires. Measured at a 1.2 s bound: 719 ms
with the early stop vs 1909 ms without.
The stall verdict now comes from
read's own exit status — EOF returns 1, an exceeded-treturnsThe trade this makes, stated in the comment: a producer trickling bytes indefinitely is never cut off
here. That is deliberate — Claude Code's own default timeout for a
commandhook is 600 seconds(hooks reference, fetched this session), so the harness is
the outer bound, and blocking a live producer is exactly the failure this function had.
4. Validate the configured timeout instead of passing it straight to
read.stdin_read_timeoutis consumer-configurable and reached
read -tdirectly. An unusable spec is a silent disable:readrejects it with rc 1, the loop reads rc 1 as EOF, the payload comes back empty, every callerskips, and a usage error prints on every hook invocation. Worse,
stdin_read_timeout=0made thechunked loop spin —
read -t 0returns success having consumed nothing.hook::resolve_read_timeoutnow falls back to the default, deciding acceptance by probing the running shell rather than a
Bash version table (the upstream changelog does not date the introduction of fractional timeouts, so
a version check would be a guess). Loop termination is additionally made structural: a successful
read that consumed nothing breaks rather than continues.
5. Never feed a hook payload to jq through a here-string.
jq -e . <<< "$buf"delivers thestring through a pipe that bash fills itself before exec'ing jq, so a payload at or above the
pipe capacity — 65536 bytes here, exactly one read chunk — blocks the shell forever. Traced:
65536 hung indefinitely, 65000 returned immediately. Every such call now goes through
printf | jq:hook::json_complete,buffer_stdin's final check,hook::jq_field, and claude-ops'skill-usage-expansion-audit.hook::jq_fieldis the load-bearing one — it takes the whole bufferedpayload and is called by most hooks in the fleet, and the old throughput ceiling is precisely what
kept payloads that large from ever reaching it. Removing the ceiling made this deadlock reachable, so
this PR had to fix it.
Bash 3.2.
read -Nis Bash 4.1+, and nine plugin READMEs document Bash 3.2+ support (macOSsystem bash), so the pre-4.1 path falls back to the delimiter read inside the same re-arming loop —
the same guard and rationale as
plugins/context-guard/scripts/statusline-tee.sh. The guard is splitinto its own predicate purely so that path stays reachable in tests:
BASH_VERSINFOis readonly andcannot be shadowed. It is not a consumer seam; nothing reads it from the environment.
The fail-closed posture is unchanged
This was the constraint the change had to respect — a guard that silently permits an unscanned write
is strictly worse than one that is annoying. Verified three ways against the real hook:
Case 2 is the improvement that matters most: a violation sitting at the very end of a 200 KB
payload is now genuinely caught, where before the whole write was swept into a content-blind
block. Case 3 is the posture proof — a truncated payload on a pipe held open still exits 2.
The jq completeness check is kept as the backstop, so the Win32 late-EOF case this function
exists for — a complete payload on a pipe that never closes — still succeeds rather than blocking.
Tests
lib/hook-utils.test.shgains five cases alongside the existing timeout test:18b — a complete JSON payload on a pipe held open past the timeout returns rc 0 with the
payload (the Win32 late-EOF contract), and settles in one window rather than two. That second
assertion is by comparison, not against a wall-clock constant: it times the real function and a
variant with the completeness predicate overridden to always fail (reproducing the pre-fix
behavior) back to back on the same host, so runner load cancels out. It fails loudly if the harness
returns no measurement on a host that has
EPOCHREALTIME.18c — a 256 KB payload on a real pipe at the default timeout returns rc 0 with the content
intact, asserted on content rather than wall-clock so a loaded runner cannot flake it. If the
byte-at-a-time read ever returns, this fails outright.
18d — a payload trickled one character per 100 ms against a 300 ms timeout (far too slow to
fill a chunk in any window) returns rc 0 with the whole payload. This is the idle-bound regression
test; against the first commit on this branch it returned rc 2.
18d' — that same trickle then going silent mid-payload still returns rc 2, so re-arming on
progress did not quietly become "never time out".
18e — first asserts the guard override actually flips to the pre-4.1 branch (otherwise the
cases would be vacuous — the first attempt at this test shadowed
BASH_VERSINFO, which isreadonly, and silently tested the modern path), then that the delimiter-read fallback buffers a
128 KB payload and still fails closed on a stalled pipe.
18g — a stall is declared near one bound, not two, asserted against a variant with the
slice count forced to 1, and gated on a precondition check that the override actually engages.
18f — five unusable
stdin_read_timeoutvalues (abc,0,-1,1e3, empty) must each fallback to the default, still deliver the payload, and emit nothing on stderr; plus a positive case
proving a valid non-default value is still honored rather than collapsed. The
0case doubles asthe loop-termination test — before the guard it hung, so a regression shows up as the suite never
finishing.
Existing Test 18 (stalled pipe → rc 2 +
BLOCKED:) is unchanged and still passes.non-boundary one, preceded by an assertion that the fixtures are exactly 65536 and 65000 bytes.
This is also the here-string-deadlock regression test: before the fix it hung outright.
Local runs:
lib/hook-utils.test.sh114/114 (66 s),hardcoded-path-check.test.sh72/72,secret-pattern-detection.test.sh42/42,markdown-format.test.sh112/112,typos-format.test.sh75/75,block-hook-bypass.test.sh203/203,stale-path-verify.test.sh87/87,lane-stop-gate.test.sh26/26.shellcheckclean,check-shell-portability.shclean.sync-hook-utils.sh --check/--check-bump,check-changelog-parity.sh --check/--check-bumpall pass.
Not run: a real Bash 3.2. No 3.2 host was available here, so the fallback is verified by forcing
the branch on a modern bash, not by executing under 3.2 itself.
Four tests on this branch were silently vacuous before being caught — one shadowed the readonly
BASH_VERSINFOand tested the modern path while claiming to test the 3.2 fallback; one bracketed awhole pipeline and measured the producer's
sleep; one had a missing;in its override string, soit exercised the unmodified function; one generated a 59578-byte "65536-byte" fixture because it
stripped newlines after truncating. Every timing, branch-forcing, and exact-size case now asserts its
own precondition first, so a broken harness fails rather than passes.
Portability lint
Two
portability-ok:annotations were added for pre-existing false positives that only surfacedbecause this change puts all 14 synced copies into the changed set: a bash glob bracket class
[\<\>]matching literal</>, and a literala\bpath fixture — neither is a GNU\</\bregex construct. Annotated with the gate's own documented hatch rather than touching the synced
linter.
Secondary:
stdin_read_timeoutin guardrails' userConfigGuardrails' hooks already read
CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUTthrough the shared librarybut never declared the option, so a consumer had no supported way to set it.
actionlintandclaude-opsboth declare it, andactionlint's setup skill records the convention explicitly("hook plugins reusing the shared lib should declare it too"). Declaring it exposes the same knob
here, documented in the guardrails README.
Deliberately not claimed: that the harness materializes the
defaultinto the env var. Theplugins reference describes
defaultas "value used when the user provides nothing" and says allvalues "are exported to hook processes as
CLAUDE_PLUGIN_OPTION_<KEY>environment variables"(plugins-reference, fetched this session), but
that specific default-materialization path is not something this PR verified end-to-end. The
effective default with nothing configured remains the shell-level
:-2fallback insidehook-utils.sh, which is code-verifiable here.Scope
lib/hook-utils.shis synced into all 14 carrying plugins byscripts/sync-hook-utils.sh; thecoupled
sync-hook-utils/changelog-parityCI gates require every carrying plugin to bump and adda changelog entry, so all 14 are bumped (guardrails to a minor,
0.18.0, for the new userConfigoption; the rest patch).
The supported Bash floor is unchanged at 3.2. (An earlier revision of this PR claimed the library
already required 4.0+ because of
${var^}inhook::normalize_path— that was wrong: those caseoperators sit behind an
OSTYPEmsys/cygwin branch that never executes on macOS. The 4.1-onlyread -Nis now guarded rather than unconditional.)Related
separately. This branch is rebased on top of it, which is why
claude-opslands on 0.21.6.hardcoded-path-checkfail-open scopeitem: this is the contrasting fail-closed observation, not confirmation of that one.
Deliberately out of scope
hook::buffer_stdinreturns 1 (skip) when stdin hits EOF with a truncated payload — a genuinelyshort write rather than a stall. Arguably that should fail closed too, but it has not been observed
firing and changing it is a separate behavior change. Left as-is.
Worth knowing when reading the new control flow: a truncated payload that goes quiet and then
closes reaches that EOF branch (rc 1) rather than the stall branch, if the close lands before the
silence exceeds one full timeout window. The stall branch requires a window that delivers nothing —
which is the Win32 late-EOF shape this guard is actually aimed at, and what Test 18 and the
end-to-end stall case both exercise.