17 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 74 | Replace shell-quote tokenizer with tree-sitter-bash for full AST-based path extraction |
Replace shell-quote tokenizer with tree-sitter-bash for full AST-based path extraction
Problem Statement
shell-quote (landed in #72) correctly handles quoted strings, operators, and shell comments, but it has no heredoc awareness.
Heredoc content is tokenized as plain string arguments, so path-like strings inside heredoc bodies produce false-positive external-directory prompts.
For example, cat << 'EOF'\n/etc/hosts\nEOF causes shell-quote to emit /etc/hosts as a regular string token, which classifyTokenAsPathCandidate flags as an external path.
Goals
- Replace
shell-quotewithweb-tree-sitter+tree-sitter-bashinextractExternalPathsFromBashCommand. - Walk the bash AST to extract only genuine path-bearing argument nodes, skipping heredoc bodies and comments.
- Make
extractExternalPathsFromBashCommandasync (WASM init requires it). - Update the single call site in
src/handlers/tool-call.tstoawaitthe result. - Remove
shell-quoteand@types/shell-quotedependencies. - Add regression tests for heredoc false positives.
- Keep
classifyTokenAsPathCandidateas the path-classification layer (unchanged).
Non-Goals
- PowerShell support — out of scope; this extension targets bash commands only.
- Changing
classifyTokenAsPathCandidateheuristics — orthogonal to tokenization. - Changing any permission surface, config format, or merge precedence.
- Bundling or pre-compiling WASM — the files ship inside
node_modulesand are located at runtime.
Background
- Permission surface:
external_directory(bash variant). - Module:
src/external-directory.ts—extractExternalPathsFromBashCommandis the entry point;classifyTokenAsPathCandidateis the classification helper. - Caller:
src/handlers/tool-call.tsline ~255 — already in anasyncfunction, soawaitis trivial. - Tests:
tests/bash-external-directory.test.ts(462 lines) covers extraction, formatting, and edge cases. - Prerequisite: #72 (shell-quote migration) — already shipped.
- Reference implementation: OpenCode (
packages/opencode/src/tool/shell.ts) usesweb-tree-sitter+tree-sitter-bashwith a lazy async init wrapper and walkscommandnodes to extract path arguments.
How Pi loads extensions
Pi uses jiti (TypeScript transpiler) to load extensions at runtime — extensions are not bundled.
This means:
- WASM files in
node_modulesare accessible via filesystem at runtime. import(..., { with: { type: "wasm" } })is not available (jiti does not support import attributes).- WASM must be loaded via
fs.readFileSyncorParser.init({ locateFile })pointing to resolved file paths. createRequire(import.meta.url).resolve("web-tree-sitter/web-tree-sitter.wasm")reliably locates the files.
tree-sitter-bash AST structure
For cat /etc/hosts | grep foo:
program
pipeline
command
name: word "cat"
argument: word "/etc/hosts"
command
name: word "grep"
argument: word "foo"
For cat << 'EOF'\n/etc/hosts\nEOF:
program
redirected_statement
command
name: word "cat"
heredoc_redirect
heredoc_start: "EOF"
heredoc_body
heredoc_content: "/etc/hosts\n"
heredoc_end: "EOF"
The key difference: /etc/hosts is an argument in the first case (real path) but heredoc_content in the second (not a path).
Design Overview
WASM initialization
Create a lazy singleton that initializes the parser once on first use:
import { createRequire } from "node:module";
import type Parser from "web-tree-sitter";
let parserPromise: Promise<Parser> | null = null;
function getParser(): Promise<Parser> {
if (!parserPromise) {
parserPromise = initParser();
}
return parserPromise;
}
async function initParser(): Promise<Parser> {
const { default: ParserModule } = await import("web-tree-sitter");
const require = createRequire(import.meta.url);
const treeSitterWasm = require.resolve(
"web-tree-sitter/web-tree-sitter.wasm",
);
await ParserModule.init({ locateFile: () => treeSitterWasm });
const parser = new ParserModule();
const bashWasm = require.resolve(
"tree-sitter-bash/tree-sitter-bash.wasm",
);
const { Language } = ParserModule;
const bash = await Language.load(bashWasm);
parser.setLanguage(bash);
return parser;
}
The lazy singleton avoids WASM init cost when no bash commands are executed.
The module-scope parserPromise variable is acceptable here because it caches a deterministic resource (the parser), not environment-derived configuration.
AST walking strategy
Walk command nodes at all depths (including inside command substitutions, subshells, pipelines).
For each command, extract word children that are arguments (not the command name).
Skip nodes whose ancestor chain includes heredoc_body or comment.
function extractArgumentWords(root: Parser.SyntaxNode): string[] {
const words: string[] = [];
visitCommands(root, (commandNode) => {
let isFirstWord = true;
for (let i = 0; i < commandNode.childCount; i++) {
const child = commandNode.child(i);
if (!child) continue;
if (child.type === "word" || child.type === "concatenation") {
if (isFirstWord) {
isFirstWord = false; // skip command name
continue;
}
words.push(child.text);
} else if (child.type === "command_name") {
isFirstWord = false; // command_name node counts as the command
}
}
});
return words;
}
The visitCommands helper recursively descends into the AST, visiting every command node but not descending into heredoc_body or comment nodes.
This naturally handles:
- Heredocs:
heredoc_bodychildren are never visited, so their text is never extracted. - Comments:
commentnodes are leaf nodes; skipped by the visitor. - Command substitutions:
command_substitutionnodes containcommandchildren, which ARE visited — paths inside$(cat /etc/hosts)are correctly detected. - Pipelines / compound commands:
pipeline,list,compound_statementnodes are transparent containers; theircommanddescendants are visited.
Redirect targets
Redirect targets like > /tmp/out.txt appear as children of redirected_statement or file_redirect nodes, not as command arguments.
These must also be scanned — a redirect to an external path is a real filesystem operation.
redirected_statement
command
name: word "echo"
argument: word "hello"
file_redirect
destination: word "/tmp/out.txt"
The walker will also extract word children from file_redirect nodes (the destination child).
Async signature change
// Before
export function extractExternalPathsFromBashCommand(
command: string,
cwd: string,
): string[]
// After
export async function extractExternalPathsFromBashCommand(
command: string,
cwd: string,
): Promise<string[]>
The single call site in tool-call.ts adds await:
const externalPaths = await extractExternalPathsFromBashCommand(
command,
ctx.cwd,
);
Removing shell-quote
shell-quote and @types/shell-quote are removed from package.json.
The import { parse } from "shell-quote" in external-directory.ts is replaced with the tree-sitter parser.
classifyTokenAsPathCandidate
This function is unchanged.
It continues to receive plain strings (now extracted from AST nodes instead of shell-quote tokens) and applies the same heuristics: skip flags, env assignments, URLs, @scope/package patterns, and bare-slash tokens.
Module-Level Changes
| File | Change |
|---|---|
package.json |
Remove shell-quote from dependencies, @types/shell-quote from devDependencies. Add web-tree-sitter to dependencies, tree-sitter-bash to dependencies. |
src/external-directory.ts |
Remove import { parse } from "shell-quote". Add lazy WASM parser init (getParser, initParser). Add extractArgumentWords AST walker. Change extractExternalPathsFromBashCommand to async. Expose resetParserForTesting for test cleanup. |
src/handlers/tool-call.ts |
Add await before extractExternalPathsFromBashCommand call. |
tests/bash-external-directory.test.ts |
Update all extractExternalPathsFromBashCommand calls to await. Add heredoc false-positive tests. Add command-substitution true-positive tests. |
TDD Order
-
test: add failing heredoc false-positive tests Add a new
describe("heredoc handling")block intests/bash-external-directory.test.tswith:- Single-quoted heredoc delimiter:
cat << 'EOF'\n/etc/hosts\nEOF→ no external path. - Double-quoted heredoc delimiter:
cat << "EOF"\n/etc/hosts\nEOF→ no external path. - Unquoted heredoc delimiter:
cat << EOF\n/etc/hosts\nEOF→ no external path. - Real path alongside heredoc:
cat /etc/hosts << 'EOF'\nsome content\nEOF→ only/etc/hosts. - Heredoc with
<<-(indented):cat <<- 'EOF'\n\t/etc/hosts\nEOF→ no external path. These tests will fail against the currentshell-quotetokenizer (red). Commit:test: add failing heredoc false-positive cases
- Single-quoted heredoc delimiter:
-
feat: add tree-sitter parser init and AST walker
- Add
web-tree-sitterandtree-sitter-bashdependencies, removeshell-quoteand@types/shell-quote. - Implement
initParser,getParser(lazy singleton), andextractArgumentWords(AST walker) insrc/external-directory.ts. - Rewrite
extractExternalPathsFromBashCommandto beasync, using the tree-sitter parser instead ofshell-quote.parse(). - Update
src/handlers/tool-call.tstoawaitthe call. - Update all existing test calls to use
await(the function is now async). - All heredoc tests pass (green).
Full suite passes.
Commit:
feat: replace shell-quote with tree-sitter-bash for AST-based path extraction
- Add
-
test: add command-substitution and redirect coverage Add tests confirming:
echo $(cat /etc/hosts)→/etc/hostsdetected (command substitution paths are real).echo hello > /tmp/out.txt→/tmp/out.txtdetected via redirect walker.cat << 'EOF'\n$(cat /etc/hosts)\nEOF→ no external path (command substitution inside heredoc body is not executed by the outer shell in single-quoted heredocs; but with unquoted delimiters it is — verify correct behavior for both). Commit:test: cover command-substitution and redirect path extraction
-
feat: handle redirect targets in AST walker (if not already covered) If step 2's walker does not already extract redirect destinations, add
file_redirectnode handling. Confirm redirect tests pass. Commit:feat: extract paths from redirect targets in AST walker -
test: verify defense-in-depth guards remain necessary
- Confirm bare-slash guard: tree-sitter parses
echo /with/as awordargument —classifyTokenAsPathCandidatemust still reject it. - Confirm env-assignment guard:
FOO=/usr/local/bin command— tree-sitter may parse the assignment as avariable_assignmentnode (not acommandargument), but verify. - Confirm URL guard:
curl https://example.com/etc/hosts— the URL is awordargument,classifyTokenAsPathCandidatemust reject it. Commit:test: verify defense-in-depth guards with tree-sitter tokenizer
- Confirm bare-slash guard: tree-sitter parses
-
docs: update plan 0072 open questions and close Mark the tree-sitter follow-up in
docs/plans/0072-shell-quote-tokenizer.mdas addressed by #74. Commit:docs: note tree-sitter follow-up addressed by #74
Risks and Mitigations
| Risk | Mitigation |
|---|---|
| Could this silently weaken a permission? | The AST walker visits command nodes at all depths including inside command substitutions and subshells. Paths that were detected by shell-quote as string tokens will still be detected as word arguments. The only paths we stop detecting are inside heredoc bodies — those are false positives (heredoc content is not a path argument). |
| WASM init fails at runtime (file not found, permissions)? | Use createRequire(import.meta.url).resolve() which follows Node resolution. If the WASM file is missing, the error surfaces immediately on the first bash command and is easily diagnosable. Add a try/catch with a clear error message. |
| WASM init latency on first bash command? | Parser init is ~50-100ms (one-time). Subsequent calls reuse the singleton. This is imperceptible for a permission prompt flow. |
jiti does not support import("web-tree-sitter")? |
web-tree-sitter ships both CJS and ESM entry points. jiti's import() falls back to require() for CJS modules. If dynamic import fails, use createRequire as a fallback. |
| npm package size increase (~20MB from tree-sitter-bash native prebuilds)? | The 20MB is native prebuilds + C source in the npm tarball. Only the ~1.4MB .wasm file is used at runtime. This is acceptable for a CLI extension. The files field in our package.json does not include node_modules, so it does not affect our package size. |
| tree-sitter-bash misparses a command? | tree-sitter-bash is the canonical bash grammar used by GitHub's syntax highlighting and many editors. It handles all POSIX and bash-specific syntax. Edge cases are far fewer than with shell-quote. |
shell-quote removal breaks something else? |
shell-quote is only imported in src/external-directory.ts. Grep confirms no other usage. Clean removal. |
| Test file churn from async migration? | Every extractExternalPathsFromBashCommand call in the 462-line test file must add await and the containing test must become async. This is mechanical — each test function signature changes from () => { to async () => {. Do this in one step alongside the implementation to avoid a broken intermediate state. |
Open Questions
- Variable expansion in tree-sitter: tree-sitter parses
$HOME/fooas anexpansion+wordconcatenation. Thetextproperty of the concatenation node includes the literal$HOME/foo.classifyTokenAsPathCandidatedoes not expand variables (same as withshell-quote), so$HOME/foowill not be detected as an external path. This is a pre-existing limitation, not a regression. - Subshell commands:
(cat /etc/hosts)— tree-sitter wraps this in asubshellnode containing acommand. The walker visits it. Verify in tests. - WASM loading in Bun-compiled Pi binary: Pi ships as a Bun-compiled binary.
WASM files in extension
node_modulesare on the filesystem (not compiled in).createRequireshould resolve them correctly, but this needs manual verification.