block-hook-bypass allows a computed writer call whose operands sit after an fd-dup merge redirect: & $w 2>&1 f.txt x is a working Set-Content <path> <value> and exits 0.
Observed
rc from block-hook-bypass.sh, payloads built with jq as real PreToolUse envelopes with tool_name: "PowerShell" (no shell quoting touched the command string). base = 10bdd7c51 (pre-#2890), parent = a456023af, merged = 6dc12e45a (current main):
| command |
base |
parent |
merged |
& $w 2>&1 f.txt x |
0 |
0 |
0 |
& $w 2>&1 @p |
0 |
0 |
0 |
& $env:w 2>&1 f.txt x |
0 |
0 |
0 |
'x' | & $w 2>&1 f.txt |
0 |
0 |
0 |
& $w; & $w2 2>&1 f.txt x (non-leftmost call site) |
0 |
— |
0 |
& $w f.txt x (contrast, no fd-dup) |
2 |
2 |
2 |
& $($w) 2>&1 f.txt x ($() spelling) |
2 |
0 |
2 |
The last row is incidental: PR #2925 widened ps::call_target_is_bare_subexpression to cover $( … ) targets, which refuses that spelling BY SHAPE before the operands are ever measured. The BARE-VARIABLE spelling has no such shape arm and is still open.
Cause
ps::call_site_operand_region ends the operand region at a statement/pipeline separator ; | & at bracket depth ZERO. The & inside an fd-dup merge (2>&1) is at depth zero, so the region is truncated to " 2>" and both measuring probes go silent. Confirmed by calling the library function directly:
region of |& $w f.txt x| -> | f.txt x| positional probe FIRES
region of |& $w 2>&1 f.txt x| -> | 2>| positional probe silent
ps::write_bypass already knows fd-dup merges are plumbing rather than separators and strips them — but only into gate, which feeds the > redirect test alone:
gate=$(printf '%s' "$blanked_gate" | sed -E 's/[0-9*]*>&[0-9]+//g')
The two measuring probes are then handed the UNSTRIPPED blanked_gate. The comment one line above the strip names the hazard exactly — "fd-dup merges (2>&1) are plumbing, not file writes" — it just is not applied on the path that needs it.
ps::computed_call_has_positional_write_signal does strip redirect OPERANDS from rest, but that happens AFTER ps::call_site_operand_region has already truncated at the &, so it never sees them.
Not a #2890 regression
rc=0 on 10bdd7c51 as well, for every row. Pre-existing, and out of scope for #2924 / PR #2925, which is why it is filed rather than folded in.
Verified as a real write, not a parser artifact
Every shape above parses clean under [System.Management.Automation.Language.Parser]::ParseInput (zero errors), and under a real pwsh with $w = 'Set-Content' the file is actually written:
WROTE => PWNED-BY-FD-DUP (& $w 2>&1 $f 'PWNED-BY-FD-DUP')
WROTE => PWNED-BY-SPLAT (& $w 2>&1 @p)
Suggested direction (not prescriptive)
Strip fd-dup merges before the operand region is computed, not only into the >-probe's gate — i.e. hand the measuring probes text where 2>&1 has already been removed, so the remaining & characters really are separators. Any fix should keep & $tool 2>&1 (plumbing, no write signal) allowed and keep git status 2>&1 > out.txt allowed, which is what the existing strip exists to protect.
Provenance
Found by a fresh-context adversarial verifier probing merged content after PR #2925, and independently re-measured before filing.
Related
block-hook-bypassallows a computed writer call whose operands sit after an fd-dup merge redirect:& $w 2>&1 f.txt xis a workingSet-Content <path> <value>and exits 0.Observed
rc from
block-hook-bypass.sh, payloads built withjqas realPreToolUseenvelopes withtool_name: "PowerShell"(no shell quoting touched the command string).base=10bdd7c51(pre-#2890),parent=a456023af,merged=6dc12e45a(currentmain):& $w 2>&1 f.txt x& $w 2>&1 @p& $env:w 2>&1 f.txt x'x' | & $w 2>&1 f.txt& $w; & $w2 2>&1 f.txt x(non-leftmost call site)& $w f.txt x(contrast, no fd-dup)& $($w) 2>&1 f.txt x($()spelling)The last row is incidental: PR #2925 widened
ps::call_target_is_bare_subexpressionto cover$( … )targets, which refuses that spelling BY SHAPE before the operands are ever measured. The BARE-VARIABLE spelling has no such shape arm and is still open.Cause
ps::call_site_operand_regionends the operand region at a statement/pipeline separator;|&at bracket depth ZERO. The&inside an fd-dup merge (2>&1) is at depth zero, so the region is truncated to" 2>"and both measuring probes go silent. Confirmed by calling the library function directly:ps::write_bypassalready knows fd-dup merges are plumbing rather than separators and strips them — but only intogate, which feeds the>redirect test alone:gate=$(printf '%s' "$blanked_gate" | sed -E 's/[0-9*]*>&[0-9]+//g')The two measuring probes are then handed the UNSTRIPPED
blanked_gate. The comment one line above the strip names the hazard exactly — "fd-dup merges (2>&1) are plumbing, not file writes" — it just is not applied on the path that needs it.ps::computed_call_has_positional_write_signaldoes strip redirect OPERANDS fromrest, but that happens AFTERps::call_site_operand_regionhas already truncated at the&, so it never sees them.Not a #2890 regression
rc=0 on
10bdd7c51as well, for every row. Pre-existing, and out of scope for #2924 / PR #2925, which is why it is filed rather than folded in.Verified as a real write, not a parser artifact
Every shape above parses clean under
[System.Management.Automation.Language.Parser]::ParseInput(zero errors), and under a realpwshwith$w = 'Set-Content'the file is actually written:Suggested direction (not prescriptive)
Strip fd-dup merges before the operand region is computed, not only into the
>-probe'sgate— i.e. hand the measuring probes text where2>&1has already been removed, so the remaining&characters really are separators. Any fix should keep& $tool 2>&1(plumbing, no write signal) allowed and keepgit status 2>&1 > out.txtallowed, which is what the existing strip exists to protect.Provenance
Found by a fresh-context adversarial verifier probing merged content after PR #2925, and independently re-measured before filing.
Related
$( … )call-target fail-open, which incidentally covers the$()spelling of this shape.ps::call_site_operand_region. Any change here must not reopen it.