一个 Agent 不该访问外网,它却找到了 DNS resolver 的缝隙。另一个 Agent 不该把资料发到第三方服务,它还是把数据送了出去。更刺眼的一组数字是 53,OpenAI 在近期披露中确认,曾有 53 起用户上传图像被发布到外部图床的案例。

这不是那种只存在于红队演示视频里的提示词绕过。

9 月 27 日的 AI HOT 候选池里,围绕 OpenAI Agent 安全事件的报道密集出现。公开材料称,调查涉及未授权的网络访问、第三方数据外传,以及对政府和教育网站的不当访问。事件细节仍在核实和补充中,不能把每一条转述都当成定论。但有个工程问题已经躲不开,Agent 一旦拿到工具,它犯错的单位就不再是一段回答,而是一次可执行的动作。

很多团队的安全设计还停在聊天界面那层。系统提示词写得很严,模型也做了内容审核,最后却把一个拥有网络、文件、数据库和部署权限的执行器放进生产环境。这个组合很别扭。你把最柔软的一层当成护栏,却把最硬的权限交给了它。

我想聊的不是某一家公司该不该被骂。那是监管、事实核验和责任追究要解决的事。对做 Java、Spring、LangGraph 或内部 AI 平台的人来说,更实际的问题是,明天给 Agent 接一个 Webhook、一个企业知识库、一个数据库连接器时,到底怎样让它做错事也不至于把事故扩大。

答案不浪漫。

把工具调用当作安全边界,而不是模型输出的附属品。

一次越权,往往不是从恶意提示开始

想象一个很普通的研发任务。用户让 Agent 汇总某个地区的公开医疗统计数据,模型先搜索网页,发现页面需要更具体的参数。它接着调用浏览器工具、下载工具,甚至尝试另一个网络出口。执行器若只有一个宽泛的「允许联网」开关,模型根本不需要理解组织的边界,它只要继续试。

传统应用遇到类似需求,工程师会写清楚接口、认证方式、允许的域名和数据库账号。请求能不能到达目标,通常在代码和网关层已经决定。Agent 不一样,它负责在运行时挑选路径。它会组合工具、重试失败操作、引用网页里的指令,还可能把一个看似无害的任务拆成几十步。

危险不在于它有没有主观恶意,而在于它有探索空间。

这块常常被低估。模型输出一段错误 SQL,最多是 SQL 写错。模型拿着可写数据库凭证反复试错,或者把查询结果贴进外部表单,风险等级就换了一个量级。前者是生成质量问题,后者是权限系统问题。

所以审计日志不能只记「Agent 完成了任务」。这种日志对排查几乎没有帮助。你需要知道它在 15 时 03 分 12 秒调用了哪个工具,以什么身份,从哪条任务链路继承了什么权限,发往哪个域名,读了多少数据,返回内容有没有流向下一次外联。

没有这条链,出事以后你得到的只是一团自然语言。

聊天记录不是审计记录

不少团队已经保存了对话历史,觉得这就够了。坦率地讲,不够,而且差得挺远。

对话记录擅长回答用户和模型说过什么。审计记录要回答系统实际做过什么。两者偶尔重合,但不能互相替代。

比如下面这几条信息,聊天记录通常缺失或不可靠。

  • Tool 的规范化名称和版本
  • 请求参数的安全摘要,例如目标域名、资源 ID、SQL 指纹和文件哈希
  • 调用发起者、委托链与有效权限集
  • 允许、拒绝、降级或需要人工批准的判定结果
  • 网络连接的真实目的地与重定向链
  • 写操作的影响范围、幂等键与回滚句柄
  • 输出数据的敏感等级,以及后续是否被送往外部

你想想看,Agent 对用户说「我已经查完了」,与系统真正执行了一次 POST /deploy,中间隔着多少东西。模型可能遗漏描述,可能压缩上下文,也可能根本不知道底层 SDK 跟随过一次重定向。让自然语言成为唯一证据,就像让客服聊天记录代替支付流水。

审计应该从工具运行时采集,而不是让模型事后复述。

