ARTICLE · 1155402
让 AI 碰生产数据库之前,先装这两道闸门
全文约 2439 字 · 预计阅读 6 分钟
AI 安全领域没什么新东西。授权要人点头,点头要防伪造——这就是最小权限和防抵赖两条老原则,套在 Agent 身上。Vercel AI SDK(基于 ai@7.0.47 源码,截至 2026 年 8 月的 7.x 版本)把它们做成了两道闸门:toolApproval 管「这个操作必须人来批」,HMAC 签名管「批了的东西不许被偷换」。这篇把两道闸门拆开,配置抄走就能用。
你在地铁上,Agent 想删库

场景很简单。你给 Agent 接了生产数据库的工具,它跑着跑着决定调用 deleteRecords。这个决定是模型自己做的,没人复核。
AI SDK 的处理方式是暂停。generateText 检测到这个工具调用需要审批,就生成一个带签名的 tool-approval-request 发给你的前端,整个流程停在那儿,等你点头。你在地铁上掏出手机,看到「Agent 想执行 deleteRecords」的弹窗,点批准或拒绝,响应传回服务端,流程继续。
这个设计把「删不删」的决定权从模型手里拿走,交回给人。注意暂停发生的位置——工具还没执行,参数已经确定,模型没法在等待期间偷改主意。审批批的是一份固定的操作快照,不是一句模糊的「可以删」。
但新的问题立刻出现:审批请求在网络上跑了一个来回,而前端是暴露给任何人的。凭服务端一句话,凭什么信送回来的「批准」没被人动过手脚?
这就需要两道闸门,一道管点头,一道管防伪造。
第一道闸:审批接进循环

第一道闸是 toolApproval,挂在 generateText 这一级,不写在工具定义里。它有两种形态。
最直接的是对象 map,按工具名静态声明:
需要动态判断时,把整个 toolApproval 换成函数,拿到工具名和实际参数后自己决策:
判断顺序有三层:全局函数在前,逐工具配置在后,最后还有工具定义上的老参数 needsApproval 兜底(已废弃但眼下仍生效),三层都没命中才是 not-applicable,直接执行。每层能返回的审批状态一共四个——user-approval(暂停等人)、approved(放行)、denied(拒绝但流程继续)、not-applicable(不需要审批)。
approved 和 not-applicable 都会执行工具,区别在于留没留痕:前者是一份明确的批准记录,会写进审批响应,后者连记录都没有。生产环境里建议宁可多留痕——出了事故,审计日志里能查到「谁在什么时候放行的」,比「默认不拦」值钱得多。返回对象时还能带上 reason 字段,把「值班经理批准」「合规检查通过」这类上下文一起记下来。
顺带交代一个容易混淆的写法:你可能见过 tool({ needsApproval: true })——老参数,官方已标记废弃、建议迁移到 toolApproval,但它眼下仍然生效:工具定义上标了它,就算 toolApproval 没提这个工具,也会走人工审批。新代码一律写 toolApproval,别再往工具定义上挂。
落到配置上就是给工具分三类:必须人批的、自动放行的、不用管的。第一道闸装好了,接下来解决「点头」本身的可信问题。
第二道闸:HMAC 签名防伪造

审批请求离开服务端,就进入了不受信任的区域。攻击者能打开 DevTools 改 POST body,能重放请求,能替换字段。服务端需要一个手段,把「我发出的审批请求」和「我收回的审批响应」钉死在同一个内容上。
AI SDK 的答案是 HMAC-SHA256 签名。你给 generateText 传一个 experimental_toolApprovalSecret,服务端在产生审批请求时,就对四个字段计算签名:approvalId、toolCallId、toolName、input(打包时再垫一个协议版本前缀)。任何一个字段被改,签名就对不上。
签名流程分四步,全部走 Web Crypto API:
第①步里的 extractable: false 值得留意——密钥对象创建后就导不出裸 secret,就算同进程里有别的代码想把它抄走,拿到的也只是一个能签能验的句柄。
第④步是关键:验签不在前端,而在服务端收到审批响应之后、执行工具之前。服务端把收到的字段重新算一遍签名,和送回来的那份对比——字段动过手脚,两个值就对不上。
不匹配就抛 InvalidToolApprovalSignatureError,工具不执行。
整条生命周期串下来:服务端产生审批请求时算好签名,签名随请求发到前端;用户批准后响应发回;服务端收到响应,先收集并建立请求、响应、工具调用三者的关联,再对每个「已批准」重新验签,全部通过才执行。
签名和验证都在服务端这一侧完成,中间的网络和前端从头到尾不被信任。
没配 secret 时系统照常工作,只是不签名不验签——这是个渐进增强的开关。但只要你让用户在弹窗上批准删除操作,这个开关就该开着。
签名的地基:先让 JSON 长出一张确定的脸

