跳转到内容

权限、审批、安全边界

旧版 eec0843c 源码提交 eec0843ce422
状态已完成
难度中等
预计阅读40 分钟
  • 章节 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
  • packages/opencode/src/permission/index.ts
  • packages/opencode/src/permission/evaluate.ts
  • packages/opencode/src/permission/schema.ts
  • packages/opencode/src/agent/agent.ts
  • packages/opencode/src/session/tools.ts

源码基线:eec0843ce422。本章用一次修改 src/config.ts 的请求推演权限状态机;这是典型源码路径,不是实际弹窗或批准记录。

学完本章,你应该能:

  • 画出 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 的副作用。

OpenCode 的权限系统是一道可异步等待的 runtime 闸门:工具在副作用发生前提交“要用什么能力、作用于哪些 pattern”,规则引擎决定直接继续、直接失败,还是发布审批事件并暂停到用户回复。

本章的中心问题是:

模型提出一个动作时,谁负责把“它想做什么”变成可判断的规则输入,又怎样确保用户尚未回复时副作用不会先发生?

2. 先看全图:权限不是弹窗,而是一条控制流

Section titled “2. 先看全图:权限不是弹窗,而是一条控制流”
模型发出 tool call
|
v
具体 Tool 先构造请求
permission + patterns + always + metadata
|
v
Tool.Context.ask(补 session/tool 身份)
|
v
Permission.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:能力类别,如 readeditbashexternal_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              })

具体工具无需自己传 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}

Permission service 的 instance state 包含:

  • pending:requestID → request + Deferred。
  • approved:已批准规则数组;初始化时从当前 project 的 PermissionTable row 读取。

路径: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 后:

  1. 生成 PermissionID
  2. 用 schema 解码成 Request。
  3. 创建 Deferred<void, RejectedError | CorrectedError>
  4. 写入 pending map。
  5. 发布 permission.asked
  6. 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

reply 先找到 pending entry,删除它并发布 permission.repliedonce 会 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按条件进入分支。

于是 EditToolctx.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 还留下多个悬空审批。

结果谁决定是否创建 Request典型错误是否等用户
allowruleset
denyrulesetDeniedError
ask -> once用户/交互消费者
ask -> always用户/交互消费者
ask -> reject用户/交互消费者RejectedError / CorrectedError

三种错误类型及消息位于 packages/opencode/src/permission/index.ts:75-97DeniedError 说明预先存在的规则阻止调用;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. 安全边界:这套系统能保证什么,不能保证什么”
  • 调用 ctx.ask 的工具会在权限 Effect 完成前暂停。
  • 无匹配 rule 的评估默认 ask。
  • 后置 rule 优先,可用 agent/session/approved 分层覆盖。
  • deny 不创建审批;reject 会结束同 session 的其他 pending。
  • 工具可把 diff、路径或命令范围放入审批 metadata/patterns。
  • 不是 OS sandbox:allow 后的进程仍拥有宿主 OS 赋予 OpenCode 的权限。
  • 不是自动拦截器:一个新工具若直接做副作用且忘记 ctx.ask,Permission service 不会自动发现。
  • 不是语义风险分析器:它匹配工具提供的字符串 pattern,不理解命令或代码的全部后果。
  • 不是绝对机密保护.env 默认 ask 是一条 policy;用户配置、后置规则或其他读取路径仍需要审计。
  • 不是永久审批承诺:本章检查到的 reply 路径只更新当前 state。

所以权限系统的安全性由三部分共同决定:工具是否正确构造请求、规则顺序是否合理、运行环境本身是否有足够隔离。

情况行为风险提示
没有匹配 rule默认 ask没有交互消费者时可能一直等待
任一 pattern deny整个 request 失败不会再询问用户
requestID 不在 pendingreply 静默返回迟到/重复回复不会恢复已结束调用
reject 带 messageCorrectedErrorAgent 应把反馈当新约束,而非重复原调用
always 范围过宽approved 追加宽 allow rule后续请求可能不再询问
后置 wildcard 规则过宽覆盖前面的细粒度规则“最后匹配”不是“最具体匹配”
tool 漏掉 askPermission service 不参与需要工具审计或更底层隔离
UI/CLI 消失Deferred 等待,service 结束时 reject消费者要处理断线和清理

11. OpenCode 的选择:规则简单,责任前移

Section titled “11. OpenCode 的选择:规则简单,责任前移”

选择一:有序 ruleset,而不是复杂策略语言

Section titled “选择一:有序 ruleset,而不是复杂策略语言”

数组 + wildcard + last match 易于合并和解释;代价是顺序本身成为安全语义,宽泛后置规则可能产生意外覆盖。

文件工具最懂 diff,shell tool 最懂命令与目录,因此 metadata 更具体;代价是每个工具作者都必须正确实现 preflight ask。

选择三:用事件解耦交互,用 Deferred 保持同步语义

Section titled “选择三:用事件解耦交互,用 Deferred 保持同步语义”

Permission service 不绑定 TUI、Desktop 或 API;工具仍能像同步调用一样等结果。代价是消费者必须可靠处理 Asked/Replied、断线与超时策略。

同一工具集可以通过 policy 塑造 build、plan、explore 等角色;代价是实际能力要看完整合并顺序,不能只读 agent 描述文本。

方法一:审批请求必须包含“能力 + 对象 + 范围”

Section titled “方法一:审批请求必须包含“能力 + 对象 + 范围””

只问“允许使用工具吗”信息太少。验证问题:patterns 和 metadata 足以让人理解当前动作及 always 的未来影响吗?

方法二:默认规则与用户覆盖要有可解释顺序

Section titled “方法二:默认规则与用户覆盖要有可解释顺序”

把 merge 和 evaluate 写成可测试的小函数。验证问题:给定冲突规则,团队成员能否不运行代码就算出结果?

方法三:把交互审批当异步协议,不是 UI 细节

Section titled “方法三:把交互审批当异步协议,不是 UI 细节”

定义 request id、pending 状态、reply、取消和消费者断线行为。验证问题:无人回复时,系统何时、由谁、以什么错误结束?

合上源码,用 90 秒回答:

  1. 为什么 ctx.askPermission.ask 要分两层?
  2. denyreject 的触发者、事件和错误有什么不同?
  3. always 为什么可能自动放行同 session 的其他 pending?
  4. 为什么这套权限系统不能替代 OS sandbox?

练习梯子:

  1. 手算题:为三条冲突 wildcard rule 判断最后 action。
  2. 追踪题:从 EditTool.ctx.ask 追到 Deferred.await,再追到 reply: once
  3. 故障题:交互客户端在 Asked 后断线,设计一个超时或取消策略。
  4. 审计题:新增一个网络写工具时,列出它必须提交的 permission、patterns 与 metadata。
  5. 实现题:写一个 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、工具与插件配置从哪里来,多个配置源冲突时又按什么顺序生效?