夜雨聆风学习资料网

ARTICLE · 1155402

让 AI 碰生产数据库之前,先装这两道闸门

让 AI 碰生产数据库之前,先装这两道闸门
全文约 2439 字 · 预计阅读 6 分钟

AI 安全领域没什么新东西。授权要人点头,点头要防伪造——这就是最小权限和防抵赖两条老原则,套在 Agent 身上。Vercel AI SDK(基于 ai@7.0.47 源码,截至 2026 年 8 月的 7.x 版本)把它们做成了两道闸门:toolApproval 管「这个操作必须人来批」,HMAC 签名管「批了的东西不许被偷换」。这篇把两道闸门拆开,配置抄走就能用。

你在地铁上,Agent 想删库

场景:你在地铁上,Agent 想删库——审批批的是一份固定的操作快照

场景很简单。你给 Agent 接了生产数据库的工具,它跑着跑着决定调用 deleteRecords。这个决定是模型自己做的,没人复核。

AI SDK 的处理方式是暂停。generateText 检测到这个工具调用需要审批,就生成一个带签名的 tool-approval-request 发给你的前端,整个流程停在那儿,等你点头。你在地铁上掏出手机,看到「Agent 想执行 deleteRecords」的弹窗,点批准或拒绝,响应传回服务端,流程继续。

这个设计把「删不删」的决定权从模型手里拿走,交回给人。注意暂停发生的位置——工具还没执行,参数已经确定,模型没法在等待期间偷改主意。审批批的是一份固定的操作快照,不是一句模糊的「可以删」。

但新的问题立刻出现:审批请求在网络上跑了一个来回,而前端是暴露给任何人的。凭服务端一句话,凭什么信送回来的「批准」没被人动过手脚?

这就需要两道闸门,一道管点头,一道管防伪造。

第一道闸:审批接进循环

第一道闸 toolApproval:审批顺序,函数在前、逐工具 map 在后、needsApproval 兜底

第一道闸是 toolApproval,挂在 generateText 这一级,不写在工具定义里。它有两种形态。

最直接的是对象 map,按工具名静态声明:

1  const result = awaitgenerateText({
2    model: openai('gpt-4o'),
3    messages,
4    tools: { readRecords, deleteRecords },
5    toolApproval: {
6      readRecords: { type: 'approved' },       // 读操作自动批
7      deleteRecords: { type: 'user-approval' }, // 删操作必须人点头
8    },
9  });

需要动态判断时,把整个 toolApproval 换成函数,拿到工具名和实际参数后自己决策:

1  toolApproval: async ({ toolCall }) => {
2  // 只有永久删除才走审批,软删除直接放行
3  if (toolCall.toolName === 'deleteRecords' && toolCall.input.permanent) {
4  return { type: 'user-approval' };
5    }
6  return { type: 'approved', reason: '低风险操作自动放行' };
7  },

判断顺序有三层:全局函数在前,逐工具配置在后,最后还有工具定义上的老参数 needsApproval 兜底(已废弃但眼下仍生效),三层都没命中才是 not-applicable,直接执行。每层能返回的审批状态一共四个——user-approval(暂停等人)、approved(放行)、denied(拒绝但流程继续)、not-applicable(不需要审批)。

approved 和 not-applicable 都会执行工具,区别在于留没留痕:前者是一份明确的批准记录,会写进审批响应,后者连记录都没有。生产环境里建议宁可多留痕——出了事故,审计日志里能查到「谁在什么时候放行的」,比「默认不拦」值钱得多。返回对象时还能带上 reason 字段,把「值班经理批准」「合规检查通过」这类上下文一起记下来。

顺带交代一个容易混淆的写法:你可能见过 tool({ needsApproval: true })——老参数,官方已标记废弃、建议迁移到 toolApproval,但它眼下仍然生效:工具定义上标了它,就算 toolApproval 没提这个工具,也会走人工审批。新代码一律写 toolApproval,别再往工具定义上挂。

落到配置上就是给工具分三类:必须人批的、自动放行的、不用管的。第一道闸装好了,接下来解决「点头」本身的可信问题。

