权限、审批、安全边界
a3647eb025c7 · 官方 Release
Agent 生成档案
Section titled “Agent 生成档案”- 章节 ID:
09-permission-security - 章节摘要:沿一次 edit 审批,理解 last-match ruleset、Deferred 等待、once/always/reject,以及默认 Agent 策略与非交互拒绝。
- 教程版本:
v1.18.16 - 源码基线:
a3647eb025c7615159d417dcc49fc39fdaeba65b - 章节元数据:/versions/v1-18-16/data/chapters.json
- 源码映射:/versions/v1-18-16/data/source-map.json
主要源码路径
Section titled “主要源码路径”packages/opencode/src/permission/index.tspackages/opencode/src/permission/evaluate.tspackages/core/src/v1/permission.tspackages/opencode/src/agent/agent.tspackages/opencode/src/session/tools.ts
源码基线:
v1.18.16(提交a3647eb025c7)。本章用一次修改src/config.ts的请求推演权限状态机;这是典型源码路径,不是实际弹窗或批准记录。
0. 本章学习目标
Section titled “0. 本章学习目标”学完本章,你应该能:
- 画出 tool、
Tool.Context.ask、ruleset、Permission service 与 UI/CLI 的关系。 - 用
permission + pattern + action解释一条规则,并手算“最后匹配优先”。 - 追踪一次 edit 请求走向 allow、deny 或 ask 的完整分支。
- 区分
once / always / reject对当前请求和同 session 待审批请求的影响。 - 说明默认 agent、plan agent、用户配置和 session 规则如何分层。
- 说出这套权限系统的安全边界:它是 runtime gate,不是 OS sandbox,也不会自动保护忘记调用
ctx.ask的副作用。
1. 一句话讲明白
Section titled “1. 一句话讲明白”OpenCode 的权限系统是一道可异步等待的 runtime 闸门:工具在副作用发生前提交“要用什么能力、作用于哪些 pattern”,规则引擎决定直接继续、直接失败,还是发布审批事件并暂停到用户回复。
本章的中心问题是:
模型提出一个动作时,谁负责把“它想做什么”变成可判断的规则输入,又怎样确保用户尚未回复时副作用不会先发生?
v1.18.16 相比旧基线改变了什么
Section titled “v1.18.16 相比旧基线改变了什么”- Permission 的 ID、Rule、Request 与错误类型已经下沉到
packages/core/src/v1/permission.ts;runtime service 只负责评估、等待和发布事件。 evaluate已收回permission/index.ts,evaluate.ts只保留兼容性 re-export;规则语义仍是最后一个同时匹配 permission 与 pattern 的规则生效。reply(always)除了记住 approved rules,还会扫描同 session 的 pending 请求,自动放行已经被新规则覆盖的项目。
规则评估与异步 ask
packages/opencode/src/permission/index.ts:24-119
只有 action 为 ask 时才创建 Deferred 并发布 Asked。
24 pending: Map<PermissionV1.ID, PendingEntry>25 approved: PermissionV1.Rule[]26}2728export function evaluate(permission: string, pattern: string, ...rulesets: PermissionV1.Ruleset[]): PermissionV1.Rule {对外暴露模块成员。29 return (返回给上一层。30 rulesets31 .flat()32 .findLast((rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern)) ?? {33 action: "ask",34 permission,35 pattern: "*",36 }37 )38}3940export class Service extends Context.Service<Service, Interface>()("@opencode/Permission") {}定义一个类。4142const layer = Layer.effect(43 Service,44 Effect.gen(function* () {Effect 异步工作流。45 const events = yield* EventV2Bridge.Service等待 Effect 结果。46 const state = yield* InstanceState.make<State>(等待 Effect 结果。47 Effect.fn("Permission.state")(function* (ctx) {Effect 异步工作流。48 void ctx49 const state = {50 pending: new Map<PermissionV1.ID, PendingEntry>(),51 approved: [],52 }5354 yield* Effect.addFinalizer(() =>Effect 异步工作流。55 Effect.gen(function* () {Effect 异步工作流。56 for (const item of state.pending.values()) {遍历集合。57 yield* Deferred.fail(item.deferred, new PermissionV1.RejectedError())等待 Effect 结果。58 }59 state.pending.clear()60 }),61 )6263 return state返回给上一层。64 }),65 )6667 const ask = Effect.fn("Permission.ask")(function* (input: PermissionV1.AskInput) {进入权限审批。68 const { approved, pending } = yield* InstanceState.get(state)等待 Effect 结果。69 const { ruleset, ...request } = input70 let needsAsk = false7172 for (const pattern of request.patterns) {遍历集合。73 const rule = evaluate(request.permission, pattern, ruleset, approved)74 yield* Effect.logInfo("evaluated", { permission: request.permission, pattern, action: rule })Effect 异步工作流。75 if (rule.action === "deny") {按条件进入分支。76 return yield* new PermissionV1.DeniedError({等待 Effect 结果。77 ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),78 })79 }80 if (rule.action === "allow") continue按条件进入分支。81 needsAsk = true82 }8384 if (!needsAsk) return按条件进入分支。8586 const id = request.id ?? PermissionV1.ID.ascending()87 const info: PermissionV1.Request = {88 id,89 sessionID: request.sessionID,90 permission: request.permission,91 patterns: request.patterns,92 metadata: request.metadata,93 always: request.always,94 tool: request.tool,95 }96 yield* Effect.logInfo("asking", { id, permission: info.permission, patterns: info.patterns })Effect 异步工作流。9798 const deferred = yield* Deferred.make<void, PermissionV1.RejectedError | PermissionV1.CorrectedError>()等待 Effect 结果。99 pending.set(id, { info, deferred })100 yield* events.publish(Event.Asked, info)广播状态变化。101 return yield* Effect.ensuring(Effect 异步工作流。102 Deferred.await(deferred),103 Effect.sync(() => {Effect 异步工作流。104 pending.delete(id)105 }),106 )107 })108109 const reply = Effect.fn("Permission.reply")(function* (input: PermissionV1.ReplyInput) {回写审批结果。110 const { approved, pending } = yield* InstanceState.get(state)等待 Effect 结果。111 const existing = pending.get(input.requestID)112 if (!existing) return yield* new PermissionV1.NotFoundError({ requestID: input.requestID })等待 Effect 结果。113114 pending.delete(input.requestID)115 yield* events.publish(Event.Replied, {广播状态变化。116 sessionID: existing.info.sessionID,117 requestID: existing.info.id,118 reply: input.reply,119 })
once、always 与 reject
packages/opencode/src/permission/index.ts:121-180
reply 决定当前请求及同 session 待审批请求的完成方式。
121 if (input.reply === "reject") {按条件进入分支。122 yield* Deferred.fail(等待 Effect 结果。123 existing.deferred,124 input.message125 ? new PermissionV1.CorrectedError({ feedback: input.message })126 : new PermissionV1.RejectedError(),127 )128129 for (const [id, item] of pending.entries()) {遍历集合。130 if (item.info.sessionID !== existing.info.sessionID) continue按条件进入分支。131 pending.delete(id)132 yield* events.publish(Event.Replied, {广播状态变化。133 sessionID: item.info.sessionID,134 requestID: item.info.id,135 reply: "reject",136 })137 yield* Deferred.fail(item.deferred, new PermissionV1.RejectedError())等待 Effect 结果。138 }139 return返回给上一层。140 }141142 yield* Deferred.succeed(existing.deferred, undefined)等待 Effect 结果。143 if (input.reply === "once") return按条件进入分支。144145 for (const pattern of existing.info.always) {遍历集合。146 approved.push({147 permission: existing.info.permission,148 pattern,149 action: "allow",150 })151 }152153 for (const [id, item] of pending.entries()) {遍历集合。154 if (item.info.sessionID !== existing.info.sessionID) continue按条件进入分支。155 const ok = item.info.patterns.every(156 (pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",157 )158 if (!ok) continue按条件进入分支。159 pending.delete(id)160 yield* events.publish(Event.Replied, {广播状态变化。161 sessionID: item.info.sessionID,162 requestID: item.info.id,163 reply: "always",164 })165 yield* Deferred.succeed(item.deferred, undefined)等待 Effect 结果。166 }167 })168169 const list = Effect.fn("Permission.list")(function* () {Effect 异步工作流。170 const pending = (yield* InstanceState.get(state)).pending等待 Effect 结果。171 return Array.from(pending.values(), (item) => item.info)返回给上一层。172 })173174 return Service.of({ ask, reply, list })返回给上一层。175 }),176)177178function expand(pattern: string): string {定义一段可复用逻辑。179 if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)按条件进入分支。180 if (pattern === "~") return os.homedir()按条件进入分支。
2. 先看全图:权限不是弹窗,而是一条控制流
Section titled “2. 先看全图:权限不是弹窗,而是一条控制流”模型发出 tool call | v具体 Tool 先构造请求permission + patterns + always + metadata | vTool.Context.ask(补 session/tool 身份) | vPermission.ask 合并并评估规则 + 已批准规则 | +----+-------------------+ | | allow deny | | 工具继续 工具失败 | +---- ask ------------------------------+ 建 Request -> pending -> publish Asked | v UI / CLI / API | once / always / reject | v Deferred succeed/fail | 工具继续或失败三层职责不要混:
- 工具层知道具体风险对象,例如文件路径、shell command、外部目录 glob。
- 权限层只解释规则、管理 pending/approved 状态,不执行文件或命令。
- 交互层显示
permission.asked并提交 reply,不自行续跑工具。
Tool.Context.ask 是二者之间的窄接口。见 packages/opencode/src/session/tools.ts。
3. 最小机制:规则判断 + 异步闸门
Section titled “3. 最小机制:规则判断 + 异步闸门”先把源码压缩成最短真相:
1async function ask(request, configuredRules, approvedRules) {定义一段可复用逻辑。2 let needsUser = false34 for (const pattern of request.patterns) {遍历集合。5 const rule = lastMatchingRule(6 request.permission,7 pattern,8 configuredRules,9 approvedRules,10 ) ?? { action: "ask" }1112 if (rule.action === "deny") throw denied()按条件进入分支。13 if (rule.action === "ask") needsUser = true按条件进入分支。14 }1516 if (!needsUser) return按条件进入分支。17 await publishAndWait(request)18}
关键不在于“弹一次窗”,而在 await:工具调用停在 ask 上,只有 Deferred 成功才会继续执行副作用。
Java 类比是 CompletableFuture<Void> 驱动的 AccessDecisionManager。类比边界是:规则优先级不是 Spring Security voter 聚合,而是扁平数组中最后一个同时匹配 permission 与 pattern 的 rule。
4. 先认识规则:三个字段,两个通配匹配
Section titled “4. 先认识规则:三个字段,两个通配匹配”1type Rule = {定义数据结构约束。2 permission: string3 pattern: string4 action: "allow" | "deny" | "ask"5}
真实 schema:packages/opencode/src/permission/index.ts。
permission:能力类别,如read、edit、bash、external_directory。pattern:这个能力作用的对象,如相对文件路径、命令模式、目录 glob。action:直接允许、直接拒绝、询问用户。
evaluate 把传入 rulesets 拍平,使用 findLast 和 wildcard 双重匹配;没有任何规则匹配时返回默认 ask。见 packages/opencode/src/permission/evaluate.ts。
例如:
1. { permission: "*", pattern: "*", action: "allow" }2. { permission: "read", pattern: "*.env", action: "ask" }3. { permission: "read", pattern: "*.env.example", action: "allow" }读取 .env.example 时规则 2 和 3 都可能匹配,最后的规则 3 获胜。这不是“最具体规则自动优先”,而是顺序覆盖。
5. 一条具体源码旅程:edit src/config.ts
Section titled “5. 一条具体源码旅程:edit src/config.ts”假设 EditTool 已经计算好 diff,准备申请:
1ctx.ask({进入权限审批。2 permission: "edit",3 patterns: ["src/config.ts"],读取运行配置。4 always: ["*"],5 metadata: { filepath, diff },6})
这是对 packages/opencode/src/tool/edit.ts 的典型化简。
5.1 Tool.Context 补齐身份与规则
Section titled “5.1 Tool.Context 补齐身份与规则”具体工具无需自己传 sessionID、messageID 或 callID。SessionTools.resolve 创建上下文时,把请求扩展为:
1permission.ask({进入权限审批。2 ...req,3 sessionID: input.session.id,4 tool: { messageID: input.processor.message.id, callID: options.toolCallId },5 ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),6})
路径:packages/opencode/src/session/tools.ts
由于 merge 只是 flat(),session permission 排在 agent permission 后面;同样匹配时,后者会被 session 规则覆盖。见 packages/opencode/src/permission/index.ts。
5.2 ask 对每个 pattern 独立评估
Section titled “5.2 ask 对每个 pattern 独立评估”Permission service 的 instance state 包含:
pending:requestID → request + Deferred。approved:已批准规则数组;初始化时从当前 project 的PermissionTablerow 读取。
路径:packages/opencode/src/permission/index.ts。
ask 对每个 pattern 调 evaluate(permission, pattern, ruleset, approved)。只要任一 pattern 命中 deny,整个请求立即抛 DeniedError;全部 allow 就直接返回;其余情况进入 ask。见 packages/opencode/src/permission/index.ts。
顺序也有含义:approved 作为最后一个 ruleset 参与评估,因此在相同 wildcard 条件下位于配置规则之后。这里只陈述当前控制流;哪些批准应允许覆盖哪些策略,是配置与安全设计需要另外审视的取舍。
5.3 ask 分支创建 Request 并真正暂停
Section titled “5.3 ask 分支创建 Request 并真正暂停”Request 带有 id / sessionID / permission / patterns / metadata / always,可选 tool 关联具体 message 与 call。见 packages/opencode/src/permission/index.ts。
进入 ask 后:
- 生成
PermissionID。 - 用 schema 解码成 Request。
- 创建
Deferred<void, RejectedError | CorrectedError>。 - 写入 pending map。
- 发布
permission.asked。 Deferred.await,并在任何结束路径确保删除 pending。
路径:packages/opencode/src/permission/index.ts。
事件只负责通知。真正把暂停的 Effect 唤醒或置为失败的是 Permission.reply。
5.4 once:只放行当前请求
Section titled “5.4 once:只放行当前请求”reply 先找到 pending entry,删除它并发布 permission.replied。once 会 succeed 当前 Deferred,然后立即返回,不新增 approved rule。见 packages/opencode/src/permission/index.ts。
于是 EditTool 从 ctx.ask 后继续,开始 writeWithDirs。这就是“审批完成前不落盘”的控制流保证。
5.5 always:放行当前请求,并扩展批准规则
Section titled “5.5 always:放行当前请求,并扩展批准规则”always 也先 succeed 当前 Deferred,然后把 request 提供的 always patterns 转成 allow rules,追加到 approved。接着扫描同 session 的其他 pending 请求:若它们的所有 patterns 都被新 approved rules 允许,就自动 succeed。见 packages/opencode/src/permission/index.ts。
为什么保存 always 而不是原始 patterns?因为工具可以给出更适合复用的授权范围。例如 shell 当前 pattern 可能是完整命令,而 always 可能是按命令 arity 生成的前缀 glob。范围由工具构造,权限服务不替工具猜。
证据边界:当前文件展示 approved.push,没有在 reply 路径展示数据库写入。因此本章只把它称为当前 Permission service state 中的已批准规则,不承诺一次 always 会跨进程永久保存。
5.6 reject:当前请求失败,同 session 的等待也一起结束
Section titled “5.6 reject:当前请求失败,同 session 的等待也一起结束”reject 让当前 Deferred 失败:有 message 时是 CorrectedError,否则是 RejectedError。随后它遍历 pending,把同 session 的其他请求也发布为 reject 并 fail。见 packages/opencode/src/permission/index.ts。
这个批量收尾避免用户明确拒绝后,同一轮并行 tool calls 还留下多个悬空审批。
6. deny 与 reject 不是一回事
Section titled “6. deny 与 reject 不是一回事”| 结果 | 谁决定 | 是否创建 Request | 典型错误 | 是否等用户 |
|---|---|---|---|---|
allow | ruleset | 否 | 无 | 否 |
deny | ruleset | 否 | DeniedError | 否 |
ask -> once | 用户/交互消费者 | 是 | 无 | 是 |
ask -> always | 用户/交互消费者 | 是 | 无 | 是 |
ask -> reject | 用户/交互消费者 | 是 | RejectedError / CorrectedError | 是 |
三种错误类型及消息位于 packages/opencode/src/permission/index.ts。DeniedError 说明预先存在的规则阻止调用;RejectedError 说明用户拒绝了这次请求;CorrectedError 还携带用户反馈,Agent 可以据此调整方案。
7. 默认策略:不同 Agent 不必拥有相同能力
Section titled “7. 默认策略:不同 Agent 不必拥有相同能力”默认基线先允许 *,再收紧若干敏感点:
doom_loop默认 ask。external_directory默认 ask,受管临时/skill 等目录有白名单。question / plan_enter / plan_exit / repo_clone / repo_overview默认 deny。read默认 allow,但*.env与*.env.*ask,.env.example再 allow。
路径:packages/opencode/src/agent/agent.ts。
build agent 在 defaults 后允许 question 与 plan_enter,再接用户规则。plan agent 在 defaults 后加入普通 edit deny,只允许特定计划文件路径,再接用户规则。见 packages/opencode/src/agent/agent.ts。
这里要精确表述:plan 的内置默认规则禁止普通编辑;由于用户规则排在后面,显式用户配置可以覆盖它。它不是不可更改的编译期禁令。
explore subagent 更接近只读研究角色:先 * deny,再允许 grep/glob/list/bash/web/read 等有限能力和只读外部目录策略。见 packages/opencode/src/agent/agent.ts。
这体现最小权限的实用形式:按角色提供不同 policy,而不是让所有 agent 共享一张万能通行证。
8. 非交互消费者必须决定如何处理 ask
Section titled “8. 非交互消费者必须决定如何处理 ask”审批事件本身不会凭空得到回复。交互 UI 可以展示按钮;非交互 CLI 则必须选择策略。
当前 opencode run 监听到本 session 的 permission.asked 后:
- 若显式设置
--dangerously-skip-permissions,回复once; - 否则打印警告并自动
reject。
路径:packages/opencode/src/cli/cmd/run.ts。
这是 fail-closed 的消费者策略,避免 CI 或无人值守运行永远挂在 pending。它不是 Permission service 自身的默认行为:若另一个消费者既不 reply 也不中断,ask 就会继续等待。
Permission state finalizer 会在 service 结束时用 RejectedError fail 所有 pending 并清空 map,见 packages/opencode/src/permission/index.ts。
9. 安全边界:这套系统能保证什么,不能保证什么
Section titled “9. 安全边界:这套系统能保证什么,不能保证什么”能由当前源码证明的
Section titled “能由当前源码证明的”- 调用
ctx.ask的工具会在权限 Effect 完成前暂停。 - 无匹配 rule 的评估默认 ask。
- 后置 rule 优先,可用 agent/session/approved 分层覆盖。
- deny 不创建审批;reject 会结束同 session 的其他 pending。
- 工具可把 diff、路径或命令范围放入审批 metadata/patterns。
不能从这套机制推出的
Section titled “不能从这套机制推出的”- 不是 OS sandbox:allow 后的进程仍拥有宿主 OS 赋予 OpenCode 的权限。
- 不是自动拦截器:一个新工具若直接做副作用且忘记
ctx.ask,Permission service 不会自动发现。 - 不是语义风险分析器:它匹配工具提供的字符串 pattern,不理解命令或代码的全部后果。
- 不是绝对机密保护:
.env默认 ask 是一条 policy;用户配置、后置规则或其他读取路径仍需要审计。 - 不是永久审批承诺:本章检查到的 reply 路径只更新当前 state。
所以权限系统的安全性由三部分共同决定:工具是否正确构造请求、规则顺序是否合理、运行环境本身是否有足够隔离。
10. 失败路径与容易踩的坑
Section titled “10. 失败路径与容易踩的坑”| 情况 | 行为 | 风险提示 |
|---|---|---|
| 没有匹配 rule | 默认 ask | 没有交互消费者时可能一直等待 |
| 任一 pattern deny | 整个 request 失败 | 不会再询问用户 |
| requestID 不在 pending | reply 静默返回 | 迟到/重复回复不会恢复已结束调用 |
| reject 带 message | CorrectedError | Agent 应把反馈当新约束,而非重复原调用 |
| always 范围过宽 | approved 追加宽 allow rule | 后续请求可能不再询问 |
| 后置 wildcard 规则过宽 | 覆盖前面的细粒度规则 | “最后匹配”不是“最具体匹配” |
| tool 漏掉 ask | Permission service 不参与 | 需要工具审计或更底层隔离 |
| UI/CLI 消失 | Deferred 等待,service 结束时 reject | 消费者要处理断线和清理 |
11. OpenCode 的选择:规则简单,责任前移
Section titled “11. OpenCode 的选择:规则简单,责任前移”选择一:有序 ruleset,而不是复杂策略语言
Section titled “选择一:有序 ruleset,而不是复杂策略语言”数组 + wildcard + last match 易于合并和解释;代价是顺序本身成为安全语义,宽泛后置规则可能产生意外覆盖。
选择二:工具定义授权对象
Section titled “选择二:工具定义授权对象”文件工具最懂 diff,shell tool 最懂命令与目录,因此 metadata 更具体;代价是每个工具作者都必须正确实现 preflight ask。
选择三:用事件解耦交互,用 Deferred 保持同步语义
Section titled “选择三:用事件解耦交互,用 Deferred 保持同步语义”Permission service 不绑定 TUI、Desktop 或 API;工具仍能像同步调用一样等结果。代价是消费者必须可靠处理 Asked/Replied、断线与超时策略。
选择四:按 agent role 组合权限
Section titled “选择四:按 agent role 组合权限”同一工具集可以通过 policy 塑造 build、plan、explore 等角色;代价是实际能力要看完整合并顺序,不能只读 agent 描述文本。
12. 可以带走的方法
Section titled “12. 可以带走的方法”方法一:审批请求必须包含“能力 + 对象 + 范围”
Section titled “方法一:审批请求必须包含“能力 + 对象 + 范围””只问“允许使用工具吗”信息太少。验证问题:patterns 和 metadata 足以让人理解当前动作及 always 的未来影响吗?
方法二:默认规则与用户覆盖要有可解释顺序
Section titled “方法二:默认规则与用户覆盖要有可解释顺序”把 merge 和 evaluate 写成可测试的小函数。验证问题:给定冲突规则,团队成员能否不运行代码就算出结果?
方法三:把交互审批当异步协议,不是 UI 细节
Section titled “方法三:把交互审批当异步协议,不是 UI 细节”定义 request id、pending 状态、reply、取消和消费者断线行为。验证问题:无人回复时,系统何时、由谁、以什么错误结束?
13. 费曼复述与练习
Section titled “13. 费曼复述与练习”合上源码,用 90 秒回答:
- 为什么
ctx.ask和Permission.ask要分两层? deny与reject的触发者、事件和错误有什么不同?always为什么可能自动放行同 session 的其他 pending?- 为什么这套权限系统不能替代 OS sandbox?
练习梯子:
- 手算题:为三条冲突 wildcard rule 判断最后 action。
- 追踪题:从
EditTool.ctx.ask追到Deferred.await,再追到reply: once。 - 故障题:交互客户端在 Asked 后断线,设计一个超时或取消策略。
- 审计题:新增一个网络写工具时,列出它必须提交的 permission、patterns 与 metadata。
- 实现题:写一个 50 行以内的 mini permission service,支持 last-match、pending 和 once/reject。
14. 最后复盘:安全不是一句 prompt
Section titled “14. 最后复盘:安全不是一句 prompt”权限主链路可以压缩为:
工具描述具体动作 -> 有序规则求值 -> allow/deny/ask -> 事件交互 -> Deferred 续跑或失败真正值得带走的不是三个字符串,而是边界意识:模型提出意图,工具把意图结构化,policy 决定是否放行,OS 隔离限制放行后的最坏后果。缺少任意一层,都不能仅靠“请谨慎操作”的 prompt 补上。
到这里,你已经看过 Agent 如何读写文件、运行命令、接收 LSP 反馈,并在副作用前等待授权。下一章自然要问:这些默认规则、Provider、工具与插件配置从哪里来,多个配置源冲突时又按什么顺序生效?