权限系统
V1 权限的规则模型与“最后匹配优先”的求值、ask 与 reply 的挂起和联动、规则的来源与叠加顺序、工具可见性过滤、doom_loop、--auto 的真实语义,以及 TUI 授权框。
前几篇反复出现的 ctx.ask,最终都落到 packages/opencode/src/permission/index.ts。
规则与求值
一条规则是三元组:permission(权限名,通常就是工具 ID)、pattern(资源模式)、action(allow、deny、ask)。规则集是规则数组,求值取最后一条同时匹配权限名与模式的规则:
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: "*",
}
)
}(packages/opencode/src/permission/index.ts:28)
没有规则匹配时默认 ask;“后者覆盖前者”意味着顺序就是优先级。匹配用的 Wildcard.match 很朴素:* 转成 .*,? 转成 .,反斜杠统一为斜杠,Windows 下不区分大小写。唯一的特例是结尾的“空格加星号”会变成可选,所以 git * 也能匹配光秃秃的 git。
各工具问的是什么
| 权限名 | patterns | always |
|---|---|---|
read | 相对路径 | * |
edit(edit、write、apply_patch) | 相对路径 | * |
glob、grep | 搜索模式 | * |
bash | 每条子命令原文 | git checkout * 这类前缀模式 |
external_directory | 目录/* | 同左 |
task | 子代理名 | * |
webfetch、websearch | URL、查询词 | * |
| MCP 工具键 | * | * |
doom_loop | 工具名 | 工具名 |
patterns 用于本次求值,always 是用户选“总是允许”时要记住的模式。多数工具的 always 是 *:一旦总是允许 edit,就是允许编辑任何文件;bash 则只记住命令前缀(见 Shell 工具)。
ask 与 reply
ask 对每个模式单独求值(packages/opencode/src/permission/index.ts:72):任一模式命中 deny 立即以 DeniedError 失败,全部 allow 直接放行,否则才生成请求。参与求值的是调用方传入的规则集(agent 规则在前、会话规则在后),再加上本实例的 approved 列表。需要询问时,请求放进 pending 表,发布 permission.asked 事件,然后用 Deferred.await 把工具挂起:工具就停在 ctx.ask 这一行,直到客户端通过 POST /permission/:requestID/reply 回复(见 HTTP 服务端)。
- once:只放行这一次。
- always:放行,并把请求的每个
always模式作为 allow 规则追加进approved;再扫描同一会话里其他挂起的请求,现在已全部允许的一并放行。 - reject:不带消息时失败为
RejectedError,带消息则是CorrectedError;同一会话里其他挂起请求也全部拒绝。
approved 只存在内存里,按实例共享、不分会话,重启即失效,TUI 的确认框也写着“until OpenCode is restarted”。它在求值时排在最后,因此会压过同一权限上的其他规则。
三种失败,两种后果
| 错误 | 来源 | 模型看到的 | 本轮循环 |
|---|---|---|---|
DeniedError | 命中 deny 规则 | 附上相关规则的 JSON | 继续 |
CorrectedError | 拒绝并附反馈 | 用户反馈原文 | 继续 |
RejectedError | 直接拒绝 | 用户拒绝了这次调用 | 停止 |
直接拒绝的意思是“别再试了”,流处理器把本轮标记为 blocked 并结束循环,配置 experimental.continue_loop_on_deny 可改为继续;带反馈的拒绝则是“换个做法”,模型拿着反馈接着干。
规则从哪来
生效的规则集由几层依次拼接,越靠后优先级越高。第一层是所有 agent 共享的默认值:
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",
},
})(packages/opencode/src/agent/agent.ts:119)
第一条就是 "*": "allow":V1 默认信任,编辑和 Shell 都不询问,只有越界目录、.env 文件和疑似死循环会问。白名单目录包括截断输出目录、临时目录、技能目录等。
往后依次是:agent 自身的规则(build 放行 question 与 plan_enter;plan 禁止编辑、只放行计划文件;explore 先全部拒绝,再放行读取、搜索、bash 与联网)→ 用户配置的 permission → 该 agent 配置里的 permission → 会话规则(子代理继承的拒绝规则、opencode run 非交互模式追加的拒绝等)→ approved。
配置里的键顺序就是优先级,core 的 schema 为此保留了原始顺序:
Runtime config parsing uses Effect's
propertyOrder: "original"parse option so user key order is preserved for permission precedence.
(packages/core/src/v1/config/permission.ts:15)
所以把 "*": "deny" 写在具体规则之后,会盖掉前面的全部放行。模式里的 ~/ 与 $HOME 会展开成主目录;旧式的 tools: { bash: false } 配置加载时被翻译成权限规则(write、edit、patch 统一映射为 edit)。
看不见的工具
权限不只在执行时把关,还决定模型能否看到某个工具:
export function disabled(tools: string[], ruleset: PermissionV1.Ruleset): Set<string> {
const edits = ["edit", "write", "apply_patch"]
const reads = ["list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource"]
return new Set(
tools.filter((tool) => {
const permission = edits.includes(tool) ? "edit" : reads.includes(tool) ? "read" : tool
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
return rule?.pattern === "*" && rule.action === "deny"
}),
)
}(packages/opencode/src/permission/index.ts:204)
某个权限名的最后一条相关规则若是“模式为 * 的 deny”,对应工具就从请求里整个删掉。"bash": "deny" 让模型根本不知道有 Shell,"bash": { "rm *": "deny" } 则保留工具、只拦 rm。子代理看不到 question、plan 以外的 agent 看不到 plan_exit,都是这个机制的结果。
doom_loop
流处理器在每次工具调用时检查最近 3 个部件:若都是同一工具且输入完全相同,就以工具名为模式询问 doom_loop(默认 ask)。TUI 把它显示为 “Continue after repeated failures”,由用户决定是否继续。
--auto 放开了什么
opencode --auto(隐藏别名 --yolo、--dangerously-skip-permissions)实现在客户端:TUI 的权限上下文处于 auto 模式时,收到 permission.asked 就立刻替用户回复 once(删减后):
case "permission.asked": {
const request = event.properties
if (permission.mode === "auto") {
void sdk.client.permission.reply({
requestID: request.id,
reply: "once",
// ...
})
break
}(packages/tui/src/context/sync.tsx:196)
由此可以推出它的边界:
- 显式 deny 在服务端求值时就已失败,根本不会产生请求,这就是“未被显式拒绝”的含义;
- 回复的是 once 而不是 always,不会往
approved里写任何东西; - 结合默认规则,它实际放开的主要是越界目录、
.env读取、doom_loop,以及用户自己配成 ask 的规则; - 命令面板可以随时切换该模式;
opencode run也有同名选项。
TUI 授权框
packages/tui/src/routes/session/permission.tsx 为常见权限定制了展示:edit 显示 diff,bash 显示 $ 命令,external_directory 列出目录模式。三个选项是 Allow once、Allow always、Reject:
- Allow always 会先弹出确认,列出将被记住的模式;
- Reject 在子代理会话里会让用户填写“希望 OpenCode 怎么改”,于是得到带反馈的
CorrectedError;在主会话里则直接拒绝。
V2 的 PermissionV2 把三元组改名为 action、resource、effect,求值同样是最后匹配优先,见 V2 工具与权限。
下一篇:提供方目录与 models.dev——进入第 3 部分,看 OpenCode 怎样组织模型提供方与模型元数据。