Agent 安全防哪几类风险:微软 AI Agent 入门课第 18 课的 verify_chain

2026-08-18

ai-agents-for-beginners 的第 18 课 18-securing-ai-agents 时,我一开始以为又是一份「Agent 安全十条」。读完 README 才发现它切的角度挺窄:整课只围绕一个提问打转——审计员来了,你把日志交出去,他问「我怎么知道这些日志没被编辑过」,你答不上来。

README 把常见的三种做法都点了名:应用自己写的日志,任何有文件系统权限的人都能改;云日志服务在平台层是防篡改的,但前提是审计员愿意相信平台运营方;数据库事务日志适合记数据库变更,不适合记任意的 tool 调用。三条的共同问题是都要求审计员先信任某个人。这一课给出的替代方案是密码学收据:审计员只需要公钥和收据本身。

所以这一课设防的对象不是模型被骗,而是记录本身不可信。README 自己划了界:输入校验是第 6 课的事,收据在输入校验的下游,不是它的替代品。下面按课程代码里真正写出拒绝逻辑的顺序,把风险和缓解动作一条条对上。

一、字段被改:签名的字节范围要抠死

第一类风险最直白——有人事后把 policy_id 改成一个更宽松的策略名。缓解动作在 code_samples/18-signed-receipts.ipynbsign_receiptverify_receipt 这对函数里。签名侧只有两步:

canonical = canonicalize(payload)
signature_bytes = signing_key.sign(canonical).signature

canonicalize 来自 jcs 包,做的是 RFC 8785 的 JSON 规范化。README 说明了它为什么必要:不做规范化,不同的 JSON 序列化实现对同一份逻辑内容会产出不同字节,签名也就对不上。

验证侧的关键那行是重建被签名的载荷:

payload = {k: v for k, v in receipt.items() if k != "signature"}

也就是说 signature 这个对象本身不在签名字节里,它是事后附上去的。这个「签名对象在签名范围之外」的约定,两个 notebook 是一致的。

真正值得注意的是 notebook 里那个负向控制。它在验证成功之后,又用 hashlib.sha256(canonicalize(payload)).digest() 重新签了一次,组装成 prehashed_receipt 再验一遍,期望结果是 False。代码上方的注释写明这是对签名范围的回归控制:签在 SHA-256(JCS(payload)) 上的收据,签的是另一串字节,不该通过验证;同一节的说明文字给出了原因——PureEdDSA 会在内部做哈希,外面再预哈希一次等于改了协议。这一步把「签名范围」这条规则从散文变成了可执行的断言——如果你要自己实现一版,先跑这个负向控制。

二、整条被删或被重排:链要自带位置

改字段能被签名挡住,整条抽走却不会。缓解动作是 previous_receipt_hash 字段加上 verify_chain 的三项检查。主 notebook 里的 verify_chain(chain: list) 对每条收据算三个布尔量:signature_valid(签名过不过)、chain_link_validprevious_receipt_hash 是否等于前一条的 receipt_hash,genesis 那条要求为 None)、sequence_validreceipt["sequence"] == i,即序号必须等于它在链里的零基下标),三者与起来才是 overall_valid

receipt_hash 的口径要看清:它算的是完整收据(含 signature 对象)的 JCS 规范化字节的 SHA-256,和 sha256_canonical 用的是同一套规范化,只是喂进去的对象不同。这个区分在你自己实现时特别容易错位。

notebook 第 3 节的演示是改掉中间那条的 tool_args_hash。它给出的结果解释也写得直白:第 0 条仍然有效,第 1 条挂在签名上,第 2 条挂在链接上——因为第 2 条的 previous_receipt_hash 是对着修改前的第 1 条算的。要把删改藏住,攻击者得从改动点往后每条重签一遍,那需要私钥。

三、公钥从哪来:两个 notebook 的口径不一样

这一处是我读下来最想提醒的。主 notebook 的 verify_receipt 是从收据里拿公钥的:

verify_key = signing.VerifyKey(b64url_decode(sig_obj["public_key"]))

而后续 notebook code_samples/human-authorization-receipts.ipynb 把这一步换了。它的 verify_envelope 不读收据里的公钥,而是用 signature.key_id 去一份钉死的登记表里查:

pub = trusted_keys.get(sig_obj.get("key_id"))
if pub is None:
    return (False, f"stale authority: key_id {sig_obj.get('key_id')!r} is not in the pinned registry (unknown or rotated out)")

这不是我拼出来的对比,notebook 自己在开头写明了这是相对主 notebook 里那个演示验证器的一处刻意升级,理由也写了:读收据里自带的公钥,等于让攻击者自带密钥就能过;改成钉死登记表,伪造才会变成一次拒绝。仓库里 APPROVER_KEYSAGENT_KEYS 是两份独立的登记表,审批人和 Agent 用同一套信封格式、同一段验证代码,但权限来源始终分开。

如果你打算把这一课的代码往真实环境里搬,这两处的差别就是「教学演示」和「可用姿势」的分界线。

四、批准的和执行的不是同一件事

后续 notebook 加了 human.approval.v1 这种收据,用来补上主 notebook 缺的那半句:收据只能说「这把密钥签了这段内容」,说不了「某个人批准了这件事」。

