开源项目 opencode 的 shell 工具:命令怎么解析、哪些会被权限层拦下
本文基于 opencode 仓库 commit 7fe9938(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/anomalyco/opencode 最新代码与文档为准。
在 opencode 里,权限系统看的不是模型交上来的那串命令文本,而是 tree-sitter 解析出来的语法树——每一条子命令、每一个能静态算出来的路径参数,都会被单独拎出来评一次规则。 这个选择决定了它的能力上限和失效边界:能拆出来的它管得很细,拆不出来的它就老老实实退回去问你。
这篇只谈 shell 这一块。想让 AI 替你写脚本,看 让 AI 写 shell 脚本;想把 Agent 整个关进容器,看 容器隔离的做法;想搞清楚权限口子开太大以后会发生什么,看 Agent 权限给太大的代价。本篇是这三者中间缺的那一环——一个具体实现里,命令从字符串走到落地执行,中间到底经过了哪几道手。
opencode 是一个用 MIT 许可证(LICENSE,Copyright 2025 opencode)发布的开源项目,跑在终端里,packages/ 下有 32 个包,packages/opencode/src/tool/ 里放着 25 个 .ts 与 15 个 .txt——工具实现和工具描述文本是分开存的,这个结构本身就是理解它的入口。
一、这块要解决的问题
模型给出的从来不是”一条命令”,而是一段 shell 文本。git add . && npm run build; rm -rf ../cache 是一次工具调用,但里面有三件性质完全不同的事:一件动了 git 索引,一件跑了项目脚本,一件删了工作区外面的目录。如果权限系统只对整串字符串做一次判断,那么用户要么每次都被打断,要么点一次”允许”就把后面两件也放了过去。
opencode 给 shell 工具定义的参数只有三个,在 packages/opencode/src/tool/shell/prompt.ts 的 parameterSchema() 里:command 是必填的命令文本,timeout 是可选的毫秒超时,workdir 是可选的工作目录。workdir 那条参数描述写得很直白——用它,不要用 cd。这不是风格洁癖:cd 换目录会让后续路径的解析基准漂移,而权限判断恰恰依赖”这个路径落在工作区里还是外面”。把目录切换从命令文本里挪到参数上,解析器才有一个稳定的基准点。
二、工具描述不是一句话,是按 shell 和平台现场拼出来的
packages/opencode/src/tool/shell/shell.txt 是模板,里面全是 ${intro}、${os}、${shell}、${workdirSection}、${tmp}、${commandSection} 这样的占位符。prompt.ts 里的 renderPrompt 负责替换,缺哪个值就直接抛错——描述文本被当成有契约的东西对待,不允许渲染出半成品。
真正的分叉在 profile() 函数里。它按 shell 名字分三条支路取值:cmd 一套、PowerShell 系(pwsh 与 powershell)一套、其余走 bash 那一套。名字怎么写给模型看,则由 shellDisplayName() 决定:pwsh 显示为 PowerShell (7+),powershell 显示为 Windows PowerShell (5.1),cmd 显示为 cmd.exe。PowerShell 那条支路内部还要再分一次——powershellNotes() 和 chainGuidance() 都会看具体是哪一个:检测到 powershell 时,chainGuidance() 告诉模型这个 shell 不支持 &&,改用 cmd1; if ($?) { cmd2 };检测到 pwsh 时则明确说它支持 && 和 ||。算下来最终渲染出的说法有四种。同一个 Agent 换台机器,拿到的工具描述文字是不一样的。
模板正文里有两段硬规矩值得原文摘出来:
IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.
# Git and GitHub
- Only commit, amend, push, or create PRs when explicitly requested.
- Before committing, inspect `git status`, `git diff`, and `git log --oneline -10`; stage only intended files and never commit secrets.
- Write a concise commit message that matches the repo style.
- Do not update git config, skip hooks, use interactive `-i`, force-push, or create empty commits unless explicitly requested.
- If a commit fails or hooks reject it, fix the issue and create a new commit; do not amend the failed commit.
把读写文件从 shell 里赶出去,不只是为了让专用工具的输出更整齐。文件工具有自己的权限键(read、edit),路径能被单独匹配;一旦读写走了 shell,这些规则就形同虚设。至于 git 那几条,是把”不许 force-push、不许跳 hook、失败别 amend”这类只能靠人守的纪律写进了描述——它是提示,不是强制,模型仍然可能不听,所以后面还有权限层兜底。
另外一个容易踩的细节在 packages/opencode/src/tool/shell/id.ts:这个工具的 ID 是 bash,源码注释明确写了这是为兼容既有插件、用户和已保存的权限而保留的名字。你在配置里写权限规则时,键仍然是 bash。
三、命令怎么被拆开:语法树与两张命令表
执行前的解析在 packages/opencode/src/tool/shell.ts。parser() 是个懒加载,第一次调用时才去装 web-tree-sitter,并分别加载 tree-sitter-bash 和 tree-sitter-powershell 两份 wasm,得到 bash 与 ps 两个解析器。命令文本先被解析成树,commands() 用 descendantsOfType("command") 把所有子命令捞出来,parts() 再从每条命令里挑出有意义的片段——只留 command_name、command_name_expr、word、string、raw_string、concatenation 这几类;碰到 PowerShell 树里的 command_elements 节点会下钻一层,把里面的 command_argument_sep 和 redirection 跳过。跳过重定向这一步顺带说明了一件事:rm foo > bar 里的 bar 不会被当成待检查的路径参数。另有一个 source() 函数负责取每条命令的原文,它会往上看一眼——若父节点是 redirected_statement,取的是带重定向的整句,这样权限提示里显示的命令跟你实际会跑的那条对得上。
真正决定”哪些命令要另眼相待”的是文件顶部那几张表:
const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"])
const FILES = new Set([
...CWD,
"rm", "cp", "mv", "mkdir", "touch", "chmod", "chown", "cat",
"get-content", "set-content", "add-content",
"copy-item", "move-item", "remove-item", "new-item", "rename-item",
])
const CMD_FILES = new Set(["copy", "del", "dir", "erase", "md", "mkdir", "move", "rd", "ren", "rename", "rmdir", "type"])
命中 FILES(或在 cmd.exe 下命中 CMD_FILES)的命令,会走一遍路径提取:pathArgs() 先挑出像路径的参数——bash 下跳过 - 开头的项,chmod 的 + 参数也排除;PowerShell 下按两张小表处理,SWITCHES 里的 -confirm、-force、-recurse、-whatif 这类开关直接丢,FLAGS 里的 -path、-literalpath、-destination 则取它后面那个值。
拿到候选参数后,argPath() 一层层往下剥:去引号、把 ~ 展开成家目录、在 PowerShell 下再展开 $env: 与 ${env:} 以及 $HOME/$PWD/$PSHOME;prefix() 遇到 ?、*、[ 就在通配符前截断,只保留能确定的那一段;provider() 处理 PowerShell 的 FileSystem:: 前缀,识别不了的 provider 直接放弃。在 Windows 上跑 POSIX shell 时,resolvePath() 还会调一次 cygpath -w 把 /c/... 这类路径翻回 Windows 形式。
关键的一步是 dynamic():参数以 ( 或 @( 开头、含 $( 或 ${、含反引号,都判为动态;bash 下只要出现 $ 就算,PowerShell 下则是出现 $ 且不是已经被展开过的 $env: 才算。命中任何一条,argPath() 随即放弃这个参数。它宁可不猜,也不假装自己知道。
算出来的绝对路径过一遍 containsPath()(在 packages/opencode/src/project/instance-context.ts),判断它是否落在实例目录或 worktree 内。落在外面的,取其所在目录塞进 scan.dirs,稍后以 external_directory 这个权限键、以 目录/* 的形式发起询问。注意这里还有一条兜底:如果 workdir 参数本身就指到了工作区外面,这个目录也会被加进去。
与此同时,每条子命令都会往 scan.patterns 里塞一份自己的原文(cd 这类目录切换命令除外),并往 scan.always 里塞一条前缀模式。这条前缀由 packages/opencode/src/permission/arity.ts 的 BashArity.prefix() 生成:
export function prefix(tokens: string[]) {
for (let len = tokens.length; len > 0; len--) {
const prefix = tokens.slice(0, len).join(" ")
const arity = ARITY[prefix]
if (arity !== undefined) return tokens.slice(0, arity)
}
if (tokens.length === 0) return []
return tokens.slice(0, 1)
}
ARITY 是一张写死在源码里的字典,记录每个命令前缀由几个 token 构成。它上面那段注释挺有意思:字典是用一段提示词生成出来的,提示词里写明了三条约束——标志(flag)永远不算 token、最长匹配的前缀优先、只有当更长的前缀 arity 与短前缀不同时才单列一条。这解释了为什么表里有 git 却没有 git checkout,而 npm run 单独列了一行。看几个具体的值:“git” 是 2,所以 git checkout main 的前缀是 git checkout;“npm” 是 2 而 “npm run” 是 3,所以 npm run dev 整条都算前缀;rm、cat、ls 这类是 1。你在弹窗里点”总是允许”,被记住的就是这条前缀加一个 *。
四、权限层怎么裁决
裁决逻辑在 packages/opencode/src/permission/index.ts(evaluate.ts 只有一行,把它转出去)。核心函数短得可以整段读:
export function evaluate(permission: string, pattern: string, ...rulesets: PermissionV1.Ruleset[]): PermissionV1.Rule {
return (
rulesets
.flat()
.findLast((rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern)) ?? {
action: "ask",
permission,
pattern: "*",
}
)
}
findLast 三个字就是全部的优先级规则:最后一条匹配上的规则赢,没有任何规则匹配则默认询问。 所以配置里 "*" 这条兜底要写在最前面,具体规则写在后面,写反了就全被兜底盖掉。
模式匹配在 packages/core/src/util/wildcard.ts,* 变成 .*,? 变成 .,Windows 上加 i 标志忽略大小写。它有一处专门的修补:
if (escaped.endsWith(" .*")) escaped = escaped.slice(0, -3) + "( .*)?"
也就是说 git status * 这条规则,同样能匹配不带参数的裸 git status。这一行解决的是”写了带星号的规则,结果无参命令还在弹窗”的问题。
ask() 拿着 shell 工具算出来的那堆 pattern 逐条评:任何一条命中 deny,整次调用立刻以 DeniedError 结束;全部命中 allow 就一声不响放行;只要有一条落到 ask,就发出 Event.Asked 事件并挂起等待。reply() 处理三种回复:once 只放行这一次;always 会把请求里的 always 模式写进内存里的 approved 列表,并顺带放行同会话中所有已被覆盖的挂起请求;reject 则更狠——除了拒掉当前这条,还会把同一会话里其它挂起的请求一并拒掉,避免你拒了一条却被剩下的连环追问。
配置侧的权限键,官方文档 packages/web/src/content/docs/permissions.mdx 列得很清楚:read、edit、glob、grep、bash、task、skill、lsp、question、webfetch、websearch,加上两个守卫——external_directory 和 doom_loop(同一工具调用带着完全相同的输入重复三次时触发)。默认值是偏宽松的:多数权限默认 allow,doom_loop 和 external_directory 默认 ask,read 允许但 .env 系列被拒。命令行上还有 --auto,它把本来要问的都自动放行,但显式的 deny 依然生效。
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 参数 schema 与描述渲染 | 定义 command/timeout/workdir,按 shell 与平台拼装描述 | packages/opencode/src/tool/shell/prompt.ts | 想搞清楚 Agent 为什么在 Windows 上换了句式 |
| 描述模板正文 | 硬规矩:别拿它读写文件、git 操作纪律 | packages/opencode/src/tool/shell/shell.txt | 想知道它为什么不肯用 cat |
| 解析与路径扫描 | 语法树拆命令、算外部目录、生成 always 模式 | packages/opencode/src/tool/shell.ts | 排查某条命令为什么弹窗或不弹窗 |
| 命令前缀字典 | 决定”总是允许”记住多长的前缀 | packages/opencode/src/permission/arity.ts | 觉得一次授权放得太宽或太窄 |
| 规则裁决与询问状态机 | last-match 决定 allow/ask/deny,管理挂起请求 | packages/opencode/src/permission/index.ts | 写 permission 配置、调试规则不生效 |
| 通配符匹配 | */? 的实际语义与尾部星号的特例 | packages/core/src/util/wildcard.ts | 规则看着对却不匹配 |
| 工具 ID 与 shell 种类 | 工具 ID 固定为 bash,识别 bash/pwsh/powershell/cmd | packages/opencode/src/tool/shell/id.ts | 纳闷配置键为什么叫 bash |
| 输出截断 | 超限时把全文落盘、返回尾部 | packages/opencode/src/tool/truncate.ts | 输出被截断、要找完整日志 |
| 工作区边界判定 | 路径是否属于当前目录或 worktree | packages/opencode/src/project/instance-context.ts | 老是被问外部目录权限 |
五、边界与代价
它不是沙箱。 命令拿到的环境是 process.env 再叠加插件通过 shell.env 注入的变量,也就是说你 shell 里的所有密钥、云凭证、npm token,被批准的命令都能读到。权限层能决定”这条命令跑不跑”,管不了”跑起来之后它拿什么”。真要把这一层堵死,得靠容器或独立账号,那是另一套东西。
动态构造的路径它主动放弃。 dynamic() 一旦判定参数含变量或子命令替换,路径推断就中止。后果是 rm -rf $TARGET 这类写法不会触发外部目录询问——整条命令文本仍然会按 bash 权限评一次,但”这条命令要动工作区外面的东西”这个信号丢了。如果你的 bash 规则里恰好有一条宽泛的 allow,护栏就是空的。
命令名匹配只认字面量。 FILES 是一张固定的名字表,sudo rm、xargs rm、bash script.sh 里的 rm、以及任何自己写的包装脚本,都不会走进路径扫描分支。源码注释也承认 PowerShell 别名暂时没有统一归一化,理由是怕重复弹窗。
前缀白名单是把双刃剑。 git 的 arity 是 2,你对 git status 点一次”总是允许”,记住的是 git status *,粒度还行;但你要是在配置里图省事写 "git *": "allow",git push --force 和 git reset --hard 也一起进来了。文档里给的示例正是用 "git commit *": "deny" 这种更具体的规则往回收。
超时只是杀进程。 timeout 到点后走 handle.kill,输出里会追加一段 <shell_metadata> 说明命令因超时被终止。但已经发生的副作用——删掉的文件、推出去的提交、发出去的请求——没有回滚这回事。
命令输出会进上下文。 stdout/stderr 合并后回给模型,超限的部分写到本地截断目录、只把尾部塞回去。这意味着你随手跑的 env、cat 某个配置文件(虽然描述里劝阻,模型未必总听)里的内容会被发到模型服务商。各家的数据处理规则不同且会调整,以官方最新说明为准,但从工程角度,默认假设”进了输出就等于出了本机”更安全。
它不看命令的内部语义。 npm run deploy 在字典里就是三个 token,脚本里干了什么它不关心。真正危险的操作往往藏在 package.json 或 Makefile 里,靠命令名做判断天然穿不透这一层。
六、上手与避坑清单
规则写反顺序,等于没写。 会踩是因为大多数配置系统是”更具体的规则优先”,而这里是 findLast 的纯顺序语义。怎么避:把 "*": "ask" 写在对象最前面,越具体的规则越往后放,改完之后拿一条真实命令在会话里试一次。
去配置里找 shell 这个权限键,会找不到。 会踩是因为工具在界面和文档里叫 shell,但 id.ts 把 ToolID 固定成了 bash 以兼容旧配置。怎么避:权限键一律写 bash,permission.bash 下面再用对象语法做细分。
规则写成 "git status" 却不生效。 会踩是因为实际参与匹配的 pattern 是解析出来的完整命令原文,带上参数就不再等于这个字符串。怎么避:一律写成 "git status *";wildcard.ts 对结尾的 * 做了可选化处理,这种写法同时覆盖有参数和无参数两种情况。
用 cd xxx && cmd 却发现权限提示怪怪的。 会踩是因为 cd 类命令被排除在 pattern 之外,而它改变了后续路径的解析基准,扫描出来的目录可能不是你以为的那个。怎么避:按描述里的要求用 workdir 参数,这也是工具本身反复强调的写法。
总被外部目录权限打断。 会踩是因为 external_directory 默认是 ask,而很多工程实际跨了工作区(monorepo 之外的依赖、临时目录)。怎么避:把确实常用的路径显式加进 external_directory 的 allow 规则;文档里也提醒了 ~ 展开只是写法上的便利,不会让外部路径变成工作区的一部分。另外,模板里提到的临时目录是预批准的,需要落临时文件时优先用它。
在 Windows 上按 Linux 习惯拼命令,链式失败。 会踩是因为 Windows PowerShell (5.1) 不支持 &&,而 opencode 只在检测到这个 shell 时才把替代写法写进描述。怎么避:确认自己配置的 shell 是哪个(Shell.acceptable 会先看配置里的 shell,没配再看 SHELL 环境变量),别把两套语法混着写。
输出被截断后自己去补 head/tail。 会踩是因为习惯性想省 token,但工具描述里明确劝阻这么做——完整输出已经落盘了。怎么避:用返回信息里给出的文件路径,配合专用的读取和搜索工具定位,比在命令里截断更准。
把 bash 直接设成 allow 图省事。 会踩是因为初期弹窗确实烦。但这一档等于同时关掉了命令层的所有护栏,只剩 external_directory 那一道,而它对动态路径又是失效的。怎么避:先用 --auto 跑一段观察期,把真正高频的命令前缀收敛成显式 allow 规则,再把危险动作(强制推送、递归删除、重置)单独写成 deny。这类粒度设计的通用思路,可以对照 最小权限怎么落地 一起看。
收个尾
如果你要把这套机制搬到自己的 Agent 上,有三件事值得先想清楚:解析层是否愿意在”猜不准”时主动认输(而不是给出一个似是而非的路径);一次授权到底记住多长的前缀,这个粒度有没有可解释的依据;以及规则的优先级语义写没写进文档——findLast 这种设计足够简单,但没读过源码的人第一次配置几乎必错。
想继续往下读,顺序建议是:先看 packages/opencode/src/tool/shell/shell.txt 弄清工具怎么自我描述,再看 packages/opencode/src/tool/shell.ts 的 collect() 弄清扫描逻辑,最后看 packages/opencode/src/permission/index.ts 的 ask() 和 reply() 弄清询问的状态机。工具描述该怎么写才让模型照做,另有一篇 工具描述的写法 专门谈;几个终端 Agent 之间的取向差异,可以看 终端 Agent 怎么选。
本篇属于一个把开源AI 编程 Agent 项目 opencode逐层拆开讲的系列,整体地图见 opencode 是什么:终端里的开源 AI 编程 Agent 全景地图;沿着这条线往下,还可以看 开源编码 Agent opencode 找代码三件套:读取、按名找、按内容找 和 opencode 怎么把活派给子 agent:task 工具与探索型子代理。