🔐 权限系统
一个能读写文件、执行命令的 Agent,如果没有权限控制就是危险品。本章拆解 OpenCode 的权限系统——三层规则合并、ctx.ask 人在回路、doom_loop 死循环检测。这是 LG6 interrupt 的生产落地,也是真实 Agent 的安全基石。
本章目标
- 理解权限的三态:allow / ask / deny
- 掌握三层权限合并:默认 / 配置 / 用户运行时
- 看清
ctx.ask如何实现"工具执行前人工批准"(HITL) - 理解 doom_loop 死循环检测机制(F3 章预告的实现)
- 对照 LG6 的 interrupt,理解生产级 HITL 的形态
为什么需要权限系统
回顾 F2 章的核心认知:LLM 只"指路"不"走路"——它输出 tool_calls,但真正执行的是应用层。这个分离是安全控制的基础:
一个能执行 bash、改文件、发请求的 Agent,如果无脑执行 LLM 的每个 tool_call,那 LLM 一个幻觉就可能:rm -rf /、覆盖重要文件、泄露密钥。生产级 Agent 必须在执行前判断"这个操作该不该做、要不要问用户"。这就是权限系统。
教学框架(LangChain/LangGraph)几乎不管这个——它们假设工具都是安全的。但真实产品(OpenCode)把权限作为一等公民,贯穿工具执行全链路。
权限三态:allow / ask / deny
每个工具操作(按工具名 + 文件路径等模式匹配)有三种裁决:
| 状态 | 行为 | 对应 LG6 概念 |
|---|---|---|
allow |
直接执行,不打扰用户 | 正常执行(无 interrupt) |
ask |
暂停,弹窗问用户"允许吗?",等批准后执行 | ★ interrupt(HITL) |
deny |
拒绝执行,返回错误给 LLM | 直接终止该工具 |
三层权限合并
权限规则来自三层,按优先级合并。源码在 agent/agent.ts:119:
// ① 第 1 层:内置默认(最宽松的部分 + 必要的拦截)
const defaults = Permission.fromConfig({
"*": "allow", // 默认所有工具允许(信任 Agent 基础能力)
doom_loop: "ask", // 死循环检测 → 必须问
external_directory: { // 项目外的目录 → 问
"*": "ask",
...whitelistedDirs.map(dir => [dir, "allow"]), // 白名单目录允许
},
question: "deny", // plan 模式相关工具默认禁用
plan_enter: "deny",
plan_exit: "deny",
read: { // 读文件:默认允许,但 .env 类文件要问
"*": "allow",
"*.env": "ask", // ← 防止泄露密钥!
"*.env.*": "ask",
"*.env.example": "allow",
},
})
// ② 第 2 层:用户配置(opencode.json 覆盖默认)
const user = Permission.fromConfig(cfg.permission ?? {})
// ③ 第 3 层:运行时用户批准(ask 时用户点"总是允许"会加到这层)
// merge 时优先级:运行时 > 用户配置 > 默认
注意 *.env: "ask"——.env 文件通常含密钥/密码(API Key、数据库密码)。如果 Agent 随便读,可能把这些敏感信息泄露给 LLM(进而可能泄露到日志/网络)。所以读 .env 类文件必须问用户。这是生产级 Agent 才会有的安全细节。
ctx.ask:人在回路的核心
OC3 看到工具里有 yield* ctx.ask(...)。这就是 HITL 的触发点:
execute: (params, ctx) => Effect.gen(function* () {
// ★ 执行前先问权限
yield* ctx.ask({
permission: "todowrite", // 工具名
patterns: ["*"], // 匹配模式
always: ["*"], // 用户可勾选"总是允许"
metadata: {},
})
// 只有 ask 返回(用户允许)才会执行到这里
yield* todo.update({ ... })
...
})
// ctx.ask 内部:
function ask({ permission, patterns, ruleset }) {
return Effect.gen(function* () {
// ① 查权限规则:合并三层,对 patterns 匹配
const decision = Permission.evaluate(ruleset, permission, patterns)
if (decision === "allow") return // 放行,继续执行
if (decision === "deny") throw new Denied() // 拒绝,抛错给 LLM
// decision === "ask"
// ② 暂停执行,发事件让 UI 弹窗(对应 LG6 的 interrupt!)
yield* events.publish("permission.request", { permission, patterns })
const response = yield* awaitUserDecision() // 阻塞等用户点击
if (response === "allow_always") {
// 用户选"总是允许" → 加到第 3 层运行时规则(持久化)
yield* permissionRules.add({ permission, patterns, allow: true })
}
if (response === "deny") throw new Denied()
// allow → 继续
})
}
ctx.ask 在 ask 状态下暂停工具执行、等用户决策——这就是 LG6 章的 interrupt!区别:LangGraph 的 interrupt 是通用的(任意节点中断),OpenCode 把它特化成权限场景(工具执行前),并加了"总是允许"的持久化。这是 HITL 在真实产品的落地形态。
doom_loop 死循环检测
F3 章预告过——真实 Agent 可能陷入死循环(反复调同一工具)。OpenCode 在 processor.ts:354 主动检测:
// 检测死循环:最近 N 个 part(DOOM_LOOP_THRESHOLD,如 3)
const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD)
if (
recentParts.length !== DOOM_LOOP_THRESHOLD ||
!recentParts.every(
(part) =>
part.type === "tool" &&
part.tool === value.name && // 同一工具
part.state.status !== "pending" &&
JSON.stringify(part.state.input) === JSON.stringify(input), // 同一输入
)
) {
return // 不满足"连续 N 次相同调用",不是死循环
}
// ★ 检测到死循环!触发权限询问(让用户决定是否继续)
yield* permission.ask({
permission: "doom_loop",
patterns: [value.name],
metadata: { tool: value.name, input },
always: [value.name],
ruleset: agent.permission,
})
它检查"最近 3 个工具调用是不是同名 + 同参数"。如果是,说明 LLM 卡住了(反复做同一件没用的事)。这时不直接中断,而是触发 ask——让用户决定"继续"还是"停"。这是优雅的设计:既防止无限循环,又给用户控制权。
子 Agent 的权限继承
OC5 会讲子 Agent(task 工具派生)。这里先看权限如何安全地传递给子 Agent:
export function deriveSubagentSessionPermission(parentRuleset) {
// 子 agent 继承父会话的:
// 1. 所有 deny 规则(父拒绝的,子也拒绝)
// 2. external_directory 规则(父限制的目录,子也限制)
// 并【额外禁止】:
// 3. todowrite(子 agent 不该改待办)
// 4. task(防止无限递归派生!)
return Permission.merge(parentRuleset, {
todowrite: "deny",
task: "deny", // ← 关键:禁止子 agent 再派生子 agent
})
}
注意 task: "deny"——子 Agent 不能再派生子 Agent。否则 LLM 可能无限递归(task 调 task 调 task...)导致爆炸。这是权限系统的结构性防护,比运行时检测更彻底。
与框架对照
| OpenCode 概念 | 框架对应 | |
|---|---|---|
| 权限三态 allow/ask/deny | → | LG6 interrupt + 手动判断(框架无原生支持) |
| 三层权限合并 | → | 无(框架不管权限,需自己实现) |
| ctx.ask 暂停 | → | LG6 interrupt()(通用版) |
| "总是允许"持久化 | → | 需用 Store 自己实现 |
| doom_loop 检测 | → | F3 预告的概念,框架无原生支持 |
| 子 agent 权限继承 | → | 需自己管理子图权限 |
| *.env 特殊保护 | → | 无(生产级安全细节) |
LangChain/LangGraph 是通用框架,不知道你的工具做什么、哪些危险。它们只提供 interrupt 这种通用暂停机制。而 OpenCode 是具体的编码产品,知道"改文件危险、读 .env 危险、递归派生危险",所以把权限做成一等公民。这正是"框架"和"产品"的差距——学完本章你会理解为什么生产 Agent 需要在框架之上加这么多工程。
小结
- 权限三态:allow(放行)、ask(暂停问用户)、deny(拒绝)。
- 三层合并:默认 → 用户配置 → 运行时批准,上层覆盖下层。
ctx.ask是 HITL 的核心,对应 LG6 的 interrupt,特化成工具执行前询问。- doom_loop 检测:连续 N 次同名同参工具调用 → 触发 ask。
- 子 Agent 权限:继承父的 deny + 禁止 todowrite/task(防递归爆炸)。
- 权限是"框架之上"的生产级工程,教学框架只提供 interrupt 这种通用机制。
下一章 OC5 · 上下文工程:F2 章提过 context window 限制。看 OpenCode 如何用 compaction(压缩历史)、子 Agent(分流任务)、动态 system prompt 工程化地解决记忆管理——这是生产 Agent 的"记忆艺术"。