Skip to content

fix(guardrails): measure a braced call target like its bare twin - #2908

Merged
kyle-sexton merged 2 commits into
mainfrom
fix/2848-braced-call-target
Aug 17, 2026
Merged

fix(guardrails): measure a braced call target like its bare twin#2908
kyle-sexton merged 2 commits into
mainfrom
fix/2848-braced-call-target

Conversation

@kyle-sexton

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

Copy link
Copy Markdown
Contributor

Follow-up to PR 2890 (4e6249ae7). Fixes a fail-open regression that PR introduced, found by a fresh-context verifier agent probing merged content adversarially.

Closes #2922.

The defect

& ${env:writer} f.txt x exited 0 from block-hook-bypass on origin/main, while the identical & $env:writer f.txt x exited 2. Both are a working Set-Content <path> <value> through a computed call target.

${env:w} and $env:w are the same referenceabout_Variables; ${env:t} -eq $env:t evaluates True on PowerShell 7. The two spellings were getting opposite verdicts:

  • ps::call_target_is_bare_computed (the gate ENTRY predicate) matches [.&][[:space:]]*[$(] — it only looks for the $, so a braced target enters the computed-target branch of ps::write_bypass.
  • ps::computed_call_has_splat_operand and ps::computed_call_has_positional_write_signal (the probes that MEASURE a call site) both keyed on re_var requiring \$[a-z0-9_:?]+. { is not in that class, so neither located a call site at all.
  • Gate entered, zero arms fired, fell through, allowed.

Before 0.28.33 the blanket ps::has_special_constructs arm covered the shape incidentally, via the braces themselves. Removing that arm is the point of issue 2848; this shape was the one it had been carrying that the replacement did not name.

Evidence

Every rc below was OBSERVED, not asserted. Payloads built with jq as real PreToolUse envelopes (no shell quoting touched the command string). base = 10bdd7c51 (pre-2890), main = 9cf4587f7 (current), fix = this branch. rc from block-hook-bypass.sh:

command base main fix
& ${env:writer} f.txt x 2 0 2
& ${env:writer} @p 2 0 2
. ${env:w} @p 2 0 2
& ${script:w} f.txt x 2 0 2
& ${global:w} @p 2 0 2
& ${my writer} f.txt x 2 0 2
& ${my`}writer} f.txt x 2 0 2
& ${my`}writer} @p 2 0 2
. ${my`}writer} @p 2 0 2
& ${my`}w`}x} f.txt x 2 0 2
& ${my}writer} f.txt x`` 2 0 2
& ${my`{writer} f.txt x 2 0 2
& ${env:w}riter f.txt x 2 0 2
& $env:writer f.txt x (bare twin) 2 2 2
& ${env:py} script.py (allowed side) 2 0 0
& ${my`}py} script.py (allowed side) 2 0 0

The two allowed rows read base = 2 because a braced target is itself a brace, so the blanket ps::has_special_constructs arm blocked an ordinary interpreter call before 0.28.33. That over-block is exactly what issue 2848 set out to remove, and this PR keeps it removed — the allowed rows stay rc=0. An earlier revision of this body listed those two rows as base = 0; that was asserted rather than measured, and is corrected here.

The fix

1. Both measuring probes accept ${…}. The braced alternative is listed first so it wins on a braced target, and the target token may carry non-space text glued after its closing brace — PowerShell concatenates & ${env:w}riter, and requiring whitespace immediately after } made that whole call site unmatchable, which is the same fail-open in another spelling:

re_var='(^|[[:space:]\;\{\}\(\|\&])[.\&][[:space:]]*(\$\{[^}]*\}[^[:space:]]*|\$[a-z0-9_:?]+)([[:space:]]+|$)(.*)'

[^}]* is what reaches & ${my writer} f.txt x — a braced name may contain a space, which no bare-name character class can express. That row is the isolating pin for the braced alternative.

The gate ENTRY predicate is deliberately left unchanged. Teaching the measuring probes closes the hole; narrowing entry to match the probes would have opened a second fail-open instead.

2. A braced name's ESCAPED closing brace is consumed before backticks are deleted (review round on this PR). ${my`}writer} names the variable my}writer, but ps::write_bypass deletes backticks first so a cmdlet name obfuscated with PowerShell's escape character resolves to its real form. That deletion rendered the text ${my}writer}genuinely indistinguishable from a ${my} reference followed by a literal writer}. No rule applied after the deletion can tell those apart, which is why the fix has to run before it. New ps::fold_escaped_brace_closers folds each escaped closer to one ordinary name character, left to right, at the point lcq is built. An escaped backtick is consumed as a unit and emitted unchanged, so it cannot lend its second backtick to a following brace and the Set-Content`` obfuscation recovery is untouched. An escaped OPENING brace needs no handling — deleting its backtick leaves a brace [^}]* matches and the real closer still terminates.

