可复用 vs 一次性:给 Agent 的写操作加一道"改一个数字就失效"的闸门

1 阅读12分钟

本文是「Agent 身份与授权层」系列的周末动手篇。前两天我们在掘金讨论过凭证最小化与越权检测点,今天不谈架构,直接给一份能跑、能抄、能进 CI 的实现:把"用户批准的那一笔"变成一次不可复用的授权。

环境:Python 3.13,纯标准库,无第三方依赖。文中所有输出均为本机实机运行结果。


0. 先说结论

生产里 90% 的"Agent 越权"事故,根因不是权限给多了,而是授权被做成了可复用的。

一个 Agent 握着 access token,这个 token 在这一小时内可以被调一万次;你给它加了"单笔退款不超过 50",它照样能连发 500 笔。日志里留下的是:

actor: svc-agent  action: refund  amount: 400

而审计员真正要问的是:是谁、在哪一秒、同意了 400 这个数字? 这条日志答不出来。

本文要解决的问题就一句话:

让"授权"绑定到具体参数、具体时刻、具体一次调用上——改一个数字,签名就不成立。


1. 原理:三层不能混

先把概念钉住,否则代码一定写歪。

层回答什么典型载体生命周期
身份(Identity)我是谁SVID / mTLS 证书 / 工作负载 token长(小时~月)
授权(Authorization)我能碰什么scope / RBAC / 策略判决中(会话~任务)
意图(Intent)这一次我同意了什么参数绑定签名 + 一次性 nonce短(秒~分钟)

常见的错误是把三层折叠成一层:拿一个 scope 为 refund:write 的 token,当成"用户同意退 400 元给这个订单"。前者是能力,后者是事实,中间隔着一整层举证。

MCP 社区最近有个说法我很认同:MCP 对"授权"几乎没有定义,默认形态是二元的——连上 server 就能调它所有工具,数据库工具里 execute_query 和 drop_table 并排摆着,"全开"和"全关"之间没有中间档。所以这层必须自己补,而且必须补在模型外面:

你告诉模型"你只能读不能删",那不是授权,是建议。判断要不要遵守的那个模型,正是攻击者试图用提示注入操纵的同一个模型。

所以正确的形状是:意图由用户签,判决在网关做,Agent 只负责搬运。


2. 实现:一个 200 行的意图闸门

2.1 结构

用户签署 ──> IntentToken{intent_id, agent, user, tool, args_hash, amount, exp, nonce, sig}
                                    │
Agent 调用 ──> Gate.authorize(intent, actual_args, now)
                 ├─ (1) 验签        INTENT_TAMPERED
                 ├─ (2) 过期        EXPIRED
                 ├─ (3) 工具白名单   TOOL_NOT_ALLOWED   ← default deny
                 ├─ (4) 参数绑定     INTENT_MISMATCH   ← 核心
                 ├─ (5) 单笔上限     OVER_PER_CALL
                 └─ (6) 重放+累计    REPLAY / OVER_DAILY_CAP  ← 原子

判定顺序不是随便排的:先证真,再谈权限。签名不过的请求,连"它想调什么工具"都不该被采信。

2.2 完整代码

# intent_gate.py  —— 纯标准库,Python 3.13
import hashlib
import hmac
import json
import threading
import time
from decimal import Decimal, ROUND_HALF_UP

SIGNING_KEY = b"key-lives-in-gateway-only"   # 只在网关进程内,Agent 永不持有

# 字段级 schema:不猜类型,只认声明过的字段
SCHEMA = {
    "amount": "money",
    "currency": "currency",
    "order_id": "token",
}


class Unschemed(Exception):
    pass