第二道闸:HMAC 签名防伪造

第二道闸 HMAC:签名钉死工具名、参数、审批身份,改一个字段就对不上

审批请求离开服务端,就进入了不受信任的区域。攻击者能打开 DevTools 改 POST body,能重放请求,能替换字段。服务端需要一个手段,把「我发出的审批请求」和「我收回的审批响应」钉死在同一个内容上。

AI SDK 的答案是 HMAC-SHA256 签名。你给 generateText 传一个 experimental_toolApprovalSecret,服务端在产生审批请求时,就对四个字段计算签名:approvalId、toolCallId、toolName、input(打包时再垫一个协议版本前缀)。任何一个字段被改,签名就对不上。

签名流程分四步,全部走 Web Crypto API:

1  // ① secret 变成不可导出的 HMAC 密钥
2  const key = await crypto.subtle.importKey(
3  'raw', encoder.encode(secret),
4    { name: 'HMAC', hash: 'SHA-256' },
5  false,             // extractable: false,JS 层面导不出去
6    ['sign', 'verify'] // 只授予签名和验签权限
7  );
8  
9  // ② 打包待签数据:版本标识+四字段,共五元素的 JSON 数组
10  const payload = encoder.encode(JSON.stringify([
11  'ai-sdk-tool-approval-v1', // 版本标识,以后改格式有依据
12    approvalId,
13    toolCallId,
14    toolName,
15    inputDigest,               // input 先做 canonicalJSON 再 SHA-256
16  ]));
17  
18  // ③ 签名
19  const sig = await crypto.subtle.sign('HMAC', key, payload);
20  
21  // ④ 用户批准后,服务端重新计算并对比
22  const valid = await crypto.subtle.verify('HMAC', key, sigBytes, payload);

第①步里的 extractable: false 值得留意——密钥对象创建后就导不出裸 secret,就算同进程里有别的代码想把它抄走,拿到的也只是一个能签能验的句柄。

第④步是关键:验签不在前端,而在服务端收到审批响应之后、执行工具之前。服务端把收到的字段重新算一遍签名,和送回来的那份对比——字段动过手脚,两个值就对不上。

不匹配就抛 InvalidToolApprovalSignatureError,工具不执行。

整条生命周期串下来:服务端产生审批请求时算好签名,签名随请求发到前端;用户批准后响应发回;服务端收到响应,先收集并建立请求、响应、工具调用三者的关联,再对每个「已批准」重新验签,全部通过才执行。

签名和验证都在服务端这一侧完成,中间的网络和前端从头到尾不被信任。

没配 secret 时系统照常工作,只是不签名不验签——这是个渐进增强的开关。但只要你让用户在弹窗上批准删除操作,这个开关就该开着。

签名的地基:先让 JSON 长出一张确定的脸

canonicalJSON:让 JSON 长出确定的脸,同义不同形算同一个

上面有个细节没展开:input 是 JSON 对象,而 JSON 序列化不保证键的顺序。同一个对象,服务端签名时序列化成 {"a":1,"b":2},经过前端一遭再送回来,键序可能就变成了 {"b":2,"a":1}——直接拿字符串哈希,同一份内容会算出两个不同的摘要,正经批准也会被当成伪造拒掉。

canonicalJSON 解决的就是这件事:所有键按字典序排列,递归标准化,再拼接成固定格式的字符串。

1  functioncanonicalJSON(value) {
2  if (value === null || typeof value !== 'object') return JSON.stringify(value);
3  if (Array.isArray(value)) return`[${value.map(canonicalJSON).join(',')}]`;
4  const keys = Object.keys(value).sort(); // 键序确定化
5  return `{${keys.map((k) =>
6  `${JSON.stringify(k)}:${canonicalJSON(value[k])}`
7    ).join(',')}}`;
8  }

这样不管键序怎么排,同一个对象永远产出同一个字符串——签名方和验签方各自独立序列化,算出来的摘要也对得上。待签数据还打包成 JSON 数组而非旧版的换行符分隔——换行符如果出现在字段内部,解析时字段会错位(所谓 retupling collision),JSON.stringify 会转义换行符,问题从根上消失。

