Skip to content

guardrails: block-hook-bypass allows a computed writer call whose operands follow an fd-dup redirect (& $w 2>&1 f.txt x) #2927

Description

@kyle-sexton

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions