Skip to content

fix(core): make the portable bash scanner sound against real shells - #53277

Merged
kitlangton merged 38 commits into
v2from
shell-scan-parser
Oct 5, 2026
Merged

kitlangton merged 38 commits into
v2from
shell-scan-parser

Conversation

@kitlangton

@kitlangton kitlangton commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

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:

[[ a]]# ]] && <cmd>
{ export X={}# ; <cmd>; }
export X="$(case esac in (esac) <cmd>;; esac)"
cat <<'}'; {
:
}
<cmd>; cat <<'}'; }
}

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.

Input Before After
[[ a]]# ]] && probe no commands probe
{ export X={}# ; probe; } no commands probe
export X="`… \"'\" ; probe; …`" no commands probe
case $r in a) ls | head;; esac opaque (tool call fails) ls, head
{ find . -exec echo {} \; ; } opaque find
unset 'a[$(probe)]', ${x@P}, zsh ${(e)x}, *(e:'probe':) missed opaque
(( probe )) with sh/dash (two nested subshells there) missed probe

On 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.scan takes a dialect (bash, zsh, or posix) from the shell's basename. posix, which is also the default, covers both bash-like and dash readings.

Values that reach arithmetic or eval only 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

cd packages/core
bun run test test/shell-scan test/shell-parse-parity.test.ts test/tool-shell.test.ts
SHELL_ORACLE_STRICT=1 bun run test test/shell-scan/oracle.test.ts
bun typecheck
cd ../.. && bun run check
  • Test suites: 3,160 pass, 0 fail.

  • Real-shell oracle tests: the new oracle.test.ts runs each fixture in every available shell (bash 3.2, bash 5.3, zsh, dash) with a harmless scan_probe on PATH. It asserts that every probe the shell executed is reported, or that the result is opaque. With SHELL_ORACLE_STRICT=1 on 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 check passes, 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):

    Tree-sitter path This scanner
    Size shipped ~2.6 MB of WASM grammars and runtime 39 KB
    CPU per command 88 µs 44 µs

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.
@kitlangton
kitlangton merged commit 2590927 into v2 Oct 5, 2026
18 of 21 checks passed
@kitlangton
kitlangton deleted the shell-scan-parser branch October 5, 2026 16:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant