Skip to content

fix(guardrails): an unspaced assignment no longer hides a PowerShell dynamic invocation or launcher from the fail-closed sink - #2992

Merged
kyle-sexton merged 6 commits into
mainfrom
fix/2984-sink-trigger-eq-lane-e
Aug 21, 2026
Merged

fix(guardrails): an unspaced assignment no longer hides a PowerShell dynamic invocation or launcher from the fail-closed sink#2992
kyle-sexton merged 6 commits into
mainfrom
fix/2984-sink-trigger-eq-lane-e

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Closes #2984

Summary

An unspaced PowerShell assignment ($out=pwsh $script, $a=& "$tool" …) no longer hides a launcher or string-literal call from the fail-closed sink. = is matched as a PowerShell assignment operator, not as a generic token separator, so quoted text and git -c <name>=<value> config overrides stay out of that sink.

Fix

ps::has_dynamic_invocation and ps::has_launcher keep their original separators (space, ;, |, &, (). The #2984 unspaced-assignment hole is a separate $name= / $scope:name= arm (about_Assignment_Operators), scanned on quote-blanked text so quoted spans stay data (about_Quoting_Rules). git(1) -c <name>=<value> (git -c section.key=cmd) has no $name= LHS and is not classified as a launcher assignment.

Each regex is spelled out literally, never shared through a variable — per the file's quote-removal note on pattern position.

Verification

block-dangerous-git.test.sh on adfd2d9a556fda0f2a3f7afc88305f49dd957b97: PASS=458 FAIL=0.

Hook rc (PowerShell PreToolUse envelopes):

command expected measured
$out=pwsh $script 2 2
$a=& "$tool" reset --hard 2 2
$a=& 'git reset --hard' 2 2
Write-Host "shell=pwsh $script" 0 0
Write-Host 'pattern=& "$tool"' 0 0
git -c section.key=cmd log --oneline -n 1 0 0
git -c core.pager=cmd log --oneline -n 1 0 0
$a=Get-Content f.txt 0 0

Direct classification pins:

pin result
ps::has_launcher: quoted shell=pwsh is not a launcher rc=1
ps::has_dynamic_invocation: quoted pattern=& "$tool" is not a call rc=1
ps::has_launcher: git -c section.key=cmd is not a launcher assignment rc=1
ps::has_launcher: $out=pwsh $script still is a launcher assignment rc=0
classify: quoted =pwsh does not enter launcher sink trigger empty
classify: git -c section.key=cmd does not enter launcher sink trigger empty
classify: $out=pwsh $script still enters launcher sink trigger=launcher
Write-Host '$a=& "$tool" reset --hard' 0
ps::has_dynamic_invocation: quoted $a=& rc=1
classify: quoted $a=& does not enter dynamic-invocation sink trigger empty

Also: block-no-verify.test.sh PASS=230 FAIL=0; check-changelog-parity.test.sh PASS=84 FAIL=0.

Related

@cursor

cursor Bot commented Aug 17, 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 17, 2026

Copy link
Copy Markdown
Contributor

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


Security review

  • Gather context (PR diff, changed files)
  • Perform security review of changed files
  • Post findings

The two regex widenings in plugins/guardrails/lib/powershell/ps-command.sh (ps::has_dynamic_invocation, ps::has_launcher) are correct and narrowly scoped — I traced both patterns and confirmed they only add = as a valid boundary character before the existing match targets, matching the PR's own test matrix. This is the fail-closed direction (widening a sink trigger), so it strictly reduces bypass surface for the two functions touched.

However, review of the surrounding file turned up an unaddressed instance of the identical defect class, in the same file, in a sibling sink-trigger function this PR does not touch:

ps::write_bypass's direct cmdlet-name checks still lack = in their separator class, so an unspaced assignment bypasses the PowerShell hook-bypass write guard entirely.

plugins/guardrails/hooks/block-hook-bypass.sh calls ps::write_bypass as the only gate for PowerShell file-write bypasses (block-hook-bypass.sh:1189). Inside it, the cmdlet-name checks use a separator class that — unlike the two classes this PR fixes — was never given =:

Failure scenario: $out=Set-Content -Path f.txt -Value 'malicious content' (no space around =) — the exact idiom this PR's own changelog calls out as "idiomatic PowerShell" for the launcher case. Tracing the control flow in block-hook-bypass.sh, for tool_name: "PowerShell" the only gate is ps::write_bypass (plus a separate, unrelated python-write check). Inside ps::write_bypass:

  • The quoted-call-target check (ps-command.sh:1535) requires &/. call syntax — not present here, so it doesn't fire.
  • The bare-computed-call-target branch (ps-command.sh:1548) also requires &/. — doesn't fire.
  • The direct cmdlet-name check (ps-command.sh:1629) requires the character immediately before set-content to be one of [[:space:]\;\|\&\(\\] (or start-of-string). = is not in that class, so var=set-content (after quote-blanking, case-folding) does not match.
  • The trailing producer-redirect scan (ps-command.sh:1673+) only fires on a > redirect, which isn't present when the write goes through -Value instead.

None of the gates match, so ps::write_bypass returns 1 (not a bypass) and block-hook-bypass.sh falls through to exit 0 — the write executes with no block, silently defeating the guard whose entire purpose is to catch exactly this. The same shape applies to iex/invoke-expression (line 1645), New-Item -Value (1652), and Export-Csv/epcsv (1663) glued to an unspaced =.

This is pre-existing (not introduced by this diff), but it's the same fail-open boundary-disagreement class the PR title and changelog explicitly claim to close ("entry and measurement have to agree on what a token boundary is, in both directions") — left open one function away. Given the PR is already touching this exact file for this exact defect shape, worth closing in the same pass or a fast follow-up: add = to the separator classes at lines 1629, 1638, 1645, 1652, and 1663, plus contrast tests mirroring the ones already added to block-dangerous-git.test.sh (unspaced-= write cmdlet blocks, spaced sibling stays blocked, and an ordinary-assignment allow-side guard like $a=Get-Content f.txt stays allowed).

Fix this →

No other security-relevant issues found. plugins/guardrails/.claude-plugin/plugin.json (version bump) and CHANGELOG.md are metadata-only. The new/changed tests in block-dangerous-git.test.sh correctly exercise both the fail-closed (unspaced =) and allow-side (ordinary assignment idiom) cases.

@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: fa1c06bee4

ℹ️ 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/lib/powershell/ps-command.sh Outdated
@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown

Last security-reviewed head: 9af6550832a999f00d80869c9f05ae3be3aea0bc. On the next push, the relevance gate compares only the commits since this SHA; delete this comment to force a full re-review.

@claude

claude Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 5m 43s —— View job


Code review complete

Note on process: the /review:code-review skill invocation errored out in this environment with no diagnostic detail, so I performed the review manually against origin/main...HEAD (via gh pr diff 2992), following the same correctness-focused scope. I also could not execute the test suite here — bash/script execution required interactive approval this session doesn't have — so the finding below is a careful static regex trace rather than a confirmed test run; please verify it locally.

Summary

The fix itself — spelling = into the ps::has_dynamic_invocation and ps::has_launcher separator classes so the sink-trigger lanes agree with the call-target lanes on what counts as a token boundary — is well-reasoned and consistent with the rest of the file's existing separator classes (ps::might_invoke_git's own launcher probe, the call-target predicates, etc. all already carry =). The PR is thorough about testing the narrow/broad boundary in both directions.

One likely regression, posted as an inline comment on plugins/guardrails/lib/powershell/ps-command.sh line 1062: the widened ps::has_launcher regex appears to also match a git -c <section>.<key>=cmd-style config value (e.g. core.pager=cmd), not just a real PowerShell assignment. Tracing it through ps::classify_git_command and block-dangerous-git.sh's default mutating sink scope, the new test row at block-dangerous-git.test.sh#L1044-L1045git -c core.pager=cmd log --oneline -n 1, pinned as allowed (rc 0) — looks like it will actually return rc 2 (blocked). See the inline comment for the full regex trace and a verification I ran with ripgrep against the extracted pattern. This is exactly the shape the PR itself calls out as "most at risk from the widened class," so it's worth confirming with a real test run before merge.

No other correctness or maintainability issues found; the CHANGELOG.md/plugin.json version bump is consistent with the fix, and the rest of the new test coverage (dynamic-invocation cases, the ordinary-assignment allow guards, the sibling-spelling parity table) looks sound.

Comment thread plugins/guardrails/lib/powershell/ps-command.sh Outdated
@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown

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

@claude

claude Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

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


Security review

  • Gather context (PR diff, previously reviewed head, changed files)
  • Determine relevant delta since last-reviewed head
  • Perform security review of changed files
  • Post findings

No security vulnerabilities found.

Scope of this pass

The last security review on this PR covered head fa1c06bee45d8943185a6b028baf50cd46ae13b2. Since then, exactly one commit was added — 9af6550 — and it touches only plugins/guardrails/hooks/block-dangerous-git.test.sh. No production code (ps-command.sh, plugin.json) changed in this delta, so there is no new security-relevant surface to review.

What that commit does

It re-pins the git -c core.pager=cmd log --oneline -n 1 test row from expecting rc=0 to expecting rc=2, and adds three companion rows (core.pager=cat stays allowed, plain git log stays allowed, a git-free =-glued launcher token stays allowed). This directly responds to the regression both Codex and the earlier Claude pass flagged against the parent commit: the widened = separator in ps::has_launcher does make that config-value shape enter the fail-closed sink. Rather than narrowing the regex, the author accepted the block as correct and documented why: ps::git_command_is_readonly's own commentary already names -c core.pager=./x as a spelling that turns a read-only git log into arbitrary local execution, and the file's stated invariant (ps-command.sh:55, unchanged) is "over-block, never under-block." Blocking a =-glued launcher token — even one sitting in a git config value rather than a real assignment — is the fail-closed direction, not a vulnerability; it cannot be exploited to bypass a guard, only to (correctly, per the file's own documented risk) over-trigger one. The new companion rows confirm the trigger is genuinely the launcher word and not = alone, and that git-free commands still pass.

The previously-open ps::write_bypass separator-class gap (flagged in the first review pass) remains real but is unchanged by this PR and lives in a sibling function this diff never touches — out of scope here as a pre-existing issue, not one introduced by this change.
· branch fix/2984-sink-trigger-eq-lane-e

@claude

claude Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

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


/review:code-review — CI code-review lane

  • Gather context (PR diff at HEAD 9af6550, description, prior review threads)
  • Run skip gate (open, non-draft, non-trivial, no prior code-review-lane pass on this head)
  • Review plugins/guardrails/lib/powershell/ps-command.sh regex changes
  • Review plugins/guardrails/hooks/block-dangerous-git.test.sh additions, including the latest re-pin commit
  • Check whether the previously-flagged git -c core.pager=cmd false-positive was addressed
  • Check maintainability/consistency of CHANGELOG.md / plugin.json
  • Post findings

Scope note

This lane covers correctness/maintainability; security-relevant findings are left to /review:security-review, which has already run on this PR.

Summary

The fix is sound: = is added literally to ps::has_dynamic_invocation's and ps::has_launcher's separator classes (ps-command.sh#L1035, ps-command.sh#L1062), matching the boundary set the call-target predicates and ps::might_invoke_git's own launcher probe already used. Not factoring = through a shared variable is consistent with the file's existing convention (each predicate spells its class out literally, per the quote-removal note that precedes these functions).

The regression flagged by the earlier Codex and Claude reviews on this PR (widened has_launcher matching git -c core.pager=cmd log as a launcher) has been resolved in the latest commit (9af6550) — not by narrowing the regex, but by re-pinning that test row to its now-correct rc=2 and adding a written parity argument: every sibling separator (space, |, ;) for the identical core.pager=cmd collision already blocked on the pre-fix base, so only the =-glued spelling was the hole, not a special case deserving an allow. I traced this myself against ps::classify_git_command and ps::might_invoke_git: the new match is real, but it only reaches a block when the command also contains a literal git token (verified via the readonly-ok/mutating sink path at ps-command.sh#L1106), and the added benign-neighbour rows (core.pager=cat, plain git log, a git-free =cmd token) confirm the trigger is the launcher word co-occurring with git, not = alone. This reasoning holds up and the accompanying comment correctly cites the file's own -c core.pager=./x arbitrary-execution note (ps-command.sh#L888) as the reason fail-closed is the correct answer here, not a bug to route around.

I did not find any additional correctness issues in the diff. One low-severity, non-blocking observation: ps::has_launcher has a second caller besides ps::classify_git_command — the computed-launcher branch of ps::might_write_via_python3 at ps-command.sh#L972 — which this PR doesn't add coverage for. The widened = boundary theoretically extends that branch's fail-closed reach too (an =-glued launcher co-occurring with an unquoted $/( plus a Python write indicator would now also block where it previously wouldn't), but that branch is already documented as an "ACCEPTED OVER-BLOCK" in this codebase's stated design, so this isn't a regression — just an untested-by-this-PR edge of the same widening. Not blocking; a follow-up test row in block-hook-bypass.test.sh mirroring the git-lane pattern would close the loop if desired.

Verification note: I could not execute block-dangerous-git.test.sh in this environment (script execution requires interactive approval unavailable to this lane), so the trace above is static analysis of the regex/control-flow, not a confirmed test run — consistent with the caveat the PR's own prior review passes noted for the same reason.
· branch fix/2984-sink-trigger-eq-lane-e

@kyle-sexton

Copy link
Copy Markdown
Contributor Author

Session stopped here — state and remaining work, for whoever picks this up

The lane working this PR was stopped mid-flight. Nothing is lost; the worktree was clean and both commits are pushed. This comment carries what would otherwise have died with the session.

Where this stands

Head is 9af6550832a999f00d80869c9f05ae3be3aea0bc, two commits: the fix, plus a pin for the =-glued launcher collision at its measured rc.

The defect, measured independently against origin/main at fdbc42137 before the work started — columns are hook-bypass / dangerous-git / no-verify:

$a=& "$tool" commit --no-verify       0 / 0 / 0     <- fail-open
$a = & "$tool" commit --no-verify     0 / 2 / 2     <- spaced contrast
$out=pwsh $script                     0 / 0 / 0     <- fail-open
$out = pwsh $script                   0 / 2 / 2     <- spaced contrast

The only difference in each pair is whitespace around =.

What is NOT yet done

  1. The other-=-gaps sweep was in progress and is unfinished. The lane's last recorded position was "no slips found, now checking the other = gaps." Fix in this PR only what can be measured in BOTH directions; anything else gets filed as its own issue rather than shipped as an unmeasured widening.
  2. The full probe table has not been re-run against the current head: the four closure rows above, the six over-block guards ($out=pwsh $script, $x=cmd $t, $p=Start-Process $app, $a=Get-Content f.txt, $env:PATH=$env:PATH, and a Latin-1 row such as an accented script name), the six issue-2848 acceptance cases, and negative controls that must exit 2 on both the Bash and PowerShell surfaces.
  3. CI state and review threads are unchecked. Threads are the merge gate in this repo, not approvals. Resolve each by measuring the claim and replying with the table, not by arguing it.
  4. The fresh-context verifier has not run. Brief it to break the fix rather than confirm it.

Why the verifier is not a formality here

The sibling lane's PR 2983 had fully green CI — 47 success, 1 skipped — and 25 pinned tests, and still introduced a new fail-open while closing one: a command that merged main blocks at 2/2 was waved through at 0/0 by that branch. Green CI is not evidence of correctness in this file; only an adversarial probe is.

This change widens a sink trigger, which is the over-block direction — but a regex edit that silently stops matching fails OPEN, and this file's own block comment says exactly that about variable-in-pattern-position constructs. So probe each edited pattern individually; one probe for the family proves nothing about which pattern actually fired. Pin the allow side too, so a future widening cannot silently over-block ordinary assignment idiom.

Standing constraints for this repo

Verify merged content by executing probes, never by reading PR or issue state. Diff against the merge commit's true parent, not the recorded base. Check base staleness by file overlap immediately before merging. Squash is the only permitted merge method. Never use --no-verify. Every new pinned case must assert an exact rc — a prior PR shipped a case asserting only a non-zero rc that passed identically with the guard deleted. On this gh version, gh pr checks accepts state but not conclusion as a JSON field.

A sibling lane is live on the same file at roughly line 300 (PR 2983). Whoever merges second rebases.

Related

  • issue 2928 / PR 2966 — added = to the call-target classes; this is the sink-trigger half of the same boundary defect.
  • issue 2965 / PR 2983 — the sibling entry-side fix in the same file.

@cursor
cursor Bot force-pushed the fix/2984-sink-trigger-eq-lane-e branch from 9af6550 to 598aae8 Compare August 21, 2026 08:06
@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Claude finished @cursor[bot]'s task in 5m 41s —— View job


/review:code-review — CI code-review lane

  • Gather context (PR diff at HEAD 598aae8, description, prior review threads)
  • Check base staleness / sibling-lane overlap (PR 2983 touches the same file)
  • Review production code changes since last-reviewed head
  • Review test additions/re-pins in block-dangerous-git.test.sh
  • Check CHANGELOG.md / plugin.json consistency
  • Post findings

Scope note

This lane covers correctness/maintainability; security-relevant findings are left to /review:security-review. Note: the security lane's last pass covered head 9af6550; the final commit 598aae8 substantially rewrote the fix (see below) and has not been security-reviewed yet — worth a fresh security pass before merge.

Summary

The final commit (598aae8) is a significant rework of what earlier review passes on this PR saw, and it resolves both regressions flagged against the prior version:

  • Codex's P1 (quoted text like Write-Host "shell=pwsh $script" tripping the sink) — fixed by requiring an actual $name= / $scope:name= assignment shape (about_Assignment_Operators) rather than a bare = in the generic separator class, confirmed quote-blanked.
  • My own earlier flagged regression (git -c core.pager=cmd matching as a launcher) — fixed for the same reason: core.pager=cmd has no $-prefixed LHS, so it no longer matches. Test row block-dangerous-git.test.sh:1046 correctly re-pins this at rc=0.

I traced both new predicates (ps::has_dynamic_invocation at ps-command.sh:1030-1043, ps::has_launcher at ps-command.sh:1055-1070) by hand against the added test fixtures and against hypothetical inputs (e.g. $a="foo".bar, git -c section.key=cmd, fully-quoted $out=pwsh). The two-stage design in has_dynamic_invocation (structural match on quote-intact text requiring the call operator immediately after =, then a quote-blanked confirmation) correctly avoids the false positives I tried to construct. ps::classify_git_command still passes PS_BLANKED (here-string-blanked, but regular-quote-intact) into both predicates, so their "operates on quote-intact text" premise still holds — unchanged by this diff.

I could not execute block-dangerous-git.test.sh or shellcheck in this environment (script/binary execution requires interactive approval unavailable to this lane), so this is a static trace, not a confirmed run — consistent with the caveat earlier passes on this PR noted for the same reason. Given the file's own "over-block, never under-block" invariant and this PR's extensive measured-parity methodology, I'd weight the static trace fairly high confidence here, but a local test run is still the authoritative check.

One test-coverage finding, posted inline on block-dangerous-git.test.sh:1099-1100: the fixture used to test that quoted text doesn't trip has_dynamic_invocation (pattern=& "$tool") lacks the $ sigil that the predicate's structural check requires, so it never actually reaches the quote-blanking confirmation step it's meant to exercise — unlike the parallel, correctly-constructed has_launcher coverage at lines 1065-1066 ('$out=pwsh $script'). Production code traces correctly regardless; this is a coverage gap, not a bug.

CHANGELOG.md and plugin.json are consistent with the shipped fix (0.29.3 → 0.29.4), and the changelog entry accurately reflects the final implementation, including the git -c and quoted-text exclusions.

Not re-litigated

  • The pre-existing ps::write_bypass separator-class gap (flagged in the first security review pass) remains out of scope — untouched by this diff.
  • Sibling PR 2983 touches the same file around a different region; a rebase will be needed on whichever of the two merges second, per the author's own note.
    · branch fix/2984-sink-trigger-eq-lane-e

kyle-sexton and others added 4 commits August 21, 2026 08:20
…dynamic invocation or launcher from the fail-closed sink

PR 2966 added = to the CALL-TARGET separator classes. The SINK-TRIGGER
classes one layer up -- ps::has_dynamic_invocation and ps::has_launcher --
still lacked it, so the two lanes disagreed about what a token boundary is.

This is the mirror image of issues 2922/2924: there gate ENTRY was BROADER
than every measuring predicate, so the gate was entered and no arm fired; here
entry was NARROWER than measurement, so the sink was never entered and the
measuring predicates never ran. Both directions fail OPEN.

Closes #2984

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Q4QNEensQgkYm8vByTydJ
git -c core.pager=cmd log fails closed after the widening. That is parity:
the identical collision already blocked on the pre-fix base under a space or
pipe separator, and only the =-glued spelling did not. Benign neighbours
(core.pager=cat, plain git log, a =-glued launcher token with no git) are
pinned alongside it to show the trigger is the launcher word, not the =.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Q4QNEensQgkYm8vByTydJ
…ot a generic separator

The first pass put `=` in the same class as `;` `|` `&`. That closed #2984
(`$out=pwsh`, `$a=& "…"`) but also matched data inside quotes
(about_Quoting_Rules) and git(1) `-c <name>=<value>`
(`git -c section.key=cmd`).

`=` is now a separate `$name=` / `$scope:name=` arm, scanned quote-blanked.
The #2984 unspaced-assignment cases still enter the sink.

Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
…terals

The new quoted-equals and classifier pins keep `$script` / `$tool` /
`$out` inside single quotes so bash does not expand them. Add the same
per-command SC2016 disable the rest of this file already uses.

Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
@cursor
cursor Bot force-pushed the fix/2984-sink-trigger-eq-lane-e branch from 25d396d to 80739f9 Compare August 21, 2026 08:20
Comment thread plugins/guardrails/hooks/block-dangerous-git.test.sh
@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Claude finished @cursor[bot]'s task in 5m 23s —— View job


/review:code-review — CI code-review lane

  • Gather context (PR diff at HEAD 80739f9, description, prior review threads)
  • Run skip gate (open, non-draft, non-trivial; prior lane passes were on earlier, now-superseded heads — re-review needed)
  • Review plugins/guardrails/lib/powershell/ps-command.sh changes
  • Review plugins/guardrails/hooks/block-dangerous-git.test.sh changes, including the assignment-arm redesign and SC2016 cleanup
  • Check maintainability/consistency of CHANGELOG.md / plugin.json
  • Post findings

Scope note

This lane covers correctness/maintainability; security-relevant findings are left to /review:security-review, which has already run on this PR (and flagged a pre-existing, out-of-scope ps::write_bypass gap this diff does not touch).

Summary

No blocking correctness or maintainability issues found.

The design settled on in the latest fix (e2f60ca) replaces the earlier "add = as a generic separator" approach with a dedicated $name= / $scope:name= assignment arm in both ps::has_dynamic_invocation (ps-command.sh#L1137) and ps::has_launcher (ps-command.sh#L1164), scanned on ps::blank_quoted_spans-processed text (a pre-existing, already-tested helper — no new quote-handling code was written for this fix). This directly resolves both regressions flagged by earlier review passes on this PR:

  • Codex's quoted-text false positive (Write-Host "shell=pwsh $script" / Write-Host 'pattern=& "$tool"' wrongly entering the sink). I traced both predicates by hand: ps::blank_quoted_spans deletes the entire quoted span before the assignment-arm regex runs, so no $name= survives inside quotes, and the bare (non-assignment) arms still require a real separator character immediately before the launcher/call token — = was never added to those. Confirmed the pinned rows (ps-command.sh pins at block-dangerous-git.test.sh#L1097-L1118) match this trace.
  • The earlier Claude pass's git -c core.pager=cmd false positive. Since the assignment arm requires a literal $name/$scope:name immediately before =, a bare core.pager=cmd config value (no $ at all) cannot match either arm. Traced independently against git -c core.pager=cmd log --oneline -n 1 and git -c section.key=cmd log --oneline -n 1 — neither reaches the launcher sink. The test file's history shows this was pinned blocked in f800acd, then correctly re-pinned back to allowed in e2f60ca once the design changed, with a comment explaining why (block-dangerous-git.test.sh#L1041-L1046).
  • The #2984 unspaced-assignment cases ($out=pwsh $script, $a=& "$tool" reset --hard, $a=& 'git reset --hard') still correctly enter the sink under the new arm — traced through both the quote-intact pre-filter and the blanked-text confirmation in ps::has_dynamic_invocation.
  • Allow-side guards ($a=Get-Content f.txt, $env:PATH=$env:PATH, $x=.5, Write-Output x=cmd y) correctly fall through: none has a launcher/call-target token immediately on the RHS of a $name=.

Consistency check: PS_SINK_TRIGGER="launcher" (ps-command.sh#L1201) is set exactly where ps::has_launcher fires inside ps::classify_git_command, matching what the new pin_sink_trigger test helper (block-dangerous-git.test.sh#L1078-L1096) asserts, and it calls ps::classify_git_command PowerShell "$cmd" with the same argument order the hook itself uses at block-dangerous-git.sh#L1378. ps-command.sh has an include-guard (_GUARDRAILS_PS_COMMAND_LOADED, line 69) so the test file's direct source of it is safe even though the hook under test also sources it transitively. No shellcheck SC2016 gaps remain — every single-quoted literal containing $script/$tool/$out has its own disable comment (verified by reading the final file state, not just the diff).

The regex classes remain spelled out literally in each predicate rather than shared through a variable, consistent with the file's own quote-removal convention. CHANGELOG.md/plugin.json are consistent with the fix and version-bumped correctly (0.29.40.29.5).

Verification note

I could not execute block-dangerous-git.test.sh (or any bash/script invocation) in this environment — only git/gh/Read/Grep/Glob are available to this lane, consistent with what earlier review passes on this PR also reported. The analysis above is an independent static trace of the regex and control flow against the specific cases the PR's test matrix claims to fix, not a confirmed test run; the PR's own PASS=458 FAIL=0 claim is author-reported and unverified by me.

Non-blocking observation

The stopped-session tracking comment mentions a planned Latin-1/accented-name probe row that was never added to the test file. ${lc,,} case-folding predates this diff (same pattern already used by the original, untouched arm of ps::has_launcher), so this isn't a regression from this change — just a coverage gap in the broader sweep the author flagged as unfinished. Not blocking.
· branch fix/2984-sink-trigger-eq-lane-e

…ng is exercised

The Codex `pattern=&` row fails the `$name=` structural check even unquoted.
`Write-Host '$a=& "$tool" reset --hard'` is the assignment-shaped call inside
quotes, so it reaches the quote-blanked confirmation the way the launcher
`$out=pwsh` pin already does.

Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Claude finished @cursor[bot]'s task in 4m 4s —— View job


/review:code-review — CI code-review lane

  • Gather context (PR diff at HEAD adfd2d9, description, prior review threads)
  • Run skip gate (open, non-draft; one new commit since last-reviewed head 80739f9 — re-review scoped to that delta)
  • Review plugins/guardrails/lib/powershell/ps-command.sh changes since last-reviewed head (none — this delta is test-only)
  • Review plugins/guardrails/hooks/block-dangerous-git.test.sh changes since last-reviewed head
  • Check CHANGELOG.md / plugin.json consistency
  • Post findings

Scope note

This lane covers correctness/maintainability; security-relevant findings are left to /review:security-review. The only change since this lane's last pass (head 80739f9) is commit adfd2d9, which touches only block-dangerous-git.test.sh (test-only, +12/-0). No production code changed in this delta.

Summary

That commit was added specifically to close a test-coverage gap I flagged in an earlier pass on this PR: the pattern=& fixture used to test ps::has_dynamic_invocation's quote-blanking never actually reached the blanking-confirmation step, because it lacked the $ sigil the predicate's structural pre-check requires. The new commit swaps in Write-Host '$a=& "$tool" reset --hard' (a genuine $name=&"..." shape, fully quoted) intending to exercise that step.

One finding, posted inline on block-dangerous-git.test.sh:1106-1111: tracing ps::has_dynamic_invocation by hand (plugins/guardrails/lib/powershell/ps-command.sh:1126-1139), the new fixture still doesn't reach the blanking-confirmation branch — for a different reason than before. The predicate is two-stage: a quote-intact structural pre-check (ps-command.sh#L1136) whose boundary class before $name is space/;/{/}/(/|/& — deliberately excluding quote characters — and only on a match does it fall through to the quote-blanked confirmation (ps-command.sh#L1137-L1138). In the new fixture, $a sits directly against the opening ', which isn't in that boundary class, so stage 1 itself fails to match and the function short-circuits on line 1136's || return 1 — line 1137's blank_quoted_spans call is never invoked. The pinned rc (1, "not a call") is still correct, but the assertion and its accompanying comment ("so it reaches the quote-blanked confirmation the way the launcher $out=pwsh pin already does") describe a code path this input doesn't take. ps::has_launcher's analogous pin is fine by contrast — that predicate blanks unconditionally before matching, so it has no equivalent stage-1 gate to dodge.

This is not a production bug: ps::has_dynamic_invocation correctly classifies both this fixture and the real #2984-closing case (the unquoted $a=& "$tool" reset --hard) either way. It's a test-accuracy/coverage issue — closing it would need a fixture where $name= sits right after one of stage 1's boundary characters while still fully inside the quoted span (e.g. a "..."-wrapped string containing ; $a=& '...'), so that blanking is actually what excludes it. Full trace and a concrete alternative fixture are in the inline comment.

CHANGELOG.md and plugin.json are unaffected by this delta and remain consistent with the fix (0.29.5).

Not re-litigated

Comment thread plugins/guardrails/hooks/block-dangerous-git.test.sh
…ctually runs

`$a` glued to the opening quote fails the quote-intact predecessor class
before blanking runs. `Write-Host "x; $a=& 'ls'"` puts `$name=` after `;`
inside a double-quoted span, so the structural check matches and
quote-blanking is what rejects it.

Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
@kyle-sexton
kyle-sexton merged commit 0aced45 into main Aug 21, 2026
49 checks passed
@kyle-sexton
kyle-sexton deleted the fix/2984-sink-trigger-eq-lane-e branch August 21, 2026 09:02
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.

guardrails: the PowerShell sink-trigger classes still lack =, so $a=& "$tool" commit --no-verify never reaches the fail-closed sink

2 participants