主 Agent 满权限,SubAgent 凭什么只读?一行 filter 的隔离哲学
场景:build 能改文件、explore 只能读——subagent 被主 Agent 调用时,权限为什么没有"继承父会话",而是一行 filter 把权限做成减法 路径:packages/opencode/src/agent/subagent-permissions.ts / packages/opencode/src/tool/task.ts
上篇我们拆了 Agent 定义体系:14 个字段的 Schema,一份数据定义 7 个内置角色。这篇回答上篇结尾留的问题——subagent 被主 Agent 调用时,权限隔离和调用链是怎么走的,为什么 explore 永远拿不到 build 的写权限。
你有没有遇到过这样的场景:你给主 Agent 配了个"只读审查助手",明明定义里写死了只能 grep、read、看代码,结果它趁你不注意,居然去改了一个文件。为什么?因为它调用了另一个有写权限的子代理——而你没法保证调用来调用去,权限不会"漏"出去。
如果你要设计一个 AI Agent 的协作系统——主 Agent 能改代码,但它派出去的只读子代理(subagent,被主 Agent 调用的助手角色)只能搜索文件——你会怎么保证子代理"想越权也越不了"?
一个直观的想法是:子会话继承父会话的权限,子代理能做什么,跟调用它的主 Agent 一样。毕竟"它是我派出去的",为什么不让它拥有我的全部工具?
opencode 的答案正好相反:子会话只继承父会话的"禁止",不继承"允许"。一个 14 行的 deriveSubagentSessionPermission(),用一行 filter 就划出了子代理的权限边界——允许靠子代理自己的定义,禁止从父会话继承,再加上默认的递归防线。

【问题】调用 subagent 时,权限边界怎么定?
naive 方案:全量继承为什么危险
最省事的方案是"子会话复制父会话权限"——主 Agent 能写,派出去的子代理也能写。听着合理,但这个方案有两个致命问题:
一是只读失效。explore 的定义里写着白名单(只有 grep/glob/list/read/bash/webfetch/websearch 放行),但它是被 build 调用的。如果子会话全量继承 build 的权限,explore 就获得了编辑能力——你辛辛苦苦在 Agent 定义里画的"只读"边界,被调用链瞬间击穿。权限的定义(Agent 的 Schema)和权限的生效(调用时的继承)打架了。更可怕的是:这个漏洞不是配置错误,而是设计决策本身——你在 Agent 定义里写的每一条权限,都成了空头支票。
二是递归放大。subagent 本身还能再调 subagent(task 工具允许)。如果每层都全量继承父权限,build → explore → explore' 每一层都是 build 的完整权限——权限不是叠加,而是每一层都在复制"最大权限"。一个只读代理链,最后人人都能写文件。

核心问题:继承什么,不继承什么
所以权限隔离的难题不是"要不要继承",而是精确地继承什么。opencode 的答案是三句话:
- 父会话的 allow 不继承——"build 能写"只属于 build,不代表它派出的 explore 能写
- 父会话的 deny 必须继承——"plan 不能编辑"的禁令,它的子代理也得遵守
- 外部目录例外保留——临时文件等基础设施路径,任何子会话都要能访问
这套取舍写进了 subagent-permissions.ts,下面拆它。
【设计】隔离核心:一行 filter 做权限减法
deriveSubagentSessionPermission:允许靠定义,禁止靠继承
整个 subagent 权限隔离的心脏在 packages/opencode/src/agent/subagent-permissions.ts:14-27,全文只有 27 行(函数本体 14 行):

