Filed by AI. Found while independently verifying PR #2100's discharge of PRRT_kwDOTCGFQM6TzGBZ
(#1275). I resolved that thread on a first round of 16 cases and was wrong to — a second round,
prompted by a list of untried forms from the session that wrote the fix, found live bypasses. The
resolution has been corrected and this issue carries what survived.
Summary
block-dangerous-git.sh still lets an unsafe --force-with-lease through in several forms. The
mechanism is the one #2100 fixed — the hash-width probe reading the wrong repository — via routes
the fix does not cover.
A 40-hex lease is a movable ref name in a SHA-256 repository, which is the exact hole
--force-with-lease exists to close. All results below: hook from origin/main, real SHA-1 and
SHA-256 fixture repositories, exit 2 = BLOCKED, exit 0 = ALLOWED. Every "ALLOWED" row was
additionally confirmed to actually execute git in the SHA-256 repository by substituting
rev-parse --show-toplevel for the push — an inert form that fails to run is not a bypass, and one
such false positive was discarded on that test.
1. The payload's cwd is never read — no wrapper required
The most serious of these, because it needs no wrapper syntax at all.
payload .cwd = sha256 repo, hook process cwd = sha1 repo -> ALLOWED <-- bypass
payload .cwd = sha256 repo, hook process cwd = sha256 repo -> BLOCKED (control)
payload .cwd = sha1 repo, hook process cwd = sha1 repo -> ALLOWED (control, correct)
repo_oid_width probes the hook process's working directory. The hook never reads the payload's
.cwd, so whenever the session's directory differs from the hook process's, the width is measured
against the wrong repository.
The sibling guard in the same plugin does not have this gap:
$ git show origin/main:plugins/guardrails/hooks/block-noncanonical-commit.sh | grep -n 'cwd'
118:HOOK_CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // empty' ...)
410: local base="${HOOK_EFFECTIVE_BASE:-${HOOK_CWD:-${CLAUDE_PROJECT_DIR:-.}}}" ...
883:HOOK_EFFECTIVE_BASE="${HOOK_CWD:-${CLAUDE_PROJECT_DIR:-.}}"
$ git show origin/main:plugins/guardrails/hooks/block-dangerous-git.sh | grep -n 'cwd'
(no output)
block-noncanonical-commit established the pattern; block-dangerous-git never adopted it.
2. env -S / --split-string splices the chdir past the parser
Squarely inside the original finding's scope — this is env performing a -C chdir — and it
bypasses:
env -S '-C <sha256> git push --force-with-lease=main:<40-hex> origin main' ALLOWED (executes)
env --split-string='-C <sha256> git push --force-with-lease=... origin main' ALLOWED (executes)
The wrapper's options arrive inside a single quoted argument, so a parser walking argv word by word
never sees -C as an option.
3. A shell cd reaches the same harm by a different mechanism
sh -c "cd <sha256> && git push --force-with-lease=main:<40-hex> origin main" ALLOWED (executes)
(cd <sha256> && git push --force-with-lease=main:<40-hex> origin main) ALLOWED (executes)
Arguably outside the wrapper-replay design, since no recognized wrapper is involved — but the harm
is identical, and #1 means even the no-cd case is already unsound. Recorded so the fix is scoped
against the harm rather than against one syntax.
4. An inline alias carrying the lease
git -C <sha256> config alias.yolo "push --force-with-lease=main:<40-hex> origin main"
env -C <sha256> git yolo ALLOWED
Not separately confirmed to execute, and alias resolution may be the other guard's responsibility —
flagged for triage rather than asserted as a bypass.
What is NOT broken
The following were tried and correctly blocked, so the #2100 fix is real and worth keeping:
timeout 60 env -C, nohup env -C, command env -C, bash -c 'env -C …', plus the sixteen cases
of round one (env -C, env --chdir=, env --chdir , bundled env -C<dir>, nice env -C,
doubled env -C … env -C, env -u FOO -C, composition with git's own -C, and four controls
proving the fixture discriminates).
xargs -I{} env -C … reads ALLOWED but did not execute in the harness (no stdin), so it is not
counted as a bypass.
Acceptance
- The payload's
.cwd is honoured as the probe's base, matching block-noncanonical-commit's
HOOK_CWD / HOOK_EFFECTIVE_BASE pattern rather than inventing a second one.
env -S / --split-string payloads are split and the resulting options parsed, or the form is
refused as unparseable — a wrapper whose arguments cannot be read is not a wrapper that can be
cleared.
- Each case carries a control that fails against the current implementation. A fixture that
answers the same on both trees proves nothing; that trap has already produced one worthless
reproduction and one premature thread resolution on this sweep.
- Every "allowed" assertion additionally proves the command executes — otherwise an inert form
reads as a hole. env FOO=1 -C <dir> git … is the worked example: GNU coreutils 8.32 stops option
parsing at the first NAME=VALUE, so -C becomes the command name, rc=127, git never runs.
- A control in the OPPOSITE direction, so the fix is not merely "block more". Payload
cwd =
the SHA-1 repo with the hook process in the SHA-256 one, 40-hex lease: that is a genuine object id
where the command actually runs, so it must be PRE=BLOCKED → POST=ALLOWED. Round one's
reverse-direction case is the right instrument and should be kept.
- A relative
git -C <dir> case. It does not misprobe today (see above), but the leading--C
fix changes how it resolves, so it needs a case pinning that it still lands on the right
repository afterwards.
Design, after review by the session that wrote #2100
Mirror block-noncanonical-commit's chain; do not invent a second mechanism. That guard's base
resolution is HOOK_EFFECTIVE_BASE → HOOK_CWD → CLAUDE_PROJECT_DIR → ., and
HOOK_EFFECTIVE_BASE is not decoration: a !-shell alias relocates the base mid-parse, so it is
saved and restored around each reparse (:636, :665-668, :746). block-dangerous-git recurses
through !-aliases the same way, so a fresh mechanism would have to rediscover that requirement and
would likely get it wrong first time.
Replay the payload cwd as a LEADING -C, not as a cd. Measured against real SHA-1/SHA-256
fixtures:
cumulative: git -C <sha256> -C ../repo-sha1 -> sha1 (a later -C composes onto the earlier)
rebased: git -C <abs-base> -C repo-sha256 -> sha256 (same from ANY process cwd)
absolute: git -C <sha256> -C <abs sha1> -> sha1 (an absolute later -C wins)
So a leading base composes exactly like the wrapper replay already shipping — same mechanism, same
ordering rule, no new semantics. Prepend the payload cwd ahead of
HOOK_GIT_RESOLVED_WRAPPER_DIRS, which already sit ahead of git's own options. That reproduces
execution order end to end.
Collateral check on the other replayed options. --git-dir, --work-tree and --namespace are
collected alongside -C. An absolute --git-dir is unaffected by a leading base; a relative
--git-dir rebases onto it. That second one is a behaviour change and it is the correct one — a
relative --git-dir should resolve against the directory the tool call actually runs in, not the
hook's — but it should be stated in the fix so a reviewer does not read it as a regression.
A claimed second bug that does NOT reproduce — tested, not assumed
It was suggested that a relative git -C <dir> already misprobes today with no wrapper and no
.cwd mismatch, on the theory that the probe resolves it against the hook process's cwd. Tested
against origin/main:
hook process cwd = parent of both repos
git -C r256 push --force-with-lease=main:<40-hex> -> BLOCKED (correct)
git -C <abs sha256> push --force-with-lease=... -> BLOCKED (control)
git -C r1 push --force-with-lease=main:<40-hex> -> ALLOWED (control: real oid there)
(relative form resolves: .../base/r256) (fixture is live)
It does not reproduce. The relative -C resolves against the hook process's cwd and the real
command's cwd, which are the same directory in that scenario, so the probe agrees with reality. The
relative case is subsumed by the .cwd bug rather than being separate: it only misprobes when
the payload cwd differs from the hook process cwd, which is #1 above. The original observation came
from a fixture that errored, and an error read as fail-closed.
No separate ticket is warranted; the leading--C fix covers it either way.
Also fix in the same PR: the docblock understates the residual
repo_oid_width's docblock describes the compound-cd residual as needing "a SHA-256 repository, a
lease pinned to a full-width hex word that is also a ref name there, and a compound cd into it."
Finding #1 above shows it needs no wrapper and no cd at all — a payload cwd that differs from
the hook process cwd is sufficient. The gap can stay open, but a documented gap that reads narrower
than it is, is how this one survived review in the first place.
Related
Filed by AI. Found while independently verifying PR #2100's discharge of
PRRT_kwDOTCGFQM6TzGBZ(#1275). I resolved that thread on a first round of 16 cases and was wrong to — a second round,
prompted by a list of untried forms from the session that wrote the fix, found live bypasses. The
resolution has been corrected and this issue carries what survived.
Summary
block-dangerous-git.shstill lets an unsafe--force-with-leasethrough in several forms. Themechanism is the one #2100 fixed — the hash-width probe reading the wrong repository — via routes
the fix does not cover.
A 40-hex lease is a movable ref name in a SHA-256 repository, which is the exact hole
--force-with-leaseexists to close. All results below: hook fromorigin/main, real SHA-1 andSHA-256 fixture repositories, exit 2 = BLOCKED, exit 0 = ALLOWED. Every "ALLOWED" row was
additionally confirmed to actually execute git in the SHA-256 repository by substituting
rev-parse --show-toplevelfor the push — an inert form that fails to run is not a bypass, and onesuch false positive was discarded on that test.
1. The payload's
cwdis never read — no wrapper requiredThe most serious of these, because it needs no wrapper syntax at all.
repo_oid_widthprobes the hook process's working directory. The hook never reads the payload's.cwd, so whenever the session's directory differs from the hook process's, the width is measuredagainst the wrong repository.
The sibling guard in the same plugin does not have this gap:
block-noncanonical-commitestablished the pattern;block-dangerous-gitnever adopted it.2.
env -S/--split-stringsplices the chdir past the parserSquarely inside the original finding's scope — this is
envperforming a-Cchdir — and itbypasses:
The wrapper's options arrive inside a single quoted argument, so a parser walking argv word by word
never sees
-Cas an option.3. A shell
cdreaches the same harm by a different mechanismArguably outside the wrapper-replay design, since no recognized wrapper is involved — but the harm
is identical, and #1 means even the no-
cdcase is already unsound. Recorded so the fix is scopedagainst the harm rather than against one syntax.
4. An inline alias carrying the lease
Not separately confirmed to execute, and alias resolution may be the other guard's responsibility —
flagged for triage rather than asserted as a bypass.
What is NOT broken
The following were tried and correctly blocked, so the #2100 fix is real and worth keeping:
timeout 60 env -C,nohup env -C,command env -C,bash -c 'env -C …', plus the sixteen casesof round one (
env -C,env --chdir=,env --chdir, bundledenv -C<dir>,nice env -C,doubled
env -C … env -C,env -u FOO -C, composition with git's own-C, and four controlsproving the fixture discriminates).
xargs -I{} env -C …reads ALLOWED but did not execute in the harness (no stdin), so it is notcounted as a bypass.
Acceptance
.cwdis honoured as the probe's base, matchingblock-noncanonical-commit'sHOOK_CWD/HOOK_EFFECTIVE_BASEpattern rather than inventing a second one.env -S/--split-stringpayloads are split and the resulting options parsed, or the form isrefused as unparseable — a wrapper whose arguments cannot be read is not a wrapper that can be
cleared.
answers the same on both trees proves nothing; that trap has already produced one worthless
reproduction and one premature thread resolution on this sweep.
reads as a hole.
env FOO=1 -C <dir> git …is the worked example: GNU coreutils 8.32 stops optionparsing at the first
NAME=VALUE, so-Cbecomes the command name, rc=127, git never runs.cwd=the SHA-1 repo with the hook process in the SHA-256 one, 40-hex lease: that is a genuine object id
where the command actually runs, so it must be PRE=BLOCKED → POST=ALLOWED. Round one's
reverse-direction case is the right instrument and should be kept.
git -C <dir>case. It does not misprobe today (see above), but the leading--Cfix changes how it resolves, so it needs a case pinning that it still lands on the right
repository afterwards.
Design, after review by the session that wrote #2100
Mirror
block-noncanonical-commit's chain; do not invent a second mechanism. That guard's baseresolution is
HOOK_EFFECTIVE_BASE→HOOK_CWD→CLAUDE_PROJECT_DIR→., andHOOK_EFFECTIVE_BASEis not decoration: a!-shell alias relocates the base mid-parse, so it issaved and restored around each reparse (
:636,:665-668,:746).block-dangerous-gitrecursesthrough
!-aliases the same way, so a fresh mechanism would have to rediscover that requirement andwould likely get it wrong first time.
Replay the payload
cwdas a LEADING-C, not as acd. Measured against real SHA-1/SHA-256fixtures:
So a leading base composes exactly like the wrapper replay already shipping — same mechanism, same
ordering rule, no new semantics. Prepend the payload
cwdahead ofHOOK_GIT_RESOLVED_WRAPPER_DIRS, which already sit ahead of git's own options. That reproducesexecution order end to end.
Collateral check on the other replayed options.
--git-dir,--work-treeand--namespacearecollected alongside
-C. An absolute--git-diris unaffected by a leading base; a relative--git-dirrebases onto it. That second one is a behaviour change and it is the correct one — arelative
--git-dirshould resolve against the directory the tool call actually runs in, not thehook's — but it should be stated in the fix so a reviewer does not read it as a regression.
A claimed second bug that does NOT reproduce — tested, not assumed
It was suggested that a relative
git -C <dir>already misprobes today with no wrapper and no.cwdmismatch, on the theory that the probe resolves it against the hook process's cwd. Testedagainst
origin/main:It does not reproduce. The relative
-Cresolves against the hook process's cwd and the realcommand's cwd, which are the same directory in that scenario, so the probe agrees with reality. The
relative case is subsumed by the
.cwdbug rather than being separate: it only misprobes whenthe payload cwd differs from the hook process cwd, which is #1 above. The original observation came
from a fixture that errored, and an error read as fail-closed.
No separate ticket is warranted; the leading-
-Cfix covers it either way.Also fix in the same PR: the docblock understates the residual
repo_oid_width's docblock describes the compound-cdresidual as needing "a SHA-256 repository, alease pinned to a full-width hex word that is also a ref name there, and a compound
cdinto it."Finding #1 above shows it needs no wrapper and no
cdat all — a payload cwd that differs fromthe hook process cwd is sufficient. The gap can stay open, but a documented gap that reads narrower
than it is, is how this one survived review in the first place.
Related
TzGBZwas filed