# 权限、审批、安全边界

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

## 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. 一句话讲明白

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

本章的中心问题是：

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

## 2. 先看全图：权限不是弹窗，而是一条控制流

```text
模型发出 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`。

## 3. 最小机制：规则判断 + 异步闸门

先把源码压缩成最短真相：

```ts
async function ask(request, configuredRules, approvedRules) {
  let needsUser = false

  for (const pattern of request.patterns) {
    const rule = lastMatchingRule(
      request.permission,
      pattern,
      configuredRules,
      approvedRules,
    ) ?? { action: "ask" }

    if (rule.action === "deny") throw denied()
    if (rule.action === "ask") needsUser = true
  }

  if (!needsUser) return
  await publishAndWait(request)
}
```

关键不在于“弹一次窗”，而在 `await`：工具调用停在 ask 上，只有 Deferred 成功才会继续执行副作用。

Java 类比是 `CompletableFuture<Void>` 驱动的 `AccessDecisionManager`。类比边界是：规则优先级不是 Spring Security voter 聚合，而是扁平数组中**最后一个同时匹配 permission 与 pattern 的 rule**。

## 4. 先认识规则：三个字段，两个通配匹配

```ts
type Rule = {
  permission: string
  pattern: string
  action: "allow" | "deny" | "ask"
}
```

真实 schema：`packages/opencode/src/permission/index.ts:19-30`。

- `permission`：能力类别，如 `read`、`edit`、`bash`、`external_directory`。
- `pattern`：这个能力作用的对象，如相对文件路径、命令模式、目录 glob。
- `action`：直接允许、直接拒绝、询问用户。

`evaluate` 把传入 rulesets 拍平，使用 `findLast` 和 wildcard 双重匹配；没有任何规则匹配时返回默认 `ask`。见 `packages/opencode/src/permission/evaluate.ts:9-15`。

例如：

```text
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`

假设 `EditTool` 已经计算好 diff，准备申请：

```ts
ctx.ask({
  permission: "edit",
  patterns: ["src/config.ts"],
  always: ["*"],
  metadata: { filepath, diff },
})
```

这是对 `packages/opencode/src/tool/edit.ts:133-149` 的典型化简。

### 5.1 Tool.Context 补齐身份与规则

具体工具无需自己传 sessionID、messageID 或 callID。`SessionTools.resolve` 创建上下文时，把请求扩展为：

```ts
permission.ask({
  ...req,
  sessionID: input.session.id,
  tool: { messageID: input.processor.message.id, callID: options.toolCallId },
  ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),
})
```

路径：`packages/opencode/src/session/tools.ts:64-72`

<!-- source-ref path="packages/opencode/src/session/tools.ts" lines="42-73" title="Tool.Context.ask：工具与权限服务的边界" note="这里补充会话、tool call 身份，并按 agent 后接 session 规则。" -->

由于 merge 只是 `flat()`，session permission 排在 agent permission 后面；同样匹配时，后者会被 session 规则覆盖。见 `packages/opencode/src/permission/index.ts:287-289`。

### 5.2 ask 对每个 pattern 独立评估

Permission service 的 instance state 包含：

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

路径：`packages/opencode/src/permission/index.ts:118-159`。

`ask` 对每个 pattern 调 `evaluate(permission, pattern, ruleset, approved)`。只要任一 pattern 命中 deny，整个请求立即抛 `DeniedError`；全部 allow 就直接返回；其余情况进入 ask。见 `packages/opencode/src/permission/index.ts:161-178`。

顺序也有含义：approved 作为最后一个 ruleset 参与评估，因此在相同 wildcard 条件下位于配置规则之后。这里只陈述当前控制流；哪些批准应允许覆盖哪些策略，是配置与安全设计需要另外审视的取舍。

### 5.3 ask 分支创建 Request 并真正暂停

Request 带有 `id / sessionID / permission / patterns / metadata / always`，可选 `tool` 关联具体 message 与 call。见 `packages/opencode/src/permission/index.ts:32-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`。

