Skip to content

guardrails: 2592's command-position fix leaves the grouping + bare-computed-call conjunction blocked by all three hooks, and pinned by no test #2848

Description

@kyle-sexton

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_2592Get-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_ONLYforeach ($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 laneps::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 laneps::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

  • BLOCK_A (grouping + bare-computed target resolved via (Get-Command …).Source) exits 0 from
    block-dangerous-git.sh, block-no-verify.sh, and block-hook-bypass.sh.
  • BLOCK_C (grouping + bare-computed target inside foreach) exits 0 from all three hooks.
  • Both are pinned by tests asserting rc 0 against all three hooks, not the git lane alone.
  • GROUPING_ONLY remains allowed and gains a test pinning it, closing the one unpinned
    single-factor allowance.
  • The existing pins at block-dangerous-git.test.sh:794-795 and :862-869,
    block-hook-bypass.test.sh:598-599, and block-no-verify.test.sh:411 all still pass.

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions