Severity carried: High (guardrails-hooks F1, F2, F3) — uncalibrated
Problem
Issue 2592 was closed by PR 2622 (302a6fa95, "match git in command position only"). That fix
works: 2592's own reproduction is allowed today, on all three blocking hooks. The #2722 narrowing of
ps::write_bypass likewise allows a bare-computed call target on its own.
Neither fix reaches the two commands the audit reproduced. Both are still blocked by all three
hooks, because the live over-blocking trigger is the conjunction of a grouping construct and a
bare-computed call target — a shape no shipped fix addresses and no test pins.
This is scoped to that residual. It does not re-litigate 2592.
Evidence
Executed against origin/main at 534eac1382a1e18212295210e18fbbf5a3b8f4dc on 2026-08-16, with
CLAUDE_PLUGIN_ROOT pointed at an extraction of main so the library under test is
plugins/guardrails/lib/powershell/ps-command.sh from main (blob OID verified against
git rev-parse origin/main:<path>). Each command was fed as a real PreToolUse payload built by jq
from a JSON case file, so no shell quoting touched the command string.
CASE dang-git no-verify hook-byp
---------------- ---------- ---------- ----------
REPRO_2592 rc=0 rc=0 rc=0
BLOCK_A rc=2 rc=2 rc=2
BLOCK_C rc=2 rc=2 rc=2
GROUPING_ONLY rc=0 rc=0 rc=0
COMPUTED_ONLY rc=0 rc=0 rc=0
CONTROL_1973 rc=0 rc=0 rc=0
Byte-exact commands:
REPRO_2592 — Get-ChildItem -LiteralPath $p -Recurse -Force -Directory | Where-Object { $_.Name -in @('node_modules','obj','bin','.git') } | ForEach-Object { $_.FullName }
BLOCK_A — $py = "C:/tools/python.exe"; if (-not (Test-Path $py)) { $py = (Get-Command python).Source }; & $py C:/s/run.py --flag
BLOCK_C — $ids = @('a','b'); foreach ($id in $ids) { & $py $script (Join-Path $dir "$id.jsonl") }
GROUPING_ONLY — foreach ($id in @('a','b')) { & "C:/tools/python.exe" C:/s/run.py $id }
COMPUTED_ONLY — & $py C:/s/run.py --flag · CONTROL_1973 — & "C:/tools/python.exe" C:/s/run.py --flag
Predicate isolation, same run, library sourced directly:
REPRO_2592 special_constructs=YES bare_computed=no might_invoke_git=no write_bypass=allows
BLOCK_A special_constructs=YES bare_computed=YES might_invoke_git=YES write_bypass=BLOCKS
BLOCK_C special_constructs=YES bare_computed=YES might_invoke_git=YES write_bypass=BLOCKS
GROUPING_ONLY special_constructs=YES bare_computed=no might_invoke_git=no write_bypass=allows
COMPUTED_ONLY special_constructs=no bare_computed=YES might_invoke_git=YES write_bypass=allows
CONTROL_1973 special_constructs=no bare_computed=no might_invoke_git=no write_bypass=allows
Either factor alone is allowed; only the conjunction blocks. Two independent paths produce it, so
a fix to one leaves the other:
- Git lane —
ps::might_invoke_git (ps-command.sh:313). The command-position probe at :319
correctly does not match (that is 2622's fix), but the bare-computed-target arm at :327
(ps::call_target_is_bare_computed "$recovered" && return 0) routes the command to the fail-closed
sink anyway.
- Write lane —
ps::write_bypass (:1047). Its computed-target branch at :1072 re-blocks
independently: ps::has_special_constructs at :1079 is satisfied by the same grouping construct.
block-hook-bypass.sh does not call ps::classify_git_command at all (its only mention is a
comment at :1201), so no change to the git lane can reach it.
Test coverage, re-derived across all 15 plugins/guardrails/hooks/*.test.sh on main. The
single-factor allowances are partly pinned; the conjunction is not pinned at all.
| Shape |
Pinned? |
REPRO_2592 |
yes — block-dangerous-git.test.sh:794-795 |
CONTROL_1973 (literal target) |
yes — block-dangerous-git.test.sh:862-869, four pins |
COMPUTED_ONLY (& $var, no grouping) |
yes — block-hook-bypass.test.sh:598-599; block-no-verify.test.sh:411 |
GROUPING_ONLY (grouping + literal target) |
no |
BLOCK_A / BLOCK_C (the conjunction) |
no |
No test in any of the 15 files pairs a grouping construct with a call operator. For BLOCK_A
specifically, grep -c "Get-Command" over every plugins/guardrails/hooks/*.test.sh returns 0 in
all 15 files.
Settled from source, so it needs no further investigation: .git inside @('…','.git') cannot
engage the probe. The leading boundary class at :319 and :449 is
(^|[[:space:]\;\|\&\(\{\}\"\'/\\:=]), which excludes .; the comment at :316-318 states this
intent explicitly. 2592's stated trigger was never the predicate that fired.
Why the current behavior is wrong
Three hooks exit 2 on the same call, and each prints a different explanation of a shape that is not
actually dangerous. block-dangerous-git.sh and block-no-verify.sh both report "cannot be parsed
with confidence"; block-hook-bypass.sh reports "PowerShell file-write cmdlet/redirect bypasses
Write/Edit hooks" — for a command that writes no file. An operator who removes one trigger surfaces
the next hook's different and less accurate message, so the guidance is a moving target.
The shape is ordinary PowerShell: resolve an interpreter into a variable behind a Test-Path
fallback, then loop. Because no test pins it, the next narrowing pass has nothing telling it this
conjunction is a false positive — and nothing stopping a fix from regressing GROUPING_ONLY, the one
single-factor allowance that is itself unpinned.
Acceptance criteria
Related
- Closed issue 2592 — the original, closed by PR 2622 (
302a6fa95).
- The
#2722 narrowing of ps::write_bypass — the bare-computed-target half; recorded in the comment
at ps-command.sh:1062-1071.
- Closed issue 1973 — narrowed the constant-target branch;
CONTROL_1973 is that fix holding. Note
the in-repo tags for this work read #1968, which is a different PR.
- Closed issue 1415 — the grouping-construct ancestor; cited by number at
ps-command.sh:441.
- Issue 2691 — stale-base squash merges silently reverting merged fixes; every citation above was
re-read from origin/main content rather than inferred from issue or PR state.
Carried-forward auditor caveats
- U2 — multi-hook blocking-message selection is unverified. The probe shows all three exit 2; the
audit did not observe which single message a live session surfaces when three block at once.
- The probe characterises the
special-construct sink trigger only. Three others exist
(herestring-unbalanced, dynamic-invocation, launcher) and had no dedicated false-positive hunt.
- Every probe ran under
bash 5.3.15(1)-release on win32 with jq 1.8.2 present, so the rc 0 results
are genuine allows rather than jq-absent fail-opens. No macOS/BSD/Linux coverage; no Linux
behavior is asserted.
Severity carried: High (
guardrails-hooksF1, F2, F3) — uncalibratedProblem
Issue 2592 was closed by PR 2622 (
302a6fa95, "match git in command position only"). That fixworks: 2592's own reproduction is allowed today, on all three blocking hooks. The
#2722narrowing ofps::write_bypasslikewise allows a bare-computed call target on its own.Neither fix reaches the two commands the audit reproduced. Both are still blocked by all three
hooks, because the live over-blocking trigger is the conjunction of a grouping construct and a
bare-computed call target — a shape no shipped fix addresses and no test pins.
This is scoped to that residual. It does not re-litigate 2592.
Evidence
Executed against
origin/mainat534eac1382a1e18212295210e18fbbf5a3b8f4dcon 2026-08-16, withCLAUDE_PLUGIN_ROOTpointed at an extraction of main so the library under test isplugins/guardrails/lib/powershell/ps-command.shfrom main (blob OID verified againstgit rev-parse origin/main:<path>). Each command was fed as a realPreToolUsepayload built byjqfrom a JSON case file, so no shell quoting touched the command string.
Byte-exact commands:
REPRO_2592—Get-ChildItem -LiteralPath $p -Recurse -Force -Directory | Where-Object { $_.Name -in @('node_modules','obj','bin','.git') } | ForEach-Object { $_.FullName }BLOCK_A—$py = "C:/tools/python.exe"; if (-not (Test-Path $py)) { $py = (Get-Command python).Source }; & $py C:/s/run.py --flagBLOCK_C—$ids = @('a','b'); foreach ($id in $ids) { & $py $script (Join-Path $dir "$id.jsonl") }GROUPING_ONLY—foreach ($id in @('a','b')) { & "C:/tools/python.exe" C:/s/run.py $id }COMPUTED_ONLY—& $py C:/s/run.py --flag·CONTROL_1973—& "C:/tools/python.exe" C:/s/run.py --flagPredicate isolation, same run, library sourced directly:
Either factor alone is allowed; only the conjunction blocks. Two independent paths produce it, so
a fix to one leaves the other:
ps::might_invoke_git(ps-command.sh:313). The command-position probe at:319correctly does not match (that is 2622's fix), but the bare-computed-target arm at
:327(
ps::call_target_is_bare_computed "$recovered" && return 0) routes the command to the fail-closedsink anyway.
ps::write_bypass(:1047). Its computed-target branch at:1072re-blocksindependently:
ps::has_special_constructsat:1079is satisfied by the same grouping construct.block-hook-bypass.shdoes not callps::classify_git_commandat all (its only mention is acomment at
:1201), so no change to the git lane can reach it.Test coverage, re-derived across all 15
plugins/guardrails/hooks/*.test.shon main. Thesingle-factor allowances are partly pinned; the conjunction is not pinned at all.
REPRO_2592block-dangerous-git.test.sh:794-795CONTROL_1973(literal target)block-dangerous-git.test.sh:862-869, four pinsCOMPUTED_ONLY(& $var, no grouping)block-hook-bypass.test.sh:598-599;block-no-verify.test.sh:411GROUPING_ONLY(grouping + literal target)BLOCK_A/BLOCK_C(the conjunction)No test in any of the 15 files pairs a grouping construct with a call operator. For
BLOCK_Aspecifically,
grep -c "Get-Command"over everyplugins/guardrails/hooks/*.test.shreturns 0 inall 15 files.
Settled from source, so it needs no further investigation:
.gitinside@('…','.git')cannotengage the probe. The leading boundary class at
:319and:449is(^|[[:space:]\;\|\&\(\{\}\"\'/\\:=]), which excludes.; the comment at:316-318states thisintent explicitly. 2592's stated trigger was never the predicate that fired.
Why the current behavior is wrong
Three hooks exit 2 on the same call, and each prints a different explanation of a shape that is not
actually dangerous.
block-dangerous-git.shandblock-no-verify.shboth report "cannot be parsedwith confidence";
block-hook-bypass.shreports "PowerShell file-write cmdlet/redirect bypassesWrite/Edit hooks" — for a command that writes no file. An operator who removes one trigger surfaces
the next hook's different and less accurate message, so the guidance is a moving target.
The shape is ordinary PowerShell: resolve an interpreter into a variable behind a
Test-Pathfallback, then loop. Because no test pins it, the next narrowing pass has nothing telling it this
conjunction is a false positive — and nothing stopping a fix from regressing
GROUPING_ONLY, the onesingle-factor allowance that is itself unpinned.
Acceptance criteria
BLOCK_A(grouping + bare-computed target resolved via(Get-Command …).Source) exits 0 fromblock-dangerous-git.sh,block-no-verify.sh, andblock-hook-bypass.sh.BLOCK_C(grouping + bare-computed target insideforeach) exits 0 from all three hooks.GROUPING_ONLYremains allowed and gains a test pinning it, closing the one unpinnedsingle-factor allowance.
block-dangerous-git.test.sh:794-795and:862-869,block-hook-bypass.test.sh:598-599, andblock-no-verify.test.sh:411all still pass.Related
302a6fa95).#2722narrowing ofps::write_bypass— the bare-computed-target half; recorded in the commentat
ps-command.sh:1062-1071.CONTROL_1973is that fix holding. Notethe in-repo tags for this work read
#1968, which is a different PR.ps-command.sh:441.re-read from
origin/maincontent rather than inferred from issue or PR state.Carried-forward auditor caveats
audit did not observe which single message a live session surfaces when three block at once.
special-constructsink trigger only. Three others exist(
herestring-unbalanced,dynamic-invocation,launcher) and had no dedicated false-positive hunt.bash 5.3.15(1)-releaseon win32 withjq1.8.2 present, so the rc 0 resultsare genuine allows rather than
jq-absent fail-opens. No macOS/BSD/Linux coverage; no Linuxbehavior is asserted.