多 Agent 权限事故复盘:执行器只校验工具,不校验调用者,数据被清了

0 阅读8分钟

摘要: 两个 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-bclean_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 的工具了,只是日志还没告诉你。

踩坑清单:三句话记住这次教训

  1. 别信单个 Agent 的权限表,要信调用链——权限配得再干净,执行器丢掉"调用者是谁",等于发共享通行证。
  2. 工具可见 ≠ 可调用——共享注册表里能看到工具,不代表有资格用;可见性要跟授权分开。
  3. 写操作默认不自动执行——requireReview 这类字段不是摆设,执行器必须实现复核逻辑,否则就是形同虚设。

互动

你在多 Agent 编排里遇到过类似的权限坑吗?执行器是只校验工具,还是做了调用者 × 工具的二维校验?欢迎在评论区分享你的踩坑经历或解决方案,一起把多 Agent 的权限边界划得更清楚。