终端 AI 编程 Agent opencode 的权限闸门怎么设
本文基于 opencode 仓库 commit 7fe9938(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/anomalyco/opencode 最新代码与文档为准。
opencode 的权限闸门不是一个开关,而是一张规则表加一句判定逻辑:最后一条匹配上的规则说了算。 你把这句话记住,剩下的配置怪象——为什么 "*": "ask" 要写在最前面、为什么 agent 里的设置能压过全局、为什么改了配置某个工具干脆从工具列表里消失了——全都能自己推出来。
先把范围划清楚。opencode 是一个跑在终端里的开源编码 Agent(MIT 许可证,LICENSE 里署名 Copyright 2025 opencode),它会在你自己的机器上执行 shell 命令、直接改你的源文件、把它读到的代码内容发给你配置的模型服务商。这三件事任何一件出岔子,代价都是真实的:命令写错删掉未提交的改动、编辑工具改坏你没在看的文件、.env 被读进上下文然后随请求发出去。权限闸门就是拦在这三件事前面的东西,值不值得花半小时读懂,你自己判断。
本站已经写过权限设计太大的通病和最小权限怎么落到 Agent 上——那两篇讲的是不挑实现的通用原则;另有一篇常驻 Agent 的写文件闸门讲的是另一个项目的做法。这篇不重复原则,只钻 opencode 这一套的判定代码和配置写法。
一、一次工具调用要过几道关
opencode 用一个叫 permission 的配置块决定某个动作是自动执行、弹出询问、还是直接拦掉。每条规则只解析成三种结果之一:"allow" 不用批准直接跑,"ask" 弹出来问你,"deny" 拦死。
规则不是只按工具名匹配的。每次询问带两样东西:权限名(permission)和一组模式(patterns)。权限名基本对应工具名,模式则是这次调用的具体输入——bash 传的是解析出来的命令文本,edit 传的是文件路径,grep 传的是正则,task 传的是子代理类型。所以你既可以整块禁掉 edit,也可以只放行某个目录下的 edit。
除了工具名,还有两个不对应任何工具的守卫:external_directory 在某个工具碰到工作目录之外的路径时触发,doom_loop 在同一个工具带完全相同的输入连续调用时触发。
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
evaluate 判定函数 | 拿权限名和模式在规则集里找最后一条匹配,找不到就回落到 ask | packages/opencode/src/permission/index.ts | 每次工具调用前,静默执行 |
Wildcard.match | 把 *、? 编译成正则做匹配,顺带归一化路径分隔符 | packages/core/src/util/wildcard.ts | 你写的每一条模式都过这里 |
fromConfig / merge | 把配置对象摊平成有序的规则数组,多份规则集按顺序拼接 | packages/opencode/src/permission/index.ts | 全局配置、内建 agent、你自己的 agent 三层叠加时 |
| 默认规则表 | 规定哪些权限开箱放行、哪些开箱询问 | packages/opencode/src/agent/agent.ts | 你什么都不配的时候,跑的就是它 |
ask / reply | 挂起请求、发事件给前端、收 once/always/reject 的回复 | packages/opencode/src/permission/index.ts | 终端里弹出那个选择框的时刻 |
| 命令前缀字典 | 把 git push origin main 归约成”人能看懂的命令”,用来生成 always 模式 | packages/opencode/src/permission/arity.ts | 你在 bash 审批里点了 always 之后 |
| 配置模式定义 | 规定哪些权限键能写成对象、哪些只能写单个动作 | packages/core/src/v1/config/permission.ts | 配置写错类型、启动报错时 |
| 文档正文 | 配置语法与可用权限清单的官方说明 | packages/web/src/content/docs/permissions.mdx | 查语法的第一站 |
二、判定逻辑:最后匹配者胜,而且两级都走通配
判定的全部逻辑就是这么一小段:
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,不是”最具体的赢”。文档里那句”last matching rule winning”是字面意思:谁排在后面谁生效,跟模式写得多细无关。配置对象的键序被保留下来当优先级用——packages/core/src/v1/config/permission.ts 的注释直接写了运行时解析用 propertyOrder: "original" 保留用户键序。所以 "*" 这条兜底必须写在最前面,写在后面会把你后面所有精细规则全盖掉。
第二,Wildcard.match 在权限名和模式两侧都调用了。也就是说规则里的 permission 字段本身也是个模式。"*": "ask" 之所以能当全局兜底,靠的就是权限名这一侧的通配匹配。
第三,一条都匹配不上时回落成 ask。这是个保守回落,但你平时几乎碰不到它,因为默认规则表里有一条覆盖一切的 "*": "allow"(见下一节)。
再看模式匹配本身:
export function match(input: string, pattern: string) {
const normalized = input.replaceAll("\\", "/")
let escaped = pattern
.replaceAll("\\", "/")
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*")
.replace(/\?/g, ".")
if (escaped.endsWith(" .*")) escaped = escaped.slice(0, -3) + "( .*)?"
return new RegExp("^" + escaped + "$", process.platform === "win32" ? "si" : "s").test(normalized)
}
要盯住的地方有四个:* 是 .*,能跨过路径分隔符,所以 packages/* 也会匹配到 packages/a/b/c,不要按 shell 里 * 和 ** 的区别去理解;反斜杠在两侧都被归一化成正斜杠,Windows 路径不用自己转义;Windows 上额外加了 i 标志,同一份配置在 macOS 上大小写敏感、在 Windows 上不敏感;最后那句尾部补丁最容易被忽略——模式以空格加 * 结尾时,尾部会被改写成可选,于是 ls * 既匹配 ls -la 也匹配光秃秃的 ls。
反过来说,ls*(不带空格)不做这个改写,它匹配的是所有以 ls 开头的字符串,包括某个叫 lstmeval 的可执行文件。仓库里 packages/opencode/test/util/wildcard.test.ts 把这个坑写成了断言(该测试针对 packages/opencode/src/util/wildcard.ts,其 match 与上面这份逻辑一致)。写 bash 白名单时漏掉那个空格,就等于把一整片前缀相同的命令都放了行。
文档里还提到 ~ 和 $HOME 开头的模式会展开成家目录,展开是 fromConfig 把配置摊平成规则时顺手对每个模式键做的(expand 只处理对象写法里的模式,写成单个动作的那种没有模式可展),只影响模式怎么写,不会让外部路径变成工作区的一部分。
三、默认表:什么默认放行,什么默认问你
不配任何东西时,跑的是 packages/opencode/src/agent/agent.ts 里这张表:
const defaults = Permission.fromConfig({
"*": "allow",
doom_loop: "ask",
external_directory: {
"*": "ask",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
},
question: "deny",
plan_enter: "deny",
plan_exit: "deny",
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
read: {
"*": "allow",
"*.env": "ask",
"*.env.*": "ask",
"*.env.example": "allow",
},
})
读一遍你就知道这套东西的取向:默认是放行的。bash、edit、write、grep、glob、task、webfetch 全都落在 "*": "allow" 上,开箱即用意味着开箱即跑命令、开箱即改文件。真正默认拦一下的只有三类——离开工作目录、原地打转、读 .env。
顺带记一个差异:文档 permissions.mdx 的 Defaults 一节写的是 .env 默认 deny,而代码里这张默认表给的是 ask。以你机器上跑的那份代码为准,别拿文档当保证。
白名单那几项也值得知道:截断产物目录、全局临时目录、技能目录和引用目录会被预先加进 external_directory 的放行名单,免得 Agent 每读一次自己写出去的长输出就问你一次。
规则集的叠加顺序在同一个文件里:先 defaults,再是内建 agent 自己的覆盖,最后才是你在配置里写的 user。因为最后匹配者胜,用户配置天然压过默认值。你自己在 agent 段落里给某个 agent 写的 permission 会再拼到该 agent 规则集的最后,所以 agent 级压过全局级。
内建 agent 是这套规则的现成范例。plan agent 把 edit 整个 "*": "deny",只对计划文件开个口子;explore agent 更狠,先 "*": "deny" 再逐条放行 grep、glob、list、bash、read、webfetch、websearch;compaction、title、summary 这几个内部 agent 直接 "*": "deny",它们本来也不该碰工具。
这里藏着一个容易吓一跳的行为。同文件里的 disabled 函数会检查每个工具对应权限的最后一条匹配规则,如果这条规则的 pattern 恰好是 "*" 且动作是 deny,这个工具会被 visibleTools 从交给模型的工具列表里摘掉——不是拦,是根本不出现。edit、write、apply_patch 三个工具名都映射到 edit 权限上一起处理。所以整块 deny 和带模式的 deny 效果不一样:前者模型压根不知道有这个工具,后者模型会调用、然后吃一个拒绝错误再自己想办法。想让模型明确知道”这条路走不通”,就用带模式的 deny;想让它彻底别惦记,就整块 deny。
四、点 always 之后到底放行了什么
弹窗有三个选项:once 只批这一次,always 批准这一类,reject 拒绝。reply 里的处理值得看清楚。
选 reject 时,除了当前这条,同一 session 里所有还挂着的权限请求会被一起拒掉。这是并发场景下的连坐设计——Agent 一口气发起好几个工具调用时,你拒一个等于按下总闸。如果你回复时附带了文字反馈,模型收到的错误里会带上你这句话,而不是干巴巴一句被拒绝。
选 always 时,被写进内存的不是你看到的那条完整命令,而是请求里带的 always 模式数组,这个数组由发起请求的工具提供。批准后它们以 allow 动作追加到 approved 数组,接着遍历同 session 中挂起的其它请求,凡是所有模式都能被新 approved 判定成 allow 的,一并放行。
approved 存在权限服务的实例状态里,不写配置文件。文档的说法是”for the rest of the current OpenCode session”,实例销毁时挂起的请求会被统一 reject。所以 always 是会话内的临时放宽,重启就没了——这既是好事(不会悄悄污染你的配置),也是坏事(你每天都得重新点一遍,点多了就麻木)。
bash 的 always 模式怎么生成,是这套设计里最需要你亲自确认的一环。packages/opencode/src/tool/shell.ts 把你的命令解析成语法树,逐个命令节点收集:
if (tokens.length && (!cmd || !CWD.has(cmd))) {
scan.patterns.add(source(node))
scan.always.add(BashArity.prefix(tokens).join(" ") + " *")
}
BashArity.prefix 查的是 packages/opencode/src/permission/arity.ts 里一张命令前缀到 token 数的字典:git 是 2,npm 是 2 而 npm run 是 3,docker 是 2 而 docker compose 是 3,rm、ls、cat、chmod 这类是 1。字典里查不到的命令退回取第一个 token。取完前缀再拼上 *,就是它写进白名单的东西。
于是同样点一次 always,粒度天差地别。npm run build 归约成 npm run build *,只放行这一个脚本;git push origin main 归约成 git push *,放行的是所有 push;而对一条 rm -rf node_modules,归约结果是 rm *——本会话内所有 rm 命令从此不再问你。这张字典的头部注释还老实交代了它是用一段提示词生成的,覆盖面靠的是常用命令枚举,不是形式化推导。
另外,一条命令行被拆成多个命令节点分别登记模式。a && b 会产生两条模式,ask 里对模式数组逐条判定,任何一条命中 deny,整条命令直接被拒。这个行为是好的:你没法靠 && 把一条被禁的命令夹带进去。
五、放太松和放太紧,各自的账单
先说松。opencode 提供了 --auto 标志,opencode run --auto 或 TUI 里的命令面板都能开,命令行帮助文本写的是自动批准所有没被显式 deny 的权限请求,后面直接跟了一句 dangerous。仓库里还有两个隐藏别名 --yolo 和 --dangerously-skip-permissions,三者取或,是同一件事的三个名字。
开了它之后,deny 规则仍然有效,ask 全部变成静默通过。账单是这样的:默认表里 .env 是 ask 而不是 deny,auto 模式下它就是直接读;external_directory 是 ask,auto 模式下 Agent 跑出工作目录你不会知道;doom_loop 是 ask,auto 模式下模型带着完全相同的输入原地空转,你只会在账单和日志上看到结果。这三样恰好是默认表里仅有的三道刹车,auto 一开全没了。要用它,配套动作是先把 deny 清单写扎实——因为在 auto 模式下,只有 deny 是真的。
再说紧。紧的代价不是”安全但慢”,而是”你会开始不看内容乱点”。审批疲劳是可测的:一个把 bash 设成 "*": "ask" 又不给任何白名单的配置,跑一次中等规模的重构可能弹几十次框,前五次你会读命令,后面就是闭眼回车。这时候闸门还在,把关的人已经不在了。
更隐蔽的一种紧法是整块 deny 用错了地方。前面说过,"*" 加 deny 会让工具从模型的工具列表里消失。模型看不见 edit 工具时,不会告诉你”我没权限改文件”,它会用别的方式凑合——比如把改动内容打印在回复里让你自己贴,或者绕道用 bash 写文件(如果 bash 还开着)。你以为你关掉了写文件的能力,实际只是把它挤到了另一条你没设闸门的路上。
中间地带的写法在文档里给过一个范例:bash 兜底 ask,git * 放行,然后单独把 git commit * 和 git push * 设成 deny 或 ask。这个思路可以复用到别处——放行一整族只读操作,单独抠出那几个有外部副作用的子命令。
子代理这一层也别忘了。packages/opencode/src/agent/subagent-permissions.ts 里,通过 task 工具派生子会话时,从父会话继承的只有 deny 规则和 external_directory 规则,文件注释明确写着父 agent 的限制只管它自己,子代理的能力由子代理自己的规则集决定。所以你给主 agent 加的那些 ask,不会自动传给它派出去的子代理,会传下去的只有 deny。这个取舍要在你设计多 agent 流程时先想清楚。
六、边界:这套设计明确不管的事
这是一套基于字符串模式的、进程内的、单点的批准机制。它不是沙箱。列几条它管不到的:
它不管命令执行时干了什么。bash 规则匹配的是解析出来的命令文本,命令一旦被放行就以你的用户身份跑,之后创建的子进程、发起的网络请求、写到别处的文件,闸门都不再介入。放行 npm run * 等于放行了 package.json 里任何人能写进去的任何脚本。
它不管内容外泄的下游。read 权限拦的是”读不读这个文件”,文件一旦被读进上下文,内容就随请求发给你配置的模型服务商了。各家服务商对数据留存和训练使用的规则不同且会调整,以官方最新说明为准。想真正防住密钥,靠的是不让密钥出现在这台机器的项目目录里,而不是靠一条 *.env 规则。
它不做语义等价判断。rm -rf ./build 和 find . -name build -exec rm -rf {} \; 在模式匹配眼里是两个毫不相关的字符串。基于前缀白名单的方案天生绕得过去,它拦的是手滑和常规误操作,不是拦一个刻意绕路的对手。
它不管路径符号链接与硬链接。external_directory 判断的是路径是否落在工作目录内,指向外面的链接是另一回事。
它不做审计留存。ask 里有日志输出,批准记录活在内存里,重启即失。你要合规意义上的操作留痕,得自己在外面接一层。
always 的记忆只在会话内,也意味着它给不了你”团队统一的信任基线”。想要跨会话稳定的策略,只能落到配置文件里,然后像对待代码一样评审它。
七、上手与避坑清单
兜底规则写在最前面。 判定是最后匹配者胜,不是最具体者胜。把 "*": "ask" 写在对象末尾,它会盖掉你上面精心写的每一条放行——而且不报错,只是你的白名单全部静默失效。写完先用一条本该放行的命令实测一次,别靠读配置自我确认。
bash 模式记得留那个空格。 ls* 匹配 lstmeval,ls * 不匹配。少一个空格就从”放行一个命令”变成”放行一整片前缀”。凡是命令类模式,一律写成 命令 * 的形式。
点 always 之前先看它要写进去的是什么。 界面上展示的是即将被批准的模式,不是你眼前这条命令。对 arity 为 1 的命令(rm、cat、chmod、mv 这类),一次 always 就是本会话内该命令全量放行。这类请求老老实实点 once。
* 会跨越路径分隔符。 它编译成 .*。写 edit 的目录白名单时,src/* 等于整棵 src 子树,不是只有直接子文件。想限制到单层,用 ? 拼或者换更精确的前缀,写完拿真实路径验一遍。
别把 deny 当成”关掉功能”。 整块 deny 会让工具从模型视野里消失,模型不会申诉,只会换条路。关掉 edit 之前先想清楚 bash 是不是也该一起收,否则你只是把写文件这件事挪到了没有闸门的通道上。
auto 模式开之前先补 deny。 --auto 只保留 deny。开它等于同时关掉 .env 询问、外部目录询问和空转询问。如果你就是要在容器里无人值守地跑,那更该在容器里跑——把隔离交给容器,而不是交给一个字符串匹配器。
多 agent 流程里,ask 传不下去。 子会话只继承父会话的 deny 和 external_directory。你要限制子代理,去改子代理自己的权限块。
跨平台的同一份配置行为不同。 Windows 上模式匹配大小写不敏感,其它平台敏感。团队共用配置时,以敏感的那一侧为准来写,才不会出现”我这儿拦住了他那儿没拦住”。
按顺序读三个文件,你就把这套东西吃透了:packages/web/src/content/docs/permissions.mdx 建立语法直觉,packages/opencode/src/permission/index.ts 看清 evaluate、ask、reply 三段真实逻辑,packages/opencode/src/agent/agent.ts 里那张默认表告诉你不配置时到底跑的是什么。想搞明白 bash 白名单的粒度,再补一个 packages/opencode/src/permission/arity.ts。
落到自查上就三句话:我的兜底规则是不是排在最前面;我点过 always 的那些命令,归约后的模式我认不认;如果今天我把 --auto 打开,我的 deny 清单挡不挡得住最坏的那条命令。三句都能回答,这套闸门才算是你在管,而不是它在替你赌。选型层面的横向比较,可以接着看终端 Agent 项目怎么挑和人在环路该卡在哪一步。
本篇属于一个把开源AI 编程 Agent 项目 opencode逐层拆开讲的系列,整体地图见 opencode 是什么:终端里的开源 AI 编程 Agent 全景地图;沿着这条线往下,还可以看 开源终端 Agent 项目 opencode 的多 agent 怎么配 和 opencode 读哪些规则文件:终端编码 Agent 的规则加载顺序。