# ---------- 1. 规范化:签名之前必须先做,否则「签名通过」是假象 ----------
def _scalar(key, v):
    kind = SCHEMA.get(key)
    if kind is None:
        raise Unschemed(key)          # 未声明的字段 = 未经批准的维度,直接拒
    if kind == "money":
        return str(Decimal(str(v)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
    if kind == "currency":
        return str(v).strip().upper()
    return str(v).strip()


def canon(args):
    def walk(node, key=None):
        if isinstance(node, dict):
            return {k: walk(node[k], k) for k in sorted(node)}
        if isinstance(node, list):
            return [walk(x, key) for x in node]
        return _scalar(key, node)

    return json.dumps(walk(args), ensure_ascii=False, separators=(",", ":"))


def args_hash(args):
    return hashlib.sha256(canon(args).encode("utf-8")).hexdigest()[:16]


# ---------- 2. 签名与校验 ----------
def sign(it):
    body = "|".join(
        [
            it["intent_id"], it["agent"], it["user"], it["tool"],
            it["args_hash"], it["amount"], str(it["exp"]), it["nonce"],
        ]
    )
    return hmac.new(SIGNING_KEY, body.encode("utf-8"), hashlib.sha256).hexdigest()[:16]


def issue(it):
    it = dict(it)
    it["args_hash"] = args_hash(it["args"])
    it["sig"] = sign(it)
    return it


# ---------- 3. 策略:默认拒绝 ----------
POLICY = {
    "default": "deny",
    "tools": {
        "read_order": {"risk": "read"},
        "create_refund": {"risk": "financial", "max_per_call": "50.00", "daily_cap": "100.00"},
    },
}


class Gate:
    def __init__(self):
        self.spent = {}
        self.seen_nonce = set()
        self.lock = threading.Lock()

    def authorize(self, intent, actual_args, now):
        # (1) 签名
        if intent.get("sig") != sign(intent):
            return "DENY", "INTENT_TAMPERED", "令牌字段被改动"

        # (2) 过期
        if now > intent["exp"]:
            return "DENY", "EXPIRED", f"过期 {now - intent['exp']}s"

        # (3) 工具白名单(default: deny)
        rule = POLICY["tools"].get(intent["tool"])
        if rule is None:
            return "DENY", "TOOL_NOT_ALLOWED", "策略未声明该工具"

        # (4) 参数绑定
        try:
            actual = args_hash(actual_args)
        except Unschemed as e:
            return "DENY", "UNSCHEMED_FIELD", f"未声明字段 {e}"
        if actual != intent["args_hash"]:
            return "DENY", "INTENT_MISMATCH", "实际参数与已授权条款不一致"

        # (5) 单笔上限
        amt = Decimal(str(actual_args.get("amount", "0")))
        if "max_per_call" in rule and amt > Decimal(rule["max_per_call"]):
            return "DENY", "OVER_PER_CALL", f"{amt} > {rule['max_per_call']}"

        # (6) 重放 + 累计额度:必须原子完成(此处用锁模拟 Redis Lua / 行锁)
        with self.lock:
            if intent["nonce"] in self.seen_nonce:
                return "DENY", "REPLAY", "同一意图被重复提交"
            key = (intent["user"], intent["tool"])
            spent = self.spent.get(key, Decimal("0"))
            if "daily_cap" in rule and spent + amt > Decimal(rule["daily_cap"]):
                return "DENY", "OVER_DAILY_CAP", f"已用 {spent} + {amt} > {rule['daily_cap']}"
            self.seen_nonce.add(intent["nonce"])
            self.spent[key] = spent + amt
            left = Decimal(rule["daily_cap"]) - self.spent[key]
            return "ALLOW", "OK", f"当日剩余额度 {left}"

2.3 实机输出

七个用例,一次跑完,两次运行 diff 一致:

==========================================================================
一次性意图授权闸门 · 七项判定
==========================================================================
A 正常:批准 18 / 执行 18                ALLOW OK                当日剩余额度 82.00
B 改金额:批准 18 / 执行 400              DENY  INTENT_MISMATCH   实际参数与已授权条款不一致
C 重放:同一意图再提交                      DENY  REPLAY            同一意图被重复提交
D 累计:3×45(日上限 100)                ALLOW/OK → ALLOW/OK → DENY/OVER_DAILY_CAP
E 未声明工具 delete_customer           DENY  TOOL_NOT_ALLOWED  策略未声明该工具
F 过期:ttl=-1s                      DENY  EXPIRED           过期 1s
G 塞入未声明字段 note                    DENY  UNSCHEMED_FIELD   未声明字段 note
--------------------------------------------------------------------------
18 == "18.0" == " 18.00 "                   True
18.004 与 18.00 同哈希(四舍五入到分)              True
18.005 与 18.01 同哈希(边界进位)                  True
"cny" 与 "CNY" 同哈希(币种归一)                   True
--------------------------------------------------------------------------
凭据类型                               复用窗口(h)     可重放          每日可被重用次数
生产环境 API Key(无过期)                     8760         是             400
卡号代扣令牌(24 个月)                        17520         是              12
用户 refresh token(30 天)                 720         是              60
会话令牌(1 小时)                               1         是              30
任务级委托令牌(10 分钟)                        0.17         否               1
一次性意图令牌(120 秒)                        0.03         否               1
--------------------------------------------------------------------------
复用窗口倍数:生产 API Key / 一次性意图令牌 = 292,000x

B 那一行是整个设计的意义:用户批准 18,Agent 想执行 400,中间没有任何"模型判断"的余地——哈希对不上,直接 DENY,理由还能落进审计。


3. 踩坑记录(都是写这段代码时真踩到的)

坑 1:规范化按 Python 类型判断 = 签名形同虚设

第一版我这么写的:

def norm(v):
    if isinstance(v, (int, float, Decimal)):
        return str(Decimal(str(v)).quantize(Decimal("0.01")))
    return str(v).strip()

跑测试时 args_hash({"amount": 18}) 和 args_hash({"amount": "18.0"}) 不相等。原因是 "18.0" 是 str,根本没进 Decimal 分支。

这不是"细节没处理",这是两条路径的输入类型不同:用户签署页面拿到的可能是 18.00(字符串,来自表单),Agent 执行时拿到的是 18.0(字符串,来自 JSON)或 18(数字,来自 SDK 序列化)。后果两种:

  • 严格实现:所有合法请求被误拒(INTENT_MISMATCH 满屏),上线当天回滚;
  • "宽容"实现:有人在比对前临时 float() 一下——归一化就成了绕过点(18.0000001 与 18 通过)。

正解是字段级 schema:不猜类型,只认声明过的字段和它的语义。amount 是 money,就永远走 Decimal 定点量化;currency 归一大小写;order_id 只 strip,绝不吃前导零——订单号 0018 和 18 是两个订单。上面输出里那四行 True 就是修好之后的结果。

坑 2:未声明的字段不是"忽略",是"拒绝"

SCHEMA.get(key) 返回 None 时,raise Unschemed 是故意的。

如果你只对已知字段做哈希、未知字段直接丢弃,攻击者加一个 "note": "加急,全额退" 或 "scope": "all",签名照样通过——因为你的哈希从来没覆盖它。等下游某个服务把 note 当真了,你的签名就成了背锅的装饰品。

未声明的字段 = 未经批准的维度。默认拒绝,而不是默认忽略。

(这也解释了为什么"参数过滤不是文本搜索问题":黑名单会漏掉编码变体、别名、路径归一化、嵌套输入、重定向和语义等价操作。正确做法是解析结构、规范化取值、只放行已知安全范围、拒绝歧义。)

坑 3:累计额度"先查后扣"= 并发下必然超发

# 错误写法
spent = self.spent.get(key, 0)
if spent + amt > cap:
    return "DENY"
self.spent[key] = spent + amt          # ← 两个请求可能同时走到这里

两个请求同时读到 spent=100、都判断"100+45 没超"、都扣 —— 额度直接作废。我的演示里用一个进程内的 threading.Lock 兜住,生产上要么 Redis Lua 脚本、要么数据库行锁 / 条件更新(UPDATE ... WHERE spent + ? <= cap)。

这里还有一个更隐蔽的推论:额度检查不能放在 Agent 侧。Agent 侧预估的额度和网关侧实际的扣减是两个不同的真相,前者可以被提示注入影响,后者不能。

坑 4:签名密钥留在 Agent 手里 = 授权退化成自我声明

为了"方便",把 issue() 直接放进 Agent 的代码里调用——那一刻起,Agent 可以给自己签任何意图。密钥必须住在能看见真实参数、且不可被模型影响的那一层(网关 / Broker)。判断标准很简单:这段代码所在的进程,能不能被 prompt 影响? 能,就不能持有密钥。

坑 5:宽工具会复现你所有被禁掉的能力

这条不是代码坑,是设计坑,但杀伤力最大。

你的 allowlist 写得很漂亮:只放了 read_order 和 create_refund。但如果你还放了一个 http_request(url, method, body) 或者 run_sql(query),那所有被禁掉的能力都能从这一个工具里长回来——它就是一把万能钥匙,你的 allowlist 只是换了个更窄的门框。

同理还有"间接绕行":Agent 访问不到外网,于是它去找一个能替它抓外网的公开工具。这不是理论,是真实事件里被记录过的行为模式。所以网关必须做的两件事:① 每个工具的能力面要单独评估(接受任意 URL / 命令 / 嵌套工具标识的,一律按"高危宽工具"处理);② 出网白名单要在网络层做,不能指望工具层。


4. 上生产前还要补的四件事

这份实现是"最小可用正确形状",不是"生产就绪"。差的部分老实列出来:

缺口现在生产该怎么做
nonce 存储进程内 setRedis(TTL = token 有效期),多副本共享
额度扣减进程内锁Redis Lua / DB 行锁,原子条件更新
签名算法HMAC-SHA256 截断 16 hex非对称签名(ES256/RS256)+ JWKS,便于第三方验证
撤销无撤销列表 + 短 TTL 双保险(短 TTL 是主,撤销列表是辅)
审计返回错误码结构化落库:intent_id / actor / acting_on_behalf_of / policy_version / decision / reason_code / ts

顺便说一句审计字段:acting_on_behalf_of 和 intent_id 这两个字段,是把"流水"变成"账本"的开关。 缺了它们,你能查出"谁干的",查不出"谁同意的"。


5. 可直接抄的 PR 清单(11 项)

  1. 新增 intent_gate.py,把 POLICY 从硬编码挪到配置文件 / 配置中心。
  2. SCHEMA 必须覆盖所有会被签名的字段;未声明字段 fail closed。
  3. nonce 存储换 Redis,key 用 intent:{intent_id},TTL 略大于 token TTL。
  4. 额度扣减改成原子操作(Lua 或 WHERE 条件更新),补并发测试。
  5. 签名密钥从代码挪到 KMS / Secret Manager,进程启动时拉取,日志里禁止打印。
  6. 增加 MAX_TTL 常量(比如 120s),签发时校验,不止在执行时校验。
  7. 审计落地:intent_id、acting_on_behalf_of、policy_version、reason_code、ts 五个字段必填。
  8. 给 INTENT_MISMATCH 单独配告警——它意味着"批准的"和"要执行的"不一致,这是最值得看的一条。
  9. 网关侧加"宽工具"清单:接受任意 URL / 命令 / SQL / 嵌套工具标识的工具,默认不进 allowlist。
  10. 出网白名单在网络层(Security Group / egress proxy),不依赖应用层判断。
  11. 演练:全环境只读开关,记录"下指令 → 无进一步外部写入"的 p95,目标 < 5s。

6. 最后

放两个数字在这里,作为"现在就该动手"的理由:

  • 154 vs 38:IETF 126 之后 agentproto 工作组得以成立,154 人支持成立,只有 38 人认可章程范围。连"agent identity 指什么"都还没共识。而最接近统一答案的 ID-JAG,还是 Internet-Draft,-04 版 2026-11-22 过期。
  • 150,000 vs 13%:Gartner 预测 2028 年平均每家财富 500 强要管 15 万个 Agent;而只有 13% 的组织认为自己具备必要的治理能力。

标准在争定义,Agent 已经在生产里跑了。所以别等 RFC——default deny 的策略、参数绑定的签名、一次性的凭证、留得住的日志,这四样无论在 OAuth 2.1、ID-JAG 还是未来的 AIMS 里,都长得差不多。

合规说明:本文所有内容仅作原理与工程实践说明,不包含任何绕过鉴权、审计或隔离的操作性内容。文中厂商与机构数据(Gartner 预测、CERT-In 建议、x401/ACE 设计描述)均已标注来源与口径,引用不等于背书。