Skip to content

fix(guardrails): fd-dup merges and PowerShell token separators no longer hide computed writer calls - #2966

Merged
kyle-sexton merged 1 commit into
mainfrom
fix/ps-fd-dup-and-separator-classes
Aug 17, 2026
Merged

fix(guardrails): fd-dup merges and PowerShell token separators no longer hide computed writer calls#2966
kyle-sexton merged 1 commit into
mainfrom
fix/ps-fd-dup-and-separator-classes

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

Closes #2927
Closes #2928

Two proven fail-opens in the PowerShell lane of the guardrails hook-bypass guard. Both let a real file write through — each shape parses clean under [System.Management.Automation.Language.Parser]::ParseInput and writes the file under a real pwsh. Both are pre-existing rather than 0.28.x regressions: every bypass row measures 0 on the pre-0.28.33 base as well.

Issue 2927 — an fd-dup merge truncated the operand region

ps::call_site_operand_region ends a call's operand region at a statement or pipeline separator (; | &) at bracket depth ZERO. The & inside 2>&1 sits at depth zero, so the region of & $w 2>&1 f.txt x was truncated to " 2>" and both measuring probes went silent.

ps::write_bypass already knew fd-dup merges are plumbing rather than writes and stripped them — but only into a separate gate variable that fed the > redirect probe alone, while ps::computed_call_has_splat_operand and ps::computed_call_has_positional_write_signal were handed the unstripped text. That divergence between what the gate stripped and what the probes measured WAS the defect. The strip now runs once, before every probe in the branch, and the separate gate variable is gone.

Issue 2928 — call-site boundaries came from bash character classes, not PowerShell's tokenizer

= is now in the separator class of every call-target predicate: gate entry (ps::call_target_is_bare_computed), the subexpression half (ps::call_target_is_bare_subexpression), the interpolating-string half, the re_var of both measuring probes, the quoted-writer regex in ps::write_bypass, and ps::might_invoke_git's launcher class. Entry and measurement move together on purpose — widening entry alone is the exact "gate admits, probes cannot see" mechanism behind issue 2922 and issue 2924, so widening one without the others would have manufactured a third instance of it.

Unicode whitespace is closed by NORMALIZING at intake, not by widening the classes. Under a single-byte locale a multi-byte sequence inside a bracket expression decomposes into independent byte members, and \xa0 is the second byte of à — so a class-widening fix would have split ordinary accented paths into extra operands, an over-block of exactly the class issue 2848 exists to keep closed. ps::blank_herestrings now maps every token-separating code point to an ASCII space, spelled as raw UTF-8 byte sequences ($'\xc2\xa0') so the substitution is byte-identical under the C and UTF-8 locales. The guards pin no locale, so a fix that only worked under one would fail open under the other.

The separator set was derived by MEASUREMENT, not from a Unicode category table: each candidate was parsed and kept only when & $w<CH>f.txt x produced ONE command of THREE elements. U+200B and U+FEFF measured two elements — they sit inside the token, the target never resolves, and there is nothing to hide — so they are excluded and pinned as such. ,& $w f.txt x, raised in the issue as a possible third member, was ruled out the same way: it does not parse ("Missing expression after unary operator ','.").

Probe table

rc from each hook, payloads built with jq as real PreToolUse envelopes. base = 6dc12e45a. Attribution measured on two extra trees, each carrying exactly one of the two fixes.

The two fail-opens, with per-fix attribution

# surface command base 2927 fix only 2928 fix only both closed by
A1 PowerShell & $w 2>&1 f.txt x 0 2 0 2 2927
A2 PowerShell & $w 2>&1 @p 0 2 0 2 2927
A3 PowerShell & $env:w 2>&1 f.txt x 0 2 0 2 2927
A4 PowerShell 'x' | & $w 2>&1 f.txt 0 2 0 2 2927
A5 PowerShell & $w; & $w2 2>&1 f.txt x 0 2 0 2 2927
B1 PowerShell $a=& $w f.txt x 0 0 2 2 2928 (=)
B2 PowerShell & $w<U+00A0>f.txt x 0 0 2 2 2928 (normalize)
B3 PowerShell & $w<U+2003>f.txt x 0 0 2 2 2928 (normalize)
B4 PowerShell $a=& $w<U+00A0>f.txt x 0 0 2 2 2928 (both halves)
B5 PowerShell & $w<U+00A0>@p 0 0 2 2 2928 (normalize)
B6 PowerShell $a=& 'Set-Content' f.txt x 0 0 2 2 2928 (=, quoted-writer regex)
B7 PowerShell $a=& ($w) f.txt x 0 0 2 2 2928 (=, subexpression predicate)