它防的两类风险,代码里各有各的拒绝理由。一类是 confused deputy 与篡改:拿着对动作 A 的有效审批去执行动作 B。缓解动作是 verify_chain 里那个 join——先算 executing_digest = sha256_canonical(action_being_executed),然后要求审批收据与 Agent 收据的 action_digest action 全对象都等于当下正在执行的这个动作,不匹配就返回 digest substitution 开头的理由。另一类是 Agent 收据不挂在这次审批上,检查是 agent_rcpt.get("parent_approval_ref") != receipt_hash(approval)

顺带一个容易忽略的细节:human_approvalagent_receipt 都对传入的动作做了 copy.deepcopy。注释解释了原因——收据必须是被批准内容的不可变快照,留一个活引用的话,之后修改 action 会悄悄改掉签名载荷所对应的对象。

五、签名还有效,授权已经不算数

这一类风险 notebook 单独拎出来叫 stale authority,判据是「签名验得过,但权威已经不成立」。verify_chain 的第三步里三种情况各有各的拒绝理由:policy_version 与当前的 CURRENT_POLICY["policy_version"] 不一致;expires_at 已经早于执行时刻;以及上一节说的 key_id 被轮转出登记表。notebook 的措辞是权威在执行时刻判定,不在签名时刻判定。

过期检查那行的写法值得看一眼:

if not isinstance(expires, str) or not expires or now >= expires:

now 是 ISO-8601 的 Zulu 字符串,verify_chain 的 docstring 自述 Zulu 字符串直接按字符串比较是正确的。反过来说,这条检查是建立在时间戳格式统一的前提上的——你要是在自己的实现里混进带时区偏移的时间串,这行就不成立了。

六、重放与畸形输入

重放的缓解动作很轻:模块级的 _consumed 集合记下已经用掉的 receipt_hash(approval),命中就返回 approval already consumed (replay refused)。一份审批只授权一次执行。

畸形输入这一类,verify_envelope 的处理方式是拒绝而不是崩溃。开头先 isinstance 卡住「不是带 signature 对象的字典」,异常捕获则是一整组:

    except (CryptoError, TypeError, ValueError, base64.binascii.Error):
        return (False, "signature invalid (forged, tampered, or malformed)")

注释写明捕 CryptoError 这个基类是有意的:它同时覆盖 BadSignatureError 和 PyNaCl 对长度不对的签名抛出的 ValueError。notebook 的失败样例里就专门放了一个 base64 合法但长度不对的 sig,以及一个直接传列表当收据的用例,期望都是干净拒绝。

收据明确不证明什么

README 把这一节称作全课最重要的部分,我照实转述四条:不证明动作正确(错答案照样能被干净地签名)、不证明 policy_id 里那个策略真的被评估过(收据记的是声称,不是执行)、不证明密钥背后是哪个人(那要另一套身份基础设施)、不证明输入是真的(Agent 被操纵的提示带偏后,收据会忠实地把这个动作记下来)。README 有一句说得挺重:把「我们有收据」当成「我们已经受治理」是常见错误。

还有两条边界必须照实标出来。其一,这一课的扁平收据与 IETF 草案 draft-farley-acta-signed-receipts{payload, signature} 信封形状不同,README 明说不作为符合该草案的实现来呈现。其二,human.approval.v1 是这一课自己定义的教学性组合,不是那份草案定义的收据类型。后续 notebook 也交代了它同样不证明的东西:审批界面给人看到的是不是他以为自己在签的内容(WYSIWYS 是另一个问题)、密钥在轮转前有没有被胁迫或窃取。

动手前的两句实务提醒

依赖在 18-securing-ai-agents/code_samples/requirements.txt 里,只有 Ed25519 签名用的 pynacl、规范化用的 jcs,外加一个 ipykernel——注释写明它是给默认没装内核的环境准备的。主 notebook 的安装格是 %pip install -q pynacl jcs;后续 notebook 那句 %pip install pynacl jcs 在仓库里是被注释掉的,上面写着「这些已经是第 18 课的依赖,没有新包」——所以别指望单独打开后续 notebook 就能自动装好环境。仓库里没有针对操作系统的分支说明;Windows 上如果你不在 notebook 里装、而是自己建虚拟环境跑,ipykernel 那条最容易漏掉,这属于通用做法提醒,不是课程官方内容。

另外,主 notebook 第 4 节的 ReceiptedTool 是个框架无关的包装类,__call__ 里先调原函数、再把 argskwargs 一起放进 tool_args 去算规范化哈希,最后把新收据挂到自己的链上。它与 Microsoft Agent Framework 的对接在 notebook 里是以注释形式给出的伪代码示意,涉及 agent_framework.foundryFoundryChatClient——请注意那一段在仓库里就是被注释掉的示意,不是可直接运行的代码,示例中的环境变量名与端点也需要按你自己的项目替换,密钥一律走 <YOUR_API_KEY> 这类占位而不要写进源码。

该项目持续更新,上面涉及的文件路径、函数名与字段名随版本变动,请以仓库最新内容为准。


本文依据 github.com/microsoft/ai-agents-for-beginners 仓库于 2026-08-18 的公开内容整理, 事实来自仓库内的课程正文与代码示例。我们没有跑过文中涉及的代码, 因此不涉及运行结果、耗时与 Agent 实际表现的任何描述。 该课程持续更新,文中涉及的文件路径、依赖与接口写法随版本变动,请以仓库最新内容为准。 文中涉及的云端服务调用会产生费用并可能上传数据,请自行评估密钥与数据边界。

安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。