🔐 权限系统

一个能读写文件、执行命令的 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,但真正执行的是应用层。这个分离是安全控制的基础

⚠️ 没有权限的 Agent 是危险的

一个能执行 bash、改文件、发请求的 Agent,如果无脑执行 LLM 的每个 tool_call,那 LLM 一个幻觉就可能:rm -rf /、覆盖重要文件、泄露密钥。生产级 Agent 必须在执行前判断"这个操作该不该做、要不要问用户"。这就是权限系统。

教学框架(LangChain/LangGraph)几乎不管这个——它们假设工具都是安全的。但真实产品(OpenCode)把权限作为一等公民,贯穿工具执行全链路。

权限三态:allow / ask / deny

每个工具操作(按工具名 + 文件路径等模式匹配)有三种裁决:

状态行为对应 LG6 概念
allow 直接执行,不打扰用户 正常执行(无 interrupt)
ask 暂停,弹窗问用户"允许吗?",等批准后执行 ★ interrupt(HITL)
deny 拒绝执行,返回错误给 LLM 直接终止该工具
LLM 想调 工具 权限? allow 直接执行 ask 弹窗问用户 deny 拒绝 执行/拒绝 回传结果
图 OC4.1 · 每个工具调用先过权限裁决:allow/ask/deny

三层权限合并

权限规则来自三层,按优先级合并。源码在 agent/agent.ts:119

📄 agent/agent.ts:119 · 三层权限默认值 typescript
// ① 第 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 时优先级:运行时 > 用户配置 > 默认
① 内置默认(defaults) *:allow, doom_loop:ask, *.env:ask... ② 用户配置(opencode.json 的 permission) 用户自定义覆盖(可收紧或放宽) ③ 运行时批准(用户点"总是允许"后) 最高优先级,持久化 ↑ 优先级递增 ↑ ↑ 优先级递增 ↑
图 OC4.2 · 三层权限,上层覆盖下层
💡 .env 为什么要 ask

注意 *.env: "ask"——.env 文件通常含密钥/密码(API Key、数据库密码)。如果 Agent 随便读,可能把这些敏感信息泄露给 LLM(进而可能泄露到日志/网络)。所以读 .env 类文件必须问用户。这是生产级 Agent 才会有的安全细节。

ctx.ask:人在回路的核心

OC3 看到工具里有 yield* ctx.ask(...)。这就是 HITL 的触发点:

📄 工具内的权限询问(OC3 todo.ts 示例回顾) typescript
execute: (params, ctx) => Effect.gen(function* () {
  // ★ 执行前先问权限
  yield* ctx.ask({
    permission: "todowrite",        // 工具名
    patterns: ["*"],                // 匹配模式
    always: ["*"],                  // 用户可勾选"总是允许"
    metadata: {},
  })
  // 只有 ask 返回(用户允许)才会执行到这里
  yield* todo.update({ ... })
  ...
})
📄 ctx.ask 的内部行为(概念) typescript
// 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 ≈ LangGraph 的 interrupt

ctx.ask 在 ask 状态下暂停工具执行、等用户决策——这就是 LG6 章的 interrupt!区别:LangGraph 的 interrupt 是通用的(任意节点中断),OpenCode 把它特化成权限场景(工具执行前),并加了"总是允许"的持久化。这是 HITL 在真实产品的落地形态。

doom_loop 死循环检测

F3 章预告过——真实 Agent 可能陷入死循环(反复调同一工具)。OpenCode 在 processor.ts:354 主动检测:

📄 session/processor.ts:354 · doom_loop 检测 typescript
// 检测死循环:最近 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

📄 agent/subagent-permissions.ts:14 · 子 Agent 权限派生 typescript
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/denyLG6 interrupt + 手动判断(框架无原生支持)
三层权限合并无(框架不管权限,需自己实现)
ctx.ask 暂停LG6 interrupt()(通用版)
"总是允许"持久化需用 Store 自己实现
doom_loop 检测F3 预告的概念,框架无原生支持
子 agent 权限继承需自己管理子图权限
*.env 特殊保护无(生产级安全细节)
🧭 框架为什么不管权限

LangChain/LangGraph 是通用框架,不知道你的工具做什么、哪些危险。它们只提供 interrupt 这种通用暂停机制。而 OpenCode 是具体的编码产品,知道"改文件危险、读 .env 危险、递归派生危险",所以把权限做成一等公民。这正是"框架"和"产品"的差距——学完本章你会理解为什么生产 Agent 需要在框架之上加这么多工程。

小结

下一章

下一章 OC5 · 上下文工程:F2 章提过 context window 限制。看 OpenCode 如何用 compaction(压缩历史)、子 Agent(分流任务)、动态 system prompt 工程化地解决记忆管理——这是生产 Agent 的"记忆艺术"。