<!-- source-ref path="packages/opencode/src/permission/index.ts" lines="161-196" title="Permission.ask：allow、deny、ask 的分叉" note="ask 分支发布事件后等待 Deferred，因此工具的后续副作用尚未执行。" -->

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

### 5.4 once：只放行当前请求

reply 先找到 pending entry，删除它并发布 `permission.replied`。`once` 会 succeed 当前 Deferred，然后立即返回，不新增 approved rule。见 `packages/opencode/src/permission/index.ts:198-230`。

于是 `EditTool` 从 `ctx.ask` 后继续，开始 `writeWithDirs`。这就是“审批完成前不落盘”的控制流保证。

### 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`。

为什么保存 `always` 而不是原始 `patterns`？因为工具可以给出更适合复用的授权范围。例如 shell 当前 pattern 可能是完整命令，而 `always` 可能是按命令 arity 生成的前缀 glob。范围由工具构造，权限服务不替工具猜。

证据边界：当前文件展示 `approved.push`，没有在 reply 路径展示数据库写入。因此本章只把它称为当前 Permission service state 中的已批准规则，不承诺一次 `always` 会跨进程永久保存。

### 5.6 reject：当前请求失败，同 session 的等待也一起结束

`reject` 让当前 Deferred 失败：有 message 时是 `CorrectedError`，否则是 `RejectedError`。随后它遍历 pending，把同 session 的其他请求也发布为 reject 并 fail。见 `packages/opencode/src/permission/index.ts:210-226`。

这个批量收尾避免用户明确拒绝后，同一轮并行 tool calls 还留下多个悬空审批。

## 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 可以据此调整方案。

## 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`。

`build` agent 在 defaults 后允许 question 与 plan_enter，再接用户规则。`plan` agent 在 defaults 后加入普通 edit deny，只允许特定计划文件路径，再接用户规则。见 `packages/opencode/src/agent/agent.ts:126-164`。

这里要精确表述：plan 的**内置默认规则**禁止普通编辑；由于用户规则排在后面，显式用户配置可以覆盖它。它不是不可更改的编译期禁令。

`explore` subagent 更接近只读研究角色：先 `* deny`，再允许 grep/glob/list/bash/web/read 等有限能力和只读外部目录策略。见 `packages/opencode/src/agent/agent.ts:165-200`。

这体现最小权限的实用形式：按角色提供不同 policy，而不是让所有 agent 共享一张万能通行证。

## 8. 非交互消费者必须决定如何处理 ask

审批事件本身不会凭空得到回复。交互 UI 可以展示按钮；非交互 CLI 则必须选择策略。

当前 `opencode run` 监听到本 session 的 `permission.asked` 后：

- 若显式设置 `--dangerously-skip-permissions`，回复 `once`；
- 否则打印警告并自动 `reject`。

路径：`packages/opencode/src/cli/cmd/run.ts:736-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`。

## 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。

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

## 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 的选择：规则简单，责任前移

### 选择一：有序 ruleset，而不是复杂策略语言

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

### 选择二：工具定义授权对象

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

### 选择三：用事件解耦交互，用 Deferred 保持同步语义

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

### 选择四：按 agent role 组合权限

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

## 12. 可以带走的方法

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

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

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

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

### 方法三：把交互审批当异步协议，不是 UI 细节

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

## 13. 费曼复述与练习

合上源码，用 90 秒回答：

1. 为什么 `ctx.ask` 和 `Permission.ask` 要分两层？
2. `deny` 与 `reject` 的触发者、事件和错误有什么不同？
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

权限主链路可以压缩为：

```text
工具描述具体动作 -> 有序规则求值 -> allow/deny/ask -> 事件交互 -> Deferred 续跑或失败
```

真正值得带走的不是三个字符串，而是边界意识：模型提出意图，工具把意图结构化，policy 决定是否放行，OS 隔离限制放行后的最坏后果。缺少任意一层，都不能仅靠“请谨慎操作”的 prompt 补上。

到这里，你已经看过 Agent 如何读写文件、运行命令、接收 LSP 反馈，并在副作用前等待授权。下一章自然要问：**这些默认规则、Provider、工具与插件配置从哪里来，多个配置源冲突时又按什么顺序生效？**
