Skip to content

fix(guardrails): read the payload cwd, and stop env -S hiding commands from every git guard - #2147

Merged
kyle-sexton merged 6 commits into
mainfrom
fix/block-dangerous-git-payload-cwd
Aug 10, 2026
Merged

fix(guardrails): read the payload cwd, and stop env -S hiding commands from every git guard#2147
kyle-sexton merged 6 commits into
mainfrom
fix/block-dangerous-git-payload-cwd

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

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 placeslib/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_BASEHOOK_CWDCLAUDE_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 .cwdCLAUDE_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.sh164 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 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.
  • 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 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_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. fix(guardrails): block-dangerous-git still clears an unsafe --force-with-lease via payload cwd and env -S #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

kyle-sexton and others added 3 commits August 10, 2026 05:49
…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>
@cursor

cursor Bot commented Aug 10, 2026

Copy link
Copy Markdown

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

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 3m 57s —— View job


Claude finished the review

  • Gather context (PR description, changed files list)
  • Review lib/hook-utils.sh (synced to 16 plugin copies) — env -S resolver fix
  • Review plugins/guardrails/hooks/block-dangerous-git.sh — payload cwd fix
  • Review test coverage (lib/hook-utils.test.sh, block-dangerous-git.test.sh)
  • Spot-check CHANGELOG/version bump parity across plugins
  • Post findings as inline comments + summary

Summary

This is a careful, well-scoped fix. I traced both changes against the surrounding code rather than taking the PR body's claims at face value:

hook::git_resolve_index (lib/hook-utils.sh, all 17 synced copies identical by blob SHA 805f68da...) — changing continue 2 to continue after an -S/--split-string splice is correct: it resumes inside env's own option-parsing while loop rather than jumping to the outer command-dispatch loop, which is what let a leading option in the split string (e.g. -C, -v) be misread as the command name and abandon the whole segment. I traced env -S '-S -S' by hand through hook::env_s_split and confirmed it terminates in 3 splices (argv shrinks to empty), matching the new regression test at lib/hook-utils.test.sh#L2065-2069.

block-dangerous-git.sh payload-cwd fix — I traced the full chain: HOOK_EFFECTIVE_BASE is initialized once at top-level (before hook::bash_parse_segments runs) from HOOK_CWD → CLAUDE_PROJECT_DIR → .; collect_git_locating_opts prepends it as a leading -C; effective_dir (new) recomputes the composed directory for !-alias reparses using git_locating_opts[@]:2 (correctly skipping the base pair it already defaults to, avoiding double-application); and the reparse save/restores HOOK_EFFECTIVE_BASE around the recursive hook::bash_parse_segments call so the relocation doesn't leak to sibling segments. This all matches block-noncanonical-commit.sh's existing effective_dir/base-chain pattern byte-for-byte (verified by diffing the two), so it isn't a new mechanism — it's adopting a working one.

Test coverage is substantial and targets the actual mechanism, not just outcomes — run_split pins the payload-cwd-vs-hook-process divergence in both directions, !-alias relocation and its non-leak into a following segment, the relative-vs-absolute -C/--git-dir distinction, and the full base-chain fallback (run_nocwd). The env -S cases cover the attached-operand form, last-wins across a splice in both directions, and the "inert form" control (env FOO=1 -C ..., which correctly never reaches git). I did not execute the test suites myself — this review sandbox's bash <script> execution requires interactive approval that wasn't available here — so the "363 pass / 0 fail" and "164 pass / 0 fail" counts in the PR body are author-claimed and unverified by me; I only confirmed the test assertions are well-targeted by reading them.

CHANGELOG/version bumps: spot-checked guardrails (0.23.1→0.24.0, minor — correct, this has a documented behavior change) and actionlint (0.8.1→0.8.2, patch) plus several others; all 16 non-guardrails plugins take a patch bump and their CHANGELOG entries accurately summarize the shared hook-utils.sh fix. No unrelated files are touched — the 52 changed files are exactly lib/hook-utils.sh + 16 synced copies + their CHANGELOGs/plugin.json bumps + the guardrails-specific guard/test/CHANGELOG changes.

