权限、审批、安全边界
eec0843ce422
Agent 生成档案
Section titled “Agent 生成档案”- 章节 ID:
09-permission-security - 章节摘要:沿一次 edit 审批,理解 last-match ruleset、Deferred 等待、once/always/reject,以及默认 Agent 策略与非交互拒绝。
- 教程版本:
eec0843c - 源码基线:
eec0843ce42298080569ca31a6455bc3f699d213 - 章节元数据:/versions/eec0843c/data/chapters.json
- 源码映射:/versions/eec0843c/data/source-map.json
主要源码路径
Section titled “主要源码路径”packages/opencode/src/permission/index.tspackages/opencode/src/permission/evaluate.tspackages/opencode/src/permission/schema.tspackages/opencode/src/agent/agent.tspackages/opencode/src/session/tools.ts
源码基线:
eec0843ce422。本章用一次修改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”,规则引擎决定直接继续、直接失败,还是发布审批事件并暂停到用户回复。
本章的中心问题是:
模型提出一个动作时,谁负责把“它想做什么”变成可判断的规则输入,又怎样确保用户尚未回复时副作用不会先发生?
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:42-73。
packages/opencode/src/session/tools.ts
packages/opencode/src/session/tools.ts:42-73
42 const context = (args: Record<string, unknown>, options: ToolExecutionOptions): Tool.Context => ({43 sessionID: input.session.id,44 abort: options.abortSignal!,45 messageID: input.processor.message.id,46 callID: options.toolCallId,47 extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps: input.promptOps },选择模型或 provider。48 agent: input.agent.name,49 messages: input.messages,50 metadata: (val) =>51 input.processor.updateToolCall(options.toolCallId, (match) => {52 if (!["running", "pending"].includes(match.state.status)) return match按条件进入分支。53 return {返回给上一层。54 ...match,55 state: {56 title: val.title,57 metadata: val.metadata,58 status: "running",59 input: args,60 time: { start: Date.now() },61 },62 }63 }),64 ask: (req) =>65 permission66 .ask({67 ...req,68 sessionID: input.session.id,69 tool: { messageID: input.processor.message.id, callID: options.toolCallId },70 ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),71 })72 .pipe(Effect.orDie),Effect 异步工作流。73 })
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:19-30。
packages/opencode/src/permission/index.ts
packages/opencode/src/permission/index.ts:19-30
19export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionAction" })定义并校验数据形状。20export type Action = Schema.Schema.Type<typeof Action>定义并校验数据形状。2122export const Rule = Schema.Struct({定义并校验数据形状。23 permission: Schema.String,定义并校验数据形状。24 pattern: Schema.String,定义并校验数据形状。25 action: Action,26}).annotate({ identifier: "PermissionRule" })27export type Rule = Schema.Schema.Type<typeof Rule>定义并校验数据形状。2829export const Ruleset = Schema.mutable(Schema.Array(Rule)).annotate({ identifier: "PermissionRuleset" })定义并校验数据形状。30export type Ruleset = Schema.Schema.Type<typeof Ruleset>定义并校验数据形状。
permission:能力类别,如read、edit、bash、external_directory。pattern:这个能力作用的对象,如相对文件路径、命令模式、目录 glob。action:直接允许、直接拒绝、询问用户。
evaluate 把传入 rulesets 拍平,使用 findLast 和 wildcard 双重匹配;没有任何规则匹配时返回默认 ask。见 packages/opencode/src/permission/evaluate.ts:9-15。
packages/opencode/src/permission/evaluate.ts
packages/opencode/src/permission/evaluate.ts:9-15
9export function evaluate(permission: string, pattern: string, ...rulesets: Rule[][]): Rule {对外暴露模块成员。10 const rules = rulesets.flat()11 const match = rules.findLast(12 (rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),13 )14 return match ?? { action: "ask", permission, pattern: "*" }返回给上一层。15}
例如:
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:133-149 的典型化简。
packages/opencode/src/tool/edit.ts
packages/opencode/src/tool/edit.ts:133-149
133 diff = trimDiff(134 createTwoFilesPatch(准备修改文件内容。135 filePath,136 filePath,137 normalizeLineEndings(contentOld),138 normalizeLineEndings(contentNew),139 ),140 )141 yield* ctx.ask({进入权限审批。142 permission: "edit",143 patterns: [path.relative(instance.worktree, filePath)],144 always: ["*"],145 metadata: {146 filepath: filePath,147 diff,148 },149 })
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:64-72
packages/opencode/src/session/tools.ts
packages/opencode/src/session/tools.ts:64-72
64 ask: (req) =>65 permission66 .ask({67 ...req,68 sessionID: input.session.id,69 tool: { messageID: input.processor.message.id, callID: options.toolCallId },70 ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),71 })72 .pipe(Effect.orDie),Effect 异步工作流。
Tool.Context.ask:工具与权限服务的边界
packages/opencode/src/session/tools.ts:42-73
这里补充会话、tool call 身份,并按 agent 后接 session 规则。
42 const context = (args: Record<string, unknown>, options: ToolExecutionOptions): Tool.Context => ({43 sessionID: input.session.id,44 abort: options.abortSignal!,45 messageID: input.processor.message.id,46 callID: options.toolCallId,47 extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps: input.promptOps },选择模型或 provider。48 agent: input.agent.name,49 messages: input.messages,50 metadata: (val) =>51 input.processor.updateToolCall(options.toolCallId, (match) => {52 if (!["running", "pending"].includes(match.state.status)) return match按条件进入分支。53 return {返回给上一层。54 ...match,55 state: {56 title: val.title,57 metadata: val.metadata,58 status: "running",59 input: args,60 time: { start: Date.now() },61 },62 }63 }),64 ask: (req) =>65 permission66 .ask({67 ...req,68 sessionID: input.session.id,69 tool: { messageID: input.processor.message.id, callID: options.toolCallId },70 ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),71 })72 .pipe(Effect.orDie),Effect 异步工作流。73 })
由于 merge 只是 flat(),session permission 排在 agent permission 后面;同样匹配时,后者会被 session 规则覆盖。见 packages/opencode/src/permission/index.ts:287-289。
packages/opencode/src/permission/index.ts
packages/opencode/src/permission/index.ts:287-289
287export function merge(...rulesets: Ruleset[]): Ruleset {对外暴露模块成员。288 return rulesets.flat()返回给上一层。289}
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:118-159。
packages/opencode/src/permission/index.ts
packages/opencode/src/permission/index.ts:118-159
118interface PendingEntry {定义数据结构约束。119 info: Request120 deferred: Deferred.Deferred<void, RejectedError | CorrectedError>121}122123interface State {定义数据结构约束。124 pending: Map<PermissionID, PendingEntry>125 approved: Ruleset126}127128export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {对外暴露模块成员。129 return evalRule(permission, pattern, ...rulesets)返回给上一层。130}131132export class Service extends Context.Service<Service, Interface>()("@opencode/Permission") {}定义一个类。133134export const layer = Layer.effect(对外暴露模块成员。135 Service,136 Effect.gen(function* () {Effect 异步工作流。137 const bus = yield* Bus.Service等待 Effect 结果。138 const state = yield* InstanceState.make<State>(等待 Effect 结果。139 Effect.fn("Permission.state")(function* (ctx) {Effect 异步工作流。140 const row = Database.use((db) =>141 db.select().from(PermissionTable).where(eq(PermissionTable.project_id, ctx.project.id)).get(),142 )143 const state = {144 pending: new Map<PermissionID, PendingEntry>(),145 approved: row?.data ?? [],146 }147148 yield* Effect.addFinalizer(() =>Effect 异步工作流。149 Effect.gen(function* () {Effect 异步工作流。150 for (const item of state.pending.values()) {遍历集合。151 yield* Deferred.fail(item.deferred, new RejectedError())等待 Effect 结果。152 }153 state.pending.clear()154 }),155 )156157 return state返回给上一层。158 }),159 )
ask 对每个 pattern 调 evaluate(permission, pattern, ruleset, approved)。只要任一 pattern 命中 deny,整个请求立即抛 DeniedError;全部 allow 就直接返回;其余情况进入 ask。见 packages/opencode/src/permission/index.ts:161-178。
packages/opencode/src/permission/index.ts
packages/opencode/src/permission/index.ts:161-178
161 const ask = Effect.fn("Permission.ask")(function* (input: AskInput) {进入权限审批。162 const { approved, pending } = yield* InstanceState.get(state)等待 Effect 结果。163 const { ruleset, ...request } = input164 let needsAsk = false165166 for (const pattern of request.patterns) {遍历集合。167 const rule = evaluate(request.permission, pattern, ruleset, approved)168 log.info("evaluated", { permission: request.permission, pattern, action: rule })169 if (rule.action === "deny") {按条件进入分支。170 return yield* new DeniedError({等待 Effect 结果。171 ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),172 })173 }174 if (rule.action === "allow") continue按条件进入分支。175 needsAsk = true176 }177178 if (!needsAsk) return按条件进入分支。
顺序也有含义: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:32-45。
packages/opencode/src/permission/index.ts
packages/opencode/src/permission/index.ts:32-45
32export class Request extends Schema.Class<Request>("PermissionRequest")({定义并校验数据形状。33 id: PermissionID,34 sessionID: SessionID,35 permission: Schema.String,定义并校验数据形状。36 patterns: Schema.Array(Schema.String),定义并校验数据形状。37 metadata: Schema.Record(Schema.String, Schema.Unknown),定义并校验数据形状。38 always: Schema.Array(Schema.String),定义并校验数据形状。39 tool: Schema.optional(定义并校验数据形状。40 Schema.Struct({定义并校验数据形状。41 messageID: MessageID,42 callID: Schema.String,定义并校验数据形状。43 }),44 ),45}) {}
进入 ask 后:
- 生成
PermissionID。 - 用 schema 解码成 Request。
- 创建
Deferred<void, RejectedError | CorrectedError>。 - 写入 pending map。
- 发布
permission.asked。 Deferred.await,并在任何结束路径确保删除 pending。
路径:packages/opencode/src/permission/index.ts:180-196。
packages/opencode/src/permission/index.ts
packages/opencode/src/permission/index.ts:180-196
180 const id = request.id ?? PermissionID.ascending()181 const info = Schema.decodeUnknownSync(Request)({定义并校验数据形状。182 id,183 ...request,184 })185 log.info("asking", { id, permission: info.permission, patterns: info.patterns })186187 const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()等待 Effect 结果。188 pending.set(id, { info, deferred })189 yield* bus.publish(Event.Asked, info)广播状态变化。190 return yield* Effect.ensuring(Effect 异步工作流。191 Deferred.await(deferred),192 Effect.sync(() => {Effect 异步工作流。193 pending.delete(id)194 }),195 )196 })
Permission.ask:allow、deny、ask 的分叉
packages/opencode/src/permission/index.ts:161-196
ask 分支发布事件后等待 Deferred,因此工具的后续副作用尚未执行。
161 const ask = Effect.fn("Permission.ask")(function* (input: AskInput) {进入权限审批。162 const { approved, pending } = yield* InstanceState.get(state)等待 Effect 结果。163 const { ruleset, ...request } = input164 let needsAsk = false165166 for (const pattern of request.patterns) {遍历集合。167 const rule = evaluate(request.permission, pattern, ruleset, approved)168 log.info("evaluated", { permission: request.permission, pattern, action: rule })169 if (rule.action === "deny") {按条件进入分支。170 return yield* new DeniedError({等待 Effect 结果。171 ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),172 })173 }174 if (rule.action === "allow") continue按条件进入分支。175 needsAsk = true176 }177178 if (!needsAsk) return按条件进入分支。179180 const id = request.id ?? PermissionID.ascending()181 const info = Schema.decodeUnknownSync(Request)({定义并校验数据形状。182 id,183 ...request,184 })185 log.info("asking", { id, permission: info.permission, patterns: info.patterns })186187 const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()等待 Effect 结果。188 pending.set(id, { info, deferred })189 yield* bus.publish(Event.Asked, info)广播状态变化。190 return yield* Effect.ensuring(Effect 异步工作流。191 Deferred.await(deferred),192 Effect.sync(() => {Effect 异步工作流。193 pending.delete(id)194 }),195 )196 })
事件只负责通知。真正把暂停的 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:198-230。
packages/opencode/src/permission/index.ts
packages/opencode/src/permission/index.ts:198-230
198 const reply = Effect.fn("Permission.reply")(function* (input: ReplyInput) {回写审批结果。199 const { approved, pending } = yield* InstanceState.get(state)等待 Effect 结果。200 const existing = pending.get(input.requestID)201 if (!existing) return按条件进入分支。202203 pending.delete(input.requestID)204 yield* bus.publish(Event.Replied, {广播状态变化。205 sessionID: existing.info.sessionID,206 requestID: existing.info.id,207 reply: input.reply,208 })209210 if (input.reply === "reject") {按条件进入分支。211 yield* Deferred.fail(等待 Effect 结果。212 existing.deferred,213 input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),214 )215216 for (const [id, item] of pending.entries()) {遍历集合。217 if (item.info.sessionID !== existing.info.sessionID) continue按条件进入分支。218 pending.delete(id)219 yield* bus.publish(Event.Replied, {广播状态变化。220 sessionID: item.info.sessionID,221 requestID: item.info.id,222 reply: "reject",223 })224 yield* Deferred.fail(item.deferred, new RejectedError())等待 Effect 结果。225 }226 return返回给上一层。227 }228229 yield* Deferred.succeed(existing.deferred, undefined)等待 Effect 结果。230 if (input.reply === "once") return按条件进入分支。
于是 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:229-253。
packages/opencode/src/permission/index.ts
packages/opencode/src/permission/index.ts:229-253
229 yield* Deferred.succeed(existing.deferred, undefined)等待 Effect 结果。230 if (input.reply === "once") return按条件进入分支。231232 for (const pattern of existing.info.always) {遍历集合。233 approved.push({234 permission: existing.info.permission,235 pattern,236 action: "allow",237 })238 }239240 for (const [id, item] of pending.entries()) {遍历集合。241 if (item.info.sessionID !== existing.info.sessionID) continue按条件进入分支。242 const ok = item.info.patterns.every(243 (pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",244 )245 if (!ok) continue按条件进入分支。246 pending.delete(id)247 yield* bus.publish(Event.Replied, {广播状态变化。248 sessionID: item.info.sessionID,249 requestID: item.info.id,250 reply: "always",251 })252 yield* Deferred.succeed(item.deferred, undefined)等待 Effect 结果。253 }
为什么保存 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:210-226。
packages/opencode/src/permission/index.ts
packages/opencode/src/permission/index.ts:210-226
210 if (input.reply === "reject") {按条件进入分支。211 yield* Deferred.fail(等待 Effect 结果。212 existing.deferred,213 input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),214 )215216 for (const [id, item] of pending.entries()) {遍历集合。217 if (item.info.sessionID !== existing.info.sessionID) continue按条件进入分支。218 pending.delete(id)219 yield* bus.publish(Event.Replied, {广播状态变化。220 sessionID: item.info.sessionID,221 requestID: item.info.id,222 reply: "reject",223 })224 yield* Deferred.fail(item.deferred, new RejectedError())等待 Effect 结果。225 }226 return返回给上一层。
这个批量收尾避免用户明确拒绝后,同一轮并行 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:75-97。DeniedError 说明预先存在的规则阻止调用;RejectedError 说明用户拒绝了这次请求;CorrectedError 还携带用户反馈,Agent 可以据此调整方案。
packages/opencode/src/permission/index.ts
packages/opencode/src/permission/index.ts:75-97
75export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {定义并校验数据形状。76 override get message() {77 return "The user rejected permission to use this specific tool call."返回给上一层。78 }79}8081export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {定义并校验数据形状。82 feedback: Schema.String,定义并校验数据形状。83}) {84 override get message() {85 return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`返回给上一层。86 }87}8889export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {定义并校验数据形状。90 ruleset: Schema.Any,定义并校验数据形状。91}) {92 override get message() {93 return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`返回给上一层。94 }95}9697export type Error = DeniedError | RejectedError | CorrectedError定义数据结构约束。
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:89-124。
packages/opencode/src/agent/agent.ts
packages/opencode/src/agent/agent.ts:89-124
89 const state = yield* InstanceState.make<State>(等待 Effect 结果。90 Effect.fn("Agent.state")(function* (ctx) {Effect 异步工作流。91 const cfg = yield* config.get()读取运行配置。92 const skillDirs = yield* skill.dirs()等待 Effect 结果。93 const whitelistedDirs = [94 Truncate.GLOB,95 path.join(Global.Path.tmp, "*"),读写本地文件。96 ...skillDirs.map((dir) => path.join(dir, "*")),97 ]98 const readonlyExternalDirectory = {99 "*": "ask",100 ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),101 } satisfies Record<string, "allow" | "ask" | "deny">102103 const defaults = Permission.fromConfig({104 "*": "allow",105 doom_loop: "ask",106 external_directory: {107 "*": "ask",108 ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),109 },110 question: "deny",111 plan_enter: "deny",112 plan_exit: "deny",113 repo_clone: "deny",114 repo_overview: "deny",115 // mirrors github.com/github/gitignore Node.gitignore pattern for .env files116 read: {117 "*": "allow",118 "*.env": "ask",119 "*.env.*": "ask",120 "*.env.example": "allow",121 },122 })123124 const user = Permission.fromConfig(cfg.permission ?? {})
build agent 在 defaults 后允许 question 与 plan_enter,再接用户规则。plan agent 在 defaults 后加入普通 edit deny,只允许特定计划文件路径,再接用户规则。见 packages/opencode/src/agent/agent.ts:126-164。
packages/opencode/src/agent/agent.ts
packages/opencode/src/agent/agent.ts:126-164
126 const agents: Record<string, Info> = {127 build: {128 name: "build",129 description: "The default agent. Executes tools based on configured permissions.",130 options: {},131 permission: Permission.merge(132 defaults,133 Permission.fromConfig({134 question: "allow",135 plan_enter: "allow",136 }),137 user,138 ),139 mode: "primary",140 native: true,141 },142 plan: {143 name: "plan",144 description: "Plan mode. Disallows all edit tools.",145 options: {},146 permission: Permission.merge(147 defaults,148 Permission.fromConfig({149 question: "allow",150 plan_exit: "allow",151 external_directory: {152 [path.join(Global.Path.data, "plans", "*")]: "allow",读写本地文件。153 },154 edit: {155 "*": "deny",156 [path.join(".opencode", "plans", "*.md")]: "allow",157 [path.relative(ctx.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow",读写本地文件。158 },159 }),160 user,161 ),162 mode: "primary",163 native: true,164 },
这里要精确表述:plan 的内置默认规则禁止普通编辑;由于用户规则排在后面,显式用户配置可以覆盖它。它不是不可更改的编译期禁令。
explore subagent 更接近只读研究角色:先 * deny,再允许 grep/glob/list/bash/web/read 等有限能力和只读外部目录策略。见 packages/opencode/src/agent/agent.ts:165-200。
packages/opencode/src/agent/agent.ts
packages/opencode/src/agent/agent.ts:165-200
165 general: {166 name: "general",167 description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,168 permission: Permission.merge(169 defaults,170 Permission.fromConfig({171 todowrite: "deny",172 }),173 user,174 ),175 options: {},176 mode: "subagent",177 native: true,178 },179 explore: {180 name: "explore",181 permission: Permission.merge(182 defaults,183 Permission.fromConfig({184 "*": "deny",185 grep: "allow",186 glob: "allow",187 list: "allow",188 bash: "allow",189 webfetch: "allow",190 websearch: "allow",191 read: "allow",192 external_directory: readonlyExternalDirectory,193 }),194 user,195 ),196 description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`,197 prompt: PROMPT_EXPLORE,198 options: {},199 mode: "subagent",200 native: true,
这体现最小权限的实用形式:按角色提供不同 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:736-755。
packages/opencode/src/cli/cmd/run.ts
packages/opencode/src/cli/cmd/run.ts:736-755
736 if (event.type === "permission.asked") {进入权限审批。737 const permission = event.properties738 if (permission.sessionID !== sessionID) continue按条件进入分支。739740 if (args["dangerously-skip-permissions"]) {按条件进入分支。741 await client.permission.reply({回写审批结果。742 requestID: permission.id,743 reply: "once",744 })745 } else {746 UI.println(747 UI.Style.TEXT_WARNING_BOLD + "!",748 UI.Style.TEXT_NORMAL +749 `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,750 )751 await client.permission.reply({回写审批结果。752 requestID: permission.id,753 reply: "reject",754 })755 }
这是 fail-closed 的消费者策略,避免 CI 或无人值守运行永远挂在 pending。它不是 Permission service 自身的默认行为:若另一个消费者既不 reply 也不中断,ask 就会继续等待。
Permission state finalizer 会在 service 结束时用 RejectedError fail 所有 pending 并清空 map,见 packages/opencode/src/permission/index.ts:148-155。
packages/opencode/src/permission/index.ts
packages/opencode/src/permission/index.ts:148-155
148 yield* Effect.addFinalizer(() =>Effect 异步工作流。149 Effect.gen(function* () {Effect 异步工作流。150 for (const item of state.pending.values()) {遍历集合。151 yield* Deferred.fail(item.deferred, new RejectedError())等待 Effect 结果。152 }153 state.pending.clear()154 }),155 )
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、工具与插件配置从哪里来,多个配置源冲突时又按什么顺序生效?