Operands are measured exactly as before once a call site is found, so widening the target token decides only where measuring starts, never the verdict.

Tests

Fifteen new rows in plugins/guardrails/hooks/block-hook-bypass.test.sh, each blocked braced row paired with its bare twin or its allowed one-positional twin so the spellings are pinned to the same verdict and cannot drift apart again — braced target with positional Path+Value, with a splat, dot-sourced, script-scoped, a name containing a space, an escaped closer in each of those positions, two escaped closers in one name, an escaped backtick followed by a real closer, an escaped opening brace, a glued trailing target token, and both allowed twins.

Verification

  • Full acceptance probe table across all three blocking hooks — the four must-block cases plus six sibling spellings, the eight must-allow cases from issue 2848 plus two braced allowed twins, and four negative controls. TOTAL MISMATCHES: 0. Issue 2848's acceptance criteria all still hold: REPRO_2592, BLOCK_A, BLOCK_C, GROUPING_ONLY, COMPUTED_ONLY, CONTROL_1973 are rc=0 on all three hooks.
  • shellcheck --rcfile .shellcheckrc clean; markdownlint-cli2 clean; all four check-changelog-parity.sh modes pass, including --check-preserved confirming all 106 headings main carried survive byte-identical.

Not included, filed separately

The verifier also surfaced that ps::blank_quoted_spans deletes quoted spans, so & $w 'f.txt' 'x' evades the positional write signal. That is pre-existing — rc=0 on 10bdd7c51 as well as on 4e6249ae7 — and fixing it changes deletion-to-placeholder semantics several probes depend on, with real over-block risk. Filed as issue 2906 with its own probe table rather than bolted onto this hotfix.

Related

  • PR 2890 (4e6249ae7) — the change that introduced this fail-open. Not closed here; its own issue 2848 stays closed and its acceptance cases are re-pinned above.
  • Issue 2906 — ps::blank_quoted_spans deletes quoted operands, so quoting evades the same positional write signal. Pre-existing, deliberately out of scope for this hotfix.
  • Issue 2848 — the grouping-construct narrowing whose replacement did not name this shape; its six acceptance cases are re-verified here and stay allowed.
  • Issue 2592 (closed by PR 2622, 302a6fa95) — the command-position fix; REPRO_2592 stays allowed and pinned.
  • Issue 1973 — the constant-target narrowing; CONTROL_1973 is that fix holding.
  • Issue 2722 — the narrowing of ps::write_bypass that established the bare-computed-target half; its four write-signal pins are unchanged.
  • Issue 2691 — stale-base squash reverts; merged content is re-verified by execution after merge.

🤖 Generated with Claude Code

https://claude.ai/code/session_018Q4QNEensQgkYm8vByTydJ

@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 25s —— View job


Code review: PR #2908

Scope: /review:code-review lane — correctness/maintainability only, scoped out of security since a dedicated security lane exists for this repo.

Verdict: no blocking findings. The fix is correct and well-isolated.

