AI Agent Security 实现实践:对话阶段防泄露、防工具暴露与审计
这次改的是一个 FastAPI + LangGraph + MCP 的 AI 控制平面。问题起点很朴素:用户问了一句“你有哪些工具”“你的执行流程是什么”,模型老老实实把内部工具和编排动作抖出来了。系统没崩,脸先丢了。
这篇记录只写本次已经提交的 security 补丁,不展开下一阶段的 ContextBuilder 重构。换句话说,这是一篇 pre-context-builder 版本的安全落地记录:先把输入/输出/工具观察值这三道口子补上,再谈 prompt 结构化治理。
提交记录:916607a feat(security): 增加对话阶段安全防护与审计
现场问题
当时暴露出来的不是“工具调用校验失效”,而是更基础的两个问题:
- 用户可以直接把内部元信息问出来。
- 工具返回内容会回流到后续 prompt,但系统没有把它明确标记成不可信数据。
这两个问题组合起来很要命。
第一种属于经典的内部信息泄露:
- 工具清单
- 执行流程
- system/developer 级提示
- 隐藏推理与内部事件名
第二种属于 prompt injection 的常见入口:
- 用户输入里带覆盖指令
- 工具输出里夹带新指令
- 模型把“数据”当成“指令”继续执行
工程上如果只盯着 query 文本,很容易修成“你有哪些工具”不能问,但“你当前具备哪些能力”又放过去。这个坑后面我们在对话里也专门复盘了。
第一个版本先做什么
这次没有直接大改成 ContextBuilder,而是先把最小安全闭环补齐:
bootstrap注入SecurityPolicyplan前做输入拦截prompting做工具目录最小暴露和观察值非可信包装chat前做最终回答拦截- 统一打隐藏安全审计事件
这个版本的目标很明确:先把明显会泄露内部信息的洞堵住,并且让工具输出进入 prompt 时至少带上“这是不可信内容”的标签。
为什么先不做 ContextBuilder
因为这次修的是安全事故,不是 prompt 架构升级。
如果直接把 prompting 全量改成 section-based ContextBuilder,收益当然更高,但会同时引入:
- prompt 结构重组
- 多节点上下文组装迁移
- 调试基线变化
- 测试断言整体改写
这会把“安全补丁”变成“安全补丁 + prompt 基建重构”,评审和回归成本一起上来。先落一版边界防护,再做第二阶段的 ContextBuilder,是更稳妥的工程节奏。
这次提交具体做了什么
1. 安全策略进入运行态
在 bootstrap 节点,把安全策略和普通 prompt policy 一起注入状态。
1 | |
这里的做法很直接:先不引入新的编排节点,先让后面的 plan、chat 有统一的安全配置可以取。
2. plan 前拦输入
最先补的是 plan 节点前置防护。模型都还没开始规划,如果用户已经明显在套内部信息,就别浪费 token 了。
代表代码:
1 | |
这一层的价值不是“答得多聪明”,而是“不要把内部 prompt 和工具元信息再送进模型去二次加工”。
3. 工具目录最小暴露
planner 需要知道有哪些工具,但不需要把所有内部元数据塞进去。
这次做的是最小暴露:
- 保留
name - 保留裁剪后的
description - 不把整套 schema 和内部实现细节直接塞进 prompt 文本
代码很短,效果很实用:
1 | |
这不是最终形态。等后续做 ContextBuilder 时,这一层更适合升级成“按 section 和 trust level 组织工具摘要”。
4. 工具观察值统一包装成不可信数据
这是这次我最想留住的一点。
以前工具结果会直接作为 observation 回灌。现在先做了一层包装:
1 | |
这一步很像给模型打预防针:
- 这是数据
- 不是指令
- 即使里面写着“忽略上文”,也不该被当成新系统规则
同时在 prompt 里加了一条硬约束:
1 | |
这还不是完美方案,但比裸灌 tool text 已经强一大截。
5. 最终回答再拦一次
plan 前拦的是输入,chat 前拦的是输出。
代表代码:
1 | |
这层是最后一道闸。即使模型前面已经开始往外抖内部信息,只要 final answer 命中了安全规则,还是会被改写成统一安全回复。
安全规则长什么样
这次没有做复杂策略中心,先上了一个轻量 SecurityPolicy:
1 | |
同时支持从 contextSnapshot.securityPolicy 合并附加规则。这意味着后面如果业务线需要更细的 tenant 级安全策略,不用再改主流程。
测试补了什么
这次补的不是样子货。核心新增了三类测试:
- 输入拦截
- 典型 case:用户询问工具清单
- 预期:不发 LLM 请求,直接安全回答
- 输出改写
- 典型 case:模型回答里带出 system prompt marker
- 预期:final 阶段改写成安全答复
- prompt 结构回归
- 典型 case:plan prompt 中含 security policy 与 untrusted tool data 包装
代表断言:
1 | |
本地针对相关测试执行结果:
1 | |
这版实现的局限
这次提交我自己也不想美化,它就是一个“先把洞堵住”的版本。
局限主要有三点:
- 规则仍然偏文本匹配
- 对明显 case 有用
- 对语义改写、间接表达、跨语言提示不够强
- prompting 仍是字符串拼接,不够结构化
- section 的边界还没彻底拉开
- 这会影响后续调试和可维护性
- 安全评审还没独立成单独节点
- 现在是在
plan/chat两个节点里做 - 后面更适合抽成结构化
security review
对话里后来为什么又聊到了 ContextBuilder
因为这次修完以后,很自然就会发现一个问题:
安全规则其实已经开始和 prompt 工程缠在一起了。
如果继续往下改,最合适的下一步不是继续堆规则,而是把 prompting 彻底改成分块组装:
[Role & Policies][Task][State][Evidence][Context][Output]
然后把 security 放进 section 语义,而不是继续靠散落的字符串规则保命。
这也是为什么我最后把“ContextBuilder 改造”单独作为下一阶段,而没有塞进这次提交里。
延伸问答
1. 能不能只写一个 security check prompt?
能,作为补充手段可以。
但如果系统没有:
- 工具最小暴露
- 工具结果非可信包装
- final 输出再审查
那一个 security check prompt 很容易沦为“多打一层嘴炮”,挡不住真正的 prompt injection。
2. 为什么先提交这版,而不是直接大改 ContextBuilder?
因为安全补丁和 prompt 基建重构不是一个量级的变更。
前者是止血。
后者是换血。
这次先把出血点堵住,后续再把整个上下文构建方式做成结构化 section,风险更可控。
最后
这次提交不是终局,但它把三件重要的事先做成了:
- 不该说的话,先别让模型随便说
- 不可信的内容,先别让模型当成指令
- 安全拦截这件事,先留下审计痕迹
对于一个已经在线跑编排的 Agent 系统来说,这三件事比“先把 prompt 写得更优雅”更紧急。
优雅可以晚一点,泄露最好不要等下个迭代。