收集审批响应时还有一层工程细节:索引用 Object.create(null) 建无原型 map,防止 ID 恰好是 toString、__proto__ 这类原型属性名;每个审批响应必须双向关联到真实存在的请求和工具调用,缺一边就抛错,伪造和孤立响应都进不来。

攻击链模拟:toolName 偷换,签名当场抓住

攻击链模拟:toolName 从 readRecords 偷换成 deleteRecords,验签失败当场拒绝

现在把两道闸门串起来,模拟一次真实的攻击。

用户在前端看到弹窗「Agent 想执行 readRecords」,点了批准。攻击者中途截获送回服务端的审批响应,连同它关联的操作数据一起改:toolName 从 readRecords 换成 deleteRecords,approvalId 保持真值原样送回。

没有签名的话,这招是致命的——「用户批准过」的响应是真的,approvalId 也对得上,服务端照着送回的数据执行,而数据已经被换成了删除操作。

有签名的世界里,这一步走不下去。服务端拿改动后的字段重新算一遍签名(就是上面第④步那套),而响应里带的还是当初针对 readRecords 算出的那份。两个值一对比,不匹配,InvalidToolApprovalSignatureError 抛出,删除动作根本到不了数据库。

攻击还有更隐蔽的变体:不动 toolName,只改 input,比如往 readRecords 的参数里塞一段越权的查询条件。同样的下场——input 参与 canonicalJSON 哈希,参数变一个字符,摘要就完全不同,签名照样对不上。四个被签名的字段里没有漏网的。

攻击者为什么不能自己算一个新签名?因为签名需要 secret,而 secret 只存在于服务端,HMAC 密钥还设了不可导出。他手里只有带签名的请求,改一个字节都过不了验。

抄走就能用

系列四篇收官:发动机、刹车、扩展点、安全网

最小可用配置就这两段,对着贴。

1  import { generateText, tool } from'ai';
2  import { openai } from'@ai-sdk/openai';
3  import { z } from'zod';
4  
5  const deleteRecords = tool({
6    description: '删除数据库记录',
7    inputSchema: z.object({        // v5 起叫 inputSchema,不是 parameters
8      table: z.string(),
9      id: z.string(),
10      permanent: z.boolean(),
11    }),
12    execute: async ({ table, id }) => {
13  await db.delete(table, id);
14  return { success: true };
15    },
16  });
17  
18  const result = awaitgenerateText({
19    model: openai('gpt-4o'),
20    messages,
21    tools: { readRecords, deleteRecords },
22    toolApproval: {
23      deleteRecords: { type: 'user-approval' }, // 第一道闸:删操作必须人点头
24    },
25    experimental_toolApprovalSecret: process.env.TOOL_APPROVAL_SECRET, // 第二道闸
26  });

多租户场景再加一条纪律:每个租户独立 secret。租户 A 的签名拿到租户 B 去验,必然失败,跨租户重放审批的路也被堵死。

secret 从密钥管理服务读取,定期轮换,别 hardcode。SDK 没有内置审批过期时间,而 toolApproval 函数是发起审批那一刻跑的,查不了「拖了多久」。想管过期得自己动手:审批请求发给前端时在你的代码里记一笔时间,工具执行前对一对,隔太久的直接拒绝——审批拖到第二天才被批准,本身就可疑。

回头看开头的主张。这两道闸门没有发明任何新东西——toolApproval 是最小权限,HMAC 签名是防抵赖,都是几十年前就写进教科书的原则。新的只是场景:第一次有一个非人类的角色,会自己决定去碰你的生产数据库。老原则配上合适的工程实现,就够了。

上一篇《不改 SDK 一行代码,照样改掉 AI 的调用行为》给调用加了中间层。到这里,这个系列四篇凑齐了一台完整的 Agent:发动机、刹车、扩展点,加上这一篇的安全网——装齐了,才敢让它上路。

如果这篇帮你把两道闸门装上了,转发给正在写 Agent 的同事,或者点个赞和在看。整个系列的完整代码都在 Vercel AI SDK 仓库里,读源码永远比读文章可靠,我们下个系列见。

相关学习资料