What I checked

  • ps-command.sh regex change (ps::computed_call_has_positional_write_signal, ps::computed_call_has_splat_operand): traced the new re_var against each new test row by hand (positional Path+Value, splat, dot-source splat, script-scope, spaced name, allowed single-positional). The braced alternative \$\{[^}]*\} is matched as one unit before the operand region is sliced (BASH_REMATCH[4]), so rest correctly starts after the closing } rather than inside it — no operand-region leakage into the target itself.
  • Confirmed ps::call_target_is_bare_computed (the gate ENTRY predicate) is genuinely unchanged, matching the PR's stated rationale that narrowing entry would open a second fail-open.
  • Confirmed re_pipe (used for the piped-write check) needed no update — it only tests for \$ immediately after the operator, which is identical for both spellings.
  • Grepped the rest of the plugin for other definitions of re_var/computed_call_has_*/call_target_is_bare_computed — these two probes are the only place this pattern is duplicated, and they're the only callers of ps::write_bypass's computed-target branch (only block-hook-bypass.sh uses it), so the fix and its new tests are correctly scoped to the one hook actually affected. ps::might_invoke_git deliberately excludes the bare-computed-target case entirely (per guardrails: 2592's command-position fix leaves the grouping + bare-computed-call conjunction blocked by all three hooks, and pinned by no test #2848), so it isn't subject to the same bug class and correctly needed no change.
  • Version bump (0.28.330.28.34) and CHANGELOG entry are consistent with the fix and the repo's semver/Keep-a-Changelog conventions.
  • Author-claimed verification (31-case cross-hook probe table, PASS=488 FAIL=0, shellcheck/typos/markdownlint/changelog-parity) is unverified by me — I did not have shell-execution permission in this review sandbox to independently run block-hook-bypass.test.sh or pwsh, only read-only inspection (git, gh, grep/Read). Treat that section of the PR body as author-claimed only.

One non-blocking documentation note

In both new comment blocks
(ps-command.sh#L382,
ps-command.sh#L490):

"The braced alternative is listed FIRST so it wins on a ${…} target."

Bash's [[ =~ ]] uses POSIX/glibc extended regex, which resolves alternation by leftmost-longest match, not left-to-right first-alternative-wins (that's PCRE/Perl semantics). Alternation order here doesn't actually do the work the comment credits it with. It happens to be harmless either way, because the two alternatives can never both match at the same position — the character immediately after \$ is either { or one of [a-z0-9_:?], never both, so there's no real ambiguity for the engine to resolve by any rule. Given how much load-bearing rationale this file's comments otherwise carry (e.g. the SSOT note at ps-command.sh#L210-218 about why a shared regex constant is deliberately avoided), I'd suggest tightening this one line so a future maintainer doesn't rely on order-based alternation precedence elsewhere in this file where it might not be so harmless. Not a functional bug, so not blocking.

@claude

claude Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

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


Security review complete ✅

  • Gather context (PR diff, changed files)
  • Run skip gate
  • Review changed files for security-relevant issues
  • Adversarially validate any candidate findings
  • Post final review summary

No security issues found.

This PR is itself a security hardening fix — it closes a fail-open regression in the PowerShell write-bypass guard (plugins/guardrails/lib/powershell/ps-command.sh) that PR #2890 introduced. I reviewed all four changed files against origin/main (gh pr diff) and traced the fix through the surrounding guard logic:

  • ps::computed_call_has_positional_write_signal and ps::computed_call_has_splat_operand (ps-command.sh#L383, ps-command.sh#L491) now recognize the braced variable spelling (${env:writer}) alongside the bare one ($env:writer), matching what PowerShell itself treats as the identical reference. Before this fix, a braced call target entered the computed-target branch of ps::write_bypass (via the unchanged gate-entry predicate ps::call_target_is_bare_computed, which only checks for $) but was invisible to both measuring probes — so the gate fired, no arm matched, and the command fell through allowed even though the identical bare-$ spelling correctly blocked.
  • The gate entry predicate (ps::call_target_is_bare_computed) is deliberately left unchanged, and the PR's rationale for that is sound: narrowing entry to match the probes would just open a different fail-open path (an unrecognized-but-computed target skipping the gate entirely) rather than closing this one.
  • The new alternation \$\{[^}]*\} is anchored to the call/dot-source operator position and runs on already quote-blanked text (ps::blank_quoted_spans applied by the caller before either probe runs), so it can't be tricked by quoted ${...}-looking text elsewhere in a command. [^}]* is a single bounded, non-nested repetition — no ReDoS/backtracking concern.
  • I checked the two sibling lanes that ask a similar "is the call target computed" question — ps::might_invoke_git and ps::might_write_via_python3 — and both deliberately exclude the bare-variable-target case already (per their own inline comments, #2848), so they were never exposed to this braced/bare divergence and don't need an equivalent change.
  • Test additions (block-hook-bypass.test.sh) pair each new braced-target case with its bare twin, including the allowed side (${env:py} script.py, single positional) — good coverage against the exact fail-open this closes, without adding a new over-block.
  • plugin.json / CHANGELOG.md changes are version bump + changelog only, no security surface.

I could not execute the test suite or pwsh locally in this environment (Bash is scoped to gh pr subcommands only for this lane), so I verified the logic by reading the guard code and its call sites rather than by running block-hook-bypass.test.sh. The PR description's own probe-table results (488 passing, all #2848 acceptance cases still rc=0) are consistent with the code as written.

The pre-existing ps::blank_quoted_spans quoted-span deletion issue the PR description calls out is correctly treated as out of scope here (pre-existing on main before this PR, filed separately as #2906).

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown

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

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown

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

@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: 62c5768066

ℹ️ 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
kyle-sexton and others added 2 commits August 17, 2026 05:05
`& ${env:writer} f.txt x` and `& ${env:writer} @p` exited 0 from
block-hook-bypass while the identical `& $env:writer …` exited 2 — a
fail-open on a working file write, introduced by 0.28.33 and found by a
fresh-context verifier running against merged content.

`${env:w}` and `$env:w` are the same reference (about_Variables;
`${env:t} -eq $env:t` is True), and ps::call_target_is_bare_computed
admits both because it only looks for the `$`. But the two probes that
MEASURE a call site — ps::computed_call_has_splat_operand and
ps::computed_call_has_positional_write_signal — keyed on the bare
spelling alone. A braced target therefore entered the computed-target
gate and then matched no call site at all: every arm stayed silent and
the command fell through allowed. Before 0.28.33 the blanket
ps::has_special_constructs arm covered the shape incidentally, via the
braces themselves.

Both probes now accept `${…}` alongside the bare form, listed first so
it wins on a braced target. The gate ENTRY predicate is deliberately
unchanged: teaching the measuring probes closes the hole, narrowing
entry to match would open a second one.

Pinned by seven tests, each braced row paired with its bare twin so the
two spellings cannot drift apart again, plus `& ${env:py} script.py`
holding the allowed side.

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

A braced PowerShell variable name may contain a closing brace by escaping it:
`${my`}writer}` names the variable `my}writer` (about_Variables). The library
deletes backticks before matching so a cmdlet name obfuscated with PowerShell's
escape character resolves to its real form — but that deletion runs FIRST, and it
turns the braced name into `${my}writer}`, text genuinely indistinguishable from
a `${my}` reference followed by the literal `writer}`.

No rule applied after the deletion can tell those apart. The braced-target
alternative's `[^}]*` stopped at the injected brace, the whitespace boundary
never matched, and the call site disappeared: both measuring probes returned
false, every arm of the computed-target gate stayed silent, and
`& ${my`}writer} f.txt x` / `& ${my`}writer} @p` fell through ALLOWED — the same
computed-writer fail-open the braced-target change exists to close.

Consume the escape while it still exists. `ps::fold_escaped_brace_closers` folds
each backtick-escaped closer to one ordinary name character, left to right,
before `ps::write_bypass` deletes backticks. An escaped backtick is consumed as a
unit and emitted unchanged, so it cannot lend its second backtick to a following
brace and the obfuscation recovery is untouched. An escaped OPENING brace needs
no handling — deleting its backtick leaves a brace `[^}]*` matches and the real
closer still terminates.

The target token may also carry non-space text glued after its closing brace,
which PowerShell concatenates (`& ${env:w}riter f.txt x`, and the `writer}`
trailing `${my``}`). Requiring whitespace immediately after `}` made those call
sites unmatchable, which is the same fail-open in another spelling. Operands are
measured exactly as before once a site is found, so widening the target token
decides only where measuring starts, never the verdict.

Eight further tests pin the shapes, including two escaped closers in one name,
an escaped backtick followed by a real closer, and the allowed one-positional
twin so consuming the escape does not itself become a write signal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Q4QNEensQgkYm8vByTydJ
@kyle-sexton
kyle-sexton force-pushed the fix/2848-braced-call-target branch from 62c5768 to 901a481 Compare August 17, 2026 09:14
@claude

claude Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

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


Code review: PR #2908 (head 901a481)

Scope: /review:code-review lane — correctness/maintainability only; security is out of scope for this lane per REVIEW.md (dedicated security lane already reviewed this PR at a prior head).

Verdict: no blocking findings.

What changed since the last code-review pass

The last review from this lane covered head 62c5768. This PR gained a second commit since then — 901a481 "consume a braced name's escaped closer before deleting backticks" — which is a direct fix for the P1 finding chatgpt-codex-connector raised: a legal PowerShell braced name with a backtick-escaped closing brace (${my`}writer}) survived the first fix's [^}]* scanner because backtick-deletion ran before matching, collapsing ${my`}writer} into ${my}writer} — indistinguishable from a bare ${my} reference glued to literal text writer}. That reopened the exact fail-open this PR exists to close.

