权限系统

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

作者 David更新于 第 18 篇(共 38 篇)

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

规则与求值

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

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。

各工具问的是什么

权限名patternsalways
read相对路径*
edit(edit、write、apply_patch)相对路径*
glob、grep搜索模式*
bash每条子命令原文git checkout * 这类前缀模式
external_directory目录/*同左
task子代理名*
webfetch、websearchURL、查询词*
MCP 工具键**
doom_loop工具名工具名

patterns 用于本次求值,always 是用户选“总是允许”时要记住的模式。多数工具的 always 是 *:一旦总是允许 edit,就是允许编辑任何文件;bash 则只记住命令前缀(见 Shell 工具)。

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 服务端)。

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

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

三种失败,两种后果

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

直接拒绝的意思是“别再试了”,流处理器把本轮标记为 blocked 并结束循环,配置 experimental.continue_loop_on_deny 可改为继续;带反馈的拒绝则是“换个做法”,模型拿着反馈接着干。

规则从哪来

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

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

看不见的工具

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

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(删减后):

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 工具与权限。

下一篇:提供方目录与 models.dev——进入第 3 部分,看 OpenCode 怎样组织模型提供方与模型元数据。

本页目录