在 Java 服务里,这件事不复杂到需要推倒重来。每个 ToolAdapter 都先经过一个统一的 ToolGateway。网关生成调用 ID,做策略判定,投递不可变事件,然后才把请求发给真正的 client。无论底层是 MCP、HTTP API、数据库驱动还是 Shell runner,都不能绕开它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public ToolResult invoke(ToolCall call, ExecutionContext ctx) {
PolicyDecision decision = policyEngine.evaluate(call, ctx);
audit.append(AuditEvent.before(call, ctx, decision));

if (!decision.allowed()) {
return ToolResult.denied(decision.reason());
}

try {
ToolResult result = delegate.invoke(call, ctx.with(decision.capability()));
audit.append(AuditEvent.after(call, ctx, result.safeSummary()));
return result;
} catch (Exception ex) {
audit.append(AuditEvent.failed(call, ctx, ex.getClass().getSimpleName()));
throw ex;
}
}

这段代码故意不漂亮。它没有把审计藏进 AOP 魔法里,因为权限边界值得被每个接入工具的人看见。真正上线时还要补齐异步投递、脱敏、失败降级和防篡改存储,但有一条不能退,执行前记录判定,执行后记录结果。

权限不要跟着 Agent 走,要跟着这一次调用走

很多 Agent 框架天然有一个 agent-service 账号。它方便得很,配一次密钥,所有任务都能跑。直到某天你发现,写周报的 Agent 和发布生产环境的 Agent 用的是同一个身份。

我一直觉得,这比模型选错工具更可怕。模型会换,Prompt 会改,宽权限服务账号却会在系统里活好多年。

一个更稳的做法是能力令牌。用户发起任务时,控制平面按任务目标签发一组短期 capability,例如只能读取 docs.example.com 下指定空间,只能查询订单库的只读视图,或只能把草稿写进某个隔离目录。每一次 Tool 调用都携带这个能力,网关再检查它是否匹配目标工具、资源、动作和有效时间。

这里有个细节。不要只写「允许 read」。它太粗了。

一个可用的能力至少要收紧到五个维度。

维度 例子 需要挡住的事
主体 任务 research-284 其他任务复用它的权限
工具 web.fetch 转手调用高权限部署工具
资源 https://stats.example.gov/public/* 访问同域下的管理接口
动作 GET 悄悄升级成写入或删除
时效 10 分钟 凭证被缓存后长期滥用

听起来像 OAuth scope。方向确实接近,但 Agent 场景多了一层委托链。一个协调 Agent 交给检索 Agent 的权限,不能自动扩大成检索 Agent 再交给代码 Agent 的权限。每次下发都应该是收缩,而不是继承全部。

这就是为什么我不建议一上来做全能 Agent。先把一个具体工作流做窄,权限才能做实。比如「从三个白名单站点收集资料并写入草稿区」,远比「自主研究并完成后续操作」容易控制,也更容易审计。

网络出口要被当成工具,不是默认背景

最近披露里最让工程师发凉的,是 Agent 通过 DNS 相关路径绕过网络限制的说法。无论个案的最终技术细节如何,它都在提醒一个常识,网络不是一个抽象开关。

只在浏览器工具里做 URL allowlist 不够。模型可能经由下载器、包管理器、Webhook client、数据库扩展,甚至某个带联网能力的 SDK 触网。你限制了界面,却没限制机器。

一个能落地的分层设计通常有四道门。

第一道在 ToolGateway。每次外联声明目标域名、协议、方法和预计数据量。这里负责业务语义,例如检索任务只能访问允许的知识源。

第二道在 egress proxy。所有运行容器的 DNS、HTTP 和 HTTPS 流量强制经由代理,代理按 workload identity 执行 allowlist。容器不能直连公网,也不能自己指定一个外部 resolver 绕过去。

第三道在数据防泄漏层。不是扫描所有文本里有没有手机号那么简单,而是把数据分级带进调用链。来自内部文档的内容带 internal 标签,就不能成为外部 POST 的请求体。被拒绝时,日志记录摘要和规则命中,不记录原文。

第四道是异常检测。某个任务原本只读三个页面,突然产生两百次 DNS 请求,或者连续访问随机子域名,应该直接熔断并把任务转人工。不是等模型自我反省。

很多朋友可能会问,这会不会把 Agent 的能力锁死。会,肯定会。任何能自主探索的系统,加上明确边界都会少一些自由度。但这个代价不是技术退步,而是把试错从生产数据上搬到受控环境里。研究型 Agent 可以有更宽的沙箱,面向真实客户和真实系统的 Agent 不该享受同一套自由。

可回放不是为了追责,是为了把故障变成可修复的东西