I traced the new ps::fold_escaped_brace_closers (ps-command.sh#L197-223) character-by-character against each new test row by hand:

  • ${my}writer}→ pair-consumes the escape, folds to${my_writer}` — real closer restored, target ends correctly.
  • ${my}w}x} (two escaped closers) → both fold correctly; a single-pass left-to-right scan with an explicit 2-index advance per match handles the second escape without losing state from the first.
  • ${my}writer} (escaped backtick immediately before a *real* closer) → the `` `` `` case in the case statement consumes the pair as a unit before the `}` case can misfire on the second backtick, so the following `}` is correctly left as a real terminator rather than being folded away. This ordering is the load-bearing part of the fix and it's correct.
  • ${my{writer}(escaped *opening* brace, needs no special handling per the PR's own reasoning) → falls through unfolded as intended,[^}]*` still matches to the real closer.
  • Confirmed the fold's output feeds lcqblanked_gate, which is what both ps::computed_call_has_splat_operand and ps::computed_call_has_positional_write_signal actually receive (ps-command.sh#L1382, #L1438-1441) — there's no second, unfolded path into either probe.
  • Re-confirmed ps::call_target_is_bare_computed (the gate entry predicate) is still genuinely untouched by either commit and has exactly one call site, on the folded lcq.
  • The new [^[:space:]]* glue-allowance after the braced alternative's \} (needed so ${env:w}riter and the writer} residual after an escaped closer stay part of one target token) doesn't leak into operand scanning — it only widens where the target token ends, and the boundary group after it still requires whitespace-or-end, so downstream operand parsing (rest/BASH_REMATCH[4]) is unaffected.
  • Version bump (0.29.00.29.1) and CHANGELOG stayed a single entry across both commits (the second commit amended the same [0.29.1] section rather than adding a new bump) — correct, since both commits ship together as one fix.

