# 权限系统

> V1 权限的规则模型与“最后匹配优先”的求值、ask 与 reply 的挂起和联动、规则的来源与叠加顺序、工具可见性过滤、doom_loop、--auto 的真实语义，以及 TUI 授权框。

- 作者：David（道雾轩）
- 专栏：OpenCode 源码解读（https://daiw.net/manual/opencode.md）
- 最后更新：2026-09-15
- 原文：https://daiw.net/manual/opencode/permission
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

前几篇反复出现的 `ctx.ask`，最终都落到 `packages/opencode/src/permission/index.ts`。

## 规则与求值

一条规则是三元组：`permission`（权限名，通常就是工具 ID）、`pattern`（资源模式）、`action`（allow、deny、ask）。规则集是规则数组，求值取**最后一条**同时匹配权限名与模式的规则：

```ts
export function evaluate(permission: string, pattern: string, ...rulesets: PermissionV1.Ruleset[]): PermissionV1.Rule {
  return (
    rulesets
      .flat()
      .findLast((rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern)) ?? {
      action: "ask",
      permission,
      pattern: "*",
    }
  )
}
```

（`packages/opencode/src/permission/index.ts:28`）

没有规则匹配时默认 ask；“后者覆盖前者”意味着顺序就是优先级。匹配用的 `Wildcard.match` 很朴素：`*` 转成 `.*`，`?` 转成 `.`，反斜杠统一为斜杠，Windows 下不区分大小写。唯一的特例是结尾的“空格加星号”会变成可选，所以 `git *` 也能匹配光秃秃的 `git`。

## 各工具问的是什么

| 权限名 | patterns | always |
| --- | --- | --- |
| `read` | 相对路径 | `*` |
| `edit`（edit、write、apply_patch） | 相对路径 | `*` |
| `glob`、`grep` | 搜索模式 | `*` |
| `bash` | 每条子命令原文 | `git checkout *` 这类前缀模式 |
| `external_directory` | `目录/*` | 同左 |
| `task` | 子代理名 | `*` |
| `webfetch`、`websearch` | URL、查询词 | `*` |
| MCP 工具键 | `*` | `*` |
| `doom_loop` | 工具名 | 工具名 |