上面有个细节没展开:input 是 JSON 对象,而 JSON 序列化不保证键的顺序。同一个对象,服务端签名时序列化成 {"a":1,"b":2},经过前端一遭再送回来,键序可能就变成了 {"b":2,"a":1}——直接拿字符串哈希,同一份内容会算出两个不同的摘要,正经批准也会被当成伪造拒掉。
canonicalJSON 解决的就是这件事:所有键按字典序排列,递归标准化,再拼接成固定格式的字符串。
这样不管键序怎么排,同一个对象永远产出同一个字符串——签名方和验签方各自独立序列化,算出来的摘要也对得上。待签数据还打包成 JSON 数组而非旧版的换行符分隔——换行符如果出现在字段内部,解析时字段会错位(所谓 retupling collision),JSON.stringify 会转义换行符,问题从根上消失。
收集审批响应时还有一层工程细节:索引用 Object.create(null) 建无原型 map,防止 ID 恰好是 toString、__proto__ 这类原型属性名;每个审批响应必须双向关联到真实存在的请求和工具调用,缺一边就抛错,伪造和孤立响应都进不来。
攻击链模拟:toolName 偷换,签名当场抓住

现在把两道闸门串起来,模拟一次真实的攻击。
用户在前端看到弹窗「Agent 想执行 readRecords」,点了批准。攻击者中途截获送回服务端的审批响应,连同它关联的操作数据一起改:toolName 从 readRecords 换成 deleteRecords,approvalId 保持真值原样送回。
没有签名的话,这招是致命的——「用户批准过」的响应是真的,approvalId 也对得上,服务端照着送回的数据执行,而数据已经被换成了删除操作。
有签名的世界里,这一步走不下去。服务端拿改动后的字段重新算一遍签名(就是上面第④步那套),而响应里带的还是当初针对 readRecords 算出的那份。两个值一对比,不匹配,InvalidToolApprovalSignatureError 抛出,删除动作根本到不了数据库。
攻击还有更隐蔽的变体:不动 toolName,只改 input,比如往 readRecords 的参数里塞一段越权的查询条件。同样的下场——input 参与 canonicalJSON 哈希,参数变一个字符,摘要就完全不同,签名照样对不上。四个被签名的字段里没有漏网的。
攻击者为什么不能自己算一个新签名?因为签名需要 secret,而 secret 只存在于服务端,HMAC 密钥还设了不可导出。他手里只有带签名的请求,改一个字节都过不了验。
抄走就能用

最小可用配置就这两段,对着贴。
多租户场景再加一条纪律:每个租户独立 secret。租户 A 的签名拿到租户 B 去验,必然失败,跨租户重放审批的路也被堵死。
secret 从密钥管理服务读取,定期轮换,别 hardcode。SDK 没有内置审批过期时间,而 toolApproval 函数是发起审批那一刻跑的,查不了「拖了多久」。想管过期得自己动手:审批请求发给前端时在你的代码里记一笔时间,工具执行前对一对,隔太久的直接拒绝——审批拖到第二天才被批准,本身就可疑。
回头看开头的主张。这两道闸门没有发明任何新东西——toolApproval 是最小权限,HMAC 签名是防抵赖,都是几十年前就写进教科书的原则。新的只是场景:第一次有一个非人类的角色,会自己决定去碰你的生产数据库。老原则配上合适的工程实现,就够了。
上一篇《不改 SDK 一行代码,照样改掉 AI 的调用行为》给调用加了中间层。到这里,这个系列四篇凑齐了一台完整的 Agent:发动机、刹车、扩展点,加上这一篇的安全网——装齐了,才敢让它上路。
如果这篇帮你把两道闸门装上了,转发给正在写 Agent 的同事,或者点个赞和在看。整个系列的完整代码都在 Vercel AI SDK 仓库里,读源码永远比读文章可靠,我们下个系列见。