Not independently verified

This lane's tool grant doesn't include general command execution (only gh pr diff/view/comment/review, Read, Glob, Grep), so I could not run block-hook-bypass.test.sh or reproduce the author's claimed PASS=488 FAIL=0 / shellcheck / markdownlint / changelog-parity results myself. I verified the logic by tracing the regex and the new fold function by hand against every new test row instead. Treat the PR body's verification section as author-claimed, not independently confirmed by this review.

The pre-existing ps::blank_quoted_spans quoted-span-deletion issue the PR description calls out is correctly out of scope here — filed separately as #2906.
· branch fix/2848-braced-call-target

@claude

claude Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

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


Security review complete ✅

  • Gather context (PR diff, changed files, prior review comments)
  • Run /review:security-review per instructions
  • Review changed files for security-relevant issues
  • Adversarially validate candidate findings (including Codex's P1 escaped-brace finding)
  • Post final review summary

No security issues found.

Since the last security review (head 62c5768066), one new commit landed: 901a481 — a direct fix for the P1 finding Codex raised on this PR: a backtick-escaped closing brace inside a braced call target (${my}writer}) survived backtick-deletion as text indistinguishable from ${my}+ literalwriter}, so the braced-target scanner's [^}]*` stopped at the injected brace and the call site vanished — the same computed-writer fail-open this PR exists to close, in another spelling.

