让Agent审批绑定实际执行参数
文章用退款示例说明,单一批准标记无法防止审批后替换执行目标,并提供可独立测试的Python样例。方案将服务端审批记录绑定租户、操作、订单、金额等字段,拒绝未知参数,并明确摘要指纹不代表身份认证。
AI 深度解读
Agent 的审批流程有个容易漏掉的漏洞:人批准给 order-17 退款,执行前目标却被改成 order-18,系统里的 approved 仍然是 true。审批通过了,实际执行的动作却已经变了。
作者给出的办法,是让服务端把审批绑定到一份明确的动作快照,并在执行前重新核对。他用一个不访问网络的 Python 示例验证判断逻辑,同时划清边界:审批有效,不等于只能执行一次,也不等于订单状态仍然允许执行。这几个区别,值得做 Agent 工具调用的程序员认真拆开看。
人点下同意时,到底批准了哪些字段
一个布尔值只能表达「曾经同意过」,无法回答「同意的是哪件事」。如果审批之后,Agent 还能修改订单、金额或者租户,执行器就需要判断:眼前这份请求,还是当时审核的那份吗?
作者选择了六个明确字段:tenant、operation、target、amount_minor、currency 和 request_id,分别覆盖租户、操作、订单、最小货币单位金额、币种及请求身份。示例只接受 refund 和 USD,金额必须是正整数。
这里还有个容易被忽略的选择:拒绝未知字段。如果以后适配器新增了 destination,而审批检查仍只比较旧字段,新参数就可能绕过审核。严格限定字段集合,可以让这种变化直接失败,迫使开发者同步修改审批规则。
接着,程序把动作序列化,再用 sha256 生成内容指纹。字典键的排列不影响结果,重要字段的值发生变化则无法匹配原审批。
指纹只能证明内容是否一致,不能证明谁批准了它。
审批记录必须由可信服务端在真实审核完成后创建,并保存在 Agent 和浏览器无法替换的位置。否则,调用方连动作和指纹一起改掉,比较仍然会通过。
执行前的判断,为什么必须留在服务端
下面这段代码解决的是:根据当前租户、有效期、审核人当前权限及动作内容,决定审批是否仍然有效。
from dataclasses import dataclass
from hashlib import sha256
import json
import unittest
FIELDS = {"tenant", "operation", "target", "amount_minor", "currency", "request_id"}
def fingerprint(action):
if not isinstance(action, dict) or set(action) != FIELDS:
raise ValueError("Unexpected action shape")
if action["operation"] != "refund" or action["currency"] != "USD":
raise ValueError("Unsupported action")
if type(action["amount_minor"]) is not int or action["amount_minor"] <= 0:
raise ValueError("Use a positive integer amount in minor units")
for name in ("tenant", "target", "request_id"):
if not isinstance(action[name], str) or not action[name].strip():
raise ValueError("Missing action identity")
encoded = json.dumps(action, sort_keys=True, separators=(",", ":"),
ensure_ascii=True, allow_nan=False).encode("utf-8")
return sha256(encoded).hexdigest()
@dataclass(frozen=True)
class Approval:
tenant: str
reviewer: str
action_digest: str
expires_at: int
def permits(approval, action, *, current_tenant, now, reviewer_allowed):
# Approval and identity inputs must come from trusted server-side systems.
if not isinstance(approval, Approval) or type(now) is not int:
return False
if now >= approval.expires_at or current_tenant != approval.tenant:
return False
if not reviewer_allowed(approval.reviewer, current_tenant):
return False
try:
return (action["tenant"] == current_tenant
and fingerprint(action) == approval.action_digest)
except (KeyError, TypeError, ValueError):
return False
重点看最后的 fingerprint(action) == approval.action_digest:它比较的是当前动作与审批记录里的指纹。前面的权限、租户和过期检查,也都必须通过。
两个细节很实用。now >= approval.expires_at 表示到期瞬间就失效;type(action["amount_minor"]) is not int 则会拒绝布尔金额,因为 Python 的 bool 是 int 的子类。
这段函数不能直接包装成一个允许调用方提交 Approval 对象的接口,也不能让调用方自行声明 reviewer_allowed 的结果。身份、审批记录与权限判断都要来自可信服务端系统。提示模型「操作前务必小心」,替代不了这条执行检查。
作者另外提供了九项测试,将两段代码放入同一文件后运行 python approval_fixture.py -v。测试使用合成数据,作者报告本地全部通过。
通过的情况是动作内容相同、字典键顺序不同。拒绝的情况包括:金额从 2500 改为 2600,订单从 order-17 改为 order-18,租户从 tenant-a 改为 tenant-b,以及到期、审核人权限被撤销、出现未知字段、金额为布尔值和缺少审批。
这些结果证明了函数在指定输入下如何判断。它们没有证明实际部署的身份系统、数据库或支付适配器提供了可信输入。
审批有效,为什么仍可能重复执行或执行错状态
连续调用两次 permits,两次都可能得到 True。函数没有消耗审批,也没有占用请求,更没有阻止两个 worker 同时退款。
因此,执行器还需要持久化请求状态,并以能够抵御并发竞争的方式进入执行阶段。审批快照应保持不变,交给适配器的也必须是刚刚验证过的那组字段。如果校验指纹之后,又让模型重新生成支付请求,前面的绑定就断了。
另一个问题是业务状态。订单快照可以一字未改,但底层订单或账户已经发生变化。如果审批中包含业务状态版本,就需要在合适的事务边界检查版本,不能只依赖对话早期的判断。
作者还提到 Stripe 的幂等机制:请求键与参数比较可以帮助处理重复请求,但无法证明这些参数经过人工授权。审批检查与幂等控制各自解决不同问题,需要同时考虑。
超时也不能简单当作失败。请求可能已经到达支付服务,只是响应没有回来。此时应检查持久化的执行尝试和服务商支持的状态查询,再决定下一步,不能为了清空队列就创建一个新的已批准动作。
审计记录则应串起审核快照、审批身份、校验决定、执行尝试和确认结果,同时减少日志中的私密载荷,并限制记录访问权限。
我的判断
我认同作者把控制点放在「即将执行的动作」上。与单独保存 approved=true 相比,它让授权范围变得可比较;与提示词约束相比,它能由应用代码独立执行和测试。
但示例的前提很窄。它只覆盖 USD 退款,真实适配器仍需要自己的字段定义、服务商校验、账户权限和业务规则。严格拒绝未知字段有助于暴露遗漏,也要求团队在新增执行参数时同步维护审批协议。
九项本地测试是明确的实测结果,却不能当作完整系统的安全证明。原文没有给出线上对照数据,也没实现数据库事务、并发执行和超时恢复。对于这些环节,作者提出的是工程建议,仍需集成验证。
更有价值的下一步,是作者建议的可暂停适配器:在审批与执行之间暂停,修改动作、撤销权限,或者让两个 worker 竞争,再同时检查应用决定与外部副作用。只有检查到最终是否发生退款,才能验证整条链路。
能拿来做什么
- 如果你在做 Agent 操作确认页,让用户看到的订单、金额、币种与请求身份对应同一份服务端快照,执行时根据这份记录重新校验。
- 如果工具执行器使用 Node,可以迁移字段白名单、稳定序列化和内容指纹这套思路,并针对运行时重新验证类型检查,尤其是金额和未知字段。
- 给现有审批流程补上动作变更、过期边界和权限撤销测试,再增加两个 worker 竞争执行的集成测试,观察外部副作用是否符合预期。
- 为可能产生实际后果的调用保存执行尝试与结果状态。遇到超时,先核实已有请求的状态,再决定是否重试,并结合服务商支持的幂等机制处理重复请求。