fix(guardrails): read the payload cwd, and stop env -S hiding commands from every git guard - #2147
Conversation
…uns in `block-dangerous-git` accepts a `--force-with-lease=<ref>:<expect>` whose `<expect>` is a full-width object id, because git cannot resolve one to something newer at push time. The width is the local repository's, and hex of the OTHER width is an ordinary movable ref name there — so the probe has to measure the repository the push actually runs in. It measured the HOOK PROCESS's directory. Claude Code launches hooks from the session root and runs the Bash tool wherever the session stands, so a payload cwd in a SHA-256 repository with the hook process in a SHA-1 one cleared a 40-hex lease that git resolves as a ref name where the push lands. No wrapper and no `cd` were needed; a plain `git push` sufficed. The payload's `.cwd` is now read and replayed as a LEADING `-C` ahead of any wrapper chdir, composing under git's own rules exactly as the wrapper replay already did. The base chain is `HOOK_EFFECTIVE_BASE` -> `HOOK_CWD` -> `CLAUDE_PROJECT_DIR` -> `.`, adopted from `block-noncanonical-commit` rather than invented a second time, and a `!` shell alias relocates the base for its reparse the same way — git launches that body in the relocated repository. `env -S` / `--split-string` was a second route to the same harm, and to worse. `-S` exists so a shebang line can pass OPTIONS to env, so the split words are env's own arguments; `hook::git_resolve_index` spliced them in 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. Parsing now resumes inside env's option loop, which also keeps env's single chdir slot last-wins across the splice. Behaviour change, intended: a RELATIVE `-C` / `--git-dir` / `--work-tree` / `--namespace` now resolves against the directory the tool call runs in rather than the hook process's. An absolute one is unaffected. The `repo_oid_width` known-gap docblock is restated at its real width; the old wording listed three conjuncts for a gap that then required none of them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…IT_DIR residual `run_pwsh` sent a payload with no `cwd`, and one of its cases is width-judged (`--force-with-lease=main:<40-hex>` expecting allowed). With the base chain live that case measured `CLAUDE_PROJECT_DIR` — whatever repository the ambient session happens to sit in — so it passed here and would flip elsewhere. It now states the payload cwd like `run_in`. A companion case keeps a PowerShell payload with NO cwd, because the tool name moved to the third jq field when `.cwd` was added and a payload missing `cwd` must still read it from the right slot; verified directly that `hook::jq_fields` returns an empty slot rather than a short array, so `TOOL_NAME` is unaffected. Also documents a residual that is NOT closed here and reproduces identically on `origin/main`: git EXPORTS an explicit `--git-dir`/`--work-tree` into a `!` shell-alias body (verified on git 2.54.0), so the body works in a repository the composed directory does not name and its lease is judged against the base. Closing it means replaying inherited globals rather than a directory, which is a larger mechanism than the base chain #2124's design scopes this change to. Suite: 364 pass / 0 fail, re-run with a deliberately hostile CLAUDE_PROJECT_DIR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…l symptom Three corrections from an independent adversarial pass, none of which change behaviour. The `!` shell-alias comment asserted the body "starts in THIS segment's relocated directory". Measured: git chdirs a `!` body to the work tree's TOP LEVEL when it can compute a prefix, so `alias.wd='!pwd'` from `<repo>/sub` prints `<repo>`. The conclusion survives — an object format is a property of the repository, and the composed directory and its top level are the same repository — but the premise was wrong and is now stated correctly rather than left load-bearing. `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 correct: only `-C` relocates a `!` body, and `git --git-dir=<other> -c alias.wd='!pwd' wd` does not move at all. A comment now says so, since the next reader would otherwise file it as the bug this one nearly did. The known gap's PRIMARY symptom is a false BLOCK, not a bypass. With a shell `cd` the probe measures a base that is often no repository, answers width 0, and fails closed — so `cd <repo> && git push --force-with-lease=main:<literal sha> origin main`, the exact form the block message prescribes, is denied from a non-repository session root. Fail-closed is right; the note exists so whoever narrows the gap measures the false block rather than only the bypass. The CHANGELOG also states the resolver hole's real blast radius: `hook-utils.sh` lives in 17 places and all were stale, and behind `env -S` the hole let `git commit --no-verify` and `git reset --hard` through as well as the lease. Co-Authored-By: Claude Opus 5 (1M context) <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. |
|
Claude finished @kyle-sexton's task in 3m 57s —— View job Claude finished the review
|
|
Claude finished @kyle-sexton's task in 3m 13s —— View job Security review completeScope:
FindingsNo security issues found. This PR is itself a security-hardening fix, and I could not identify a new vulnerability it introduces. Notes from the review (not findings, just what I checked and ruled out):
I was unable to execute the test suites in this sandboxed environment (script execution requires interactive approval not available here), so the 363/164 pass counts in the PR description are unverified by me, but the code-level reasoning for the fix checks out against manual trace-through and the diff's own regression tests. |
|
Claude has reviewed this PR 4 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: d39f49ebca
ℹ️ 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".
Caught in review of this branch, and a defect this branch introduced. Making the lease verdict a function of the effective base means the base has to be part of any key that memoizes that verdict, and `HOOK_ALIAS_MEMO` keyed only on kind, seen-set and command text. So one Bash command invoking the SAME `!` alias text twice — first under a SHA-1 `git -C`, where a 40-hex expectation is a genuine object id and is correctly allowed, then under a SHA-256 `git -C`, where the identical word is a movable ref name — had its second analysis skipped as already seen, and the guard exited 0. That is this change's own bypass, reintroduced through the cache. The base is now in the key, built inside `alias_reexpand_admit` rather than at the call sites so EVERY recursion is covered — the git-alias splice is equally base-dependent, not just the `!` branch. `block-noncanonical-commit` keys its memo on the base for the same reason; this mirrors it rather than inventing a second shape. The MEMO docblock claimed the object format was invocation-constant, which stopped being true the moment the width became base-dependent; it now states the tuple correctly. Verified against this branch's own pre-fix head (d39f49e), NOT origin/main — main has no base-dependent verdict to mis-cache, so it is the wrong control. The `bash -x` width trace shows the mechanism directly, not just the verdict: case BUG (d39f49e) FIX (head) sha1 alias only 0 widths 40 0 widths 40 sha256 alias only 2 widths 64 2 widths 64 sha1 THEN sha256 0 widths 40 2 widths 40,64 The buggy tree runs the width probe ONCE and allows; the fixed tree runs it twice and blocks. The two single-segment rows answer identically on both trees, so the two-segment verdict can only come from the cache. Swept the same class across every other cache. `repo_oid_width` is already base-keyed — its key is the replayed option list, which now leads with the base — confirmed empirically (two segments under different `-C` values trace `40,64`, not a reused `40`), not by inspection. `HOOK_ALIAS_SEEN` is reset in lockstep with the base at each `!` reparse, so it cannot carry a verdict across bases. Those are the only caches the guard keeps. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 4m 34s —— View job Code review complete
Scope noteThis is the correctness/architecture/maintainability lane; security aspects are left to the dedicated security-review lane (already run on this PR). I did not re-run the test suites myself — no shell-execution tool is available to this lane — so the "363/164 pass" counts in the PR body are author-claimed and unverified by me. All statements below are from reading the diff and tracing the control flow by hand. FindingsOne inline comment posted:
What I verified independently
No correctness bugs, error-handling gaps, or test-coverage holes found beyond the one low-severity note above. |
|
Claude finished @kyle-sexton's task in 2m 36s —— View job Security review completeScope: files changed in this PR (
FindingsNo security issues found. The reported P1 has been fixed, and I found nothing new. Codex's I independently swept for other base-dependent caches rather than trusting the commit message's claim of completeness:
The Other changes since the last review ( Fan-out integrity: all 16 synced I was unable to execute the test suites in this sandbox (script execution requires interactive approval unavailable here), so I verified the fix by manual trace-through of the control flow and the new test's assertions rather than by running |
…x costs Adds the cases the memo fix perturbs and the control that isolates the key as the cause. The sharpest control: two segments differing ONLY by a space inside the alias body — same repositories, same bases, same danger, different key. It was blocked before the base joined the key too, so it discriminates nothing on its own; it is here to pin that a base-blind key was the entire difference, and to fail loudly if the key ever stops covering the body. The `&&` twin of the paired case is pinned alongside `;`, because the collision is in the key, not the operator. Cost, measured rather than asserted. Keying on the base dedups less, so analyses scale with the number of DISTINCT bases instead of collapsing to one. Counted from the `bash -x` trace, bases -> analyses (width probes): old: 1 -> 1 (2) 4 -> 1 (2) 16 -> 1 (2) 32 -> 1 (2) new: 1 -> 1 (2) 4 -> 4 (8) 16 -> 16 (32) 32 -> 32 (64) Linear, and the old collapse to 1 WAS the defect, not an optimization worth keeping. HOOK_ALIAS_WORK_MAX (128) still bounds it and exhausting it fails closed, so the weakened dedup costs work, never safety. Two fixtures pin it: 16 distinct bases naming the same SHA-1 repository stays allowed and bounded, and the same walk with a SHA-256 base appended still blocks — the dangerous tail must not ride in on the safe keys ahead of it. Wall-clock was deliberately NOT used as the instrument: measured on a loaded box it read 2x at one base, where there is no dedup difference at all, which is noise. The analysis count is load-independent and is what the cap bounds. Both items the reviewer flagged as reasoned-not-run are now run. The memo does not survive a hook invocation — a shell variable in a process that exits, and the sha1-then-sha256 pair split across two invocations gives 0 then 2. The git-alias branch shares the memo under a different tag and is covered by construction, since the base is keyed inside alias_reexpand_admit rather than at the call sites; no live case is constructible there, because a git alias splices words into the same argv and cannot relocate the base. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 1m 40s —— View job Security review completeScope: all files changed on this branch vs
FindingsNo security issues found. What's new since the last security pass ( Independent re-verification of the two production changes (not just re-reading prior review comments):
Fan-out integrity: all 16 synced No injection surfaces, authorization gaps, secret handling, or dangerous GitHub Actions patterns were introduced by this PR. All git invocations throughout the diff use array expansion, never |
|
Claude finished @kyle-sexton's task in 2m 39s —— View job Code review completeScope: correctness/architecture/maintainability lane (security left to the dedicated lane, already run on this PR). This run focused on the delta since the last review round — commits What's new since the last review round
Re-verified from earlier in the PR
Outstanding from earlier roundsThe one maintainability nit already posted inline on this PR (the Not verifiedI do not have permission to execute shell scripts in this sandbox ( ConclusionNo new correctness bugs, error-handling gaps, or test-coverage holes found. The base-keying fix for the alias memo is sound, its test coverage is well-targeted (including the cost/bound it introduces), and CHANGELOG/version parity holds across all 17 files. |
Adversarial verification of
|
Review noted the offset is a magic number whose correctness depends on an invariant that lives in another function: collect_git_locating_opts always leads with the base's own `-C <dir>` pair, and effective_dir supplies that same value from its HOOK_EFFECTIVE_BASE default, so passing it again would apply the base twice. effective_dir's docblock explains the general contract but never this slice, so nothing at the call site tied the two together. If collect_git_locating_opts ever grows a second leading synthetic pair, or stops leading with the base, this breaks silently and the wrapper dirs are read one pair too late. Comment only; no behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 55s —— View job Security review completeScope: delta since the last automated security pass on this PR (
FindingsNo security issues found. The only change since the last security review is commit
This adds an 8-line comment above the existing
Since this line was already reviewed and traced as sound in the prior security pass (the Full-PR posture (recap, unchanged from prior passes): all git invocations throughout the diff use array expansion, never |
|
Claude finished @kyle-sexton's task in 1m 8s —— View job Code review completeScope: correctness/architecture/maintainability lane (security left to the dedicated lane, already run multiple times on this PR). This round covers the delta since the last review pass — commit
What's new in this commit
This closes the maintainability nit both Codex flagged and I raised in an earlier round on this same line: the Placement and style are consistent with the rest of the file's unusually dense inline-documentation convention (matches the tone of the surrounding ConclusionNo functional change to re-verify, and no new correctness bugs, error-handling gaps, or test-coverage holes from this commit. This was documentation-only, appropriately not paired with a CHANGELOG or version bump since there's no user-visible behavior change. All prior findings from this review lane are now resolved — I have nothing outstanding to flag. |
main moved again while this PR sat DIRTY: #2147 landed the payload-cwd and env -S fixes, taking guardrails to 0.24.0 and touching block-dangerous-git.sh, which this branch also edits. Resolutions: - plugins/guardrails/hooks/block-dangerous-git.sh — main's three-field hook::jq_fields call ('.tool_input.command' '.cwd' '.tool_name') is kept verbatim, with this branch's NUL fail-closed block appended after it. The branch's clarification that the allow-on-unparsable path is NOT what the NUL check covers is spliced back into main's comment, since a NUL check now sits directly beneath it. - lib/hook-utils.sh auto-merged this time. Verified by hand that the flag is still computed BEFORE the strip; #2147 touched hook::git_resolve_index, a different function. - 16 CHANGELOGs and 16 manifests re-resolved one patch above main's new numbers. Every manifest had again auto-merged to main's number with no bump. Twice on this branch. A NUL byte this branch had accidentally written into plugins/guardrails/CHANGELOG.md is removed here, and it is the reason this merge needed a second pass. git classifies a file containing 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 to show for it. Caught by a byte count, not by review. The escape is now spelt as the literal text it was meant to be. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d pass) main moved again while this PR sat DIRTY: #2147 landed a shared hook-utils.sh change and took markdown-format 0.11.3, the slot this branch's entry occupied after the first merge. Same conflict shape as before, same resolution — this branch's entry moves up to 0.11.4, main's 0.11.3 is kept below it, order strictly descending, and plugin.json is bumped to match. plugin.json auto-merged to main's number again, silently leaving no bump. That is twice on this branch alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nale #2147 landed source-control 0.51.3 for the shared hook-utils `env -S` fix, so this branch's entry moves to 0.51.4 and main's 0.51.3 is kept intact above it. Also corrects the repo23 comment, per review. It claimed `core.quotepath true` made "the C-quoted-path form the one actually exercised". That is false: quotepath only quotes bytes >0x80, control characters, backslash and double-quote, and `git help config` states that "a simple space character is not considered 'unusual'". A path of ASCII letters and spaces is never quoted whatever the setting, so the config line is a no-op matching the default. The case itself is unchanged and still valid — what it pins is the three-field NUL-terminated read on the copy arm with spaces in both paths, the shape most likely to desynchronize the stream. The comment now says that, and says plainly that C-quoted-path handling is NOT covered by any case in this file, since exercising it needs a path carrying one of those bytes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main landed #2147, which bumped guardrails to 0.24.0 -- the version this branch had already claimed. Resolved by renumbering this branch's entry to 0.25.0 and keeping both CHANGELOG sections; no code conflict, #2147 touched block-dangerous-git and hook-utils, not block-convention-violation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main landed #2147, which bumped guardrails to 0.24.0 -- the version this branch had already claimed. Resolved by renumbering this branch's entry and keeping both CHANGELOG sections; no code conflict, #2147 touched block-dangerous-git and hook-utils, not skill-reference-verify. Numbered 0.26.0 rather than 0.25.0 because the sibling PR for #2113 claims 0.25.0. Deliberate gap: it costs nothing if that PR merges first, and avoids a guaranteed plugin.json conflict between two PRs that are otherwise independent. 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>
…peration guards (#2178) ## What `hook::require_jq` was `command -v jq && return 0`, else a once-per-session notice and **`exit 0`** — the whole hook skipped, the tool call proceeds. Measured against `origin/main`, with the jq-present column as the discrimination control: ``` jq PRESENT jq HIDDEN dangerous push DENY ALLOW <-- the guard was skipped entirely safe command ALLOW ALLOW ``` The same two scripts fail **closed** on the other input they cannot parse: above `MAX_COMMAND_LEN` (16384) a command is treated as obfuscation and blocked unread. Two opposite postures toward "I cannot read this input" in one file — so an author who could not fit a dangerous command under the ceiling could simply be on a machine without `jq`. ## The disposition: fail CLOSED, scoped to the irreversible-operation guards After (same harness, same machine): ``` block-dangerous-git.sh jq PRESENT jq HIDDEN dangerous push DENY DENY safe command ALLOW DENY block-no-verify.sh jq PRESENT jq HIDDEN commit --no-verify DENY DENY safe command ALLOW DENY posture control (unchanged) block-convention-violation.sh jq PRESENT=ALLOW jq HIDDEN=ALLOW block-hook-bypass.sh jq PRESENT=ALLOW jq HIDDEN=ALLOW block-noncanonical-commit.sh jq PRESENT=ALLOW jq HIDDEN=ALLOW ``` **The (safe, jq HIDDEN) = DENY cell is a real cost, not an oversight.** These guards run on every Bash/PowerShell tool call; without `jq` they cannot read the command at all, so they cannot tell a dangerous one from a safe one and deny both. On a `jq`-less machine every matched tool call is blocked until `jq` is installed or the guard's kill switch is set. That is the hard dependency option 2 named. Option 3 (a `jq`-free substring pre-check) was rejected and is not implemented. The kill switch is still a real exit: `hook::check_enabled` runs *before* the gate, so `block_dangerous_git_enabled=false` bypasses the guard on a `jq`-less machine. Asserted. ## Which hooks are in the class — mechanical, not a taste judgement The criterion is **internal consistency**: a hook is fail-closed iff it *already* fails closed on another unparsable-input condition. Today that is a `MAX_COMMAND_LEN` ceiling, and **repo-wide that is exactly two files** — `block-dangerous-git.sh` and `block-no-verify.sh`, the two the issue names. That is not a coincidence: intra-script contradiction is what #2146 reports, and a script with no length-ceiling posture has no contradiction to resolve. **Considered and deliberately excluded**, so this is not a silent scoping choice: | Hook | Why not | | --- | --- | | `block-hook-bypass` | exits 2 and carries the same "the only supported deliberate bypass is the kill switch" sentence — but it guards a **file write** (`cat > path`), trivially reversible, and has no length ceiling | | `block-noncanonical-commit` | guards a message *shape*; a mangled message is recoverable by `--amend` | | `secret-pattern-detection`, `hardcoded-path-check`, `block-convention-violation` | all guard reversible file writes | | other-plugin blocking `require_jq` callers (`context-guard/zone-gate`, `source-control/pr-*-gate`, `autonomy/lane-stop-gate`) | checked repo-wide; **none** carries a length ceiling | Severity is a slope; "already fails closed elsewhere in the same script" is a line. `require-jq-posture.test.sh` pins the membership both ways, so a hook that grows a ceiling and keeps the fail-open gate fails, and so does a hook that adopts the blocking gate without one. ## Helper design: a sibling function, not a parameter `hook::require_jq_blocking` alongside the unchanged `hook::require_jq`. **Why not a flag on the existing function:** a parameter's *omitted* value has to default to something, and the safe-looking default (fail open, today's behaviour) means a guard that should fail closed but whose flag someone forgot fails open **silently** — which is the exact defect this PR fixes, reintroduced at the API. Two names make the posture greppable, make the fail-closed path impossible to reach by accident, and make omission a visible choice. **Why not branch at the call sites:** the issue's own acceptance says the reasoning belongs at the helper, and a call-site branch leaves the decision point still unexplained. (It also would not have avoided the 16 plugin bumps: `sync-hook-utils.sh --check-bump` is content-based, so even a comment-only lib edit requires them.) **The reasoning is at the helper.** One `TWO POSTURES, AND WHY THERE ARE TWO` block sits above both functions — why fail-open is the default, why a minority must not be, the membership criterion, the exclusions, the disclosed cost, and why two functions rather than a flag. The call-site comments now say "this asserts the behaviour; that explains it", and the posture test asserts the block is actually there. ## The control that FAILS against current `main` `require-jq-posture.test.sh` was run **unchanged against `origin/main`'s guardrails plugin** (`git archive origin/main plugins/guardrails`, hashes verified equal to `origin/main`'s blobs): ``` FAIL: block-dangerous-git.sh defines MAX_COMMAND_LEN but does not call hook::require_jq_blocking FAIL: block-no-verify.sh defines MAX_COMMAND_LEN but does not call hook::require_jq_blocking FAIL: hook-utils.sh's posture block mentions 'TWO POSTURES' FAIL: jq HIDDEN, dangerous push: DENY ... : expected 'DENY', got 'ALLOW' FAIL: jq HIDDEN, commit --no-verify: DENY ... : expected 'DENY', got 'ALLOW' ... PASS=21 FAIL=15 SUITE EXIT: 1 ``` with the four cells against `main` reproducing the issue's table exactly (`DENY/ALLOW` over `ALLOW/ALLOW`). Against this branch: **PASS=36 FAIL=0**. ## How `jq` was hidden — and how that measurement was kept honest A `BASH_ENV` file defines a `command` shell function that reports `jq` absent and forwards every other lookup to the real builtin, plus a `jq` function that fails like a missing binary. **`PATH` is untouched.** Stripping `PATH` directories also removes `git`, which these guards invoke, and a guard that cannot find `git` produces the same ALLOW for an entirely unrelated reason. The suite prints a precondition line measured **inside** the hidden environment and refuses to read a verdict until it holds: ``` PRECONDITION (measured inside the jq-hidden environment): jq=hidden git=visible bash=visible path-to-jq=intact ``` `path-to-jq=intact` is `builtin command -v jq` still resolving — proof the **lookup** was hidden and the tool was not removed. Every `jq` probe in `hook-utils.sh` is a `command -v jq` (verified: 7 sites, all of that form), so the override reaches all of them. A second check sources the real `hook-utils.sh` inside the hidden environment and asserts the gate's own predicate sees no `jq`, plus the inverse without the override. **The harness caught itself once.** The first `origin/main` run archived only `plugins/guardrails/hooks`, so `block-dangerous-git.sh` could not source its bundled PowerShell classifier from `<plugin-root>/lib` and exited early — producing `ALLOW` in **all four** cells, including `jq PRESENT / dangerous push`. The jq-present discrimination control is what flagged it as a broken harness rather than a measured result. Fixed by archiving the whole plugin. This is the failure mode the issue says invalidated three prior attempts. ## Proof the fixture reached the path under test Under `jq` hidden, the denial is asserted to be the **new** path and not some unrelated failure: - names `jq` as the missing prerequisite; - carries the documented install route `https://jqlang.org/download/`; - names the guard's own kill switch (`block_dangerous_git_enabled` / `block_no_verify_enabled`); - is **not** the fail-open skip notice (`hook skipped for this session` asserted absent). And the advisory control asserts the inverse — that the fail-closed denial text is absent from every advisory hook's stderr. ## Blast radius `sync-hook-utils.sh --check-bump` requires every carrying plugin to bump when the shared lib changes, so all 16 are bumped with a CHANGELOG entry (the precedent set by b20e70a / #2147). The 15 non-guardrails entries state honestly that the lib gained a fail-closed sibling with **no behaviour change in that plugin** — nothing outside `guardrails` calls it. `guardrails` takes a **minor** bump (`0.24.1` → **`0.25.0`**), not a patch: it now denies calls it previously allowed. `origin/main` moved under this branch mid-flight — #2135 landed the *same* 16-plugin lib bump for its NUL-byte fix, so every version collided. Resolved by taking main's side of every CHANGELOG and manifest wholesale and re-applying this change's entry and bump on top, so main's entries survive intact and every plugin here is strictly above what main now carries. `block-no-verify.sh` conflicted textually on the very comment both changes rewrote; both survive (see the merge commit message). `require-jq-notice-isolation.test.sh` needed one adjustment: its discovery matched `hook::require_jq` as a substring and so would have swept in `hook::require_jq_blocking`, whose callers have no notice key to collide. The match is now anchored, and the test's own subject is documented as not applying to the blocking gate. ## Verification run on this branch | Check | Result | | --- | --- | | `require-jq-posture.test.sh` (new) | PASS=36 FAIL=0 | | same suite vs `origin/main` | PASS=21 **FAIL=15**, exit 1 | | `require-jq-notice-isolation.test.sh` | PASS=2 FAIL=0 | | `block-no-verify.test.sh` | see CI | | `block-dangerous-git.test.sh` | see CI | | `scripts/sync-hook-utils.sh --check` / `--check-bump origin/main` | pass | | `scripts/check-changelog-parity.sh --check` / `--check-bump origin/main` | pass | | `scripts/check-silent-skips.sh` | pass | | `scripts/check-shell-portability.sh --paths <changed>` | pass | | `shellcheck -x -S warning <changed>` | clean | ## Merge-order note This PR and #2167 **both bump `plugins/source-control`** (this one because it carries the shared `hook-utils.sh`). Main is at `0.51.5`; #2167 now claims `0.51.6` and this PR claims **`0.51.7`**, so it stays strictly greater either way. **Merge #2167 first** — it is one plugin and cheaper to redo. If this one lands first instead, #2167 must re-bump to `0.51.8`. Closes #2146 ## Related - #2124 / #2147 — a separate live bypass in the same guard, and the precedent for a 16-plugin lib bump - #2145 — the contract-line inaccuracy in the same file - #1938 — the stranded post-merge review-findings sweep - #2167 — the other PR in this pair; collides with this one on the `source-control` version bump --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>

What
Two live holes on
origin/main. One is specific toblock-dangerous-git's lease-width probe; theother is in the shared argv resolver and reached every guard in every plugin.
hook-utils.shexists in 17 places —lib/hook-utils.shplus a synced copy in each of 16plugins — and all 17 were stale. An independent adversary confirmed the resolver hole is not
lease-specific: behind
env -S,block-no-verifyallowedgit commit --no-verifyandblock-dangerous-gitallowedgit 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 theremote branch with unrelated orphan history, rc=0, with
rev-parsecaptured before and after.The guard allows
--force-with-lease=<ref>:<expect>only when<expect>is a full-width objectid 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-leaseexists to close.Route 1 — the payload's
cwdwas never read. The probe rangit rev-parse --show-object-formatfrom the hook process's directory. Claude Code launches hooks from thesession root and runs the Bash tool wherever the session stands, so the two differ routinely. No
wrapper and no
cdwere required: a plaingit pushwas enough.Route 2 —
env -S/--split-stringspliced options past the parser.-Sexists so a shebangline can pass OPTIONS to env (
#!/usr/bin/env -S -i prog), so its split words are env's ownarguments.
hook::git_resolve_indexspliced them back into its scan but resumed at the commanddispatcher, 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 alease-width hole; a bare
env -S '-v git push --force'also went unexamined.The fix
.cwdis read and replayed as a leading-C, ahead ofHOOK_GIT_RESOLVED_WRAPPER_DIRS, which already precede git's own options. That reproducesexecution order end to end and composes under git's own rules — a later
-Ccomposes onto anearlier one, an absolute one wins — so it is the same mechanism the wrapper replay already ships,
with a first term added. Not a
cd: acdwould move the hook process and leak across therecursive alias walk.
HOOK_EFFECTIVE_BASE→HOOK_CWD→CLAUDE_PROJECT_DIR→., adoptedverbatim from
block-noncanonical-commitrather than invented a second time.HOOK_EFFECTIVE_BASEis not decoration: a!shell alias runs its body as a fresh command in therelocated 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_indexresumes inside env's own option loop after an-Ssplice. That alsokeeps env's single chdir slot last-wins across the splice (
env -C a -S '-C b git …'lands inb), matching GNU env.repo_oid_widthknown-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/--namespacenow rebases onto the payload cwdinstead 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
.cwdin the payload at all,CLAUDE_PROJECT_DIRis preferred over the hook process's directory. A real PreToolUse payloadalways carries
cwd, and this matchesblock-noncanonical-commit; case 5b pins it either way.Verification
Every row was run against both trees from one script — PRE is
origin/mainextracted 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:
bash -x(_repo_oid_width=NN).The guard fails closed on width
0, so a BLOCK from0is fail-closed noise, not the fix working.Every POST=BLOCKED row below resolved a real width.
rev-parse --show-object-format, the exact wrapper form run for real from the payload cwd. A form that neverreaches git is not a bypass.
env -S '-C <sha256> git …'env --split-string='-C <sha256> git …'env -S '-v git push --force'— a plain force push hidden behind a leading optionenv -S 'git push --force'(no leading option) was and stays blockedenv -C <sha1> -S '-C <sha256> …'— one slot, last winsenv -C <sha256> -S '-C <sha1> …'— last wins the other way (semantics pin, paired with 2e)git -C <sha256> -c alias.y='!git <lease>' y— the!body runs in the relocated repo!pathgit -Cwith both directories agreeing — unchangedgit -Cresolves against the payload cwd (PRE probed width0— it was resolving nothing)--git-dirrebases the same way — the disclosed change.cwd, noCLAUDE_PROJECT_DIR→.(pre-fix behaviour preserved).cwd→CLAUDE_PROJECT_DIR(chain rung 2; EXEC differs because the divergence is synthetic)env FOO=1 -C <dir> git …— coreutils stops atNAME=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_innowstates the payload
cwdalongside the process directory (without it the suite silently measuresCLAUDE_PROJECT_DIR, i.e. the host repository, in any session that exports it);run_splitandrun_nocwdcover the divergent and degraded payload shapes.lib/hook-utils.test.sh— 164 pass / 0 fail, with resolver-levelenv -Scases including theattached-operand spelling, the last-wins slot across a splice, and a self-referential
env -S '-S -S'termination check.Not in scope, deliberately
cdrelocation (cd X && git push …,(cd X && …),sh -c 'cd X && …'). Resolvingit 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 wererequired. A documented gap that reads narrower than it is, is how this one survived review.
git config alias.yolo 'push --force-with-lease=…'thenenv -C <dir> git yolo). This guard resolves inline-caliases only;persisted-alias resolution is a separate capability
block-noncanonical-commithas and this onedoes not. Flagged in fix(guardrails): block-dangerous-git still clears an unsafe --force-with-lease via payload cwd and env -S #2124 for triage, not asserted there as a bypass.
--git-dir/--work-treeinherited by a!shell-alias body. git EXPORTS theminto the body's environment (verified on git 2.54.0 — the body prints
sha256from a SHA-1directory and sees
GIT_DIRset), so the body works in a repository the composed directory doesnot name.
effective_dircomposes-Conly, so the lease is judged against the base.Reproduced against BOTH
origin/mainand this branch (PRE=0, POST=0, EXEC=sha256) — it ispre-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 fix(guardrails): block-dangerous-git still clears an unsafe --force-with-lease via payload cwd and env -S #2124's design
section scopes this change to. Now documented in the
effective_dirdocblock and the CHANGELOGrather than left implicit, on the same principle that motivated the docblock correction above.
git -Cmisprobe that does not reproduce. fix(guardrails): block-dangerous-git still clears an unsafe --force-with-lease via payload cwd and env -S #2124 records it as testedagainst
origin/mainand not reproducing — the relative form resolves against the hook process'scwd 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
!shell-alias body "starts in THISsegment's relocated directory". Measured: a
!body runs from the repository top level, notthe caller's directory (
alias.wd='!pwd'from<repo>/subprints<repo>). The conclusion isunchanged — 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.
effective_dircomposes only-Cwhile
collect_git_locating_optsalso replays--git-dir/--work-tree/--namespace. Thereviewer expected a bug and found it right: only
-Crelocates a!body (git -C <other> -c alias.wd='!pwd' wdmoves,git --git-dir=<other> …does not). A comment now says why, so thenext 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 failsclosed. So
— 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 asthe 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
Bash|PowerShell, so the entire lease-width andenv -Ssurface is unverified on that arm by theadversary. This branch adds PowerShell cases of its own (payload-cwd pinning plus a missing-
cwdtool-name case) but they do not cover the
env -Ssurface.hook::require_jqwas not read, and this guard now requests three payload fields instead oftwo. The behaviour when jq is absent — the guard skipping entirely — is a separate, already-filed
concern, not something this branch changes.
with a short ref name is real, and blocking them is correct.
+refspecforce detection held on every form tried;-Stermination held across six degenerateoperands under a 25 s timeout.
was still running when the adversary reported.
Blast radius
lib/hook-utils.shis a synced library:scripts/sync-hook-utils.shdistributes it to every plugincarrying
hooks/hook-utils.sh— 16 plugin copies plus thelib/source, 17 files, all stale onorigin/main— and each plugin must bump so consumers receive the change. All 16 carrying pluginsare bumped with a CHANGELOG entry;
guardrailstakes a minor bump (0.23.1 → 0.24.0) for thebehaviour change above, the other 15 take a patch.
scripts/sync-hook-utils.sh --check-bump origin/mainandscripts/check-changelog-parity.sh --check-bump origin/mainboth pass, as do--check-order,check-silent-skips.shandcheck-cross-plugin-source-drift.sh --check.Closes #2124
Related
PRRT_kwDOTCGFQM6TzGBZwas filedlib/hook-utils.shchange, whose 15-plugin fan-out this one mirrors