I traced the fix rather than taking the commit message at face value:

  • New helper ps::fold_escaped_brace_closers folds each backtick-escaped } to one ordinary name character, left to right, before backticks are deleted — I hand-traced it against ${my}writer}(single escaped closer),${my}w}x}` (two escaped closers in one name), and ${my}writer} `` (escaped backtick followed by a real closer, so the escaped backtick must not lend its second backtick to the following brace); all three fold to the correct single-name-then-closer shape.
  • Confirmed the fold is wired into the load-bearing spot: ps::write_bypass calls it on PS_BLANKED to build lcq, then derives blanked_gate from lcq — and blanked_gate is the value handed to both measuring probes, ps::computed_call_has_splat_operand and ps::computed_call_has_positional_write_signal. This matters because each probe still does its own lc="${lc//\/}"` internally (L445, L559) — I checked whether that second, un-folded deletion could reopen the hole, but since the input arriving at that line has already had the escape folded upstream, it's a no-op on this path rather than a regression.
  • The companion widening of re_var's braced alternative to \$\{[^}]*\}[^[:space:]]* (permitting non-space text glued after the closing brace, e.g. ${env:w}riter) is a target-token change only — I confirmed operand measurement still starts at the same post-boundary position (BASH_REMATCH[4]), so it doesn't loosen what counts as a write signal, only where the scanner starts looking.
  • Eight new test rows in block-hook-bypass.test.sh pin exactly these shapes (two escaped closers in one name, escaped-backtick-then-real-closer, glued trailing text with and without an escape, escaped opening brace, and the allowed one-positional twin) to blocked/allowed as expected. I was not able to execute block-hook-bypass.test.sh in this review sandbox (Bash is scoped to gh pr subcommands for this lane), so this is a logic trace, not an executed-suite confirmation — but it lines up with the author's claimed PASS=488 FAIL=0.
  • No other file in the diff since the last review (plugin.json, CHANGELOG.md) carries security-relevant surface — version/doc-only changes.

Nothing else in this PR's surface (all four changed files, entire diff vs origin/main) presents a security concern beyond the one Codex flagged, which is now fixed.

@kyle-sexton
kyle-sexton merged commit a456023 into main Aug 17, 2026
50 of 51 checks passed
@kyle-sexton
kyle-sexton deleted the fix/2848-braced-call-target branch August 17, 2026 09:22
kyle-sexton added a commit that referenced this pull request Aug 17, 2026
Closes #2924

## What was open