One low-severity, pre-existing edge case I looked at but am not filing as a new finding: effective_dir's textual -C composition would misparse a directory whose two-word --git-dir value is literally the string -C (it would misread that value as a fresh -C flag on the next loop iteration). I confirmed this is byte-identical to block-noncanonical-commit.sh's existing effective_dir (both at block-noncanonical-commit.sh#L417-L432) — it's inherited verbatim from the sibling guard exactly as the PR states ("adopted verbatim from block-noncanonical-commit"), not introduced by this change, and requires a directory literally named -C, so I don't think it's worth a finding here.

No inline findings — I didn't find a correctness bug, error-handling gap, or test-coverage hole in the changed logic itself.

@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 3m 13s —— View job


Security review complete

Scope: lib/hook-utils.sh (shared resolver, synced verbatim to 16 plugin copies), plugins/guardrails/hooks/block-dangerous-git.sh, and the two associated test files. Version/CHANGELOG-only files across the other 15 plugins were spot-checked and contain no functional changes (confirmed all 16 hooks/hook-utils.sh copies are byte-identical to lib/hook-utils.sh via cmp).

  • Gather context (PR diff, changed files)
  • Read lib/hook-utils.sh diff (shared resolver, synced to 16 plugins)
  • Read plugins/guardrails/hooks/block-dangerous-git.sh diff (lease-width probe)
  • Read test files for behavior confirmation
  • Check plugin.json/CHANGELOG diffs for anomalies
  • Compile findings and post review

Findings

No 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):

  • env -S resume fix (lib/hook-utils.sh#L1172-L1189): changing continue 2continue correctly resumes inside env's own option-parsing loop (the while ((i < n)) at line 1131) rather than the outer command-dispatch loop. Traced the control flow by hand against the described env -S '-C <dir> git push --force' bypass — the fix closes it as claimed, and the shared array/index vars (i, n, w) are function-scoped so the splice-and-resume is safe across the nested loop boundary.
  • Payload .cwd as probe base (block-dangerous-git.sh#L337-L344, #L403-L426): the value is passed to git as a discrete -C argv element (never through eval/sh -c/string interpolation into a shell command), so there's no injection surface from an attacker-shaped path string. It reflects the real directory Claude Code's harness reports the tool call runs in, so using it as the probe's origin makes the probe more accurate, not less trustworthy.
  • HOOK_EFFECTIVE_BASE save/restore across the ! shell-alias recursion (#L643-L695): checked for a state-leak path where a relocated base could persist into a sibling segment (which would under-probe a later command). Every entry into the relocated state is paired with an unconditional restore on the same code path (including when alias_reexpand_admit short-circuits), and local scoping gives each recursive check_segment call its own saved_base. The test suite includes a case for exactly this leak ("a segment after a relocating ! alias is measured from the payload cwd again").
  • effective_dir() (#L409-L426) does pure textual path composition (no realpath/subprocess), consistent with the PR's stated rationale for avoiding symlink-resolution bypass in either direction.
  • No eval, sh -c, or string-built commands were introduced; all git invocations use array expansion (git "$@" / "${git_locating_opts[@]}").
  • No workflow files, permissions blocks, or dependency pins changed in this diff — the fan-out to 15 other plugins is version/CHANGELOG-only (verified via diff for a representative sample, including claude-ops's larger diff, which is a → literal em-dash normalization plus the version bump, nothing functional).
  • The PR's own "Not in scope, deliberately" section (shell cd relocation, persisted-alias lease, !-alias GIT_DIR inheritance) matches what I independently traced through the code — these are honestly disclosed pre-existing/residual gaps, not issues newly introduced or newly hidden by this change.

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.

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown

Claude has reviewed this PR 4 times. The lane skips further automatic reviews after 5; deleting this comment resets the count.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread plugins/guardrails/hooks/block-dangerous-git.sh
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

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 4m 34s —— View job


Code review complete

  • Gather context (PR description, diff, changed files)
  • Review lib/hook-utils.sh resolver fix (correctness, maintainability)
  • Review plugins/guardrails/hooks/block-dangerous-git.sh payload-cwd fix
  • Review test coverage (lib/hook-utils.test.sh, block-dangerous-git.test.sh)
  • Check CHANGELOG/version bump parity across the 16 synced plugins
  • Note existing Codex finding (alias memo key) for context
  • Post findings as inline comments + summary

Scope note

This 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.

Findings

One inline comment posted:

  • block-dangerous-git.sh:693 — the ${git_locating_opts[@]:2} slice depends on collect_git_locating_opts always prepending exactly the base's -C <dir> pair before anything else. That's true today and I confirmed it's correct (avoids double-applying the base, since effective_dir defaults to the same HOOK_EFFECTIVE_BASE), but the magic offset carries no comment at the point of use, unlike the rest of this PR's unusually well-documented style. block-noncanonical-commit.sh's equivalent call site avoids the coupling entirely (its callers pass wrapper_cd + git's own global slice directly, never prepending the base into the sliced array). Low severity — not a functional bug, just a latent trap for a future edit to collect_git_locating_opts.

What I verified independently

  • hook::git_resolve_index's continue vs continue 2 fix (lib/hook-utils.sh:1172-1189): traced the nesting by hand — the -S/--split-string case sits inside env's own while ((i < n)) option loop (line 1131), itself nested inside the outer segment-scan while (line 1110). continue correctly resumes the inner loop; the old continue 2 jumped to the outer loop's command dispatcher, which is exactly the bug described. Hand-traced env -S '-S -S' through the splice logic and confirmed it terminates in 3 iterations (matches the new regression test at lib/hook-utils.test.sh:2065-2069), and confirmed env -C a -S '-C b …' is last-wins because env_ci stays in scope across the continue.
  • The HOOK_EFFECTIVE_BASE chain and !-alias relocation in block-dangerous-git.sh: collect_git_locating_opts (line 342) leads with -C "${HOOK_EFFECTIVE_BASE:-${HOOK_CWD:-${CLAUDE_PROJECT_DIR:-.}}}"; effective_dir (line 408) composes the same chain plus every -C seen so far; the reparse save/restore around the !-alias recursion (lines 663, 693, 696, 710-711) is symmetric on every path I could find — I did not find a return/exit between the save and restore that would leak a relocated base into a sibling segment.
  • The alias-memo Codex finding is already fixed in this branch. The Codex review comment (posted against commit d39f49e) flagged that HOOK_ALIAS_MEMO's key didn't include the effective base, so a memoized verdict from one repository could wrongly be reused in another. The latest commit, bc3cf8e ("fix(guardrails): key the alias re-expansion memo on the effective base"), addresses this directly: alias_reexpand_admit (line 547) now folds HOOK_EFFECTIVE_BASE into the memo key via printf -v q '%q' before ${#HOOK_ALIAS_SEEN[@]}. The new test at block-dangerous-git.test.sh (memo_alias cases, "the same alias text under a SHA-1 then a SHA-256 -C: the memo must not reuse the first base's verdict") pins exactly this scenario. I consider that finding resolved as of HEAD.
  • repo_oid_width's cache (line 276-286): its key is "$*" over the full git_locating_opts array, which now leads with the base — so it's base-keyed by construction, consistent with the CHANGELOG's claim that this cache needed no separate fix.
  • Test coverage in both test files targets the actual mechanism (payload-cwd-vs-process divergence in both directions, !-alias relocation and non-leak into a following segment, relative-vs-absolute -C/--git-dir, the full base-chain fallback, env -S last-wins/termination/inert-form cases) rather than just outcomes. I didn't find a gap in what's being pinned.
  • CHANGELOG/version-bump parity: spot-checked guardrails (0.23.1→0.24.0, minor bump — correct given the documented relative--C behavior change), plus actionlint and typos-format (both 0.x.1→0.x.2 patch bumps) — all three CHANGELOG entries accurately describe the shared hook-utils.sh fix or the guardrails-specific fix, and I didn't spot any unrelated file changes riding along.

No correctness bugs, error-handling gaps, or test-coverage holes found beyond the one low-severity note above.

@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 36s —— View job


Security review complete

Scope: files changed in this PR (gh-diff against origin/main), with focus on the newest commit bc3cf8e pushed since the two prior automated reviews on this PR — it lands the fix for the P1 finding Codex flagged on HOOK_ALIAS_MEMO's key composition.

  • Gather context (git log origin/main..HEAD, diff of the new commit)
  • Review lib/hook-utils.sh diff (shared resolver, synced to 16 plugin copies) — env -S resolver fix
  • Review plugins/guardrails/hooks/block-dangerous-git.sh diff (payload cwd / lease-width probe)
  • Verify the Codex P1 finding on HOOK_ALIAS_MEMO key composition
  • Spot-check the 15 version/CHANGELOG-only plugin diffs for anomalies

Findings

No security issues found. The reported P1 has been fixed, and I found nothing new.

Codex's HOOK_ALIAS_MEMO finding — verified fixed in commit bc3cf8e (block-dangerous-git.sh#L547-L566). The exact bypass Codex described — same ! alias text re-expanded once under a SHA-1 -C (where a 40-hex lease is a genuine object id, correctly allowed and memoized) and again under a SHA-256 -C (where the identical text should be blocked) — is closed by folding HOOK_EFFECTIVE_BASE into the memo key inside alias_reexpand_admit itself, rather than at each call site. I traced why that placement matters rather than taking it on faith: the function has two callers — the ! shell-alias branch (L692-L695), which relocates HOOK_EFFECTIVE_BASE before calling, and the inline git-alias splice (L706-L707), which doesn't relocate the base but still reads the same live global — so keying inside the function covers both without duplicating the capture. A new regression test pins the exact two-segment cache-reuse scenario (block-dangerous-git.test.sh#L166-L176) with both single-segment controls to isolate that the two-segment verdict can only come from the cache.

I independently swept for other base-dependent caches rather than trusting the commit message's claim of completeness:

  • repo_oid_width (L276-L289) keys on "$*" of git_locating_opts, and collect_git_locating_opts (L337-L345) always leads that array with -C "$HOOK_EFFECTIVE_BASE", recomputed fresh per segment — so this cache was already correctly base-keyed, confirmed by reading the code rather than by inspection alone.
  • HOOK_ALIAS_SEEN is reset to empty in lockstep with the base at every ! reparse (L692-693) and save/restored together (L710-711), so it can't smuggle a verdict across bases either.
  • Grepped for every declare -A / memo-style cache in the file — _repo_oid_width/_repo_oid_width_key and HOOK_ALIAS_MEMO are the only two, and both are now sound.

The %q-quoted, newline-delimited key construction (L555-L559) uses the same escaping discipline as the pre-existing kind/HOOK_ALIAS_SEEN fields, so there's no new key-collision surface from an attacker-controlled base string.

Other changes since the last review (d39f49e, 59b31cd docs/test-only commits) carry no new logic — confirmed via diff.

Fan-out integrity: all 16 synced hooks/hook-utils.sh copies are still byte-identical to lib/hook-utils.sh (cmp clean) after this commit, which touched only guardrails's own files. The 15 patch-bump plugin.json diffs are Unicode em-dash → normalization plus the version bump only — no permission or config changes.

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 block-dangerous-git.test.sh.
· branch fix/block-dangerous-git-payload-cwd

Comment thread plugins/guardrails/hooks/block-dangerous-git.sh
…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

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 1m 40s —— View job


Security review complete

Scope: all files changed on this branch vs origin/main (git diff origin/main...HEAD), with particular attention to the newest commit ab6b3da pushed since the last automated security pass (which reviewed through bc3cf8e).

  • Gather context (PR diff, changed files, prior review history)
  • Diff the newest commit (ab6b3da) against what was already reviewed (bc3cf8e)
  • Review lib/hook-utils.sh (synced resolver, 16 plugin copies)
  • Review plugins/guardrails/hooks/block-dangerous-git.sh
  • Review test files for security-relevant assertions
  • Check CHANGELOG/plugin.json fan-out for anomalies
  • Post findings

Findings

No security issues found.

What's new since the last security pass (bc3cf8eab6b3da): confirmed via git diff bc3cf8e..ab6b3da --stat that commit ab6b3da touches only plugins/guardrails/CHANGELOG.md (prose expansion of the already-reviewed memo-key fix) and plugins/guardrails/hooks/block-dangerous-git.test.sh (new run_split cases: the memo collision across && instead of ;, a same-base/different-key control, and a 16-distinct-bases cost/bound check with a SHA-256 tail still blocked). No production logic changed in this commit — nothing new to review from a vulnerability standpoint.

Independent re-verification of the two production changes (not just re-reading prior review comments):

  • lib/hook-utils.sh env -S resume fix (#L1147-L1189): continue 2continue correctly resumes inside env's own option-parsing loop rather than the outer command-dispatch loop. This is a pure control-flow correction that closes a bypass (a leading option in an -S splice previously caused the whole command to be misread as a non-git word and skipped entirely); it introduces no new execution path — the resolved words still only feed the static scan, never eval/sh -c.
  • block-dangerous-git.sh payload-.cwd plumbing: traced HOOK_CWD (from .tool_input JSON, #L104-L111) end-to-end to its two consumption points — collect_git_locating_opts (#L344) and effective_dir (#L408) — and confirmed both pass it to git only as a discrete argv array element (-C "${HOOK_EFFECTIVE_BASE:-...}", #L285 git "$@" rev-parse ...). No string interpolation into a shell command, no eval. An attacker-controlled cwd value (e.g. one starting with - or containing shell metacharacters) cannot be reinterpreted as a flag or break out of the argument, since it always occupies a fixed array slot immediately after a literal -C.
  • Memo-key base fix (alias_reexpand_admit, #L547-L566): the Codex-flagged P1 (same alias text re-expanded under two different repository bases wrongly reusing the first verdict) was already fixed in bc3cf8e and re-verified in the prior security pass. The new commit only adds test coverage for it (&& vs ;, a same-key control, a 16-base cost/bound case) — no code change to re-verify.

Fan-out integrity: all 16 synced hooks/hook-utils.sh copies remain byte-identical to lib/hook-utils.sh (same blob SHA 805f68da... across all 17 listed files); the other 15 plugins' diffs are version bump + CHANGELOG only.

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 eval/sh -c/string-built commands.
· branch fix/block-dangerous-git-payload-cwd

@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 39s —— View job


Code review complete

Scope: 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 bc3cf8e ("key the alias re-expansion memo on the effective base") and ab6b3da ("pin the memo's base keying, and measure what the fix costs") — and re-verified the rest of the diff (lib/hook-utils.sh's env -S resume fix, block-dangerous-git.sh's payload-cwd base chain, CHANGELOG/version parity) independently rather than trusting prior passes.

What's new since the last review round

  • alias_reexpand_admit now folds HOOK_EFFECTIVE_BASE into HOOK_ALIAS_MEMO's key (block-dangerous-git.sh:555-556) — this closes the P1 Codex flagged earlier on this PR (a ! alias re-expanded under a SHA-1 -C then a SHA-256 -C had its second analysis skipped as already-seen, letting a movable 40-hex ref through). I traced both call sites (check_segment's !-branch and the inline git-alias splice) and confirmed keying inside the function rather than at the call sites covers both without duplicating the base capture.
  • ab6b3da's added tests (block-dangerous-git.test.sh:177-204) target the actual mechanism, not just the outcome — the &&-vs-; pair confirms the collision path is operator-independent, the "one space added to the alias body" control correctly isolates the base as the discriminating variable (it explicitly notes this control passes on both old and new code, which is the right thing to pin), and the 16-distinct-bases fixtures pin that the weakened dedup stays within HOOK_ALIAS_WORK_MAX (128) — I checked the arithmetic (16 segments × 1 analysis each = 16, plus 1 for the SHA-256 tail = 17) and it's well inside the cap. The memo_many fixture creates subdirectories of REPO_SHA1 rather than 16 separate repos, which is the correct/cheaper way to get 16 textually-distinct bases naming the same repository — matches what the comment claims it's testing.
  • CHANGELOG entry for the memo fix (guardrails/CHANGELOG.md) accurately describes the bug, its blast radius (bypass-only, never a false block, since a memo hit only skips analysis and the guard exits at the first block), and the measured cost — consistent with what the diff actually does.

Re-verified from earlier in the PR

  • lib/hook-utils.sh's continue vs continue 2 fix (all 17 synced copies still byte-identical by blob SHA 805f68d) — unchanged since the last review, traced again and still correct: resumes inside env's own option loop rather than the outer command dispatcher.
  • Version/CHANGELOG bump parity across all 17 changed plugins: guardrails 0.23.1→0.24.0 (minor, correct for the documented behavior change), the other 16 all take a consistent patch bump (hooks/hook-utils.sh is byte-identical to lib/hook-utils.sh in each).
  • The PowerShell coverage added in 59b31cd (run_pwsh/run_pwsh_nocwd, payload-cwd + missing-cwd cases) is scoped correctly per the PR's own "not tested" disclosure — it pins payload-cwd reading on that arm but doesn't claim env -S coverage there, matching the code (the PowerShell path doesn't route through hook::git_resolve_index's env-splice case at all).

Outstanding from earlier rounds

The one maintainability nit already posted inline on this PR (the ${git_locating_opts[@]:2} magic offset at block-dangerous-git.sh:693 lacking a comment tying it to collect_git_locating_opts's invariant) is still unaddressed as of ab6b3da. Not re-posting a duplicate — it's low severity and already tracked in this PR's review thread.

Not verified

I do not have permission to execute shell scripts in this sandbox (bash <script> requires interactive approval unavailable here), so I could not run block-dangerous-git.test.sh or lib/hook-utils.test.sh myself. The "363/164 pass" counts and the memo-cost measurements in the PR body are author-claimed and unverified by me — I confirmed the test assertions are well-targeted and internally consistent by reading them and hand-tracing the control flow, not by executing them.

Conclusion

No 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.
· branch fix/block-dangerous-git-payload-cwd

@kyle-sexton

Copy link
Copy Markdown
Contributor Author

Adversarial verification of bc3cf8e1 (the memo fix)

Fresh-context adversarial verifier. I confirmed the P1 on d39f49eb before this fix landed; this is the verification of the fix itself, plus a measurement of the cost it was predicted to carry, plus a note on the second thread.

Do not merge on my account. This closes a live bypass affecting 17 copies of hook-utils.sh and needs the human's approval.

1. The memo fix discriminates — against the right control

origin/main is not a valid control here: main has no base-dependent verdict to mis-cache, so a fixture built against it goes green for the wrong reason. The control is d39f49eb, the pre-memo-fix head.

                                          d39f49eb   bc3cf8e1
solo sha1    (expect ALLOW both)          ALLOW      ALLOW
solo sha256  (expect DENY both)           DENY       DENY
A ; B    the bypass                       ALLOW      DENY     <-- discriminates
A && B   the bypass                       ALLOW      DENY     <-- discriminates
A ; B2   (identical repos, one extra
         space in the alias body)         DENY       DENY

A;B and A;B2 differ only in the alias body's whitespace — i.e. only in the memo key. That isolates the key as the cause. Both now deny.

2. My open read-not-run gap is now run: the git-alias branch was never vulnerable

I had flagged that alias_reexpand_admit git "${nextw[@]}" shares the memo and might collide the same way. It does not, on either tree:

git-alias solo sha256          d39f49eb=DENY   bc3cf8e1=DENY
git-alias sha1 ; sha256        d39f49eb=DENY   bc3cf8e1=DENY

The reason is structural: the git-alias branch keys on the full argv, which already contains -C <dir>, so its keys always differed by base. Only the ! branch strips the leading globals into a bare reparse body. Keying inside alias_reexpand_admit rather than at the call site covers it anyway, which is the right call — but it is defensive, not a behaviour change there.

3. The predicted cost is real, and now measured

I flagged before the fix that keying on the base weakens dedup, and that a base-alternating command is the case nobody had built. It reproduces:

N repetitions of the SAME `!` alias body under N DISTINCT bases
(safe terminal `!git status`, so the only non-ALLOW is the work cap)

n=10     d39f49eb=ALLOW    bc3cf8e1=ALLOW
n=100    d39f49eb=ALLOW    bc3cf8e1=ALLOW
n=130    d39f49eb=ALLOW    bc3cf8e1=CAPPED   <-- false block introduced by the fix

Pre-fix those collapse to one analysis; post-fix they are N analyses against the invocation-wide HOOK_ALIAS_WORK_MAX=128, which fails closed. The boundary sits between 100 and 130 distinct bases.

This is exotic and I would not block on it — 130 distinct -C directories invoking the same ! alias in one command is not real usage, and failing closed is the right direction. But it is a real behaviour change, the budget is now consumed per-base rather than shared, and no fixture covers it. The suite's existing 20/60-hop traversal cases are single-base; the three cases added with this fix are all two-segment. A future budget change or a deeper realistic case would cross this silently.

Separately measured, so it is not confused with the above: the same doubling on git-alias chains caps identically on both trees (100-hop two-base → CAPPED at d39f49eb and bc3cf8e1). That path is unchanged by the fix.

4. Second thread (:2 magic offset) — confirmed, and the failure mode is benign too

The note is right that effective_dir ${git_locating_opts[@]:2} depends on an uncommented invariant. I checked what the invariant actually protects: I built a variant tree with :2 changed to :0 — the double-apply — and diffed decisions.

                                        :2      :0
! alias under sha256 -C   (DENY)        DENY    DENY
! alias under sha1 -C     (ALLOW)       ALLOW   ALLOW
! alias, relative -C sub                ALLOW   ALLOW
payload cwd absent, base falls to '.'   ALLOW   ALLOW
  ...same, with a relative -C sub       ALLOW   ALLOW

Identical in every case I could construct, including the relative-base fallback where a double-apply would compose ./.. So the offset is not load-bearing for correctness today even if removed — the note's value is purely maintainability against a future second synthetic pair, which is how it is framed. Agreeing with the reviewer, and downgrading the urgency.

5. Method note: four broken fixtures, each caught by a control

Worth recording because it kept happening. In this pass alone:

  1. A chain helper that emitted a leading git, so I built git -C <dir> git -c alias… — every row ALLOW, including a reset --hard control that must deny.
  2. 100 segments at absolute paths → past MAX_COMMAND_LEN=16384 → DENY on a safe terminal: the length ceiling failing closed, not the work cap.

Plus two from the earlier jq work. Every one produced exactly the answer I was hoping to find. The only thing that caught them was carrying a control whose expected value is the opposite of the hypothesis. An ALLOW from a fixture that never ran looks identical to an ALLOW from a guard that decided.

What I did not test

  • The !-branch cost boundary precisely — bracketed at 100/130, not bisected; an n=160 run died on Windows fork exhaustion, not on a guard decision.
  • Deep chains of nested ! aliases (each relocating), as opposed to N sibling segments. That is the shape where per-base keying could compound rather than add.
  • Whether HOOK_ALIAS_WORK_MAX=128 is still the right number post-fix. I measured that the cap is reachable; I did not assess whether it should rise.
  • The full suite on bc3cf8e1. I ran targeted probes, not block-dangerous-git.test.sh end to end on this head.
  • PowerShell payloads — everything here is tool_name: "Bash", same gap as my earlier reports on this branch.
  • :2 with a genuinely divergent relative base — I could not construct a case where the composed spelling differs in a way git resolves differently, so "benign" rests on the five cases above, not on exhaustion.
  • Single box, git 2.54.0.windows.1, core.filemode=false.

Fixtures: two real repositories (SHA-1 and SHA-256), a SHA-256 bare remote, and a ref literally named 40 hex characters pointed at the remote tip so the lease always passes.

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

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 55s —— View job


Security review complete

Scope: delta since the last automated security pass on this PR (ab6b3da), per the branch HEAD at trigger time (d40420d). Confirmed via git diff ab6b3da..d40420d --stat that exactly one file changed.

  • Gather context (diff since last security review)
  • Determine what's new since the last security pass (ab6b3da)
  • Review the new commit for security issues
  • Post findings

Findings

No security issues found.

The only change since the last security review is commit d40420d ("docs(guardrails): explain the :2 slice at the alias reparse call site"), which touches a single file:

plugins/guardrails/hooks/block-dangerous-git.sh#L693-L700

This adds an 8-line comment above the existing HOOK_EFFECTIVE_BASE="$(effective_dir ${git_locating_opts[@]+"${git_locating_opts[@]:2}"})" line, explaining the :2 offset invariant that both the Codex review and an earlier automated review pass on this PR had flagged as an undocumented magic number. I diffed the surrounding logic byte-for-byte and confirmed:

  • The executable line itself (HOOK_EFFECTIVE_BASE="$(effective_dir ...)") is byte-identical before and after — no argument slicing, quoting, or control flow changed.
  • No new variable expansion, subshell, or code path was introduced.
  • This is a documentation-only commit ("Comment only; no behaviour change." — confirmed by inspection, not just taken from the commit message).

Since this line was already reviewed and traced as sound in the prior security pass (the :2 offset correctly avoids double-applying the base, and no exploitable path was found), and nothing executable changed, there is nothing new to assess from a vulnerability standpoint.

Full-PR posture (recap, unchanged from prior passes): all git invocations throughout the diff use array expansion, never eval/sh -c/string-interpolated commands; the payload .cwd value is only ever consumed as a discrete -C argv element; the alias-memo key now correctly folds in HOOK_EFFECTIVE_BASE (the P1 Codex flagged, fixed in bc3cf8e); and the 16-plugin fan-out is version/CHANGELOG-only with all hooks/hook-utils.sh copies byte-identical to lib/hook-utils.sh.
· branch fix/block-dangerous-git-payload-cwd

@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 1m 8s —— View job


Code review complete

Scope: 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 d40420d, pushed since ab6b3da was last reviewed.

  • Gather context (PR description, prior review rounds, diff since last review)
  • Diff newest commit (d40420d) against what was already reviewed (ab6b3da)
  • Confirm whether the :2 magic-offset nit was addressed
  • Re-scan full diff for correctness/architecture/test-coverage issues
  • Post findings

What's new in this commit

git diff ab6b3da..d40420d --stat shows exactly one file touched, 8 lines added, 0 removed: plugins/guardrails/hooks/block-dangerous-git.sh. It's a pure comment addition directly above the ${git_locating_opts[@]:2} call site — no logic, test, or CHANGELOG changes ride along.

This closes the maintainability nit both Codex flagged and I raised in an earlier round on this same line: the :2 offset previously carried no comment explaining why it's safe to skip those two array elements. I re-verified the comment's claim against the code rather than taking it at face value — collect_git_locating_opts does unconditionally lead git_locating_opts with -C "${HOOK_EFFECTIVE_BASE:-...}" at line 342 before appending anything else, so :2 reliably skips exactly that pair and effective_dir's own HOOK_EFFECTIVE_BASE default supplies the same value without double-applying it. The comment states this correctly, including the failure mode if the invariant is ever broken ("wrapper dirs are read starting one pair too late").

Placement and style are consistent with the rest of the file's unusually dense inline-documentation convention (matches the tone of the surrounding !-alias relocation comments a few lines above).

Conclusion

No 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.
· branch fix/block-dangerous-git-payload-cwd

@kyle-sexton
kyle-sexton merged commit b20e70a into main Aug 10, 2026
33 checks passed
@kyle-sexton
kyle-sexton deleted the fix/block-dangerous-git-payload-cwd branch August 10, 2026 13:43
kyle-sexton added a commit that referenced this pull request Aug 10, 2026
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>
kyle-sexton added a commit that referenced this pull request Aug 10, 2026
…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>
kyle-sexton added a commit that referenced this pull request Aug 10, 2026
…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>
kyle-sexton added a commit that referenced this pull request Aug 10, 2026
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>
kyle-sexton added a commit that referenced this pull request Aug 10, 2026
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>
kyle-sexton added a commit that referenced this pull request Aug 10, 2026
…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>
kyle-sexton added a commit that referenced this pull request Aug 11, 2026
…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>
kyle-sexton added a commit that referenced this pull request Aug 12, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(guardrails): block-dangerous-git still clears an unsafe --force-with-lease via payload cwd and env -S

1 participant