fix(guardrails): stop the here-string deadlock that voided two blocking guards - #2123
Conversation
…wo blocking guards `secret-pattern-detection` and `hardcoded-path-check` returned NO VERDICT AT ALL — not slowly, ever — for a Write/Edit payload of 65536-65663 bytes. Bash delivers a here-string by filling a pipe ITSELF before the reader is exec'd, and appends a newline, so a payload in that band lands 1-128 bytes past the 65536-byte pipe capacity and blocks forever; at >=129 bytes over it spills to a temp file, which is why 65535 and 65664 always passed and only the band between them hung. Measured on Git Bash against the pre-fix hooks: a 65536-byte payload carrying a live-shape AWS access-key id returned nothing at a 200s bound, where the same token in a small payload exits 2 immediately. Both are registered at `timeout: 60`, so the harness cancels the guard and the verdict is lost. Every whole-payload `<<<` now feeds its reader through process substitution. `printf | grep -q` is NOT the alternative and the comment that justified the here-string was half right about why: `grep -q` early-exits and SIGPIPEs printf, so under the `set -uo pipefail` these hooks run with, the pipeline reports 141 and `if ! grep -q` reads that as "no match" — inverting a real detection into a fail-open. Process substitution keeps the writer out of the pipeline while preserving the early exit. Verified at every boundary size, both directions. Swept the same class beyond the four reported sites: the telemetry-label grep in hardcoded-path-check.sh (`$VIOLATIONS` embeds matched lines verbatim, and `head -3` bounds lines, not bytes), the command-scanning loops in block-convention-violation, block-hook-bypass, flag-commit-pr-skill-bypass and the PowerShell command lib, workflow-resilience-check's script scan, and source-control's PR-body linkage gate and validator — where GitHub's 65536-char body cap sits inside the hang window. Adds boundary regression cases at 65535/65536/65600/65663/65664 to both suites, including detectable secrets and paths inside the window that must exit 2; every case is bounded by `timeout` and asserts the exact code, with 124 as its own loud failure. Reconciles the contradictory here-string comments in hook-utils.sh and the pattern lib, and corrects the README's six `PreToolUse · Bash` rows whose registered matcher is `Bash|PowerShell`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…not a literal The eleven `done < <(printf '%s\n' …)` sites landed with a LITERAL newline inside the format string instead of the `\n` escape. `printf` emits the same bytes either way, so behavior and the passing suites were unaffected, but the source read as a two-line statement. Also records why these bumps are patch rather than minor: nothing legitimate becomes refused that the guards did not already intend to refuse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ring-deadlock # Conflicts: # plugins/guardrails/.claude-plugin/plugin.json # plugins/guardrails/CHANGELOG.md # plugins/source-control/.claude-plugin/plugin.json # plugins/source-control/CHANGELOG.md
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
|
Claude finished @kyle-sexton's task in 5m 59s —— View job Review completeTodo list
SummaryThis is a well-executed, well-documented fix. I independently verified the core mechanics rather than taking the PR's narrative at face value:
I did not independently re-run the byte-boundary timing measurements (65535–65664, the 41–67s "known follow-up" figures, Two minor, non-blocking findings posted inline:
No correctness bugs, security-lane-scoped items (left to the security lane per REVIEW.md), or missing test coverage found. Version bumps (both patch) and CHANGELOG entries are consistent with the diff and with the repo's stated patch-vs-minor precedent. |
|
Claude finished @kyle-sexton's task in 2m 41s —— View job Security Review — PR #2123Todo list
ScopeReviewed all 34 changed files: the 15 duplicated AnalysisThis is a mechanical, well-reasoned fix for a real fail-open (blocking guards losing their verdict to a bash here-string deadlock in the 65536–65663 byte band). I traced the security-relevant properties of the fix rather than trusting the PR description:
FindingsNo security issues found. The fix correctly closes the fail-open described in the PR body without introducing injection, authorization, or logic-inversion regressions in the process-substitution rewrite. This PR touches no GitHub Actions workflows, so trigger/permission/supply-chain concerns are out of scope for this review (and are zizmor's lane per the review brief). |
|
Claude has reviewed this PR 3 times. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f3db590050
ℹ️ 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".
The comment-precision edit to lib/hook-utils.sh tripped the hook-utils-sync gate, which requires EVERY plugin carrying the shared lib to bump its version so consumers receive the change: fourteen more plugin bumps plus fourteen changelog entries, in exchange for a comment-only edit that would bury a security fix in churn. Its guidance was already correct (`printf | jq`, never `jq <<<`) — only its upper bound was imprecise. The contradiction this PR was asked to reconcile lived on the guardrails side, where the pattern lib told readers to PREFER a here-string; that lib now states the same rule as hook-utils.sh and cites it. `scripts/sync-hook-utils.sh --check` and `--check-bump origin/main` both pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 1m 54s —— View job Security Review — PR #2123 (re-run at HEAD
|
|
Claude finished @kyle-sexton's task in 4m 6s —— View job Review completeTodo list
SummaryThis synchronize event added one commit (
Reviewing the current full diff for correctness/maintainability, I found one new issue (not previously flagged) and confirmed two previously-flagged nits are still open: New finding — filed as inline comments:
Previously-flagged, still unaddressed (not re-filed, to avoid duplicate threads):
Everything else re-checked against this HEAD (process-substitution correctness, patch-vs-minor version bump reasoning, README table accuracy, |
Suite evidenceCI is authoritative and covers every affected suite. All 33 checks pass, including the three required ones:
Local runs (Git Bash on Windows — the host where the deadlock reproduces)Recorded because CI runs Linux, and the defect was found on Windows:
All 20 new boundary assertions pass on both hosts: Every one of these is bounded by |
… comment Review findings on #2123, all four verified against the source first. Both CHANGELOGs embedded a LITERAL newline inside an inline-code span rather than the two-character `\n`. CommonMark converts a line ending inside a code span to a space, so `< <(printf '%s\n' …)` rendered as `< <(printf '%s ' …)` — dropping the escape from view in the very sentence explaining why it is what makes the substitution byte-identical to the here-string. Same defect class as the shell one this PR already fixed, from the same authoring mistake. pr-linkage-validator.sh:113 was the only converted site missing the trailing `# not <<<` note, in exactly the file where a future edit might revert to `<<<"$body"` for readability. secret-pattern-detection.sh's comment claimed `printf … | grep` "is not the fix either" at a site whose reader takes no `-q`, drains its input, and has its pipeline status discarded — where a plain pipe WOULD have been correct. Measured at 65600 bytes: that same `grep -nE` hangs on a here-string exactly as `grep -q` does, so the deadlock is a property of `<<<` alone and the conversion was required regardless; only the CHOICE of replacement depends on the reader. The comment now says that, and names uniformity with the `-q` gates as the reason process substitution is used here. No behavior change: three comment/prose edits and one comment line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 37s —— View job Code Review — PR #2123 (re-run at HEAD
|
|
Claude finished @kyle-sexton's task in 1m 20s —— View job Security Review — PR #2123 (re-run at HEAD
|
…ing guards (#2135) Closes #2122 ## Update — `main` moved under this PR, and the disposition changed with it **#2120 merged (`fd075c27`), and it fixed the same function with the opposite value disposition: it STRIPS every NUL out of a value where this branch TRUNCATED at the first one.** The PR went `DIRTY`. Resolved by merging `origin/main` into the branch — never a rebase, since force-push is blocked here twice over. **The resolution keeps `main`'s strip and this branch's flag plus fail-closed guards.** That is additive over `main` rather than a choice between the two sides, and it is what this body already argued for in its own words: the disposition is *immaterial for this PR's own two callers*, which refuse on the flag before reading a value, while `main` now carries the ten scanner-class callers #2120 converted, none of which consults the flag. Truncating would have hidden a credential placed after a NUL from `secret-pattern-detection` and `hardcoded-path-check`. **Everything below that says "truncate" describes the pre-merge branch; the shipped behaviour is strip + flag.** ### The textual merge git produced was silently fatal, and was not taken git auto-merged the function body into a hybrid carrying BOTH `main`'s per-filter `split("\u0000") | join("")` **and** this branch's array-level `explode | .[0:(index(0) // length)] | implode`. Strip runs first, so `index(0)` looked at a value with no NUL left in it and **the flag read `0` on every payload** — the guards would never have fired, with no conflict marker and no test of the pre-merge branch able to see it. The flag is now computed from the untouched values with the strip applied after, and both the library and the guard comments say the ordering is load-bearing, because it is exactly what the next textual merge will get wrong again. ### Why the flag and the guards are still needed after #2120 #2120 closed the fail-open for the CONTENT guards. It did not close the COMMAND guards: stripping SPLICES the bytes either side of the NUL into a token the payload never carried contiguously, and the guards then match against that token. Re-measured at the hook boundary, `origin/main` at `fd075c27` versus this tree, same script, same host, on fixtures whose NUL is a real byte — verified by decoding each fixture and counting the byte (`jq -j .tool_input.command | tr -dc '\u0000' | wc -c` = 1) rather than trusting that the escape survived construction: | payload | `main` | this change | | --- | --- | --- | | `git commit --no-verify<NUL>x` | **0 ALLOWED** | **2 blocked** | | `git push --force<NUL>x` | **0 ALLOWED** | **2 blocked** | | a lone NUL | **0 ALLOWED** | **2 blocked** | | a trailing NUL | **0 ALLOWED** | **2 blocked** | | `git commit --no-veri<NUL>fy` | 2 blocked | 2 blocked | | clean `--no-verify` | 2 | 2 | | clean `--force` | 2 | 2 | | harmless (`git status`) | 0 | 0 | Identical on both guards. **The fifth row is stated, not counted:** the splice happens to reassemble a real `--no-verify` there, so `main` already blocks it and it evidences nothing about this change. The live rows are the first four, and the first two are the ones that matter — a real `--no-verify` and a real `--force` that `main` waves through. No clean command changed verdict in either direction. ### Tests re-pointed rather than deleted Every assertion this branch wrote against truncation was measuring a value the helper no longer produces, so each was rewritten for strip and two new cases were added: the splice (`--no-verify<NUL>x` -> the single token `--no-verifyx`), and an ALL-NUL value, which strips to empty — that case, and not a leading NUL, is the real reason both guards consult the flag ahead of their empty-command skip. The guard suites keep every NUL row at exit 2; the verdict never depended on the disposition, only its justification did, and one mislabelled row was corrected accordingly. ### Conflicts and versions - `lib/hook-utils.sh` — header comment and jq program, resolved by hand. - The 16 vendored copies were **regenerated with `scripts/sync-hook-utils.sh`**, not hand-resolved; `--check` reports 16/16 byte-identical. - 16 CHANGELOGs where both sides claimed the same version: this branch's entry moves up one patch above `main`'s and is rewritten for the resolved design. - **All 16 `plugin.json` files had auto-merged to `main`'s number, leaving no bump at all** — no conflict, only `--check-bump` catches it, exactly the trap flagged below. Re-bumped: `guardrails 0.23.1 -> 0.23.2`, `markdown-format 0.11.2 -> 0.11.3`, `source-control 0.51.2 -> 0.51.3`, patch bumps for the other 13. - **Coordination with #2130:** it also bumps `markdown-format` to `0.11.3`. Whichever merges second must re-bump. ### `main` moved twice more: three merges, and one of them was silently lossy `main` landed #2147, then #2140 and #2149, while this PR sat. Three merge passes, no rebase at any point. Second pass: #2147 took `guardrails` to `0.24.0` and edited `block-dangerous-git.sh`, which this branch also edits — resolved by keeping main's three-field `hook::jq_fields "$INPUT" '.tool_input.command' '.cwd' '.tool_name'` call verbatim and appending this branch's NUL block after it. Third pass: one changelog conflict on `source-control`. Every plugin manifest had auto-merged to main's number with no bump on **both** passes. **The second pass exposed a defect this branch had introduced, and it is worth reading even if you skip the rest.** An earlier commit here accidentally wrote a **real NUL byte** into `plugins/guardrails/CHANGELOG.md` — a `\u0000` that was meant to be literal text in a prose description of the fixtures. git classifies any file containing a NUL as **binary**, so the textual three-way merge never ran on that changelog: it kept ours wholesale and **silently discarded main's entire `0.24.0` section**, with no conflict marker and nothing in `git status` to distinguish it from a file that merged cleanly. It was caught by counting NUL bytes across the touched files, not by reading the diff. The byte is gone, the section is restored, and the changelog's `0.24.1` entry now sits above main's `0.24.0`. That is a mistake this PR made, not a pre-existing one, and it is reported rather than quietly fixed because the failure mode generalises: **a NUL in a tracked text file turns every future merge of that file into a silent take-ours.** In a repository whose CHANGELOGs are the merge-conflict surface for every shared-library change, that is worth knowing independently of this fix. ### Incidental, and relevant to the "what I could NOT verify" list below While posting a review reply, the **harness itself refused a tool call** whose `command` field carried a stray control character, with `command contains control characters that would be hidden in the approval dialog`. That is a live observation of the validation the list below names as unverified — it fires, and it fires on the `command` field. It is **not** the discriminating probe: it says nothing about whether that validation runs before or after PreToolUse hooks, and nothing about whether the rejected class includes NUL specifically rather than the control characters it does cover. Recorded as an observation, not as evidence that the guards are unreachable. Nothing in this change leans on it in either direction. ### Gates re-run after the merge `sync-hook-utils.sh --check` (16/16) - `sync-hook-utils.sh --check-bump origin/main` - `check-changelog-parity.sh --check` / `--check-bump origin/main` / `--check-order` - `shellcheck -x` with **no severity floor** on `lib/hook-utils.sh`, the `bash-format` vendored copy, both guards and all three test files (rc 0 — this is what the two open review threads reported failing; the jq-variable spelling they flagged is gone from the current program text) - `shfmt -d -i 2` (rc 0). Suite results after the merge are in the thread below. ## The defect `hook::jq_fields` frames its fields with a NUL delimiter drawn from the same byte space as the values it separates. A JSON NUL escape inside a value splits that value in two, the cardinality check `((${#values[@]} == $#)) || return 1` fires, and both real callers spell that `|| exit 0` — a PreToolUse **ALLOW**, emitted with no diagnostic of any kind. One correction to the issue's mechanism, because it moves where the fix belongs. The collision is **reliably detected**, not intermittently: every NUL adds exactly one record, so the count is always `N + k` for `k >= 1` and the check never misses. The defect therefore never lived in the library's return value. It lives in **one exit path serving two conditions with opposite correct responses** — "jq is absent or cannot parse this" (where allowing is the documented, deliberate behaviour) and "this payload carries a NUL" (where allowing is wrong). Separating those two is the fix. ## Design **jq truncates each value at its first NUL and reports the fact; the caller owns the verdict.** - `lib/hook-utils.sh` — each filter becomes `... | explode | .[0:(index(0) // length)] | implode`. The separator then cannot occur inside a value, so the record count no longer depends on what a parseable payload holds. - A leading record carries the NUL flag, computed from the untruncated values and emitted by the **same** jq program, so reporting it costs no second spawn. It surfaces as `HOOK_JQ_FIELDS_NUL`, assigned in the same unconditional block that resets `HOOK_JQ_FIELDS` — above all three return paths, so no early return can leak a stale `1`, which in a guard would mean blocking a clean payload on the strength of an earlier one. - `block-no-verify.sh` and `block-dangerous-git.sh` fail **CLOSED** on that flag, **before** their empty-command skip, because the helper truncates at the first NUL and a leading one therefore leaves an empty value that would otherwise be waved through as "no command". ### Why fail CLOSED, and why that argument does not depend on the executor **No executor-fidelity claim is made here, in either direction.** Two behaviours were measured and they disagree, and which of them a hook payload actually reaches has **not been traced by anyone**: | measured | result | | --- | --- | | bash parsing a command it reads (stdin, script file) | **discards** the NUL — `echo ha<NUL>rd` prints `hard`, and `--no-verify<NUL>x` becomes `--no-verifyx` | | a NUL inside an argv word handed to `execve` | the string simply ends there | | Node v24.18.0 `child_process` — argv, `shell: true`, and `execSync` | **refuses** outright, `ERR_INVALID_ARG_VALUE: must be a string without null bytes`, while the same calls with a clean string run normally | An earlier draft of this PR argued that truncation was right *because the executor truncates*. That was wrong — it generalised the argv case to a path that is not known to be the one in use. **The correct argument is that the design does not need it:** failing closed on the flag is correct under deletion, under truncation, and under refusal alike, so it cannot be invalidated by tracing the path later. That is the whole case for it. Matching the value would need the trace; refusing does not. ### Truncate rather than delete, on grounds that appeal to no shell Truncation never fabricates a token the payload did not carry contiguously, and when a caller forgets the flag it is the *content* class that degrades rather than the command class — a matcher sees a prefix rather than a joined token that matches nothing. **For this PR's own two callers the choice is immaterial: they refuse on the flag before reading a value at all.** It is the conservative default, not the accurate one, and the flag is the load-bearing part. ### Why the library does not block on its own It is sourced by 15 other plugins, formatters among them, for which exiting 2 would be wrong; and a sourced library calling `exit` on its caller's behalf is hidden control flow. Policy stays with the caller and the library only reports the fact. ### Rejected alternatives | Alternative | Why not | | --- | --- | | Delete the NUL (`map(select(. != 0))`) | Fabricates contiguity the payload did not have, and inverts which caller class degrades unsafely when a hook forgets the flag; see above. Not rejected on executor grounds. | | `gsub` / `split`+`join` on a NUL | Both work on jq 1.8.2 here, but each puts a NUL inside the jq **program** text — a regex pattern and a string literal. A construct whose behaviour varied across jq builds would fail EVERY payload: a universal fail-open, strictly worse than the payload-dependent one. `explode`/`implode` use integer comparison only, with no NUL anywhere in the program. This is a reason, not a measurement — see the unverified list. | | Length-prefixed framing | Needs `read -N` (bash 4.1+); this lib supports 3.2+. | | An explicit emitted count | Redundant once the separator is absent from the value space. | | Per-field `@base64` | Needs a `base64` binary; only `jq` is a documented prerequisite. | | `@sh` + `eval` | Puts payload-derived text through `eval`. | | Fail closed inside the library | Impossible without the library exiting on its caller's behalf, which is wrong for the 15 other plugins. | ## Scope **This is a shared-library change, and the repo's own gate makes it 55 files.** `plugins/guardrails/hooks/hook-utils.sh` is a **vendored copy**; `lib/hook-utils.sh` is the source of truth. CI enforces `scripts/sync-hook-utils.sh --check` (all 16 copies byte-identical) and `--check-bump` (every carrying plugin bumped when the lib changes), so editing only the guardrails copy would fail CI. Precedent: 9b90e35, 50 files. Hence 16 vendored copies, 16 `plugin.json` bumps and 16 changelog entries, plus the lib, its test, the two guards, their two test files and the guardrails README. **`hook::jq_field` — SINGULAR — is untouched.** It is a separate two-line function; there is no shared internal the two route through. `grep -rn "hook::jq_field " --include=*.sh plugins/`, with the vendored copies excluded, finds **22 call sites across 12 files** in `claude-ops`, `context-guard` and `source-control`. None of them are touched. `git diff origin/main -- lib/hook-utils.sh` mentions `hook::jq_field` on exactly two lines, both of them the same doc-comment cross-reference inside the *plural* function's header ("Values are CR-stripped, as in `hook::jq_field`"); the singular function's own body appears nowhere in the diff. **Blast radius is exactly the two guards.** **No other plugin is affected by the truncation.** `grep -rn "hook::jq_fields" --include=*.sh .`, excluding the 16 vendored copies and `lib/hook-utils.*`, returns exactly two call sites — both in this PR. Every other hit across the 16 plugins is the doc comment in the vendored library. Nothing round-trips a value into a file, and nothing compares a length or hash against one. **Versions**, taken against `origin/main` at the time of the last rebase: `guardrails 0.23.0 -> 0.23.1`, `markdown-format 0.11.1 -> 0.11.2`, `source-control 0.51.1 -> 0.51.2`, and plain patch bumps for the other 13. Worth flagging for anyone rebasing a sibling branch: when a plugin's version moved on `main` mid-flight, `git` **auto-merged the manifest to main's number**, silently leaving no bump at all — no conflict, and only `sync-hook-utils.sh --check-bump` catches it. That happened three times here. #2120 is still open against the same guardrails files and owes a re-bump. ## Two caller classes want opposite dispositions — which is why there is a flag This is the strongest argument for the design, and it is demonstrated rather than theoretical. #2120 has independently fixed the same function with the **opposite** disposition: at its head `9fb8383d`, `hook::jq_fields` does `... | tostring | split("<NUL>") | join("")` — it **strips**. Neither disposition is simply right, because the two caller classes disagree: | payload | under strip | under truncate | | --- | --- | --- | | `content: harmless<NUL>aws_secret=AKIA…` (a scanner) | secret is joined and **scanned** | secret is cut off and **invisible** | | `command: --no-verify<NUL>x` (a guard) | joins to `--no-verifyx`, matches nothing, **allowed** | leaves `--no-verify`, **blocked** | (Which of those two readings the executor would agree with is untraced, and is not the argument — see above. The point is only that a caller ignoring the flag degrades unsafely in one class or the other, depending which disposition the helper picks.) Both halves measured. The command half is the boundary table below. The content half I measured by driving the helper directly, since no shipped hook reads `.tool_input.content` through it on `main`: ``` payload: .tool_input.content = "harmless preamble<NUL>aws_secret=AKIA…" this branch (truncate) rc=0 flag=1 value=[harmless preamble] credential NOT visible 468bb2d (base) rc=1 flag=- value=[<none>] credential NOT visible ``` **So yes — truncation loses post-NUL content for a scanning caller.** Stated plainly because it is a real consequence of this design. It is not a regression (the base loses it too, and additionally allows), and truncation is still the chosen default: it keeps the *command* class safe when a caller ignores the flag, where strip keeps the *content* class safe instead. Strip inverts which class fails unsafely; it does not remove the failure. Neither is chosen on executor grounds. **A single disposition cannot serve both callers. The flag is what resolves it** — the helper reports, and each caller decides: a command guard refuses outright, a content scanner refuses the write rather than scanning a value it knows is incomplete. Either way the credential never lands. ### The count, measured on `9fb8383d` **Every one of the ten hooks #2120 converts calls `hook::jq_fields`. Zero of them consult any NUL signal. Six own an `exit 2` verdict:** | hook | `jq_fields` calls | flag checks | `exit 2` paths | | --- | --- | --- | --- | | `secret-pattern-detection` | 2 | **0** | 2 | | `hardcoded-path-check` | 2 | **0** | 2 | | `block-convention-violation` | 2 | **0** | 3 | | `block-hook-bypass` | 2 | **0** | 2 | | `block-noncanonical-commit` | 2 | **0** | 5 | | `cli-flag-verify` | 2 | **0** | 1 | | `skill-reference-verify` | 3 | **0** | 0 | | `stale-path-verify` | 3 | **0** | 0 | | `flag-commit-pr-skill-bypass` | 2 | **0** | 0 | | `workflow-resilience-check` | 2 | **0** | 0 | Zero flag checks is expected — the flag does not exist on their branch. The point is what it implies for whichever of us merges second: **merge order does not rescue it.** This PR first, then their rebase, and the scanning hooks receive truncated values with no flag check. Theirs first, then this one, and the same is true the moment strip becomes truncate. **A reader must not conclude that this PR makes that conversion safe. It does not.** Adding the flag checks to those ten hooks is a prerequisite for the conversion, not a follow-up — and it is theirs to do, since those hooks exist in converted form only on their branch. This PR deliberately does not touch them. `hardcoded-path-check.sh` is a **third** caller class worth calling out: it reads `.tool_input.content`, `.new_string` and `.new_source` **and** owns two `exit 2` paths, so it is both scanner and guard. Per-field reachability was checked separately and holds: at their head, both `secret-pattern-detection.sh` and `hardcoded-path-check.sh` reach `exit 2` through `.content` and through `.new_string`. (`hardcoded-path-check.sh` returns early unless `CLAUDE_PROJECT_DIR` is set, so a probe without it exits 0 on every payload and looks exactly like "not reachable".) #2123 needs nothing — its diff introduces zero `hook::jq_fields` call sites. **Merge coordination:** #2120 now also edits `lib/hook-utils.sh`, so this is a direct conflict on the same function rather than only on the manifest and changelog. Whoever merges second must **keep both correctness properties** — the flag and the fail-closed guards from here, and the scanning-caller requirement from there — rather than resolving by taking one side of the hunk. ## Evidence ### Hook boundary, before and after Real hooks, payload piped on stdin, exit code read. BEFORE is a `git archive` of `origin/main` at `468bb2d9` — re-measured after #2123 merged, because #2123 changed `plugins/guardrails/lib/powershell/ps-command.sh`, which both guards source. AFTER is this branch. Same script, same host. | case | before | after | | --- | --- | --- | | clean `git push --no-verify` / `git reset --hard` | 2 | 2 | | clean harmless (`echo hi` / `git status`) | 0 | 0 | | trailing NUL | **0** | **2** | | NUL splitting the flag (`--no-veri<NUL>fy`) | **0** | **2** | | NUL then junk (`--no-verify<NUL>x`) | **0** | **2** | | leading NUL | **0** | **2** | | NUL in an otherwise harmless command | **0** | **2** | Identical for both guards. No row where a clean command changed verdict. The `<NUL>x` row is the one that matters most: it is the payload that executes as the dangerous command. ### The leading-NUL row blocks for the right reason Identical truncated content, opposite verdicts, so the flag decides rather than incidental matching: | payload | exit | | --- | --- | | `"command": ""` (empty, no NUL) | 0 | | `command` field absent entirely | 0 | | leading NUL, truncates to empty | **2** | | a lone NUL and nothing else | **2** | Same on both guards. ### Test suites, same host, baseline vs branch **Both arms ran in full**, serially, on an uncontended host: every `*.test.sh` under `plugins/guardrails/hooks/` plus `lib/hook-utils.test.sh` — 14 suites, every one of them listed below. BASELINE is the same `468bb2d9` tree used for the boundary table; BRANCH is this tip. | suite | baseline | branch | delta | | --- | --- | --- | --- | | `lib/hook-utils.test.sh` | 156 / 0 | **162 / 0** | +6 new cases | | `block-dangerous-git.test.sh` | 341 / 0 | **346 / 0** | +5 new cases | | `block-no-verify.test.sh` | 120 / 0 | **127 / 0** | +7 new cases | | `block-convention-violation.test.sh` | 31 / 0 | 31 / 0 | — | | `block-hook-bypass.test.sh` | 260 / 0 | 260 / 0 | — | | `block-noncanonical-commit.test.sh` | 202 / 0 | 202 / 0 | — | | `cli-flag-verify.test.sh` | 52 / 0 | 52 / 0 | — | | `flag-commit-pr-skill-bypass.test.sh` | 29 / 0 | 29 / 0 | — | | `hardcoded-path-check.test.sh` | 94 / 0 | 94 / 0 | — | | `require-jq-notice-isolation.test.sh` | 2 / 0 | 2 / 0 | — | | `secret-pattern-detection.test.sh` | 52 / 0 | 52 / 0 | — | | `skill-reference-verify.test.sh` | 96 / 0 | 96 / 0 | — | | `stale-path-verify.test.sh` | 87 / 0 | 87 / 0 | — | | `workflow-resilience-check.test.sh` | 16 / 0 | 16 / 0 | — | | **total** | **1538 / 0** | **1556 / 0** | **+18, 0 failures either side** | Every suite that does not exercise the new path is byte-identical across the two arms, so the +18 is entirely the new cases. No pre-existing failure to disambiguate. Two of the new library tests look redundant and are not: `HOOK_JQ_FIELDS_NUL` is checked both after a clean payload and after an **early return**, each running a NUL payload first, because a single-call test cannot observe a stale flag however it is written, and two of the three return paths fire before any NUL could be seen. ### Other gates, all re-run after the rebase `sync-hook-utils.sh --check` (16/16) - `sync-hook-utils.sh --check-bump origin/main` - `check-changelog-parity.sh --check` / `--check-bump origin/main` / `--check-order` - `check-silent-skips.sh` - `check-contract-clause-coverage.py` - `check-cross-plugin-source-drift.sh --check` - `check-hook-userconfig-argv.sh` - `check-plugin-manifest-presence.sh` - `sync-parse-concern-value.sh --check` - `sync-resolve-convention-pattern.sh --check` - `sync-standards-contract.sh --check` - `check-skill-leaf-names.sh --check` - `check-shell-portability.sh --paths` - `shellcheck -x -S warning` (rc 0) - `shfmt -d -i 2` (rc 0) - `markdownlint-cli2` (0 issues) - `check-manifest-duplicate-keys.py`. ## What this PR does NOT fix, stated rather than implied **A payload jq cannot parse still returns 1 and is still allowed.** Malformed JSON, a wrongly typed field or an empty buffer all reach the same `|| exit 0`, exactly as before this change. Process substitution also means jq's own exit status is never observed. That path is untouched here and out of scope, and the header comment now says so instead of claiming — as an earlier draft of this very fix did — that nothing a payload contains can reach it. That claim is the same reasoning shape that produced #2122, and it should not ship inside its fix. ## What I could NOT verify - **How a command actually travels from hook payload to execution.** Nobody traced it. Two shell behaviours were measured and they disagree, and Node refuses NUL-bearing strings on every shape tried, so the command may never reach a shell parser at all. The design is built so this does not matter: fail-closed is right under deletion, truncation, and refusal alike. An earlier draft of this PR did lean on it, in one direction and then the other; both are gone, from the body and from the code comments, the README and the changelog. - **Whether the harness's control-character validation runs before or after PreToolUse hooks**, and **whether the class it rejects includes NUL specifically.** The discriminating probe is bypass-shaped and was deliberately not run. The guard that exists is worded *"contains control characters that would be hidden in the approval dialog"* — approval-surface anti-spoofing, covering `command` / `script` / `url` only, with no equivalent on `content` / `new_string` / `file_text`. It is an implementation detail, not a documented guarantee, and nothing here leans on it in either direction. - **Behaviour on jq builds other than 1.8.2, and on bash other than 5.3.9 (Cygwin).** The chosen construct uses only `explode`, `implode`, `index`, array slicing and `any` — core since jq 1.5 — precisely to keep that risk low, but it was not executed against an older jq. The repo's `hook-utils-windows` job exercises Git Bash on windows-2025 in CI. - **Any performance claim.** The spawn count is unchanged at one, which is structural. Measured per-field cost of the sanitiser was below spawn noise on this host — the no-op control benchmarked *slower* than all three candidates — so no number is claimed. - **Whether a NUL payload can reach a PreToolUse hook through the harness's own serialization.** Being settled separately. This fix does not depend on the answer: failing open on a parse failure is wrong regardless of how the parse came to fail. ## Related - Closes #2122 — the reproduction and the shipped-hook measurements this PR fixes. - Refs #2120 (`perf/guardrails-jq-fields`) — converts ten further guardrails hooks to `hook::jq_fields` and, at `9fb8383d`, independently fixes the same function by **stripping** NULs. Direct conflict on `lib/hook-utils.sh`; whoever merges second must keep both correctness properties rather than taking one side of the hunk, and those ten hooks need `HOOK_JQ_FIELDS_NUL` checks as a prerequisite. Not closed by this PR. - Refs #2123 — merged mid-work; changed `plugins/guardrails/lib/powershell/ps-command.sh`, which both guards source, so the boundary table was re-measured against it. Introduces no `hook::jq_fields` call site, so it needs nothing from this change. - Refs 9b90e35 (#1979) — the precedent for a shared-lib change costing a version bump and a changelog entry in every carrying plugin. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
No linked issue
Summary
Two BLOCKING PreToolUse guards —
secret-pattern-detectionandhardcoded-path-check— returnedno 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 guardand 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. ThisPR 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 itappends 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):The
while … done <<<"$var"shape hangs identically (rc 124 at 65536 / 65600 / 65663), which is whatpulled 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: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 cleancolumn is the stronger claim: nothing in the window got a verdict, violating or not.
Why not
printf … | grep -q— the pipefail inversion is realhardcoded-path-patterns.sh:73-75carried a comment justifying the here-string, and thejustification was half right. Measured under
set -o pipefail, with the match on line 1 sogrep -qcan exit before the writer finishes (a single-line payload never reproduces this — grep must read it
all, so
printfnever gets SIGPIPE'd):set +o pipefailset -o pipefailgrep -qE pat <<<"$C"printf … | grep -qE patprintf … | grep -E pat >/dev/nullgrep -qE pat < <(printf …)Both hooks run under
set -uo pipefail(set -eis off), so the inversion is live — and it is worsethan a wrong status, because both gate sites are written
if ! grep -q …:Chosen idiom: process substitution. It keeps the writer outside the pipeline, so
pipefailcannever see its SIGPIPE, while preserving the
-qearly exit the gate exists for — and it neverblocks. 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.shinside the same plugin — it told readers to PREFER a here-stringover
printf | grep, whilehook-utils.shtold them a whole payload must never go through<<<.The lib now states the same rule and cites it:
jq,grepwithout-q) →printf … | readergrep -q) →reader < <(printf …)For
while … doneloops the substitution is< <(printf '%s\n' …). The\nis mandatory and makesit byte-identical to the here-string it replaces (
<<<appends a newline unconditionally), so noloop can drop its final line.
Repo-wide sweep of
<<<— every site, with a verdict876 occurrences total; 329 outside
*.test.sh. Fix criterion: the string can reach 65536–65663bytes from an agent- or attacker-controlled source, AND a hang loses a security verdict.
Fixed (18 sites)
guardrails/lib/path-detection/hardcoded-path-patterns.sh:76,83guardrails/hooks/secret-pattern-detection.sh:158,207guardrails/hooks/hardcoded-path-check.sh:219$VIOLATIONS$VIOLATIONSembeds each MATCHED LINE verbatim, and the lib'shead -3bounds 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 beforeexit 2. Measured separately belowguardrails/hooks/block-convention-violation.sh:132,158$cmd(Bash/PowerShell command)guardrails/hooks/block-hook-bypass.sh:248,497,566$cmd,$NORMALIZED_SEGMENTS(derived from$COMMAND)guardrails/hooks/flag-commit-pr-skill-bypass.sh:229$cmdguardrails/lib/powershell/ps-command.sh:143,671$cmd,$normguardrails/hooks/workflow-resilience-check.sh:72,79$SCRIPT(inline Workflow script, or ascriptPathfile read)source-control/hooks/pr-body-linkage-gate.sh:194$text(PR body)source-control/hooks/pr-linkage-validator.sh:76,113$bodysource-controlis deliberately in scope rather than left as a half-fix; it costs the second pluginbump in this PR.
Judged safe — no fix, with reason
guardrails/lib/verification/verify-cli-flag.sh:159,161($HELP_OUTPUT)--helpoutput; not attacker-influenced. A latent hang, not a security hole — noted, not fixedguardrails/hooks/cli-flag-verify.sh:188,337(read -raon$seg/$chainstr)guardrails/hooks/flag-commit-pr-skill-bypass.sh:196($keys),skill-reference-verify.sh:160($declared)guardrails/hooks/block-no-verify.sh:102userConfigoption value (administrator-provided scalar)biome-format:211,237,251,ruff-format:250,275($OUTPUT)claude-ops-paths.sh:19,68,worktree-create.sh:373,412,babysit-readiness-gate.sh:286,293,check-plugin-manifest-presence.sh:75IFS=… read -rasplits of a path or a short CSV; cannot approach 64KBsource-control/skills/pull-request/scripts/fetch-annotations.sh:177($FILTERED)claude-config/skills/audit/scripts/*,claude-ops/skills/lanes/scripts/*,work-itemsadapters*.test.shlib/hook-utils.shitself was already clean (#1587), and the stdin→CONTENTpath in both hooks isprintf '%s' "$INPUT" | jq -rthroughout — verified, because otherwise the payload would have hungupstream and this fix would have changed nothing.
The
$VIOLATIONSsite, measuredA 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:
Tests
Neither suite had a single payload-size case before this (
grep -n '65536\|head -c'returnednothing). Added to both
secret-pattern-detection.test.shandhardcoded-path-check.test.sh:printf '%s' ""(zero bytes) matches the old<<<""(oneempty line) in outcome;
grep -qearly-exit leaks noBroken pipenoise onto the hook'suser-facing channel.
Every case is bounded by
timeout 150so a regression fails loudly instead of hanging CI, andasserts the exact expected code —
124is reported as its own named failure. A "non-zero meansblocked" 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: 60registration in
hooks.json. So on Git Bash under Defender a large payload can still lose its verdictto 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 -xclean on all 17 changed files.shfmt -dclean on every changed file. The pre-existing drift in the two.test.shfiles isunchanged 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 --checkand--check-bump origin/main— pass.lib/hook-utils.shis left byte-identical tomainon 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=0hardcoded-path-check.test.sh— PASS=94 FAIL=0Version 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-control0.49.3 shipped a behavior-changingexec-bit-checkfix at patch level;the
guardrails0.21.0 minor was called out specifically for an acceptance change that couldrefuse previously-allowed legitimate work).
Rider
The README hook table listed all six guards registered under the
Bash|PowerShellmatcher asPreToolUse · Bash; no row named PowerShell at all. Verified row-by-row againsthooks.json(6 rows, 6 hooks, exact match) and corrected.
Related
hook-utils.sh's JSON path and stopped at that onecall site; this PR sweeps the rest.
guardrailsfix in the same review stream.