`block-hook-bypass` allowed a computed writer call whose target is
spelled `$( … )` while blocking the `( … )` spelling of the same
construct. `block-dangerous-git` carried the identical hole. Measured
with `jq`-built `PreToolUse` envelopes (no shell quoting touched the
command string), `base` = `10bdd7c51` (pre-#2890), `main` = `a456023af`:

| hook | command | base | main | this PR |
|---|---|---|---|---|
| block-hook-bypass | `& $($w) f.txt x` | 2 | **0** | 2 |
| block-hook-bypass | `& $($w) @p` | 2 | **0** | 2 |
| block-hook-bypass | `& ($w) f.txt x` (paren twin) | 2 | 2 | 2 |
| block-dangerous-git | `& $($g) reset --hard` | 2 | **0** | 2 |
| block-dangerous-git | `& ($g) reset --hard` (paren twin) | 2 | 2 | 2 |

The `0` cells are a working `Set-Content <path> <value>`, and a working
`git reset --hard`, sailing past their guards.

## Cause

The same "gate admits, probes cannot see" mechanism as #2922, one
construct over.

- `ps::call_target_is_bare_computed` — the gate ENTRY predicate —
matches `[.&][[:space:]]*[$(]`, so `& $(` enters the computed-target
branch of `ps::write_bypass` on the `$` alone.
- `ps::call_target_is_bare_subexpression` required `(` **immediately**
after the call operator, so it never fired on `$(`.
- `ps::computed_call_has_positional_write_signal` and
`ps::computed_call_has_splat_operand` both require a `$name` or
`${name}` target; `$(` is neither, so neither probe located a call site
at all.

Gate entered, zero arms fired, command fell through allowed. Before
0.28.33 the blanket `ps::has_special_constructs` arm covered the shape
incidentally, via the parentheses inside `$(`.

## Fix

`ps::call_target_is_bare_subexpression` now accepts an OPTIONAL `$`
before the opening paren, so `$( … )` and `( … )` — one construct, two
spellings — reach one verdict on both lanes.

The `$` is ESCAPED in the pattern. An unescaped `$?` in that position is
a parameter expansion of the last exit status, which would silently
rewrite the pattern and stop it matching the paren spelling too: a
fail-OPEN on `& ('Set-'+'Content') f.txt x`, the very shape the
predicate exists for. That row is kept in the test set as the tripwire.

Unlike #2908 this is deliberately NOT a pre-deletion transform. Nothing
destroys the evidence upstream here — `$(` survives backtick deletion,
quote blanking, and lowercasing intact — so the fix belongs in the
predicate, not in a pass ahead of it.

## The one rc change that is not a bypass row

`& $($py) script.py` goes 0 to 2. That is intended, and it is not a
reintroduced over-block: a SUBEXPRESSION target is refused BY SHAPE
regardless of its operands, which is why `& ($py) script.py` is rc=2 on
`main` today and on the pre-#2890 base. #2848 dropped grouping ANYWHERE
ELSE in the command as a write signal and deliberately KEPT the
target-is-subexpression arm. Allowing the dollar spelling while the
paren spelling blocks would be a fresh instance of the defect class this
PR closes.

`ps::write_bypass` still contains zero `ps::has_special_constructs`
calls — the #2848 fix is untouched.

## Over-block regression set (all still rc=0 on all three hooks)

`REPRO_2592`, `BLOCK_A`, `BLOCK_C`, `GROUPING_ONLY`, `COMPUTED_ONLY`,
`CONTROL_1973`, and the splat pair `Write-Output @Args; & $py script.py`
/ `Write-Output; & $py script.py`.

## Siblings probed and NOT fixed here

Confirmed rc=0 on the pre-#2890 base as well, so pre-existing rather
than a #2890 regression, and already enumerated in #2924's own body — no
new issues filed:

- interpolating quoted targets: `& "$env:writer" f.txt x`
- index and member targets: `& $tools[0] f.txt x`, `& $tools.writer
f.txt x`
- quoted operands erasing the positional write signal (#2906)

## Tests

Twenty rows across `block-hook-bypass.test.sh` and
`block-dangerous-git.test.sh`, each `$( … )` row PAIRED with its `( … )`
twin so the two spellings are pinned to one verdict and cannot drift
apart again. Covers positional Path+Value, splat, dot-source, glued and
extra-whitespace operators, nested `$($())`, `$(& $w)`, `$(Get-Command
x).Source`, a braced reference inside the subexpression, a scoped
variable, and the assembled-name tripwire in both spellings.

## Related

- PR #2890 — narrowed the computed-target gate and introduced this
fail-open family (guardrails 0.28.33).
- PR #2908 — closed the BRACED spelling (#2922) with the same "teach the
measuring side, do not narrow entry" shape.
- Issue #2922 — the braced spelling of this class.
- Issue #2906 — quoted operands erase the positional write signal.
Genuinely pre-existing; deliberately NOT addressed here, since its fix
changes deletion-to-placeholder semantics with real over-block risk.
- Issue #2848 — the over-block fix whose narrowing this PR must not and
does not reverse.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_018Q4QNEensQgkYm8vByTydJ

Co-authored-by: Claude Opus 5 <noreply@anthropic.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

Development

Successfully merging this pull request may close these issues.

guardrails: a BRACED computed call target is allowed while its identical bare twin is blocked (fail-open regression from PR 2890)

1 participant