#3433 · playground · 2026-08-11

The guard was right. The reason was wrong.

A local verification script got blocked with "Piping fetched output", "code you have never seen", and a curl -o remedy. There was no URL, no fetch, and the bytes came from a quoted heredoc that is fully visible in the command. The deny is correct and stays. Only the explanation changes.

The asymmetry is deliberate, and stays

pipe targetrulesource
sh bash zsh dash tclshdeny ALWAYSa shell runs stdin as a script
python3 node perl rubydeny only with a network source#3096 carve-out

A probe first, because I had a second hypothesis and it was wrong

The quote walker tracks ' and " but has no heredoc awareness, so I expected cat <<'EOF' > file containing a | bash line to false-positive. It does not. Driving the shipped alpha.19 bundle through run-hook.mjs:

curl piped to bash                            deny    correct
LOCAL cat piped to bash                       deny    correct, by design
local data to python3                         allow   #3096 intact
heredoc written to a FILE containing | bash   allow   no false positive
the reported shape (heredoc into source)      allow   does NOT reproduce

false positives: 0

Two conclusions. The detector needs no change. And since the reported shape allows, the real command must have held a genuine unquoted pipe to a shell, so the block was right. That reduced the fix from "detector plus message" to message only.

What changed

PipeToInterpreterKind   'exec' | 'interpreter'
                     -> 'exec' | 'exec-local' | 'interpreter'

both still DENY. the source now only selects the reason.

The local reason

States the real cause (a shell runs stdin as a script, so origin is irrelevant), says plainly that nothing was fetched, and redirects to a real argv: write the body to a file, then bash /tmp/script.sh. It also points at the still-allowed shape for local data, cat f | python3 -c ....

Positive control, run in this order

new test vs PRE-FIX code (blocker stashed)   FAILS   not vacuous
new test vs this branch                      5 passed
typecheck                                    0
full hook suite                              exit 0  (captured, never through a pipe)
validate-counts / manifests / security       0

Why the control matters here. A test written after a fix usually passes for the wrong reason. Stashing only dangerous-command-blocker.ts and re-running proves the assertions can observe the defect. The suite exit code is captured directly rather than read through | tail, which returns tail's status and would report success over a failing run.