Neither fix closes any of the other's rows. An all-rc=2 union would not have distinguished them.

Bonus closes on the git lanes (the normalization sits at intake)

# surface command base both hook
C1 PowerShell git commit --no-verify<U+00A0>-m x 0 2 block-no-verify
C2 PowerShell git<U+00A0>commit --no-verify -m x 0 2 block-no-verify
C3 PowerShell git push --force<U+00A0>origin main 0 2 block-dangerous-git
C4 PowerShell git<U+00A0>reset --hard 0 2 block-dangerous-git
C5 PowerShell $p=Start-Process ('g'+'it') reset 0 2 block-dangerous-git, block-no-verify

ACCEPTED behavior change

# surface command base both why it is accepted
G6 PowerShell & $py a.py 2>&1 b.txt 0 2 With the merge stripped, one literal positional before it and one after it read as the Path+Value pair. Consistent with & $py script.py arg, which already blocked. The class is narrow: it needs a positional on BOTH sides of the merge — & $npm run build 2>&1 log.txt was already 2 at base.

The six issue-2848 must-allow cases — rc=0 on all three hooks, base AND merged

# command bypass dang-git no-verify
M1 $py = "..."; if (-not (Test-Path $py)) { $py = (Get-Command python).Source }; & $py run.py --flag 0 0 0
M2 & $py $script (Join-Path $dir "$id.jsonl") 0 0 0
M3 & $py -m unittest discover 0 0 0
M4 . $PROFILE 0 0 0
M5 & $py script.py 0 0 0
M6 foreach ($x in @('a')) { & $w f.txt } 0 0 0

Negative controls — the hooks are not silently no-opping

# surface command bypass dang-git no-verify
N1 Bash git commit --no-verify -m x 0 0 2
N2 Bash git push --force origin main 0 2 0
N3 Bash cat > somefile.txt 2 0 0
N4 PowerShell Set-Content -Path out.txt -Value hi 2 0 0
N5 PowerShell git commit --no-verify -m x 0 0 2
N6 PowerShell git push --force origin main 0 2 0

Over-block guards — 0 at base and 0 merged

# command note
G1 & $tool 2>&1 fd-dup plumbing, no operands after the merge
G2 git status 2>&1 > out.txt tool producer — what the existing strip protects
G3 & $py café.py Latin-1 supplement: \xa0 must not act as a separator member
G4 & $py -m café same
G5 & $w café.txt same, writer-shaped target
G9 & $py -m pip install x 2>&1 flag-first, trailing merge
G20 & $w<U+200B>f.txt x U+200B is zero-width, deliberately NOT normalized
L3 $out=pwsh $script the launcher = widening must not over-block
L5 $p=Start-Process $app same
C6 git log --oneline<U+00A0>-n 5 normalization must not turn read-only git into a block

Tests

Every new case asserts an EXACT rc, never RC -ne 0. The Unicode separators are built from byte escapes ($'\xc2\xa0'), never pasted as literal characters — a formatter or .gitattributes rule that normalized a raw U+00A0 to a plain space would degrade the row to & $w f.txt x, which blocks anyway, leaving a case that passes while pinning nothing.

Mutation-checked per case against a tree with the fix reverted: every "expect 2" case measures 0 without the fix (RED) and 2 with it (GREEN); every "expect 0" case measures 0 in both.

Residual noticed, not fixed here

$out = pwsh $script fails closed on block-dangerous-git while $out=pwsh $script stays allowed, because the sink TRIGGER ps::has_launcher carries its own separator class that still lacks =. Widening that one is the over-block direction (it would flip ordinary launcher assignments from 0 to 2), so it is left alone and flagged rather than folded in.

Related

  • issue 2924 / PR 2925 — the $( … ) call-target fail-open; the $() spelling of the 2927 shape is refused by that shape arm, the bare-variable spelling was not.
  • issue 2922 / PR 2908 — the braced spelling of that family.
  • issue 2890 — narrowed the computed-target gate; introduced neither hole here.
  • issue 2848 — the over-block removal that introduced ps::call_site_operand_region. Its six must-allow cases are pinned unchanged above.
  • issue 2906 — a different pre-existing evasion of the same positional probe (quoted operands), untouched by this PR.

🤖 Generated with Claude Code

