Repository navigation
fix(core): make the portable bash scanner sound against real shells - #53277
Merged
Merged
Conversation
Replace bashDelimited and bashExpansion with recursive-descent parsing in scanBash so nested subshells, command substitutions, process substitutions, brace groups, and nofork substitutions use the same tokenizer and command parser rather than a separate delimiter pre-scan. Update redirect.test.ts for 'pwd && cd >out /outside', where a real shell executes cd /outside after the redirect while legacy tree-sitter drops the trailing directory operand.
… zsh builtin checks
This was referenced Oct 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
The portable shell scanner (
experimental.portable_shell_scanner) is meant to replace the tree-sitter analyzer that decides which commands the shell tool asks permission for. Its Bash scanner had three separate lexers that each decided where$(…),{…},[[…]]and heredocs end. When they disagreed, the rest of the command was silently treated as a comment or literal text. Inputs like these ran a hidden command in real shells while the scanner reported no commands at all, so no permission was requested:Several of these, such as
for i in 1; { <cmd>; }and escaped quotes inside double-quoted backticks, are also missed by the tree-sitter analyzer that is the default today.What Changes
The Bash/POSIX scanner is now a single recursive-descent parser that fails closed. Every command a real shell would execute is reported, or the whole command is
opaque.[[ a]]# ]] && probeprobe{ export X={}# ; probe; }probeexport X="`… \"'\" ; probe; …`"probecase $r in a) ls | head;; esacls,head{ find . -exec echo {} \; ; }findunset 'a[$(probe)]',${x@P}, zsh${(e)x},*(e:'probe':)(( probe ))withsh/dash(two nested subshells there)probeOn 41,499 real agent shell commands replayed locally (not committed), only 4 outputs differ from
v2, all improvements, and the opaque count stays at 6.Recursive Parsing
Nested constructs no longer get a separate pre-scan to find where they end.
$(…), backticks,<(…),${ …; }, subshells, brace groups,case, loops, and[[ ]]recurse into the same command-list parser, and the construct ends wherever that parse ends, the same way bash parses$(…). Reserved words count only in reserved-word position. Pending heredocs are shared across nesting levels, so a body is read after the next newline wherever it occurs. A single quoting/expansion primitive replaces about 13 hand-copied variants.Where shells read the same text differently, the scanner fails closed. Examples:
<<$"…"delimiters,$[…], line continuations that split operators, single quotes inside double-quoted${…}, and quoted or escaped$(/`/${in an arithmetic context or a value bound to a variable.ShellScan.scantakes a dialect (bash,zsh, orposix) from the shell's basename.posix, which is also the default, covers both bash-like and dash readings.Values that reach arithmetic or
evalonly at runtime, from the environment, function arguments, or another program's output, remain out of reach for static analysis. This is documented in the code; tree-sitter has the same limit.Scope
This changes only the Bash/POSIX path of the portable scanner. Tree-sitter stays the default; the scanner remains opt-in through
experimental.portable_shell_scanner. The PowerShell scanner is unchanged and needs the same treatment in a follow-up. An opaque result still fails the tool call; asking permission for the raw command instead would be a separate change (see #49954). Making the portable scanner the default is a separate decision.Verification
Test suites: 3,160 pass, 0 fail.
Real-shell oracle tests: the new
oracle.test.tsruns each fixture in every available shell (bash 3.2, bash 5.3, zsh, dash) with a harmlessscan_probeonPATH. It asserts that every probe the shell executed is reported, or that the result is opaque. WithSHELL_ORACLE_STRICT=1on a machine that has all four shells, it also asserts that every fixture still executes somewhere; this run used strict mode. CI covers whichever shells the runner has. These tests are skipped on Windows.Repo check:
bun run checkpasses, 36/36 tasks.Fuzzing (local): a random nested-program generator, run against all four shells, found 3 bypasses in 600 programs on the old scanner and 0 in 7,500 programs on this branch, across several seeds.
Benchmark (local):