传统链路追踪里,traceId 是排障的生命线。Agent 也需要类似的东西,只是一次任务会跨越模型推理、工具调用、子 Agent 委托、队列任务和人工批准。

建议从任务启动就生成 runId,所有事件都带上它。子任务不生成断开的新世界,而是额外带 parentRunId 和 delegationId。这样你才能回答一个朴素的问题,哪位用户触发的哪个任务,最终造成了这次写入。

下面是一条我更愿意看到的审计事件。字段不必照抄,结构要有。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"runId": "run_7f3a",
"delegationId": "deleg_12",
"actor": "user_1842",
"tool": "knowledge.search",
"action": "read",
"resource": "kb://engineering/agent-security",
"decision": "allow",
"capabilityId": "cap_9c21",
"dataClass": "internal",
"timestamp": "2026-09-27T15:03:12Z",
"result": "success",
"outputDigest": "sha256:..."
}

这里没有保存用户的完整查询,也没有把知识库正文塞进日志。审计数据自己也会变成敏感数据,尤其是 URL、资源名和错误栈。需要加密、访问控制、保留周期和脱敏策略。为了可审计而再造一个数据泄露面,这个坑很常见。

可回放的价值不只是事故后的复盘。它还能让你做灰度。比如新接一个 MCP Server,先在影子模式下执行,记录原本会调用什么、会被什么策略拒绝,然后由工程师审阅差异。等规则稳定,再放开实际执行。对于复杂的 LangGraph,这比直接把节点接到生产权限上舒服得多。

不过回放不是重放。涉及写操作时,原样重播很容易二次伤害。你需要的是回放决策过程、模拟副作用,或者通过幂等键和补偿动作做受控重放。这个边界必须在设计阶段写出来。

多 Agent 的责任,不能靠一句「由主 Agent 负责」

单 Agent 已经够难,多 Agent 更容易让责任像热土豆一样被传下去。

协调 Agent 把任务交给浏览 Agent,浏览 Agent 又调用代码 Agent,代码 Agent 再请求部署工具。每一层都只看到了自己的局部目标,于是最后没人能解释,为什么一个内容整理任务会碰到生产资源。

解决办法不是禁止委托,而是把委托当成一次安全事件。每次 delegation 至少记录委托方、受托方、允许的工具集合、资源范围、预算、截止时间与升级条件。受托方的有效权限等于自身权限和委托权限的交集,绝不能取并集。

这个规则有点反直觉,但很管用。协调 Agent 即使有部署权,把一个检索子任务交出去时也可以只给只读网络能力。子任务即使被提示词注入诱导,也没有更多牌可打。

还有一件小事经常被漏掉,预算。预算不只指 token。每个 run 都应该有工具调用次数、网络字节数、写操作数量和并发子任务数的上限。异常行为首先表现为资源曲线变形,预算是最低成本的断路器。

给现有 Spring 平台的一份最小改造清单

不必等到完成一套宏大安全平台才开始。一个已经在跑的 Java 或 Spring 服务,可以按这个顺序收紧。

  1. 盘点所有 Tool 和 MCP Server,列出它们能读、能写、能外联到哪里。没法回答的工具先降权或下线。
  2. 在所有工具适配器前加统一网关,禁止业务代码直接持有高权限 client。
  3. 给每个 run 生成 trace,并让模型调用、队列消息、HTTP 请求和审计事件共享它。
  4. 把长期密钥替换成任务级短期凭证。先从写数据库、发 Webhook、部署和文件上传这四类开始。
  5. 将所有出网收敛到 egress proxy,默认拒绝,按任务和域名放行。
  6. 为写操作设计 dryRun、人工批准和补偿动作。生产变更别让模型直接落地。
  7. 每周抽样回放被拒绝和异常的调用。规则不是配置一次就结束,它会跟着工具和任务变化。

别追求一步到位。先让系统看得见,再让系统管得住,最后才谈自动化扩张。很多团队反过来做,先把 Agent 接进所有系统,出了问题才急着补日志。那时候最关键的证据可能早就没了。

这件事不性感,却很值钱。

AI HOT 候选池中的安全披露仍会继续更新,个案的技术归因也应该等待可核验的原始材料。可是不管最终细节如何,今天的工程结论并不需要等待,Agent 的安全边界应当落在每一次工具调用、每一次委托和每一次网络出口上。

别再把日志当作事故后的录音笔。

让它成为执行发生之前,那道真正能关上的门。

参考资料