跳到主要内容

04 · 权限系统:动手前先举手

本章讲 opencode 怎么在每个危险动作(写文件、跑命令、读项目外目录)前设一道闸门,让你能 allow / ask(批准)/ deny。

1. 它要解决的小问题

agent 能直接操作你的工作区,这很强,也很危险——你不希望它在你不知情时删文件、curl | sh、或读你 ~/.ssh。需要一个统一闸门:每个工具执行前先报"我要对 X 做 Y",由规则或用户决定放行、拒绝、还是逐次询问。

2. 三态裁决:allow / ask / deny

权限的核心是一组规则,每条规则形如 { permission, pattern, action },action 是 allow / ask / deny。裁决靠通配匹配,evaluate(permission/index.ts:28)取最后一条同时匹配 permissionpattern 的规则("后定义者优先"),没匹配上则默认 ask:

// permission/index.ts:28 起,示意精简
export function evaluate(permission, pattern, ...rulesets) {
return rulesets.flat().findLast(
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern)
) ?? { action: "ask", permission, pattern: "*" } // 默认问
}

例:{ permission: "edit", pattern: "src/**", action: "allow" } 表示"改 src 下的文件直接放行";{ permission: "shell", pattern: "rm *", action: "deny" } 表示"禁止 rm"。

3. ask 的运行机制:挂起一个 Deferred

当裁决为 ask,服务端不能继续——它得停下来等人。ask(permission/index.ts:67)的做法是创建一个 Effect Deferred(类似一个待兑现的 promise),把请求放进 pending 表,广播 Asked 事件给客户端,然后 await 这个 Deferred:

// permission/index.ts:84 起,示意精简
if (!needsAsk) return // 全 allow,直接放行
const deferred = yield* Deferred.make<void, ...>()
pending.set(id, { info, deferred })
yield* events.publish(Event.Asked, info) // 通知 UI 弹出批准框
return yield* Deferred.await(deferred) // 挂起,直到用户回复

用户在 UI 点"批准/拒绝"→ 客户端调 reply(permission/index.ts:109)→ 兑现或失败那个 Deferred:

  • reject:Deferred 失败,工具执行抛错;还会连带拒绝同会话其它 pending 请求(index.ts:129-138)。
  • once:这一次放行。
  • always:放行,并把该 pattern 追加进 approved 规则,以后同类不再问;还会顺手放行 pending 里已被这条新规则覆盖的请求(index.ts:145-166)。

4. bash 命令:拆成"人类可懂的前缀"

直接对整条 shell 命令做权限匹配没意义(git commit -m "..."git commit -m "别的" 是同一类)。opencode 先把命令拆成有意义的前缀再问。这就是 permission/arity.tsprefix:

// permission/arity.ts:1 起,示意精简
export function prefix(tokens) {
for (let len = tokens.length; len > 0; len--) { // 最长匹配优先
const arity = ARITY[tokens.slice(0, len).join(" ")]
if (arity !== undefined) return tokens.slice(0, arity)
}
return tokens.slice(0, 1) // 默认取第一个 token
}

ARITY 是一张手工 + LLM 生成的词典(arity.ts:24 起),记录每个命令"几个 token 才算定义了这条命令":

命令arity取到的前缀
rm file.txt1rm
git checkout maingit=2git checkout
npm installnpm=2npm install
npm run devnpm run=3npm run dev
python script.pypython=2python script.py

这样权限规则可以写成"git * 一律 allow,rm * 一律 deny",既精确又不会因为参数变化反复问。

5. doom-loop 防呆:别原地打转

模型有时会卡死,反复用完全相同的参数调同一个工具。processor 在每次 tool-call 时检测:如果最近 3 个 part 是同名工具、同样输入(DOOM_LOOP_THRESHOLD = 3,processor.ts:29),就触发一次 doom_loop 权限询问,把模型从死循环里拽出来交还给用户:

// processor.ts:354 起,示意精简
const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD)
if (recentParts.every((p) => p.type === "tool" && p.tool === value.name
&& JSON.stringify(p.state.input) === JSON.stringify(input))) {
yield* permission.ask({ permission: "doom_loop", patterns: [value.name], ... })
}

doom_loop 默认 action 是 ask(agent.ts:121)。

6. 默认规则:开放但护住要害

每个 agent 自带一套默认权限(agent.ts:119Permission.fromConfig):

  • "*": "allow" —— 默认放行(开发体验优先)。
  • external_directory: { "*": "ask" } —— 读写项目外目录要问;但白名单目录(临时目录、技能目录、reference 目录)放行(agent.ts:108-117)。
  • read.env 类文件特殊处理(agent.ts:129 注释:mirror github 的 .env gitignore 模式)。
  • question / plan_enter / plan_exit 默认 deny(这些是特定模式才开的)。

规则还会分层合并:agent 的默认规则 + 会话级规则 + 运行中累积的 approved,后者优先(permission/index.ts:73session/tools.ts:84Permission.merge)。

7. 巧妙之处 / 边界

  • "后定义者优先 + 默认 ask" 让规则既可叠加又安全:没人显式 allow 的,一律先问。
  • 拒绝会连坐同会话其它待批请求(index.ts:129):一次"停"能干净地刹住整批动作。
  • arity 词典是 LLM 生成的(arity.ts:10-23 保留了生成 prompt),覆盖广但不保证全;表外命令一律退化成"取第一个 token",可能粒度偏粗。
  • 权限状态是实例内存态(InstanceState),approved 不跨进程持久化;重启后"always"批准会丢。

8. 代码地图

主题文件路径符号名
规则裁决packages/opencode/src/permission/index.tsevaluate
询问(挂起 Deferred)packages/opencode/src/permission/index.tsask
回复(兑现/连坐)packages/opencode/src/permission/index.tsreply
配置→规则packages/opencode/src/permission/index.tsfromConfig, merge, disabled
bash 命令拆前缀packages/opencode/src/permission/arity.tsprefix, ARITY
doom-loop 检测packages/opencode/src/session/processor.tshandleEvent(tool-call 分支), DOOM_LOOP_THRESHOLD
agent 默认权限packages/opencode/src/agent/agent.tsdefaults(Permission.fromConfig)
工具执行前包权限packages/opencode/src/session/tools.tsresolve(ask)