前面几篇已经把 Agent 的几条主线陆续接起来了。
Tool Registry 让工具变成正式协议。文件和网页工具让 Agent 能接触对话之外的材料。原生工具循环让一次请求可以经历“决策 -> 行动 -> 观察 -> 再决策”。Gateway 让 Agent 走出 CLI,进入长期在线的入口。记忆系统则开始处理长期上下文,不再把所有历史都塞回 prompt。
这些能力叠在一起以后,一个更现实的问题会浮出来:
Agent 现在真的能行动了。
它不只是回答问题,不只是读取资料,也不只是保存一点记忆。它开始有机会修改文件、访问外部资源、跨入口响应用户,甚至以后还会执行命令、操作浏览器、触发自动任务。
这时安全边界就不能再停留在“先把危险工具关掉”。
关掉危险工具当然安全。
但一个永远不能执行危险动作的 Agent,也很难真正完成工作。
问题不再是“能不能执行”。
问题变成了:
谁在什么入口里,批准了哪个工具,用哪些参数,执行一次什么动作?
这一篇讲的就是工具安全的第一层:approval。
风险等级只是入口
Tool Registry 里给工具加风险等级,是一个很重要的开始。
比如:
safe
normal
dangerous
读一条记忆,通常是 safe。
写一条普通事实,可能是 normal。
写文件、执行命令、调用外部副作用接口,就应该是 dangerous。
这个分类的价值,不在于它解决了所有安全问题。
它的价值在于让系统承认:工具不是同一种东西。
如果没有风险等级,安全策略就没有稳定挂点。CLI 可能写一段判断,TUI 写一段判断,Gateway 再写一段判断。每个入口都觉得自己在保护用户,最后系统里会出现很多份互相不一致的安全逻辑。
风险等级把第一道门放回了工具协议层。
Tool Registry 不只负责找到 handler,也要负责在 handler 之前做 policy gate。
这件事很小,但位置很关键。
因为真正危险的不是某个入口忘记弹窗,而是系统没有一个统一位置承认“这里即将发生副作用”。
默认拒绝是必要的,但不够
第一版最稳妥的策略是:dangerous 工具默认拒绝。
这让系统有一个安全底线。
没有 approval handler 的调用者,不能执行 dangerous 工具。测试环境、非交互入口、还没有接安全 UI 的入口,都保持 fail closed。
这个默认值很重要。
它避免了一个常见问题:功能一接上,危险能力就自动对所有入口开放。
但默认拒绝不是最终形态。
因为用户确实会希望 Agent 做一些有副作用的事情。
比如:
- 写一个文件
- 更新一段配置
- 创建一个任务
- 发出一条消息
- 以后执行一段命令
这些动作不能永远禁止。
它们需要的是明确批准。
也就是说,dangerous 工具不应该只有两种状态:
永远允许
永远拒绝
更合理的状态是:
默认拒绝
看到明确批准后,只执行这一次
这就是 approval flow 的核心。
Approval 应该挂在工具执行边界
一个自然的做法是把 approval 写在入口里。
CLI 里遇到危险工具,就问一次 y/N。
TUI 里遇到危险工具,就弹一个确认。
Telegram 里遇到危险工具,就发一个确认命令。
这个做法看起来直观,但如果每个入口都自己判断工具风险,系统很快会散。
入口层应该负责交互。
但工具是否需要 approval,应该由工具执行边界统一决定。
更清晰的结构是:
AgentCore
-> ToolRegistry.run(tool, arguments, approval_handler)
-> risk check
-> approval request
-> handler or rejection
Tool Registry 仍然是最后一道门。
入口只提供一个 approval handler。
CLI 的 handler 可以同步问用户。
TUI 的 handler 可以在界面里等一个按键。
Gateway 的 handler 可以创建一个一次性 approval code。
Agent Core 不需要知道这些细节。
它只知道:工具执行成功、工具被拒绝、或者工具失败,都会变成 observation 回到模型。
这样边界就比较干净。
Agent Core 不负责 UI。
入口不负责判断工具风险。
Tool Registry 不负责怎么问用户。
每一层只做自己的事。
本地确认和远程确认不是同一种东西
CLI 里的 approval 很简单。
用户就在终端前。系统可以打印:
Tool: file.write
Risk: dangerous
Arguments: {"path": "...", "content": "..."}
Approve dangerous tool? [y/N]
用户输入 y 或 yes,这一次工具调用就执行。输入别的,或者 EOF,就拒绝。
TUI 也类似。
它只是把确认动作从一行输入变成界面状态。按 y 批准,其他键拒绝。确认结束后,界面状态要恢复,不能把整个 TUI 卡在 approval 状态里。
但 Telegram 这类远程入口不一样。
远程入口不能随便把下一条“yes”当成批准。
因为消息是异步的。
用户可能发了别的内容。
同一个 chat 里可能有不同命令。
一个长期运行的 gateway 也不能把危险工具调用卡在内存里,等未来某条消息回来。
所以远程 approval 更适合用一次性 code。
流程可以是:
模型请求 dangerous tool
-> gateway 创建 pending approval
-> 回复用户:工具名、风险、参数、/approve <code>
-> 当前工具调用先被拒绝为 observation
-> 用户发送 /approve <code>
-> gateway 校验 code 的平台、用户、session、过期时间
-> 执行 stored tool request 一次
这不是最完整的远程任务恢复系统。
它没有把原来的模型回合挂起,也没有在批准后自动恢复完整推理链。
但它有一个很重要的优点:边界清楚。
批准的是一个已经存下来的具体工具请求。
执行的是这个请求本身。
不是“未来一段时间都允许 file.write”。
也不是“这个用户说了 approve,所以模型接下来想做什么都可以”。
第一版做到这里,比做一个模糊的远程确认要安全得多。
用户批准前必须看到参数
approval 最大的陷阱,是只让用户批准工具名。
比如系统说:
Approve file.write?
这不够。
file.write 可以写一个临时测试文件,也可以覆盖项目配置。可以写一行日志,也可以写一大段用户没有看过的内容。只展示工具名,用户批准的其实不是一次行动,而是一个模糊类别。
真正的 approval 必须展示至少三件事:
Tool
Risk
Arguments
工具名告诉用户能力类型。
风险等级告诉用户这不是普通只读动作。
参数告诉用户这一次到底要做什么。
对远程入口尤其如此。
因为 Gateway 的回复通常会夹在模型生成的自然语言里。如果 approval code 周围没有明确参数,用户就会被要求批准一个由模型控制上下文描述的动作。
这会削弱 approval 的意义。
所以 approval prompt 不能只说:
/approve 123456
它应该接近:
Pending tool approval:
Tool: file.write
Risk: dangerous
Arguments: {"content": "...", "path": "..."}
Approve pending tool request with /approve 123456
这才是用户真正可以判断的东西。
Approval 结果也要保守
approval handler 的返回值也应该保守。
最宽松的写法是:
if approved:
run_tool()
但这个判断太松了。
如果 handler 不小心返回了一个非空字符串、一个对象、一个 metadata dict,Python 都会把它当成 truthy。
在普通业务逻辑里,这可能只是一个小 bug。
在 dangerous 工具边界上,这就是错误批准。
所以 approval 应该要求:
approved is True
只有明确布尔值 True 才执行。
其他任何返回值都拒绝。
同样,approval handler 抛异常也应该拒绝。
安全边界上,失败默认不应该变成允许。
批准的参数不能被偷换
还有一个更隐蔽的问题:参数可变性。
假设 registry 把原始 arguments 传给 approval handler。
handler 看到了:
{"path": "safe.txt"}
然后某个地方修改了这个 dict:
{"path": "danger.txt"}
如果工具最终拿到的是同一个可变对象,系统就变成了“批准 safe.txt,执行 danger.txt”。
这类问题不一定来自恶意。
它也可能来自调试代码、日志包装、UI 组件整理参数时的副作用。
但在 approval 边界上,结果一样严重。
所以工具执行前要把参数快照固定下来。
approval handler 看到的是一个副本。
工具真正执行的也是已经批准的快照。
而且不能只做浅拷贝。
因为参数里可能有嵌套 dict 或 list。
安全边界上,最好假设参数是复杂结构。
Approval code 必须有作用域
远程 approval code 不能只是一个全局数字。
如果 /approve 123456 可以被任何人、任何 session 消费,它就不是授权,而是一个临时密码泄漏点。
一次 pending approval 至少要绑定:
platform
user_id
session_id
tool_name
arguments
expires_at
consumed_at
平台用于区分 Telegram、Slack、Web 等入口。
用户用于保证批准者就是请求者。
session 用于避免跨会话批准。
工具名和参数用于保存这一次具体请求。
过期时间让 code 不会长期有效。
consumed_at 让 code 只能使用一次。
消费 code 时也要原子化。
不能先查“这个 code 还没用”,再单独更新“现在用掉了”。在并发情况下,两次请求可能都通过查询,然后都执行。
更稳的方式是用带条件的更新一次性 claim:
UPDATE pending_approvals
SET consumed_at = now
WHERE code = ?
AND platform = ?
AND user_id = ?
AND session_id = ?
AND expires_at >= now
AND consumed_at IS NULL
只有这一步真的更新到一行,才继续执行工具。
这就是 approval code 的单次消费语义。
Approval 不是 sandbox
做到 approval 以后,很容易产生一种错觉:危险工具已经安全了。
其实还没有。
approval 解决的是“用户是否明确同意这一次动作”。
sandbox 解决的是“即使同意了,这个动作最多能影响哪里”。
这两件事不一样。
用户批准写文件,不代表工具可以写系统任意路径。
用户批准执行命令,不代表命令可以访问所有环境变量、网络和文件系统。
用户批准浏览器操作,不代表它可以随便提交表单或下载文件。
approval 是人类意图边界。
sandbox 是执行环境边界。
一个完整的工具安全系统,至少需要两层:
approval: 这次动作是否被明确批准
sandbox: 被批准的动作能在什么范围内执行
第一版可以先做 approval。
但系统设计上必须给 sandbox 留位置。
否则 approval 很容易变成危险动作的万能通行证。
这一篇的结论
工具安全不是一个开关。
它不是简单地把 dangerous 工具打开或关掉。
一个能长期演进的 Agent,需要更细的工具安全语义。
风险等级负责分类。
Tool Registry 负责统一 gate。
本地入口负责同步确认。
远程入口负责生成有作用域的一次性 approval code。
approval prompt 必须展示工具名、风险和参数。
approval handler 必须 fail closed。
被批准的参数必须固定,不能在批准后被偷换。
code 消费必须有平台、用户、session、过期和单次执行约束。
而即使这些都做了,approval 也仍然不是 sandbox。
它只是让 Agent 从“完全不能做危险动作”,走到“可以在用户明确批准后做这一次具体动作”。
这一步很基础,也很关键。
因为当 Agent 真的开始行动时,系统最需要的不是胆子更大。
而是边界更清楚。
Discussion