perf(guardrails): parse each hook payload in one jq process, not two or three - #2120
Conversation
…or three PR #2007 added hook::jq_fields and converted block-dangerous-git and block-no-verify. The other ten guardrails hooks still ran a separate `printf … | jq … | tr -d '\r'` pipeline per field over the SAME buffered stdin envelope — work jq does once, paid for two or three times per invocation. Converted, 3 jq execs -> 1: block-noncanonical-commit, block-convention-violation, hardcoded-path-check, secret-pattern-detection, skill-reference-verify, stale-path-verify. Converted, 2 -> 1: block-hook-bypass, flag-commit-pr-skill-bypass, cli-flag-verify, workflow-resilience-check. Where a hook selected a per-tool content field with a case statement, every candidate field is now fetched in the one call and the tool-specific choice happens in the shell — selecting inside jq would still cost the same process. Two semantics kept byte-identical rather than merely equivalent: * `.tool_name // "Bash"` moves its default to the bash-side expansion (`${HOOK_JQ_FIELDS[n]:-Bash}`), the block-dangerous-git pattern. * `replace_all` keeps `// false | tostring` INSIDE the filter. hook::jq_fields wraps each filter in `// ""`, and jq's `//` treats the boolean false as empty, so a bare `.tool_input.replace_all` returns "" where the old call returned "false". Verified against absent/false/true payloads. Failure semantics are unchanged everywhere: `hook::jq_fields … || exit 0` lands on the same skip the old empty-field guard produced, and hook::require_jq above it still makes a missing jq visible once per session. No behavior change. Every converted hook's contract suite passes unchanged, and a payload-level differential against origin/main copies covers the regression class issue #1403 recorded against the previous extraction attempt — multi-line command values, backslash-newline continuations, PowerShell here-strings, embedded quotes and tabs — requiring identical exit code, stdout and stderr. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…median The 0.20.1 entry led with the loaded median (-991 to -1033 ms for a 3-field hook), which its own end-to-end whole-hook number (-687 ms) contradicts — the parse block cannot recover more than the whole hook does. Lead with the p75 of the paired deltas instead (-404 ms / -194 ms), which is the least-favourable quartile and agrees independently with the min-of-arms floor (-394 / -192). Keep the medians and the end-to-end figure alongside it, labelled as taken under this host's concurrent-agent load. Also records the one trade the conversion makes: hardcoded-path-check and secret-pattern-detection now serialize the per-tool content field before the gitignore skip that used to precede it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
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. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b2f08cb590
ℹ️ 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/guardrails/.claude-plugin/plugin.json # plugins/guardrails/CHANGELOG.md
|
Claude finished @kyle-sexton's task in 4m 19s —— View job Code review —
|
|
Claude finished @kyle-sexton's task in 4m 48s —— View job Security review — PR #2120Scope: the 12 changed files (10 guardrail hooks +
IMPORTANT — NUL byte in a payload field silently disables the guard it's inside (fail-open), across all 10 converted hooksConfidence: High (confirms and generalizes the Codex bot comment on
This is not collision-safe: if any field's own value contains a NUL (a JSON string can carry one via ``, and That fail-open is a regression specifically for this PR's two content-scanning security guards:
The same collision also lets a NUL inside
Note Suggested direction (matches the Codex comment): don't use a byte that can also occur inside a field's own value as the delimiter. E.g. length-prefix each value, or use jq's own array/NUL-terminated JSON output ( No other security-relevant findingsReviewed the remaining diff surface across all ten hooks: the batched filters passed to No GitHub Actions workflow files are touched by this PR, so nothing here falls under zizmor's advisory lane. |
|
Claude has reviewed this PR 5 times. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
…le hook `hook::jq_fields` delimits its batched fields with a NUL byte. JSON may legitimately encode a NUL inside a string, and a Write/Edit/NotebookEdit `content` field is exactly where one arrives: jq emitted the raw byte, the NUL-separated read split that value in two, the cardinality check saw one value too many, the helper returned non-zero — and every caller's `|| exit 0` skipped its guard outright. A credential or machine path placed after the NUL passed unblocked. That is a regression this branch introduced. The per-field command substitution it replaced discarded the NUL and scanned the rest, which the reproduction shows directly: the same payload (content = "harmless first line" + NUL + an AWS-shaped token) exits 2 on origin/main — with bash's own "ignored null byte in input" warning on stderr — and exited 0 here. Each value is now NUL-stripped INSIDE the jq filter, with the 1-arity plain string `split`/`join` rather than `gsub`, which would put a NUL inside an Oniguruma pattern. After the strip the delimiter provably cannot occur in a value, so the framing cannot collide with content. Stripping is not the weaker alternative to an encoding scheme, it is the only representable one: a bash variable cannot hold a NUL byte, so no framing — length prefix, base64, JSON — could deliver one into HOOK_JQ_FIELDS, and both of those also cost a decode process per field, which is the whole cost this helper exists to remove. It is byte-for-byte what `$( )` did, so content AFTER the NUL is still returned and still scanned. The mismatch policy is deliberately unchanged: `return 1` plus the caller's `|| exit 0` is the documented jq-absent fail-open and matches the pre-conversion empty-field guard. What is gone is the CAUSE of the spurious mismatch — a mid-stream jq filter error is now the only way to trip it, as on main. Regression cases, proved by mutation (revert only the split/join): the new lib/hook-utils.test.sh case goes PASS=155 FAIL=0 -> PASS=154 FAIL=1, and secret-pattern-detection.test.sh goes PASS=44 FAIL=0 -> PASS=42 FAIL=2. hardcoded-path-check.test.sh gets the same shape. Payloads are built with jq's `[0] | implode`, so no literal escape for the byte appears in any test source. The lib is synced, so scripts/sync-hook-utils.sh ran and all 16 carrying plugins take a patch bump with their own changelog entry. Also softens the `replace_all` comment in skill-reference-verify and stale-path-verify: keeping `// false | tostring` inside the filter is for parity with the pre-conversion output, not because a branch depends on it — every consumer tests `== "true"`, which "" and "false" fail alike. Comment only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
I was sent to address the two The premise held, verified independently at your current headI enumerated the consumer set rather than taking the nit's word for it, at Three comparisons, all one-way. Two residuals, offered as one comment-only commit you can ignoreBranch: 1. The re-wrap left 2. "Unobservable" now reads as "harmless", and it is not. This is the part I think is worth your attention. With the The commit adds a clause saying exactly that and pointing at #2126, and it deliberately does not decide what EvidenceComment-only; no behaviour changes. Same host, both suites, at
I did not push to |
# Conflicts: # plugins/guardrails/.claude-plugin/plugin.json # plugins/guardrails/CHANGELOG.md # plugins/markdown-format/CHANGELOG.md # plugins/source-control/CHANGELOG.md
|
Claude finished @kyle-sexton's task in 4m 47s —— View job Code review —
|
|
Claude finished @kyle-sexton's task in 5m 17s —— View job Security review — PR #2120
Scope: the diff between IMPORTANT — the NUL strip in
|
Merging origin/main into this branch, then reverting a batch of plugins that looked over-bumped, discarded the merge result for those files -- taking each CHANGELOG back to the branch's pre-merge state and erasing the newest entry main had added. The audit found it across 28 plugins. Every affected changelog is rebuilt as main's full history with this branch's own entry on top, renumbered strictly above main's current version where the two collided. Plugins this branch did not bump take main's file verbatim. Two shapes needed special handling: - session-flow had regressed BELOW main (0.22.0 against main's 0.22.1) rather than merely colliding, which the parity gate reports differently. - evals writes its older entries as `## 0.1.0` rather than `## [0.1.0]`. The first rebuild only recognised the bracketed form, so the top entry swallowed the bracketless section and duplicated it. Boundary detection now accepts both. Gates: --check-bump and --check-order both pass across 73 changelogs, and all 16 hook-utils copies still match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 3m 33s —— View job Security review — PR #2120 (HEAD
|
Softening the `replace_all` comment left "# Failure" orphaned on its own line, ahead of "# semantics are unchanged:" on the next. Cosmetic, in both hooks that carry the paragraph. Reflowed; no behavior change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts: # plugins/guardrails/.claude-plugin/plugin.json # plugins/guardrails/CHANGELOG.md
|
Claude finished @kyle-sexton's task in 8m 33s —— View job Code review —
|
|
Claude finished @kyle-sexton's task in 2m 6s —— View job Security review — PR #2120 (HEAD
|
The changelog restore in 62e2270 over-applied. It rebuilt every plugin whose version differed from main, but 29 of those differed only because an earlier erroneous bump had touched them -- they carry no hook-utils.sh copy and have no functional change in this PR. The rebuild then dressed that up as a real release: a version bump plus a changelog entry byte-identical to the one already on main one section below. Every non-carrier is restored to origin/main verbatim. The PR's plugin diff is now exactly the 16 plugins that actually carry a hook-utils.sh copy: actionlint autonomy bash-format biome-format claude-ops context-guard desktop-notification eol-normalizer go-format guardrails markdown-format powershell-format rate-limit-guard ruff-format source-control typos-format session-flow is among the restored: it is not a carrier, so the right answer was origin/main's 0.22.1, not the 0.22.2 I renumbered it to while fixing the earlier regression. markdown-format IS a carrier and keeps its bump, but its [0.11.2] entry had restated the whole [0.11.1] "host without git" paragraph alongside the new NUL fix. Only the NUL bullet belongs under the new version. Gates: --check-bump and --check-order pass across 73 changelogs, no duplicate versions, no entry present on main missing here, 16/16 hook-utils copies match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 57s —— View job Security review — PR #2120 (HEAD
|
|
Claude finished @kyle-sexton's task in 3m 18s —— View job Code review —
|
One instance of the duplicate-entry pattern survived 6051543. source-control's new [0.51.2] section restated the PR-body-linkage bullet verbatim from [0.51.1] below it, which is already on origin/main and untouched by this PR. Only the shared hook-utils NUL fix belongs under the new version. source-control keeps its bump: it IS one of the 16 hook-utils.sh carriers, so unlike the non-carriers restored in the previous commit, the right fix here was to trim the entry rather than revert the plugin. Swept every changelog for a bullet repeated verbatim across two version sections rather than fixing only the file that was reported; this was the only one left. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 53s —— View job Security review — PR #2120 (HEAD
|
|
Claude finished @kyle-sexton's task in 2m 14s —— View job Code review — re-review after synchronize (head
|
Conflict: plugins/markdown-format/CHANGELOG.md — both sides claimed 0.11.2. main's 0.11.2 is #2120's shared hook-utils NUL fix; this branch's entry moves up to 0.11.3 and main's 0.11.2 is kept below it, order strictly descending. plugin.json auto-merged to main's 0.11.2, silently leaving no bump at all — no conflict, and only check-changelog-parity.sh --check-bump catches it. Bumped to 0.11.3 to match the changelog. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#2120 landed on main and fixed the same function with the opposite value disposition: it STRIPS every NUL out of a value, where this branch TRUNCATED each value at its first NUL. Resolved by keeping main's strip and this branch's flag plus fail-closed guards, which is additive over main rather than a choice between the two sides. Why strip wins the disposition. main now carries ten scanner-class callers that #2120 converted, none of which consults the flag; truncation would hide a credential placed after a NUL from secret-pattern-detection and hardcoded-path-check. This branch's own body already conceded the disposition is immaterial for its two callers, which refuse on the flag before reading a value. Why the flag and the guards are still needed after #2120. Stripping SPLICES the bytes either side of the NUL into a token the payload never carried contiguously, and the command guards then match against it. Measured at the hook boundary, origin/main at fd075c2 versus this tree, on fixtures whose NUL is a real byte decoded from a JSON \u0000 escape: git commit --no-verify<NUL>x main 0 ALLOWED -> here 2 blocked git push --force<NUL>x main 0 ALLOWED -> here 2 blocked lone NUL / trailing NUL main 0 ALLOWED -> here 2 blocked git commit --no-veri<NUL>fy main 2 -> here 2 (same, evidences nothing) clean --no-verify / --force / harmless 2 / 2 / 0 both trees The textual merge git produced was silently fatal and was NOT taken: it kept main's per-filter split/join AND this branch's array-level truncate, which put the strip BEFORE the flag computation, so index(0) saw a value with no NUL left and the flag read 0 on every payload — the guards would never have fired. The flag is now computed from the untouched values and the strip applied after, with a comment saying so, because that ordering is exactly what a future textual merge will get wrong again. Conflicts: lib/hook-utils.sh header comment and jq program, resolved by hand; the 16 vendored copies regenerated with scripts/sync-hook-utils.sh rather than hand-resolved (16/16 byte-identical); 16 CHANGELOGs where both sides claimed the same version, this branch's entry moved up one patch above main's and rewritten for the resolved design; 16 plugin.json bumps, all of which had auto-merged to main's number leaving no bump at all. Two guard comments justified the flag check's position by truncation ("a leading NUL leaves an empty command"). Under strip a leading NUL keeps its text and only an all-NUL command arrives empty, so the check's position is still right and the comments now say why for the real reason. Verified, not reasoned. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…the splice case The merge resolution kept #2120's stripping disposition, so every assertion this branch wrote against truncation was measuring a value the helper no longer produces. Fixed rather than deleted, and the labels now match what is asserted. lib/hook-utils.test.sh - the framing case expects the stripped values (`git push --no-verify`, `s1`, `pq`) instead of the truncated prefixes; - the leading-NUL case asserts the text is PRESERVED and the flag still rises, which is the real behaviour under strip; - new: `--no-verify<NUL>x` arrives as the single token `--no-verifyx`. This is the case the whole fix exists for — a token the payload never carried contiguously, which no matcher recognizes, so a caller reading only the value allows it. Verified red against origin/main's guards (exit 0) and green here (exit 2); - new: an ALL-NUL value strips to empty and still raises the flag. That case, not a leading NUL, is why both guards consult the flag ahead of their empty-command skip. Both guard suites keep every NUL row at exit 2 — the verdict never depended on the disposition, only the justification did — with one mislabelled row corrected ("leading NUL truncates to no command" does not truncate under strip) and the all-NUL row added alongside it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s from every git guard (#2147) ## What Two live holes on `origin/main`. One is specific to `block-dangerous-git`'s lease-width probe; the other is in the **shared argv resolver** and reached every guard in every plugin. `hook-utils.sh` exists in **17 places** — `lib/hook-utils.sh` plus a synced copy in each of 16 plugins — and all 17 were stale. An independent adversary confirmed the resolver hole is not lease-specific: behind `env -S`, `block-no-verify` allowed `git commit --no-verify` and `block-dangerous-git` allowed `git reset --hard`. All 17 copies are patched here. It also proved the lease hole live rather than theoretical: in a SHA-256 repository carrying a ref literally named `0123456789abcdef0123456789abcdef01234567`, the cleared force push **clobbered the remote branch with unrelated orphan history**, rc=0, with `rev-parse` captured before and after. The guard allows `--force-with-lease=<ref>:<expect>` only when `<expect>` is a **full-width object id for that repository's hash format**, because git cannot resolve one to something newer at push time. Hex of the *other* width is an ordinary, movable ref name there — a 40-hex lease in a SHA-256 repository is exactly the hole `--force-with-lease` exists to close. **Route 1 — the payload's `cwd` was never read.** The probe ran `git rev-parse --show-object-format` from the **hook process's** directory. Claude Code launches hooks from the session root and runs the Bash tool wherever the session stands, so the two differ routinely. No wrapper and no `cd` were required: a plain `git push` was enough. **Route 2 — `env -S` / `--split-string` spliced options past the parser.** `-S` exists so a shebang line can pass OPTIONS to env (`#!/usr/bin/env -S -i prog`), so its split words are env's own arguments. `hook::git_resolve_index` spliced them back into its scan but resumed at the **command dispatcher**, which read a leading option in the split string as the command NAME and abandoned the segment. `env -S '-C <dir> git push --force'` resolved to *no git at all* — so this was not only a lease-width hole; a bare `env -S '-v git push --force'` also went unexamined. ## The fix - The payload's `.cwd` is read and replayed as a **leading `-C`**, ahead of `HOOK_GIT_RESOLVED_WRAPPER_DIRS`, which already precede git's own options. That reproduces execution order end to end and composes under git's own rules — a later `-C` composes onto an earlier one, an absolute one wins — so it is the same mechanism the wrapper replay already ships, with a first term added. Not a `cd`: a `cd` would move the hook process and leak across the recursive alias walk. - The base chain is `HOOK_EFFECTIVE_BASE` → `HOOK_CWD` → `CLAUDE_PROJECT_DIR` → `.`, adopted verbatim from `block-noncanonical-commit` rather than invented a second time. `HOOK_EFFECTIVE_BASE` is not decoration: a `!` shell alias runs its body as a fresh command in the relocated repository, so the base is relocated for that reparse and save/restored around it. This guard recurses through `!` aliases the same way the sibling does. - `hook::git_resolve_index` resumes inside **env's own option loop** after an `-S` splice. That also keeps env's single chdir slot last-wins across the splice (`env -C a -S '-C b git …'` lands in `b`), matching GNU env. - The `repo_oid_width` known-gap docblock is restated at its real width (see below). ## Behaviour change, stated so it is not read as a regression **A RELATIVE `-C` / `--git-dir` / `--work-tree` / `--namespace` now rebases onto the payload cwd** instead of the hook process's directory. That is the correct resolution — a relative path written in a tool call means relative to where that call runs — and it is a change only in the sense that the previous answer was measured from the wrong origin. An **absolute** one is unaffected. Cases 4b/4c below pin it, and there is a test for the absolute form staying put. One further consequence of adopting the sibling's chain: with **no `.cwd` in the payload at all**, `CLAUDE_PROJECT_DIR` is preferred over the hook process's directory. A real PreToolUse payload always carries `cwd`, and this matches `block-noncanonical-commit`; case 5b pins it either way. ## Verification Every row was run against **both trees from one script** — PRE is `origin/main` extracted verbatim, POST is this branch — over real SHA-1 and SHA-256 fixture repositories. Exit 2 = BLOCKED, 0 = ALLOWED. Two independent liveness columns, because a table can be inert in two different ways: - **pPOST** — the width the hook's own probe resolved, scraped from `bash -x` (`_repo_oid_width=NN`). The guard fails closed on width `0`, so a BLOCK from `0` is fail-closed noise, not the fix working. Every POST=BLOCKED row below resolved a real width. - **EXEC** — what the command's git *actually does*: the push replaced by `rev-parse --show-object-format`, the exact wrapper form run for real from the payload cwd. A form that never reaches git is not a bypass. | case | PRE | POST | pPRE | pPOST | EXEC | what it pins | |---|---|---|---|---|---|---| | 1a | 0 | **2** | 40 | 64 | sha256 | payload cwd = SHA-256 repo, hook process in SHA-1 one, 40-hex lease — **the bypass** | | 1b | 2 | 2 | 64 | 64 | sha256 | control: both directories agree; fixture discriminates | | 1c | **2** | **0** | 64 | 40 | sha1 | **opposite direction** — payload cwd = SHA-1 repo, 40-hex is a genuine object id where it runs | | 2a | 0 | **2** | – | 64 | sha256 | `env -S '-C <sha256> git …'` | | 2b | 0 | **2** | – | 64 | sha256 | `env --split-string='-C <sha256> git …'` | | 2c | 0 | **2** | – | – | sha1 | `env -S '-v git push --force'` — a plain force push hidden behind a leading option | | 2d | 2 | 2 | – | – | sha1 | no-regression: `env -S 'git push --force'` (no leading option) was and stays blocked | | 2e | 0 | **2** | – | 64 | sha256 | `env -C <sha1> -S '-C <sha256> …'` — one slot, last wins | | 2f | 0 | 0 | – | 40 | sha1 | `env -C <sha256> -S '-C <sha1> …'` — last wins the other way (semantics pin, paired with 2e) | | 3a | 0 | **2** | 40 | 64 | sha256 | `git -C <sha256> -c alias.y='!git <lease>' y` — the `!` body runs in the relocated repo | | 3b | **2** | **0** | 64 | 40 | sha1 | opposite direction through the same `!` path | | 4a | 2 | 2 | 64 | 64 | sha256 | relative `git -C` with both directories agreeing — unchanged | | 4b | **2** | **0** | 0 | 40 | sha1 | relative `git -C` resolves against the payload cwd (PRE probed width `0` — it was resolving nothing) | | 4c | **2** | **0** | 0 | 40 | sha1 | relative `--git-dir` rebases the same way — the disclosed change | | 5a | 2 | 2 | 64 | 64 | sha256 | no `.cwd`, no `CLAUDE_PROJECT_DIR` → `.` (pre-fix behaviour preserved) | | 5b | 2 | **0** | 64 | 40 | sha256 | no `.cwd` → `CLAUDE_PROJECT_DIR` (chain rung 2; EXEC differs because the divergence is synthetic) | | 6a | 0 | 0 | – | – | *(none)* | inert-form control: `env FOO=1 -C <dir> git …` — coreutils stops at `NAME=VALUE`, rc 127, git never runs, so there is nothing to block | `–` in a probe column means no probe ran (no lease expectation on that row, or no git resolved). **Every case that claims a fix carries a control that FAILS against `origin/main`**: 1a, 2a, 2b, 2c, 2e, 3a (PRE allowed, POST blocked) and 1c, 3b, 4b, 4c, 5b (PRE blocked, POST allowed). 1b, 2d, 4a, 5a and 6a answer the same on both trees by design and are labelled as controls, not as evidence. ### Regression coverage added - `plugins/guardrails/hooks/block-dangerous-git.test.sh` — 341 → **363 pass / 0 fail**. `run_in` now states the payload `cwd` alongside the process directory (without it the suite silently measures `CLAUDE_PROJECT_DIR`, i.e. the host repository, in any session that exports it); `run_split` and `run_nocwd` cover the divergent and degraded payload shapes. - `lib/hook-utils.test.sh` — **164 pass / 0 fail**, with resolver-level `env -S` cases including the attached-operand spelling, the last-wins slot across a splice, and a self-referential `env -S '-S -S'` termination check. ## Not in scope, deliberately - **A shell `cd` relocation** (`cd X && git push …`, `(cd X && …)`, `sh -c 'cd X && …'`). Resolving it means evaluating arbitrary shell word expansion, which this guard deliberately does not do. It remains a documented gap — and the docblock describing it is corrected in this PR, because it listed a "compound `cd`" as one of three required conjuncts when at the time **none** of them were required. A documented gap that reads narrower than it is, is how this one survived review. - **A persisted (config-file) alias carrying the lease** (`git config alias.yolo 'push --force-with-lease=…'` then `env -C <dir> git yolo`). This guard resolves inline `-c` aliases only; persisted-alias resolution is a separate capability `block-noncanonical-commit` has and this one does not. Flagged in #2124 for triage, not asserted there as a bypass. - **An explicit `--git-dir` / `--work-tree` inherited by a `!` shell-alias body.** git EXPORTS them into the body's environment (verified on git 2.54.0 — the body prints `sha256` from a SHA-1 directory and sees `GIT_DIR` set), so the body works in a repository the composed directory does not name. `effective_dir` composes `-C` only, so the lease is judged against the base. **Reproduced against BOTH `origin/main` and this branch (PRE=0, POST=0, EXEC=sha256)** — it is pre-existing and of the same family, not introduced here, and closing it means replaying the inherited globals rather than a directory: a larger mechanism than the base chain #2124's design section scopes this change to. Now documented in the `effective_dir` docblock and the CHANGELOG rather than left implicit, on the same principle that motivated the docblock correction above. - **The claimed relative-`git -C` misprobe that does not reproduce.** #2124 records it as tested against `origin/main` and not reproducing — the relative form resolves against the hook process's cwd *and* the command's cwd, which are the same directory in that scenario. It is subsumed by route 1, not separate, and no separate change was made for it. ## Two findings from adversarial review, folded in - **A false git semantic in the diff's own prose.** It said a `!` shell-alias body "starts in THIS segment's relocated directory". Measured: a `!` body runs from the repository **top level**, not the caller's directory (`alias.wd='!pwd'` from `<repo>/sub` prints `<repo>`). The conclusion is unchanged — an object format is a property of the repository, and the composed directory and its top level are the same repository — but the claim is corrected rather than left load-bearing on a wrong premise. - **An unexplained asymmetry that turned out to be correct.** `effective_dir` composes only `-C` while `collect_git_locating_opts` also replays `--git-dir`/`--work-tree`/`--namespace`. The reviewer expected a bug and found it right: only `-C` relocates a `!` body (`git -C <other> -c alias.wd='!pwd' wd` moves, `git --git-dir=<other> …` does not). A comment now says why, so the next reader does not file it as the bug this one nearly did. ## The known gap's primary symptom is a FALSE BLOCK, not a bypass Worth stating plainly because reviewers reasonably read "known gap" as "hole": with a shell `cd`, the probe measures a base that is frequently not a repository at all, answers width `0`, and fails closed. So ``` cd <repo> && git push --force-with-lease=main:<literal full-width sha> origin main -> BLOCKED ``` — the exact form the guard's own block message prescribes — is denied from a session root that is not itself a repository. Fail-closed is the right default for an unresolvable base, and this is not a regression (it behaves the same on `origin/main`), but the docblock now records the false block as the symptom to measure, because a guard that refuses correct usage it just recommended teaches people to route around it. Conversely, the fix **removes** a false block as well as a bypass: the inverse-skew row (hook process in SHA-256, payload cwd in SHA-1, 40-hex lease) goes DENY → ALLOW, which is correct because that word is a genuine object id where the command runs. ## What was NOT tested — carried forward rather than buried - **No PowerShell payloads were used by the adversarial pass at all.** The guard matches `Bash|PowerShell`, so the entire lease-width and `env -S` surface is unverified on that arm by the adversary. This branch adds PowerShell cases of its own (payload-cwd pinning plus a missing-`cwd` tool-name case) but they do not cover the `env -S` surface. - **`hook::require_jq` was not read**, and this guard now requests three payload fields instead of two. The behaviour when jq is absent — the guard skipping entirely — is a separate, already-filed concern, not something this branch changes. - The abbreviated-hex rows (7 and 12 hex) were examined and deliberately **not** "fixed": ambiguity with a short ref name is real, and blocking them is correct. - `+refspec` force detection held on every form tried; `-S` termination held across six degenerate operands under a 25 s timeout. - The 13/0 PRE-vs-POST discrimination split reproduced twice, but the final uncontended full pass was still running when the adversary reported. ## Blast radius `lib/hook-utils.sh` is a synced library: `scripts/sync-hook-utils.sh` distributes it to every plugin carrying `hooks/hook-utils.sh` — 16 plugin copies plus the `lib/` source, 17 files, all stale on `origin/main` — and each plugin must bump so consumers receive the change. All 16 carrying plugins are bumped with a CHANGELOG entry; `guardrails` takes a minor bump (0.23.1 → 0.24.0) for the behaviour change above, the other 15 take a patch. `scripts/sync-hook-utils.sh --check-bump origin/main` and `scripts/check-changelog-parity.sh --check-bump origin/main` both pass, as do `--check-order`, `check-silent-skips.sh` and `check-cross-plugin-source-drift.sh --check`. Closes #2124 ## Related - #1275 — where `PRRT_kwDOTCGFQM6TzGBZ` was filed - #2100 — the partial fix this completes, and the round-one verification that wrongly closed the thread - #1938 — the stranded post-merge review-findings sweep - #2120 — the previous `lib/hook-utils.sh` change, whose 15-plugin fan-out this one mirrors --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…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>
…sted files reach the root config without git (#2130) Follow-up to #2121. **Both gaps are live on `main` right now** — not stale review findings. Reproduced independently: the two new tests, run against `main` own unmodified hook, give **PASS=136 FAIL=2**. With the change, **138/0**. ## The two defects **1. `markdown-format.sh:119` calls `hook::repo_root` raw.** With `git` and `jq` both absent, a nested file makes the opt-in pre-check read an opted-in repo as opted-out, and the `jq` notice is swallowed. A repository that did opt in is treated as if it had not, silently. **2. The `REPO_ROOT` guard at `229-237` covers only the `CLAUDE_PROJECT_DIR`-set case.** The membership scope it exists to fix is gated on that variable being **unset**, and the no-git fixture runs unset — so the configuration the fix was written for is still broken for nested files. `hook::repo_root` falls back to the file own directory, the root markdownlint config is never discovered, and the edit is skipped with no diagnostic. The second is the one #2121 review comment described as "leaving the normal nested-docs case unfixed". That reading was correct and remains correct at `main`. ## The change Resolve the repository root from the **filesystem** rather than from a variable: walk up for a `.git` entry, accepting a directory **or** a file so linked worktrees and submodules resolve. Git own answer is returned untouched whenever git produced one, and `CLAUDE_PROJECT_DIR` is kept as a further fallback, so the case `main` already handles is subsumed rather than replaced. Four commits, ordered so the defect is demonstrated before it is fixed: ``` 9cbb3c2 tests (red against main) 4d2cd84 fix b42a935 coverage 66a100d changelog + version ``` ## Verification - Baseline `main` **135/0**; with the change **138/0**; the two new tests **red** against `main` own hook (independently reproduced at `e47964ca`). - `main` newest positive override test passes unchanged under the replacement — verified rather than assumed, after confirming no `.git` sits on the temp-dir ancestor chain that would have made the walk answer differently on this host. - `shellcheck -x -S warning`, shell-portability, silent-skips, markdownlint, and changelog-parity all clean. ## Stated rather than glossed — three things not confirmed - **The POSIX-host spawn count was simulated**, by addressing the repo in git own path spelling on a Windows host. It was never observed on a real POSIX host. - **A perf claim was wrong on first pass and is corrected here.** An unconditional ~140ms Git Bash cost was expected; measurement showed **zero** extra spawns on Git Bash, because `rev-parse --show-toplevel` and `dirname` never produce the same path spelling there. The extra probe fires only where the spellings agree — 2 to 3 spawns, root-level files only. - **One `PASS=133 FAIL=1` intermittent** was seen at an abandoned intermediate commit. It was unnamed, did not reproduce in five runs at the successor commit, and never recurred in any run backing these numbers. Unconfirmed rather than dismissed. ## Provenance Prepared as a cherry-pickable offer while #2121 was open; #2121 merged at `5f92d946` without taking it, leaving no branch to cherry-pick onto, so this is cut from `main` instead. The offer comment on #2121 remains accurate for what it offered at the time. Fixes #2134 ## Conflict resolution against a moving `main` `main` moved under this branch twice and the PR went `DIRTY`. The version collision was resolved twice, and the branch now carries the second resolution's numbers. - **Conflict, both times: `plugins/markdown-format/CHANGELOG.md`.** `main` took `0.11.2` (#2120's shared `hook-utils.sh` NUL fix), then `0.11.3` (#2147). This branch's entry moved up each time and now sits at **`0.11.4`**, with `main`'s `0.11.3` and `0.11.2` kept below it, order strictly descending. - **`plugin.json` auto-merged to `main`'s number on both passes, silently leaving no bump at all** — no conflict marker, and only `check-changelog-parity.sh --check-bump` catches it. Bumped to `0.11.4` to match the changelog. This is the trap worth carrying forward: a manifest version collision does not conflict, it resolves to whichever side git saw last. - `check-changelog-parity.sh --check-bump origin/main` clean at the resolved tree. **History note, stated rather than glossed.** This resolution was first delivered as two merge commits (`git merge origin/main`, never a rebase, since force-push is blocked here). The branch was subsequently **force-pushed** to a rebased, linear history carrying the same resolved content and the same `0.11.4` numbers, which discarded those merge commits. The shipped branch is therefore a rebase, not the merge described above; the resolution it carries is the same one. **Version coordination with #2135:** that PR also bumps `markdown-format`, and after its own merges of `main` it currently takes `0.11.4` as well. Whichever of the two merges second must re-bump — the manifests will auto-merge to the same number without conflicting, exactly as described above. ## Related - Fixes #2134 — the two no-git root-resolution defects this PR closes. - Refs #2121 — the predecessor whose review comment identified the nested-docs case; merged at `5f92d946` without taking the offered follow-up, which is why this is cut from `main`. - Refs #2120 — merged into `main` mid-flight; its shared `hook-utils.sh` change took the `0.11.2` slot this branch's changelog entry originally occupied. - Refs #2135 — concurrent `markdown-format` version bump; see the coordination note above. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…2386) Fixes #2136 ## Summary - Add `HOOK_JQ_FIELDS_NUL` checks to five verdict-owning hooks that were missing them: `block-convention-violation`, `block-hook-bypass`, `block-noncanonical-commit`, `secret-pattern-detection`, and `hardcoded-path-check`. - Refuse (`exit 2`) before matching/scoring when any requested field carried a NUL byte — the helper strips NULs, so a clean verdict would not reflect the bytes the payload carried. - `block-dangerous-git` and `block-no-verify` already consulted the flag. ## Test plan - [x] `secret-pattern-detection.test.sh` (54/0) - [x] `hardcoded-path-check.test.sh` (96/0) - [x] `block-convention-violation.test.sh` (44/0) - [x] `block-hook-bypass.test.sh` (413/0) - [x] `block-noncanonical-commit.test.sh` (204/0) ## Related - #2120 / #2122 — `HOOK_JQ_FIELDS_NUL` signal in the helper - #2157 — unparsable-payload fail-closed (separate PR) Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
No linked issue
hook::jq_fieldslanded in #1979 and got its first two adopters in #2007(
block-dangerous-git,block-no-verify). The other ten guardrails hooks were still parsingtheir PreToolUse/PostToolUse payload with a separate
printf '%s' "$INPUT" | jq -r … | tr -d '\r'pipeline per field, over the same already-buffered stdin envelope. This converts all ten.
Survey — what was still forking per field
Counting only
jqexecs against the buffered payload.jq -nenvelope builders,jq -R | jq -sfinding serializers, and jq reading a file from disk are out of scope and untouched.
mainblock-noncanonical-commitcommand,cwd,tool_nameblock-convention-violationtool_name,command,cwdhardcoded-path-checktool_name,file_path,content/new_string/new_sourcesecret-pattern-detectionskill-reference-verifytool_name,new_string,replace_all/contentstale-path-verifyblock-hook-bypasscommand,tool_nameflag-commit-pr-skill-bypasscommand,tool_namecli-flag-verifytool_name,new_string/contentworkflow-resilience-checkscript,scriptPathblock-dangerous-gitblock-no-verifyCollateral, not claimed as the headline: each old line is three process creations
(
$( )subshell +jq+tr), so a 3-field hook went 9 → 3 and a 2-field hook 6 → 3 — thetr -d '\r'per field disappears too, becausehook::jq_fieldsstrips CR shell-side.Deliberately NOT converted
hook::read_file_path—cli-flag-verify,skill-reference-verifyandstale-path-verifyeach still pay one jq exec there. Folding
file_pathinto the batched call would mean eitherduplicating or restructuring that helper's existence + project-membership validation, and it lives
in the synced shared lib (
lib/hook-utils.sh→ 13 plugin copies + the CI drift check), so the blastradius reaches every plugin for one exec. Left alone on purpose.
flag-commit-pr-skill-bypass'senabledPluginsreads (two jq calls at L145/L155) read asettings file, not the payload. Different input, not batchable here.
How the fields were kept byte-identical
Two spots would have changed behavior under a naive conversion, and both are handled:
.tool_name // "Bash"— the default moves to the shell side(
TOOL_NAME="${HOOK_JQ_FIELDS[n]:-Bash}"), matchingblock-dangerous-git.replace_allkeeps// false | tostringinside the filter.hook::jq_fieldswraps every filter in// "", and jq's//treats the booleanfalseasempty — so a bare
.tool_input.replace_allreturns""where the old call returned"false".Verified against all three input shapes (absent /
false/true):Failure semantics are unchanged in every hook.
hook::jq_fields … || exit 0lands on exactlythe skip the old empty-field guard produced — each hook's statement right after its first old jq call
was already
[[ -n "$X" ]] || exit 0or acase … *) exit 0.hook::require_jqstill runs first andstill makes a missing jq visible once per session.
One trade stated plainly. In
hardcoded-path-checkandsecret-pattern-detectionthe per-toolcontent field is now serialized in the first call, i.e. BEFORE the file-path exclusions and the
git check-ignoreskip that used to precede it. On a skipped write that is one extra copy out of jqof a payload already buffered in memory, traded for one fewer process on every path. Process
creation, not jq's parse, is the cost centre on the host this targets.
Measurement
Method. Two checkouts — arm A at
origin/main, arm B this branch — with the arms interleavedinside one loop, alternating which runs first each iteration, so both arms share one load sample.
Compared as paired deltas (
B_i − A_i), summarized by median and quartiles. Never "50× A, then50× B": one instrumented fork on this host has been recorded swinging 93 ms → 3234 ms, so a single
sequential before/after pair proves nothing.
Machine load — every number below was taken under load, and is labelled as such. This box runs
several agents concurrently. Snapshot during the runs:
cpu_pct_avg=20.3,procs_total=405,bash_procs=18,free_mem_gb=31.4. Windows 11, Git Bash (GNU bash 5.3.15 x86_64-pc-cygwin),jq-1.8.2. Load is why absolute per-arm times below run into seconds; it is also why themedians are inflated relative to a quiet box and the conservative statistics are the headline.
Headline, conservative — p75 (least-favourable quartile) of the paired deltas:
Run 1 was reproduced by run 2 to within 45 ms at p75 and 42 ms at the median — the point the task
brief makes about a "PASS=154 FAIL=0" claim from a single run that did not reproduce. Run 1's raw
samples were not retained to a file (its summary line is quoted above); runs 2 and the 2-field run
have every sample below, and either alone carries the claim.
The p75 and the independently-computed floor (fastest observed A minus fastest observed B, i.e. the
least-contended sample of each arm) agree to within 10 ms in both shapes. Two conservative estimators
converging is the strongest claim here; the medians are the same effect amplified by contention.
End-to-end, whole-hook —
block-noncanonical-commit.shinvoked as a process, N=60 interleaved:median paired delta -687 ms, range -13039 ms to +11774 ms. Reported deliberately even though it
is noisier and smaller than the isolated 3-field median: the parse block cannot recover more than
the whole hook does, and omitting the weaker own-number is what makes a stronger one look selected.
Against the prior model. A previous session's model predicted ~280 ms recovered and the handoff
recorded "the measured-versus-model gap says expect LESS." Stated plainly: the conservative 2-field
number (-194 ms) is under that model, and the conservative 3-field number (-404 ms) is
over it. The model was a single figure for a range of shapes.
Every sample is in the collapsed sections below.
Behavior verification
Payload-level differential vs
origin/main— 62/62 identicalIssue #1403 records that the previous extraction attempt (#1385) regressed on multi-line command
values — four suites failed, all on multi-line payloads. That is the exact risk class for this
change, so it is tested directly: the same payload fed to the
origin/maincopy and the convertedcopy of each hook, requiring identical exit code, identical stdout and identical stderr.
Cases: plain command, backslash-newline continuation (
git commit --no\<newline>verify), multi-line-mbody, escaped quotes, embedded tab, PowerShell here-string, stdout-redirect write,gh pr create, empty command; Write/Edit/NotebookEdit multi-line content, unmatched tool, empty content;replace_alltrue/false; Workflow inline-script /scriptPath-only / neither.Result:
DIFFERENTIAL PASS=62 FAIL=0. This is a deterministic comparison of outputs, not atiming measurement, so it does not carry the reproducibility caveat the numbers above do.
Contract suites — run STRICTLY one at a time
Their wall-clock assertions corrupt under contention, so the runner is serial by construction.
Re-run after the NUL fix (this is the authoritative set; the pre-fix tallies below it are kept
for the record).
lib/hook-utils.test.shis included because that is where the helper and its newregression case live.
secret-pattern-detectionandhardcoded-path-checkeach gained exactly +2 assertions — the twoadded by the NUL regression case in each file. That is visible directly rather than by subtraction:
under mutation (the
split | joinreverted, tests kept) the same trees reportPASS=42 FAIL=2andPASS=155 → 154 FAIL=1, failing on precisely those assertions and nothing else.skill-reference-verifyreads higher than the pre-fix table below becausemainwas merged inbetween; no case was added to it here.
On the
block-noncanonical-commitpromise. This description previously said that suite "wasstill running when this PR was opened" and that "its result will be posted as a comment." No such
comment was ever posted, so it is settled here instead: the suite was re-run after the NUL fix and
passes, 202/0. Worth stating because it nearly went into this description as a false negative —
that suite reports
passed: N failed: N, not thePASS=N FAIL=Nevery other guardrails suite uses,so the first run's output filter matched nothing and the run looked like an abort. It was not; the
filter was wrong. The tally above is from an unfiltered re-run.
Not re-run, and why. The remaining guardrails suites (
block-hook-bypass,block-convention-violation,flag-commit-pr-skill-bypass,cli-flag-verify,workflow-resilience-check, plus the two already-converted git guards) and the 15non-guardrails plugins were not re-run for the NUL fix. The strip is a no-op for any
payload without a NUL, and
grep -rln 'hook::jq_fields' plugins/*/hooks/*.shreturns guardrails files only — the other 15plugins carry the lib text and a version bump but have no call site. Their pre-fix tallies stand.
Pre-fix tallies (the original
hook::jq_fieldsconversion, before the NUL fix):One caveat from that run, stated rather than hidden:
hardcoded-path-checkandstale-path-verifyeach show two lines because a background runner believed killed had survived, so a second copy
of each ran concurrently. Both copies of both suites returned the same tally. Contention can only
produce spurious failures in a wall-clock assertion, never a spurious pass, so a green result
under contention is the stronger reading. (The first
hardcoded-path-checkline's tally column is agrepartifact — its log endsPASS=84 FAIL=0.)Every sample — isolated parse block, 3 fields to 1 (N=100)
Every sample — isolated parse block, 2 fields to 1 (N=100)
Payload-level differential vs origin/main — all 62 cases
Review follow-up — the NUL fail-open (P1)
Review found a fail-open this PR introduced, and it reproduces.
hook::jq_fieldsdelimits itsbatched fields with a NUL byte. JSON may legitimately encode a NUL inside a string, and a
Write/Edit/NotebookEditcontentfield is exactly where one arrives — jq emitted the rawbyte, the read split that value in two, the cardinality check saw one value too many, the helper
returned non-zero, and the hook's
|| exit 0skipped detection entirely. The per-field commandsubstitution this PR replaced discarded the NUL and scanned the rest, so this was a regression, not
a pre-existing gap.
Reproduction — one payload,
tool_input.content=harmless first line+ NUL +aws_key = AKIA…, fed tosecret-pattern-detection.shat both refs:origin/mainwarning: command substitution: ignored null byte in input— the old path saw the NUL, dropped it, and scanned the restThe framing scheme, and why this one. Each value is now NUL-stripped inside the jq filter
(
split("<NUL>") | join(""), the 1-arity plain-string split — notgsub, which would put a NULinside an Oniguruma pattern), so the delimiter provably cannot occur in a value. The three options
weighed:
read -N(Bash 4.1+); this lib supports3.2+ and says so.
@base64/@jsonencoding costs a decode per field shell-side — a spawn each, which undoesthe whole PR — and still cannot deliver the byte, see below.
cannot hold a NUL byte, so no scheme delivers one into
HOOK_JQ_FIELDS. It is also byte-for-bytewhat the pre-conversion
$( )did. Content after the NUL is returned and scanned exactly asbefore.
On "rather than failing open". The mismatch policy is unchanged and deliberately so:
return 1|| exit 0is the documented jq-absent fail-open (hook::require_jqmakes it visibleonce per session) and matches the pre-conversion empty-field guard. What changed is that the
cause of the spurious mismatch is gone — a mid-stream jq filter error is now the only way to
trip it, exactly as on
main.Regression cases (all three go red on reverting the strip, green with it):
lib/hook-utils.test.sh— a NUL-bearing value keeps its slot and its post-NUL content.Mutated:
PASS=154 FAIL=1. Fixed:PASS=155 FAIL=0.plugins/guardrails/hooks/secret-pattern-detection.test.sh— a secret after a NUL exits 2.plugins/guardrails/hooks/hardcoded-path-check.test.sh— a machine path after a NUL exits 2.Payloads are built with jq's
[0] | implode, so no literal escape sequence for the byte lives inany test file's source.
Blast radius. The fix is in the synced shared lib, so
scripts/sync-hook-utils.shran and all16 carrying plugins take a patch bump with an identical
### Fixedentry — the mechanism #1979 usedfor the same file.
guardrailsadditionally documents the guard-level regression and the commentsoftening below.
Review nits —
replace_allcomment (both files)skill-reference-verify.shandstale-path-verify.shnow say the// false | tostringis kept forparity with the pre-conversion output, not because a branch depends on it: every consumer tests
== "true", which""and"false"fail alike. Comment only; behavior unchanged.Checks run locally
shellcheck -xclean on every changed.shfile (the ten hooks, the shared lib, thethree test files).
shfmt -dclean on the same set.npx --no-install markdownlint-cli2 plugins/guardrails/CHANGELOG.md— 0 issues.bash scripts/check-changelog-parity.sh --check-bump origin/main— passes(
guardrails0.22.0→0.22.2plus a patch bump on all 15 other carrying plugins,each with its own new
## [<version>]entry).bash scripts/sync-hook-utils.sh --check— all 16 plugin copies matchlib/hook-utils.sh;--check-bump origin/main— every carrying plugin bumped.printf '%s' "$INPUT" | jqremains anywhere underplugins/guardrails/hooks/.Related
block-dangerous-git,block-no-verify) andset the pattern this PR follows; the
// "Bash"shell-side default is copied from it verbatim.hook::jq_fieldstolib/hook-utils.sh. The review follow-up above doesedit that shared lib (the NUL strip), so
scripts/sync-hook-utils.shran, all 16 plugincopies were re-synced, and every carrying plugin took a patch bump — the same mechanism
perf(hook-utils): cut three subprocess spawns per hook invocation #1979 itself used. An earlier revision of this description claimed no shared-lib edit; that
is no longer true and is corrected here.
one part of it: the
hook::jq_fieldsconversion across the remaining guards, verified againstthe multi-line regression class that sank fix(guardrails): restore PreToolUse enforcement — guards were killed at their timeout #1385 (precondition 1, differential above). It does
not discharge:
strip_quoted_spansinflag-commit-pr-skill-bypass, the deferredgit rev-parse --is-inside-work-treeprobe inhardcoded-path-check, committed multi-lineregression cases in the suites (precondition 3 — the differential here is a working harness,
not committed test coverage), or the unreviewed
hook_latency_report.py. Left open.plugin-gatecarries no signal for a change whosewhole point is MSYS fork-emulation cost. That is why this PR carries local Windows measurements
and a payload-level differential rather than leaning on CI.
Reproducing the numbers
The harnesses are scratch scripts, not committed. To re-derive: clone
origin/mainand this branchside by side, then for each iteration time one invocation of each arm back to back (alternating
order), and take the median/p75 of
B_i − A_i. State the machine load with any number produced —on a quiet box the absolute times will be far lower than those above, and the recovery should land
nearer the min-of-arms floor (-394 ms for 3 fields, -192 ms for 2) than the loaded medians.