摘要: 两个 Agent 各配各的权限,单独看都合法,配合一次却把测试环境旧数据清了。根因是执行器只校验"工具在白名单里",不校验"调用者有没有资格用"——权限挂在工具维度,调用链上的身份丢了。本文走完现象、排查、根因、修复、边界五步,附可运行的权限校验代码,帮你避开同类事故。
适用场景: 正在搭建或维护多 Agent 编排系统、共享工具执行器的开发者;对 Agent 权限模型、调用链安全感兴趣的读者。前置知识: 了解 Agent 基本概念、工具调用机制即可,无需深入框架源码。先说清楚:这是沙箱演练,不是生产事故。我在本地搭了一个最小的多 Agent 环境——两个 Agent,一个负责导出测试数据报表,一个负责清理测试环境的过期数据,共享同一个工具执行器。演练的目的本来是想看看"多 Agent 协作时权限怎么给才安全",结果跑了一轮,我盯着日志坐了很久。
现象很直接:测试环境那个还没备份的旧数据分区,一百多条旧记录,全没了。
单独看这两个 Agent 的配置,谁都没越权。负责导数的 export-agent-a 只配了只读工具,负责清理的 ops-agent-b 配了清理工具带复核。可执行器日志里写得明明白白:export-agent-a 调用了 clean_test_data,然后放行了。每个调用单拎出来都"合法",组合起来就是事故。
现象:日志里每个调用都合法,数据就是没了
先把现场摆一下。执行器审计日志长这样(Node 25.8.2 真跑):
[audit] caller=export-agent-a tool=read_report result=ok
[audit] caller=export-agent-a tool=clean_test_data result=ok
[audit] caller=ops-agent-b tool=clean_test_data result=ok
看第二条:export-agent-a 调了 clean_test_data,结果是 ok——放行了。可 clean_test_data 是配给 ops-agent-b 的工具,A 根本没这个权限。但日志不会骗人,它确实执行了,数据也确实没了。
排查:顺着调用链,找到了"看不见的调用者"
我第一反应是查执行器代码——一个工具执行器,权限校验逻辑应该在里面。排查脚本很简单,把审计日志里"调用者不在这个工具白名单里却成功"的记录筛出来:
// 为什么看这段:审计日志记录了 caller + tool + result,
// 把"不该成功却成功"的调用筛出来,看是不是执行器漏校验
const logs = [
{ caller: "export-agent-a", tool: "read_report", result: "ok" },
{ caller: "export-agent-a", tool: "clean_test_data", result: "ok" },
{ caller: "ops-agent-b", tool: "clean_test_data", result: "ok" },
];
const TOOL_ACL = {
read_report: { callers: ["export-agent-a"], level: "read" },
clean_test_data: { callers: ["ops-agent-b"], level: "write", requireReview: true },
};
const violations = logs.filter((l) => {
const acl = TOOL_ACL[l.tool];
return acl && !acl.callers.includes(l.caller) && l.result !== "denied";
});
console.log("越权放行记录:", violations.map((l) => `${l.caller} -> ${l.tool}`));
运行结果(Node 25.8.2 真跑):
越权放行记录: [ 'export-agent-a -> clean_test_data' ]
一次就筛出来了。问题不在 Agent,在执行器——它压根没把"调用者是谁"这件事纳入校验。
根因:权限挂在"工具"上,调用链的身份丢了
翻执行器的代码,权限校验长这样:
function execute(tool, args) {
if (!TOOL_ACL[tool]) return { denied: "unknown tool" };
// 到这里就直接执行了,没看 caller
return run(tool, args);
}
这段代码真跑,对 clean_test_data 的任意调用都返回成功,完全不管调用者是谁:
{ ok: true, tool: 'clean_test_data' }
{ ok: true, tool: 'read_report' }
问题一目了然:只校验"工具在白名单里",不校验"这个调用者有没有资格用"。为什么会出现这种写法?因为多 Agent 编排里,工具注册在共享执行器上,执行器从 Agent 的响应里解析出工具名和参数就执行——它默认"能看见这个工具的 Agent 才会调它",这就是默认信任。它甚至没读 requireReview 这个字段——B 的配置里明明写了"清理工具带复核",可执行器眼里只有工具名,复核逻辑压根没实现。export-agent-a 在共享注册表里看到了 clean_test_data(可见=可调用),它不知道这是 ops-agent-b 的专用工具,看描述是"清理测试环境过期数据",就顺手调了。
export-agent-a(只读导出) ops-agent-b(清理+复核)
│ │
└──────────┐ │
▼ ▼
┌────────────────────────────────┐
│ 共享执行器 │
│ 只校验:工具在白名单里? │
│ read_report ✓ │
│ clean_test_data ✓ ← B 的工具 │
└────────────────────────────────┘
│ 放行(不管调用者是谁)
▼
测试环境旧数据分区被清
这里要跟两类常见事故划清界限:这不是接口越权那种"数据被人读到"——A 不是读到了 B 的数据,是借 B 的工具执行了写操作;也不是 token 层面的 audience 校验缺失——那个是"验证 token 给谁",这个是"验证动作是谁干的"。一个是认证层,一个是授权层,这轮翻在授权层。
这个坑最阴的地方在于:单个 Agent 的权限配置全是合法的。你查 A 的配置——只读,没问题;查 B 的配置——清理工具带复核,也没问题。越权发生在"调用链"上,不在任何一份配置里。
修复:调用者 × 工具二维校验,敏感工具加复核
修复的思路就一句话:执行器在执行前,先查"调用者 × 工具"的权限矩阵。工具在白名单只是第一道,调用者有没有资格才是第二道:
// 为什么看这段:二维校验是修复的核心,
// 工具名 + 调用者都对上,才放行;写操作还强制走复核
const TOOL_ACL = {
read_report: { callers: ["export-agent-a"], level: "read" },
clean_test_data: { callers: ["ops-agent-b"], level: "write", requireReview: true },
};
function execute(caller, tool, args) {
const acl = TOOL_ACL[tool];
if (!acl) return { denied: "unknown tool" };
if (!acl.callers.includes(caller)) {
return { denied: `caller ${caller} not authorized for ${tool}` };
}
if (acl.requireReview) return { denied: "review required, not auto-executed" };
return run(tool, args);
}
// 复现场景:A 再调清理工具,应该被拒
console.log(execute("export-agent-a", "clean_test_data", { scope: "old_data" }));
console.log(execute("ops-agent-b", "clean_test_data", { scope: "old_data" }));
运行结果(同一环境真跑):
{ denied: 'caller export-agent-a not authorized for clean_test_data' }
{ denied: 'review required, not auto-executed' }
两行输出把修复效果钉死了:export-agent-a 再调清理工具,直接拒绝;连 ops-agent-b 自己调,也因为 requireReview 没走复核流程而被拦——写操作默认不自动执行。
把翻车前后的差异整理一下:
| Agent | 设计权限 | 修复前执行器实际放行 | 修复后 |
|---|---|---|---|
| export-agent-a | 只读 read_report | 调用了 clean_test_data 并放行(数据被清) | 调用被拒(caller 不在白名单) |
| ops-agent-b | clean_test_data(写+复核) | 正常(复核后执行) | 必须先走复核,否则拒绝 |
边界:多 Agent 编排里,权限边界本来就难划
这个修复不是免费的。二维校验意味着每次工具调用都要携带完整的调用者上下文,多 Agent 编排里一个任务可能串好几个 Agent(不少编排框架的工具调用是"注册表+执行器"这个模型,比如 OpenAI Agents SDK 的 tool 机制),上下文传递本身就是工程量。更现实的问题是:如果设计上就允许"Agent A 委托 B 执行"(合法场景),二维校验会误伤这种委托——它分不清"A 借 B 的手越权"和"A 请 B 帮忙执行"。
所以我的建议是分两层兜底:执行器做二维校验挡越权,敏感工具(写操作、删除、外发)一律强制人工复核。复核环节里人来判断"这次委托合不合法",比让权限模型去猜 Agent 意图可靠得多。至于完全开放的多 Agent 协作,权限边界划到哪里、信任怎么传递,我自己也还在踩,这篇只解决"执行器不校验调用者"这一层。
想自己复现这个坑也不难:搭一个只校验"工具在白名单里"的执行器,两个调用者共用同一份工具注册表,让 A 去调 B 的工具,审计日志里就会多一条"不该成功却成功"的记录——再把二维校验换进去,同样的调用会变成 denied。整个演练不需要真的起两个 Agent,模拟两个调用者就够了,代码就上面那段。
总结
这个演练教会我一件事:别信单个 Agent 的权限表,要信调用链。权限配得再干净,只要执行器在调用链上丢掉了"调用者是谁",等于给所有 Agent 发了一张共享通行证。
给多 Agent 环境排权限的时候,先回答一个问题:你的执行器知道这次调用是谁发起的吗? 如果它只知道"工具在白名单里"——那你的 A 可能已经在用 B 的工具了,只是日志还没告诉你。
踩坑清单:三句话记住这次教训
- 别信单个 Agent 的权限表,要信调用链——权限配得再干净,执行器丢掉"调用者是谁",等于发共享通行证。
- 工具可见 ≠ 可调用——共享注册表里能看到工具,不代表有资格用;可见性要跟授权分开。
- 写操作默认不自动执行——
requireReview这类字段不是摆设,执行器必须实现复核逻辑,否则就是形同虚设。
互动
你在多 Agent 编排里遇到过类似的权限坑吗?执行器是只校验工具,还是做了调用者 × 工具的二维校验?欢迎在评论区分享你的踩坑经历或解决方案,一起把多 Agent 的权限边界划得更清楚。