`patterns` 用于本次求值，`always` 是用户选“总是允许”时要记住的模式。多数工具的 `always` 是 `*`：一旦总是允许 edit，就是允许编辑任何文件；bash 则只记住命令前缀（见 [Shell 工具](https://daiw.net/manual/opencode/shell-tool)）。

## ask 与 reply

`ask` 对每个模式单独求值（`packages/opencode/src/permission/index.ts:72`）：任一模式命中 deny 立即以 `DeniedError` 失败，全部 allow 直接放行，否则才生成请求。参与求值的是调用方传入的规则集（agent 规则在前、会话规则在后），再加上本实例的 `approved` 列表。需要询问时，请求放进 `pending` 表，发布 `permission.asked` 事件，然后用 `Deferred.await` 把工具挂起：工具就停在 `ctx.ask` 这一行，直到客户端通过 `POST /permission/:requestID/reply` 回复（见 [HTTP 服务端](https://daiw.net/manual/opencode/server)）。

- **once**：只放行这一次。
- **always**：放行，并把请求的每个 `always` 模式作为 allow 规则追加进 `approved`；再扫描同一会话里其他挂起的请求，现在已全部允许的一并放行。
- **reject**：不带消息时失败为 `RejectedError`，带消息则是 `CorrectedError`；同一会话里其他挂起请求也全部拒绝。

`approved` 只存在内存里，按实例共享、不分会话，重启即失效，TUI 的确认框也写着“until OpenCode is restarted”。它在求值时排在最后，因此会压过同一权限上的其他规则。

## 三种失败，两种后果

| 错误 | 来源 | 模型看到的 | 本轮循环 |
| --- | --- | --- | --- |
| `DeniedError` | 命中 deny 规则 | 附上相关规则的 JSON | 继续 |
| `CorrectedError` | 拒绝并附反馈 | 用户反馈原文 | 继续 |
| `RejectedError` | 直接拒绝 | 用户拒绝了这次调用 | 停止 |

直接拒绝的意思是“别再试了”，[流处理器](https://daiw.net/manual/opencode/processor)把本轮标记为 blocked 并结束循环，配置 `experimental.continue_loop_on_deny` 可改为继续；带反馈的拒绝则是“换个做法”，模型拿着反馈接着干。

## 规则从哪来

生效的规则集由几层依次拼接，越靠后优先级越高。第一层是所有 agent 共享的默认值：

```ts
const defaults = Permission.fromConfig({
  "*": "allow",
  doom_loop: "ask",
  external_directory: {
    "*": "ask",
    ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
  },
  question: "deny",
  plan_enter: "deny",
  plan_exit: "deny",
  // mirrors github.com/github/gitignore Node.gitignore pattern for .env files
  read: {
    "*": "allow",
    "*.env": "ask",
    "*.env.*": "ask",
    "*.env.example": "allow",
  },
})
```

（`packages/opencode/src/agent/agent.ts:119`）

第一条就是 `"*": "allow"`：V1 默认信任，编辑和 Shell 都不询问，只有越界目录、`.env` 文件和疑似死循环会问。白名单目录包括截断输出目录、临时目录、技能目录等。

往后依次是：agent 自身的规则（build 放行 question 与 plan_enter；plan 禁止编辑、只放行计划文件；explore 先全部拒绝，再放行读取、搜索、bash 与联网）→ 用户配置的 `permission` → 该 agent 配置里的 `permission` → 会话规则（子代理继承的拒绝规则、`opencode run` 非交互模式追加的拒绝等）→ `approved`。

配置里的键顺序就是优先级，core 的 schema 为此保留了原始顺序：

> Runtime config parsing uses Effect's `propertyOrder: "original"` parse option so user key order is preserved for permission precedence.

（`packages/core/src/v1/config/permission.ts:15`）

所以把 `"*": "deny"` 写在具体规则之后，会盖掉前面的全部放行。模式里的 `~/` 与 `$HOME` 会展开成主目录；旧式的 `tools: { bash: false }` 配置加载时被翻译成权限规则（write、edit、patch 统一映射为 `edit`）。

## 看不见的工具

权限不只在执行时把关，还决定模型能否看到某个工具：

```ts
export function disabled(tools: string[], ruleset: PermissionV1.Ruleset): Set<string> {
  const edits = ["edit", "write", "apply_patch"]
  const reads = ["list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource"]
  return new Set(
    tools.filter((tool) => {
      const permission = edits.includes(tool) ? "edit" : reads.includes(tool) ? "read" : tool
      const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
      return rule?.pattern === "*" && rule.action === "deny"
    }),
  )
}
```

（`packages/opencode/src/permission/index.ts:204`）

某个权限名的最后一条相关规则若是“模式为 `*` 的 deny”，对应工具就从请求里整个删掉。`"bash": "deny"` 让模型根本不知道有 Shell，`"bash": { "rm *": "deny" }` 则保留工具、只拦 rm。子代理看不到 question、plan 以外的 agent 看不到 plan_exit，都是这个机制的结果。

## doom_loop

流处理器在每次工具调用时检查最近 3 个部件：若都是同一工具且输入完全相同，就以工具名为模式询问 `doom_loop`（默认 ask）。TUI 把它显示为 “Continue after repeated failures”，由用户决定是否继续。

## --auto 放开了什么

`opencode --auto`（隐藏别名 `--yolo`、`--dangerously-skip-permissions`）实现在客户端：TUI 的权限上下文处于 auto 模式时，收到 `permission.asked` 就立刻替用户回复 once（删减后）：

```ts
case "permission.asked": {
  const request = event.properties
  if (permission.mode === "auto") {
    void sdk.client.permission.reply({
      requestID: request.id,
      reply: "once",
      // ...
    })
    break
  }
```

（`packages/tui/src/context/sync.tsx:196`）

由此可以推出它的边界：

- 显式 deny 在服务端求值时就已失败，根本不会产生请求，这就是“未被显式拒绝”的含义；
- 回复的是 once 而不是 always，不会往 `approved` 里写任何东西；
- 结合默认规则，它实际放开的主要是越界目录、`.env` 读取、doom_loop，以及用户自己配成 ask 的规则；
- 命令面板可以随时切换该模式；`opencode run` 也有同名选项。

## TUI 授权框

`packages/tui/src/routes/session/permission.tsx` 为常见权限定制了展示：edit 显示 diff，bash 显示 `$ 命令`，external_directory 列出目录模式。三个选项是 Allow once、Allow always、Reject：

- Allow always 会先弹出确认，列出将被记住的模式；
- Reject 在子代理会话里会让用户填写“希望 OpenCode 怎么改”，于是得到带反馈的 `CorrectedError`；在主会话里则直接拒绝。

V2 的 `PermissionV2` 把三元组改名为 action、resource、effect，求值同样是最后匹配优先，见 [V2 工具与权限](https://daiw.net/manual/opencode/tools-v2)。

下一篇：[提供方目录与 models.dev](https://daiw.net/manual/opencode/provider-catalog)——进入第 3 部分，看 OpenCode 怎样组织模型提供方与模型元数据。
