本文是「Agent 工程实战」系列的第 02 篇,属于保命线。
系列索引(21 篇阅读路线):Agent 放权前,先补权限、出站、发现这三道口子
摘要:两个 Agent 各配各的权限,单独看都合法,配合一次却把测试环境旧数据清了。根因是执行器只校验"工具在白名单里",不校验"调用者有没有资格用"——权限挂在工具维度,调用链上的身份丢了。本文走完现象、排查、根因、修复、边界五步,附可运行的权限校验代码,帮你避开同类事故。
文章目录
- 现象:日志里每个调用都合法,数据就是没了
- 排查:顺着调用链,找到了"看不见的调用者"
- 根因:权限挂在"工具"上,调用链的身份丢了
- 修复:调用者 × 工具二维校验,敏感工具加复核
- 边界:多 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,// 把"不该成功却成功"的调用筛出来,看是不是执行器漏校验constlogs=[{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"},];constTOOL_ACL={read_report:{callers:["export-agent-a"],level:"read"},clean_test_data:{callers:["ops-agent-b"],level:"write",requireReview:true},};constviolations=logs.filter((l)=>{constacl=TOOL_ACL[l.tool];returnacl&&!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,在执行器——它压根没把"调用者是谁"这件事纳入校验。
根因:权限挂在"工具"上,调用链的身份丢了
翻执行器的代码,权限校验长这样:
functionexecute(tool,args){if(!TOOL_ACL[tool])return{denied:"unknown tool"};// 到这里就直接执行了,没看 callerreturnrun(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 的配置——清理工具带复核,也没问题。越权发生在"调用链"上,不在任何一份配置里。
修复:调用者 × 工具二维校验,敏感工具加复核
修复的思路就一句话:执行器在执行前,先查"调用者 × 工具"的权限矩阵。工具在白名单只是第一道,调用者有没有资格才是第二道:
// 为什么看这段:二维校验是修复的核心,// 工具名 + 调用者都对上,才放行;写操作还强制走复核constTOOL_ACL={read_report:{callers:["export-agent-a"],level:"read"},clean_test_data:{callers:["ops-agent-b"],level:"write",requireReview:true},};functionexecute(caller,tool,args){constacl=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"};returnrun(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 的权限边界划得更清楚。
「Agent 工程实战」系列
- 路线:保命线(权限/出站/发现)
- 上一篇:01 公网留言板互相传答案
- 下一篇:03 出站责任清单
- 系列索引(21 篇阅读路线):Agent 放权前,先补权限、出站、发现这三道口子
- 专栏:Agent 工程实战
- 如果这篇对你有用,欢迎收藏;想追更可以关注「Agent 工程实战」专栏。