Problem
The PowerShell classifier (plugins/guardrails/lib/powershell/ps-command.sh) and the five blocking guards that consume it have no committed way to prove a change never allows what main refuses. The #4303 lane's quote-walk rewrite went green on every suite round and was still withdrawn after four adversarial passes and a CI bot each found a command main blocked and the rewrite allowed. Every fix to the live bypasses in the sibling issue (draft 20260921-164559-b) needs that proof first.
Two smaller defects on current main are buildable now and need no design decision:
block-dangerous-git.sh:1451-1456 exits 0 once _ps_sink_attempts passes 4: "Only opaque residue left after bounded blanks, treat as allowed." With all five ps-unparsable-* allow tokens set, a five-trigger command carrying git reset --hard reaches the cap and is allowed with the destructive text still visible (measured by an audit validator on main b877295). This breaks the library's "over-block, never under-block" invariant (ps-command.sh:57-62). It fails open only for operators who configured tokens, so it is not a default-config bypass.
block-convention-violation.sh:621 and block-noncanonical-commit.sh:934 refuse only when PS_SINK_TRIGGER == herestring-comment-char. That name is set only when no other trigger fired (ps-command.sh:1755-1757). A flagged command whose first trigger is another construct (for example { raising special-construct) returns under that name, and both guards defer. Keying on the flag (PS_HERESTRING_OPENER_COMMENT_CHAR) instead is a strict superset and only moves rc from 0 to 2.
Evidence
Verified this pass (2026-09-27, main a6ba321):
git log b877295b7..HEAD over lib/powershell, block-dangerous-git.sh, block-convention-violation.sh, block-noncanonical-commit.sh, block-no-verify.sh: no commits, so the ledger's line citations hold (convention guard line moved 620 to 621).
- Read
block-dangerous-git.sh:1406-1460 and ps-command.sh:383-392, 1728-1760.
Carried from the 2026-09-23/24 interview and two-validator audit (not re-measured this pass): the budget-arm measurement above, and the Q10 trace for the commit guards.
Proposed approach
Three slices, in this order, each a draft PR:
scripts/check-guardrails-ps-differential.sh plus a check-guardrails-ps-differential.test.sh sibling (repo convention: scripts/check-*.sh with a .test.sh). It extracts the base ref's plugins/guardrails with git archive into a temp dir (no worktree), runs a corpus through the blocking PowerShell consumers on both trees, and exits nonzero on any cell where base refuses (2) and the branch does not. It prints the cell table. Required fixes from the audit:
- Pin or unset
CLAUDE_PLUGIN_ROOT per tree. guard-requires.sh:141 sources ${CLAUDE_PLUGIN_ROOT:-$_GUARD_REQUIRES_DIR/..}/<lib>, so without this both arms load one library and prove nothing.
- Run the 32-subset allow-token powerset on block-dangerous-git only (the only guard that reads
CLAUDE_PLUGIN_OPTION_BLOCK_DANGEROUS_GIT_ALLOW), and only for commands whose token-free result is 2. A full powerset over every guard costs about 20 hours at about 1.5 s per call on Windows.
- Drive the production path too:
run-guards.sh --lib lib/powershell/ps-command.sh ... (hooks.json Bash row), which shares one library load per process.
- Corpus: commands harvested from the guard suites, plus every payload listed in draft 20260921-164559-b and their literal-git,
Set-Content write, and parse-clean variants.
- Consumers: block-dangerous-git, block-no-verify, block-convention-violation, block-noncanonical-commit, block-hook-bypass. flag-commit-pr-skill-bypass is advisory (always 0) and has no cell to show.
- Budget arm: change the
exit 0 at block-dangerous-git.sh:1452-1456 to a refusal with its own telemetry form token and a message naming budget exhaustion. Audit note: the cap is checked after the increment, so consider one final re-classify of the remainder before refusing.
- Flag-keyed commit guards: test the flag, not the trigger name, at
block-convention-violation.sh:621 and block-noncanonical-commit.sh:934.
Acceptance criteria
Constraints and gotchas
Context
Source: local handoff item 20260921-164559-guardrails-powershell-classifier-nine-residual-bypasses-measured-on-main.md (retired into this issue and draft 20260921-164559-b). Prior: #4302 / PR #4303. The item's two "also seen" notes are dropped: the block-hook-bypass.test.sh temp-dir leak did not reproduce for either audit validator, and the security-review lane is not defined in this repo's workflows (ci.yml:39 already lists synchronize).
Problem
The PowerShell classifier (
plugins/guardrails/lib/powershell/ps-command.sh) and the five blocking guards that consume it have no committed way to prove a change never allows what main refuses. The #4303 lane's quote-walk rewrite went green on every suite round and was still withdrawn after four adversarial passes and a CI bot each found a command main blocked and the rewrite allowed. Every fix to the live bypasses in the sibling issue (draft 20260921-164559-b) needs that proof first.Two smaller defects on current main are buildable now and need no design decision:
block-dangerous-git.sh:1451-1456exits 0 once_ps_sink_attemptspasses 4: "Only opaque residue left after bounded blanks, treat as allowed." With all fiveps-unparsable-*allow tokens set, a five-trigger command carryinggit reset --hardreaches the cap and is allowed with the destructive text still visible (measured by an audit validator on main b877295). This breaks the library's "over-block, never under-block" invariant (ps-command.sh:57-62). It fails open only for operators who configured tokens, so it is not a default-config bypass.block-convention-violation.sh:621andblock-noncanonical-commit.sh:934refuse only whenPS_SINK_TRIGGER == herestring-comment-char. That name is set only when no other trigger fired (ps-command.sh:1755-1757). A flagged command whose first trigger is another construct (for example{raisingspecial-construct) returns under that name, and both guards defer. Keying on the flag (PS_HERESTRING_OPENER_COMMENT_CHAR) instead is a strict superset and only moves rc from 0 to 2.Evidence
Verified this pass (2026-09-27, main a6ba321):
git log b877295b7..HEADoverlib/powershell,block-dangerous-git.sh,block-convention-violation.sh,block-noncanonical-commit.sh,block-no-verify.sh: no commits, so the ledger's line citations hold (convention guard line moved 620 to 621).block-dangerous-git.sh:1406-1460andps-command.sh:383-392, 1728-1760.Carried from the 2026-09-23/24 interview and two-validator audit (not re-measured this pass): the budget-arm measurement above, and the Q10 trace for the commit guards.
Proposed approach
Three slices, in this order, each a draft PR:
scripts/check-guardrails-ps-differential.shplus acheck-guardrails-ps-differential.test.shsibling (repo convention:scripts/check-*.shwith a.test.sh). It extracts the base ref'splugins/guardrailswithgit archiveinto a temp dir (no worktree), runs a corpus through the blocking PowerShell consumers on both trees, and exits nonzero on any cell where base refuses (2) and the branch does not. It prints the cell table. Required fixes from the audit:CLAUDE_PLUGIN_ROOTper tree.guard-requires.sh:141sources${CLAUDE_PLUGIN_ROOT:-$_GUARD_REQUIRES_DIR/..}/<lib>, so without this both arms load one library and prove nothing.CLAUDE_PLUGIN_OPTION_BLOCK_DANGEROUS_GIT_ALLOW), and only for commands whose token-free result is 2. A full powerset over every guard costs about 20 hours at about 1.5 s per call on Windows.run-guards.sh --lib lib/powershell/ps-command.sh ...(hooks.json Bash row), which shares one library load per process.Set-Contentwrite, and parse-clean variants.exit 0atblock-dangerous-git.sh:1452-1456to a refusal with its own telemetry form token and a message naming budget exhaustion. Audit note: the cap is checked after the increment, so consider one final re-classify of the remainder before refusing.block-convention-violation.sh:621andblock-noncanonical-commit.sh:934.Acceptance criteria
scripts/check-guardrails-ps-differential.sh <base-ref>exists, its.test.shpasses, and it fails when fed a planted branch that allows a base-refused command.ps-command.sh).run-guards.sh --lib.ps-unparsable-*tokens set, a command carrying five sink triggers plusgit reset --hardexits 2 from block-dangerous-git (main: 0).PS_HERESTRING_OPENER_COMMENT_CHARis 1, whatever trigger fired first.scripts/check-changelog-parity.sh --check-bumppasses.Constraints and gotchas
ps-command.sh:150-165): no pwsh spawn on the hook path. perf(guardrails): make the PowerShell guard path fork-free and narrow its fail-close to executable git #4188 took the PowerShell lane from 80 process creations to 3.block-dangerous-git.sh:1406-1460.Context
Source: local handoff item 20260921-164559-guardrails-powershell-classifier-nine-residual-bypasses-measured-on-main.md (retired into this issue and draft 20260921-164559-b). Prior: #4302 / PR #4303. The item's two "also seen" notes are dropped: the
block-hook-bypass.test.shtemp-dir leak did not reproduce for either audit validator, and the security-review lane is not defined in this repo's workflows (ci.yml:39already listssynchronize).