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