// packages/opencode/src/agent/subagent-permissions.ts:14-27
export function deriveSubagentSessionPermission(input: {
parentSessionPermission: PermissionV1.Ruleset
subagent: Agent.Info
}): PermissionV1.Ruleset {
const canTask = input.subagent.permission.some((rule) => rule.permission === "task")
const canTodo = input.subagent.permission.some((rule) => rule.permission === "todowrite")
return [
...input.parentSessionPermission.filter(
(rule) => rule.permission === "external_directory" || rule.action === "deny",
),
...(canTodo ? [] : [{ permission: "todowrite", pattern: "*", action: "deny" }]),
...(canTask ? [] : [{ permission: "task", pattern: "*", action: "deny" }]),
]
}
核心就是那个 filter(第 21-23 行):从父会话的权限规则里,只挑出 external_directory 类型和 action === "deny" 的规则。规则集(ruleset,一组 {permission, pattern, action} 规则的数组)里那些 allow 规则被全部丢弃。
为什么 allow 不继承?看文件头部的注释(第 9-10 行):"父 Agent 的限制只管它自己;子代理的能力由它自己的权限决定"(原文:Parent agent restrictions only govern that agent; the subagent's own permissions determine its capabilities)。权限是加法时靠子代理自己的定义,是减法时靠父会话的禁令——这样才能保证 explore 被 build 调用时,它的白名单(自己的定义)依然生效,而 build 的写权限(父会话的 allow)不会漏进来。
两条默认 deny:todowrite 与 task
函数后半段(第 18-25 行)还做了件巧妙的事:检查子代理自己的定义里有没有 todowrite 和 task 权限,没有就默认补一条 deny。
这两条防线各有深意:
task默认 deny = 防递归套娃。task 工具是"再开一个 subagent"的唯一入口。如果子代理默认能调 task,explore就能自己再派一个 explore,子代理链可以无限加深。opencode 的默认是:subagent 不能递归开 subagent,除非你在它的权限里显式授权 task。这就是上篇文章里general禁用todowrite之外的第二个隐藏约束。todowrite默认 deny = 任务计划留给主 Agent。todowrite 是维护任务清单的工具,子代理专注执行单一任务,不背一整个任务清单。
这两条 deny 不是写在父会话里,而是根据子代理自己的定义动态补的(subagent.permission.some(...) 判断有没有,没有才补)。又一次印证了上篇的结论:权限挂在数据(Agent 定义)上,不挂在代码上。
childToolDenies:主代理专属工具不上交
deriveSubagentSessionPermission 之外,task.ts 里还有一段 childToolDenies(packages/opencode/src/tool/task.ts:129-141),做第二层防护:
// packages/opencode/src/tool/task.ts:129-141
const childToolDenies = [
...(next.permission.some((rule) => rule.permission === "todowrite")
? []
: [{ permission: "todowrite", pattern: "*", action: "deny" }]),
...(next.permission.some((rule) => rule.permission === id)
? []
: [{ permission: id, pattern: "*", action: "deny" }]),
...(cfg.experimental?.primary_tools?.map((permission) => ({
permission,
pattern: "*",
action: "deny",
})) ?? []),
]
它和 deriveSubagentSessionPermission 的默认 deny 逻辑重复了一遍(todowrite/task),但多了一个关键项:cfg.experimental.primary_tools——主代理专属工具列表。管理员可以把某些工具标记为"只有主 Agent 能用",配置里写下这些工具名,子代理一律 deny。这是"主代理特权"的显式出口:有些能力,天生只属于发号施令的人,不随调用链下放。
【源码】task 工具拉起子会话全链路
第一站:权限询问前置
subagent 的诞生,始于 task 工具的调用。packages/opencode/src/tool/task.ts:104-114 先做权限询问(ctx.ask 是工具的权限请求口,向用户弹确认框),请求的 permission 就是 task,patterns 是子代理类型:
// packages/opencode/src/tool/task.ts:104-114
if (!ctx.extra?.bypassAgentCheck) {
yield* ctx.ask({
permission: id,
patterns: [params.subagent_type],
always: ["*"],
metadata: { description: params.description, subagent_type: params.subagent_type },
})
}
注意 patterns: [params.subagent_type]——权限询问的粒度精确到"开哪个子代理"。主 Agent 每开一个不同类型的 subagent,都是一次独立的权限请求。你在 TUI(文本用户界面)里看到的"Task → 允许运行 explore 吗?"就是这个。
第二站:查子代理定义,算权限
拿到类型后(task.ts:116-128),先 agent.get(params.subagent_type) 从注册表取出子代理的 Info(上篇文章的消费端终于登场),再取父会话 sessions.get(ctx.sessionID),把两者交给 deriveSubagentSessionPermission 算出子会话的权限:
// packages/opencode/src/tool/task.ts:121-128
const session = params.task_id
? yield* sessions.get(SessionID.make(params.task_id)).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
: undefined
const parent = yield* sessions.get(ctx.sessionID)
const childPermission = deriveSubagentSessionPermission({
parentSessionPermission: parent.permission ?? [],
subagent: next,
})
顺带一提 params.task_id——传了 task_id 就续用之前的子会话,不传就新建。这是 subagent 会话的复用机制,下一篇 Agent 生成会用到。
第三站:创建子会话
然后 sessions.create(task.ts:142-158)创建子会话,关键参数三个:parentID 挂父子关系、agent 指定子代理类型、permission 塞进刚算好的权限:

// packages/opencode/src/tool/task.ts:142-158
const nextSession =
session ??
(yield* sessions.create({
parentID: ctx.sessionID,
title: params.description + ` (@${next.name} subagent)`,
agent: next.name,
permission: [
...childPermission,
...childToolDenies.filter(
(deny) =>
!childPermission.some(
(rule) =>
rule.permission === deny.permission && rule.pattern === deny.pattern && rule.action === deny.action,
),
),
],
}))
childToolDenies 用 filter 去重后拼到 childPermission 后面(避免重复规则)。这个数组最终成为子会话的 session.permission,持久化在会话表里(packages/opencode/src/session/session.ts:541-580 的 createNext 把 parentID、agent、permission 一并写入)。子会话标题也带着父信息:"<描述> (@explore subagent)"——@ 标记说明它是个 subagent。
第四站:子会话跑自己的 loop,结果回注父会话
子会话创建后(task.ts:186-200),runTask 把任务的 prompt 发进子会话,子代理用自己的定义(agent: next.name)+ 自己的权限跑完整的 Agent loop(LLM 循环调工具直到任务完成)。注意这里传给子会话的权限就是刚算好的 session.permission(packages/opencode/src/session/prompt.ts:1339)。
子代理完成后,结果不是直接拼回主会话,而是包进一个 <task> 标签(task.ts:64-79):
// packages/opencode/src/tool/task.ts:64-79
function renderOutput(input) {
const tag = input.state === "error" ? "task_error" : "task_result"
return [
`<task id="${input.sessionID}" state="${input.state}">`,
...(input.summary ? [`<summary>${input.summary}</summary>`] : []),
`<${tag}>`,
input.text,
`</${tag}>`,
"</task>",
].join("\n")
}
这看起来是给主 Agent 的 LLM 看的"格式包装"——主 Agent 通过 <task id=...> 知道有个子代理在哪个会话跑完了,结果是什么。子代理的产出以结构化标签回注,父会话的 Agent loop 继续推进。
闭环:权限在运行时如何真正生效
子会话跑 loop 时,权限评估点在 packages/opencode/src/session/llm.ts:149——子会话每轮 loop 开始时,把子代理自己的权限和子会话继承来的权限合并成一份 ruleset,每次工具调用就用它裁决:
// packages/opencode/src/session/llm.ts:149
const ruleset = Permission.merge(input.agent.permission ?? [], input.permission ?? [])
这里 input.agent.permission 是子代理定义里的权限(如 explore 的白名单),input.permission 是子会话继承来的 session.permission(父 deny + 默认 deny)。两者扁平拼接,Permission.evaluate 用 findLast(packages/opencode/src/permission/index.ts:39-49)取最后一条匹配的规则——而合并时继承来的 deny 排在后,所以 父会话的 deny 能压过子代理自己的 allow。这正是"允许靠定义,禁止靠继承"在运行时的落地。
等一下,这里有个容易被忽略但极其精妙的设计:一条 merge 的顺序,决定了安全性的优先级。子代理自己定义的 allow 排在前面,父会话继承来的 deny 排在后面,findLast 从后往前找——结果就是父会话的禁令天然拥有"否决权"。不用写任何判断逻辑,光靠数组拼接顺序,就实现了"父的禁止 > 子的允许"这条安全铁律。这是全文最值得回味的"还能这样?"时刻:安全规则不用代码实现,用数据顺序实现。
【权衡】三种隔离策略的取舍
方案 A:全量继承——直觉但不安全
把父会话权限整个复制给子会话。实现最省事,但上节已经说了:只读失效 + 递归放大。而且它有个更隐蔽的问题——权限语义从"子代理是什么"漂移成了"谁在调用"。同一个 explore,被 build 调用就是全能,被 plan 调用就只能看,行为完全不可预期。权限隔离是为了安全,但全量继承让边界形同虚设。
方案 B:完全独立——安全但断网
子会话权限 = 子代理自己定义的权限,父会话的禁令完全不传。这保证了 explore 永远是白名单那几样,但它把父会话的约束也隔离掉了:如果 plan(禁止编辑)派一个子代理,子代理理论上又能编辑了——因为 plan 的"禁止编辑"没传下来。安全要的是"父子一致地收紧",完全独立做不到这一点。
方案 C:只继承 deny(opencode 的选择)——减法哲学
父会话的 allow 不上交,deny 和外部目录例外必传,再加上默认的 todowrite/task deny 和 primary_tools 白名单。它是方案 A 和 B 的交集:
| 策略 | allow 继承 | deny 继承 | 外部目录 | 递归控制 | 一致性 |
|---|---|---|---|---|---|
| A 全量继承 | ✅ | ✅ | ✅ | ❌ 无 | ❌ 随调用者漂移 |
| B 完全独立 | ❌ | ❌ | ❌ | ❌ 无 | ✅ 固定 |
| C 只继承 deny | ❌ | ✅ | ✅ | ✅ 默认 task deny | ✅ 收敛 |
允许是加权的、局部的(子代理自己定义);禁止是传染的、全局的(沿调用链向下收紧)。 这让权限系统满足一个朴素但重要的性质:任何一层 Agent 的禁令,它的整棵调用子树都遵守;任何一层 Agent 的特权,不会漏给它派出的子代理。上篇说的"权限是行为边界的最小单位",在这里有了完整的运行时语义。

锚点:设计子代理系统的两个心智模型
心智模型一:权限是减法,不是加法
设计任何"能调用子任务/子进程/子代理"的系统,先把权限想成减法:子任务的能力上限 = 它自己的定义,父任务只负责往回收紧。继承"允许"是给子任务加权利,继承"禁止"是给子任务立边界——你真正想跨层传递的,永远是边界而不是权利。
心智模型二:递归能力必须显式授权
凡是"能开子任务"的入口,默认都应该是关闭的。opencode 用一条 task 默认 deny 堵住了无限递归套娃。默认关闭递归,是对系统自身的一种安全——否则一个失控的子代理就能无限繁衍。想开递归?在子代理的权限里显式写一条 task: allow 即可。

如果你要设计子代理系统,记住三句话:允许靠定义,禁止靠继承——权限不是往下复制"能做什么",而是往下传递"不能做什么";而父的禁止,永远大过子的允许。
如果你也在做 Agent 协作、任务编排或多进程权限隔离,评论区聊聊你的边界怎么划的。觉得有用点个赞,下篇聊 Agent 生成更开脑洞 👇
下篇我们聊 Agent 生成——generate() 怎么让 LLM 动态创建 Agent:你一句话描述需求,Agent 当场定义出来,还自带权限边界。它和本篇"权限挂数据"的设计如何闭环?——如果 Agent 是数据,那让 AI 自己写数据,就是这门手艺的终极形态。