https://claude.ai/code/session_018Q4QNEensQgkYm8vByTydJ

…ger hide computed writer calls

Two proven fail-opens in the PowerShell lane of the hook-bypass guard, each a
working file write verified under pwsh.

#2927 — `ps::call_site_operand_region` ends a call's operand region at a
depth-zero `;` `|` `&`, and the `&` inside `2>&1` is at depth zero. The region
of `& $w 2>&1 f.txt x` was truncated to `" 2>"`, both measuring probes went
silent, and the command fell through allowed. `ps::write_bypass` already
stripped fd-dup merges, but only into a separate `gate` variable feeding the
`>` probe alone; the measuring probes got the unstripped text. The strip now
runs once, before every probe in the branch.

#2928 — the call-site separator classes were derived from bash character
classes rather than from PowerShell's tokenizer. `=` is now in every one of
them (entry AND measurement together, so widening does not repeat the #2922 /
#2924 gate-admits-probes-cannot-see mechanism), and the Unicode gap is closed
by NORMALIZING token-separating code points to an ASCII space at intake rather
than by putting multi-byte sequences into a bracket expression, which would
decompose into independent byte members under a single-byte locale and
over-block accented paths.

Accepted behavior change: `& $py a.py 2>&1 b.txt` moves 0 -> 2, consistent with
`& $py script.py arg`, which already blocked.

The six #2848 must-allow cases stay 0 on all three blocking hooks.

Closes #2927
Closes #2928

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

claude Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Claude Code is working…

I'll analyze this and get back to you.

View job run

@claude

claude Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

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


Security review complete

No security issues found. See summary below.

  • Gather context (PR diff, changed files)
  • Apply skip gate
  • Review changed files for security-relevant issues
  • Post findings

This PR modifies plugins/guardrails/lib/powershell/ps-command.sh, the security control itself, so the review covered both "does it introduce a new logic flaw" and "does it actually close what it claims to."

#2927 fix: the fd-dup strip (2>&1) now runs once into blanked_gate, feeding every probe in the branch instead of leaving two measuring probes on unstripped text via a separate gate variable. Verified each probe now reads the same stripped text.

#2928 fix: = added consistently to every call-target predicate's separator class (gate entry, both measuring re_vars, quoted-writer regex, subexpression/interpolating-string halves, and ps::might_invoke_git's launcher class). Confirmed entry and measurement move together everywhere, avoiding the #2922/#2924 "entry admits, probes can't see" bug class.

Unicode normalization: applied once at intake in ps::blank_herestrings, which both real entry points (ps::write_bypass, ps::classify_git_command) call first. All separator bytes are multi-byte UTF-8 (≥0x80) mapped to ASCII space, so no risk of chained/incorrect substitutions or ASCII mangling; byte-literal spelling is locale-safe.

Test cases in all three *.test.sh files assert exact rc and match the PR's probe table.

Pre-existing, already-disclosed gap, correctly left alone: ps::has_launcher (sink-routing trigger) still lacks =, so $out=pwsh $script skips the fail-closed git sink while $out = pwsh $script doesn't. Predates this diff, explicitly called out in the PR body, and the author's rationale for not widening it (would over-block ordinary launcher assignments) checks out.

No injection, secrets-handling, or GitHub Actions concerns — only the bash guard library, tests, and changelog/version were touched.

@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: 937088f161

ℹ️ 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
@github-actions

Copy link
Copy Markdown

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

@kyle-sexton
kyle-sexton merged commit fdbc421 into main Aug 17, 2026
48 checks passed
@kyle-sexton
kyle-sexton deleted the fix/ps-fd-dup-and-separator-classes branch August 17, 2026 17:48
kyle-sexton added a commit that referenced this pull request Aug 21, 2026
…dynamic invocation or launcher from the fail-closed sink (#2992)

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](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_assignment_operators)),
scanned on quote-blanked text so quoted spans stay data
([about_Quoting_Rules](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_quoting_rules)).
git(1) [`-c
<name>=<value>`](https://git-scm.com/docs/git#Documentation/git.txt--cltnamegtltvaluegt)
(`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 | 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

- Refs #2928 / PR #2966 — added `=` to the call-target classes; this is
the sink-trigger half of the same boundary defect.
- Refs #2922 and #2924 — the entry-broader-than-measurement failures;
this is the inverse.
- Refs #2848 — the computed-writer over-block removal whose six
acceptance cases guard this change.
- Refs #2965 — a different entry-side defect in the same sink-trigger
path.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant