跳转到内容

权限、审批、安全边界

v1.18.16 源码提交 a3647eb025c7 · 官方 Release
状态已完成
难度中等
预计阅读40 分钟
  • 章节 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
  • packages/opencode/src/permission/index.ts
  • packages/opencode/src/permission/evaluate.ts
  • packages/core/src/v1/permission.ts
  • packages/opencode/src/agent/agent.ts
  • packages/opencode/src/session/tools.ts

源码基线:v1.18.16(提交 a3647eb025c7)。本章用一次修改 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”,规则引擎决定直接继续、直接失败,还是发布审批事件并暂停到用户回复。

本章的中心问题是:

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

  • Permission 的 ID、Rule、Request 与错误类型已经下沉到 packages/core/src/v1/permission.ts;runtime service 只负责评估、等待和发布事件。
  • evaluate 已收回 permission/index.tsevaluate.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
|
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

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:能力类别,如 readeditbashexternal_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 的典型化简。

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

Permission service 的 instance state 包含:

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

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

  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

事件只负责通知。真正把暂停的 Effect 唤醒或置为失败的是 Permission.reply

reply 先找到 pending entry,删除它并发布 permission.repliedonce 会 succeed 当前 Deferred,然后立即返回,不新增 approved rule。见 packages/opencode/src/permission/index.ts

于是 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

为什么保存 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 还留下多个悬空审批。

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

三种错误类型及消息位于 packages/opencode/src/permission/index.tsDeniedError 说明预先存在的规则阻止调用;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. 安全边界:这套系统能保证什么,不能保证什么”
  • 调用 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、工具与插件配置从哪里来,多个配置源冲突时又按什么顺序生效?