背景:为什么需要这个脚本
上周 Meta+Sierra 的 PAP(Poppy)Draft 0.1 和 Decagon 的 PACT 1.0 同周发布。两份规范有一个共性:它们的检查项都是"可机械验证"的------字段在不在、域名对不对、有效期超没超、scope 有没有越界、receipt 缺不缺 claim。但规范文本是自然语言,实际集成时这些检查散在文档各处,非常容易漏。
更麻烦的是,这类检查漏掉不会报错。PAP 的 issuer 交叉校验如果被删掉,一切照常运行------直到有人拿一个伪造的域名指向你的真实登录服务器。而就在这个月,MCP Python SDK 刚因为一条"校验被跳过而不是失败"的回退路径,暴露了一个可以把 authorization code + client secret + PKCE verifier 一起外发的高危漏洞。
所以我写了一个离线审计器:把 PAP 发现文件、issuer 元数据交叉校验、PACT 委托令牌与 receipt 的检查项,全部固化成代码里的固定判定规则。核心设计原则只有一条:fail closed。
一、先把检查项列清楚(这就是规范给你的全部)
1. poppy.json 发现文件(Draft 0.1)
| 检查 | 规则 |
|---|---|
protocol_version |
必需;major.minor;主版本不支持 → 直接拒绝 |
organization |
必需;domain 必须与请求主机名一致(忽略 www.) |
auth |
条件必需(列出 agent/apis/web.browser_session_endpoint 时);issuer 必须 HTTPS |
custom_scopes |
不得占用保留前缀 poppy:(只有 poppy:read/poppy:write 合法) |
| 路由 | agent.protocols[].endpoint、apis[].url、browser_session_endpoint 一律 HTTPS |
apis[].type |
只能是 openapi 或 mcp |
extensions |
内置 operations 之外,第三方扩展名必须 域名/功能名 形式 |
2. issuer 元数据交叉校验(真正的信任锚)
| 检查 | 规则 |
|---|---|
| issuer 一致 | 元数据 issuer == poppy.json 的 auth.issuer(精确匹配) |
| 域名认领 | 元数据 poppy_domains 必须列出本域名 |
| 端点齐备 | 必须有 token_endpoint + revocation_endpoint;direct 登录需 authorization_endpoint;device 登录需 device_authorization_endpoint |
| 三份对齐 | 列出 MCP 接口时,issuer 需出现在 MCP 资源元数据的 authorization_servers 中 |
| 取不到 | 判为不通过(fail-closed) |
3. PACT 委托链与 receipt
| 检查 | 规则 |
|---|---|
| 平台身份 JWT | 有效期 ≤ 300s;建议公布 JWKS |
| 委托令牌 | 必须绑定 user / pa / brand / scopes;有效期 ≤ 3600s;无 jti 则无法跟踪重放(规范默认不跟踪) |
| user 标识 | 禁止从姓名/邮箱/手机号派生(含加盐哈希) |
| receipt | 必需 claim:grantId/user/pa/brand/scopesUsed/actions/ts |
| 越权检测 | scopesUsed 必须是授予 scope 的子集 |
argsHash |
缺失则只能证明"调用过",证不了"改了什么" |
| 留存方 | receipt 留存是 SHOULD,且落在个人 Agent 一侧------业务方要自己补台账 |
二、完整脚本
纯标准库、无网络依赖(把"取元数据"当作输入,因为决定 fail-closed 的应该是发起请求的那一层)。
python
# -*- coding: utf-8 -*-
"""
PAP/PACT 授权面审计器(纯标准库,可离线运行)
Part 1: poppy.json 静态校验(发现文件)
Part 2: issuer 元数据交叉校验(信任锚 / fail-closed)
Part 3: PACT 委托令牌与 receipt 校验(授权面 + 可验证同意)
"""
import json
import re
VIOL = []
def add(level, code, msg):
VIOL.append((level, code, msg))
# ---------------- Part 1 ----------------
SUPPORTED_MAJOR = "0" # Draft 0.1
RESERVED_PREFIX = "poppy:"
ALLOWED_RESERVED = {"poppy:read", "poppy:write"}
SIGNIN_TYPES = ("direct", "device", "mediated")
def _is_https(u):
return isinstance(u, str) and u.startswith("https://")
def check_poppy(doc, requested_domain):
"""按 Draft 0.1 应做的检查逐条执行。任何一项取不到 -> 记为失败(fail-closed)。"""
if not isinstance(doc, dict):
add("HIGH", "PAP-000", "发现文件无法解析为 JSON 对象")
return
# protocol_version
pv = doc.get("protocol_version")
if not pv:
add("HIGH", "PAP-001", "缺少 protocol_version(必需字段)")
elif not re.fullmatch(r"\d+\.\d+", str(pv)):
add("HIGH", "PAP-002", "protocol_version 不是 major.minor 格式")
elif str(pv).split(".")[0] != SUPPORTED_MAJOR:
add("HIGH", "PAP-003",
"主版本 %s 不受支持(本审计器只认 Draft %s.x)"
% (str(pv).split(".")[0], SUPPORTED_MAJOR))
# organization
org = doc.get("organization")
if not isinstance(org, dict) or not org.get("name") or not org.get("domain"):
add("HIGH", "PAP-004", "organization 缺 name 或 domain(必需字段)")
else:
got = str(org["domain"]).lower()
want = str(requested_domain).lower()
if got.startswith("www."):
got = got[4:]
if want.startswith("www."):
want = want[4:]
if got != want:
add("HIGH", "PAP-005",
"organization.domain(%s)与请求域名(%s)不一致 ------ 这份文件在替别的域名说话"
% (org["domain"], requested_domain))
# 三类接口至少有一类
has_agent = "agent" in doc
has_apis = "apis" in doc
web = doc.get("web") or {}
has_web = "web" in doc
if not (has_agent or has_apis or has_web):
add("HIGH", "PAP-006", "agent / apis / web 三类接口一个都没有(至少需其一)")
# auth 的触发条件
need_auth = has_agent or has_apis or bool(web.get("browser_session_endpoint"))
auth = doc.get("auth")
if need_auth and not isinstance(auth, dict):
add("HIGH", "PAP-007", "列出 agent/apis/browser_session 却缺 auth 块")
if isinstance(auth, dict):
if not _is_https(auth.get("issuer")):
add("HIGH", "PAP-008", "auth.issuer 缺失或不是 HTTPS")
custom = auth.get("custom_scopes") or {}
if not isinstance(custom, dict):
add("MEDIUM", "PAP-009", "custom_scopes 应为 名称->说明 的映射")
else:
for name in custom:
if name in ALLOWED_RESERVED:
continue
if name.startswith(RESERVED_PREFIX):
add("HIGH", "PAP-010",
"自定义 scope「%s」占用了保留前缀 %s" % (name, RESERVED_PREFIX))
# 路由一律 HTTPS
for p in (doc.get("agent") or {}).get("protocols", []) or []:
if not _is_https(p.get("endpoint")):
add("HIGH", "PAP-011", "agent.protocols 端点不是 HTTPS")
for api in doc.get("apis", []) or []:
if api.get("type") not in ("openapi", "mcp"):
add("MEDIUM", "PAP-012", "apis[].type 只能是 openapi 或 mcp")
if not _is_https(api.get("url")):
add("HIGH", "PAP-013", "apis[].url 不是 HTTPS")
if web.get("browser_session_endpoint") and not _is_https(web["browser_session_endpoint"]):
add("HIGH", "PAP-014", "web.browser_session_endpoint 不是 HTTPS")
# extensions 命名空间
for name in (doc.get("extensions") or {}):
if name == "operations":
continue
if "." not in name or "/" not in name:
add("MEDIUM", "PAP-015",
"扩展名「%s」未用 域名/功能名 形式(协议内置的 operations 除外)" % name)
# ---------------- Part 2 ----------------
def check_issuer(poppy_doc, meta, ):
"""issuer 元数据交叉校验。meta 为 None 表示取不到 ------ 必须 fail-closed。"""
auth = (poppy_doc or {}).get("auth") or {}
declared = auth.get("issuer")
org_domain = ((poppy_doc or {}).get("organization") or {}).get("domain")
if meta is None:
add("HIGH", "ISS-000", "取不到 issuer 元数据 -> fail-closed,判定为不通过"
"(不要写成 if meta: check(),那是一条静默放行通道)")
return
if meta.get("issuer") != declared:
add("HIGH", "ISS-001",
"元数据 issuer(%s)与 poppy.json 的 auth.issuer(%s)不一致 ------ 典型的混淆代理入口"
% (meta.get("issuer"), declared))
domains = meta.get("poppy_domains")
if domains is None:
add("HIGH", "ISS-002", "元数据缺 poppy_domains ------ 无法阻止别的域名冒名指向这个 issuer")
elif org_domain not in domains:
add("HIGH", "ISS-003",
"poppy_domains 未列出本域名(%s)------ 登录服务器不认领这份文件" % org_domain)
for ep in ("token_endpoint", "revocation_endpoint"):
if not _is_https(meta.get(ep)):
add("HIGH", "ISS-004", "元数据缺 %s(PAP 要求公布,缺了就无法撤销)" % ep)
if isinstance(auth.get("direct"), dict) and not _is_https(meta.get("authorization_endpoint")):
add("HIGH", "ISS-005", "提供 direct 登录却缺 authorization_endpoint")
if isinstance(auth.get("device"), dict) and not _is_https(meta.get("device_authorization_endpoint")):
add("HIGH", "ISS-006", "提供 device 登录却缺 device_authorization_endpoint")
# 列出 MCP 接口时,三份文档必须对齐
if any(a.get("type") == "mcp" for a in (poppy_doc.get("apis") or [])):
auth_servers = meta.get("authorization_servers") or []
if declared not in auth_servers:
add("HIGH", "ISS-007",
"有 MCP 接口,但 issuer 未出现在 MCP 资源元数据的 authorization_servers 中"
"(poppy.json / issuer 元数据 / MCP 资源元数据三份必须互相对得上)")
# ---------------- Part 3 ----------------
JWT_MAX = 300
DELEG_MAX = 3600
RECEIPT_REQ = ("grantId", "user", "pa", "brand", "scopesUsed", "actions", "ts")
def check_pact(grant, agent_jwt, delegation, receipt):
"""PACT 委托链与 receipt 校验。"""
# 平台身份 JWT
life = None
if not agent_jwt or "exp" not in agent_jwt or "iat" not in agent_jwt:
add("HIGH", "PACT-100", "平台身份 JWT 缺 exp / iat,无法判断有效期")
else:
life = agent_jwt["exp"] - agent_jwt["iat"]
if life > JWT_MAX:
add("HIGH", "PACT-101", "平台身份 JWT 有效期 %ss > %ss 上限" % (life, JWT_MAX))
if not agent_jwt.get("jwk_url"):
add("MEDIUM", "PACT-102", "未给出 JWKS 地址,签名无法独立复核")
# 委托令牌
if not delegation:
add("HIGH", "PACT-110", "没有委托令牌 ------ 只有平台身份,没有客户授权")
else:
bound = {k: delegation.get(k) for k in ("sub", "pa", "brand", "scopes")}
for k, v in bound.items():
if not v:
add("HIGH", "PACT-111", "委托令牌未绑定 %s" % k)
if delegation.get("exp_in", DELEG_MAX + 1) > DELEG_MAX:
add("HIGH", "PACT-112",
"委托令牌有效期 %ss > %ss 上限" % (delegation.get("exp_in"), DELEG_MAX))
if not delegation.get("jti"):
add("MEDIUM", "PACT-113",
"委托令牌缺 jti -> 无法跟踪重放(规范里 jti 是可选项,provider「无需」跟踪重放,"
"这一条只能你自己加)")
if delegation.get("sub", {}).get("kind") == "pii":
add("HIGH", "PACT-114",
"user 标识疑似由姓名/邮箱/手机号派生 ------ 规范禁止(含加盐哈希)")
# receipt
if not receipt:
add("HIGH", "PACT-120", "没有 receipt ------ 「客户同意了」目前只是聊天记录里的一句话")
return
for k in RECEIPT_REQ:
if k not in receipt:
add("HIGH", "PACT-121", "receipt 缺必需 claim:%s" % k)
granted = set(grant.get("scopes") or [])
used = set(receipt.get("scopesUsed") or [])
extra = used - granted
if extra:
add("HIGH", "PACT-122", "receipt 记录了未授权 scope:%s" % sorted(extra))
for i, act in enumerate(receipt.get("actions") or []):
if not act.get("argsHash"):
add("MEDIUM", "PACT-123",
"receipt.actions[%d] 无 argsHash ------ 只能证明「调用过」,证不了「改了什么」" % i)
if receipt.get("receipt_kept_by") != "business":
add("MEDIUM", "PACT-124",
"receipt 由个人 Agent 侧留存 ------ 而签字方(业务方)恰恰是规范没要求留存任何东西的一方")
# ---------------- 编排 ----------------
def run_case(title, poppy, meta, requested_domain, grant=None,
agent_jwt=None, delegation=None, receipt=None):
VIOL.clear()
print("=" * 68)
print("CASE:", title)
check_poppy(poppy, requested_domain)
check_issuer(poppy, meta)
if grant is not None:
check_pact(grant, agent_jwt, delegation, receipt)
if not VIOL:
print(" 通过:未发现问题")
else:
rank = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
for lv, code, msg in sorted(VIOL, key=lambda v: rank[v[0]]):
print(" [%-6s] %-9s %s" % (lv, code, msg))
hi = sum(1 for v in VIOL if v[0] == "HIGH")
print(" -> 共 %d 项问题(HIGH %d / MEDIUM %d)"
% (len(VIOL), hi, len(VIOL) - hi))
print()
GOOD = {
"protocol_version": "0.1",
"organization": {"name": "Northwind Outfitters", "domain": "northwind.example"},
"auth": {
"issuer": "https://auth.northwind.example",
"direct": {"scopes": ["poppy:read", "poppy:write", "accounts"]},
"device": {"scopes": ["poppy:read", "poppy:write"]},
"custom_scopes": {"accounts": "Manage saved shipping accounts"},
},
"agent": {"protocols": [{"type": "poppy",
"endpoint": "https://api.northwind.example/poppy/conversations"}]},
"web": {"browser_session_endpoint": "https://northwind.example/poppy/browser-session"},
"apis": [{"type": "mcp", "url": "https://mcp.northwind.example/mcp",
"description": "Product search and sizing"}],
"extensions": {"operations": {"version": "0.1"},
"northwind.example/gift-wrap": {"version": "0.1"}},
}
GOOD_META = {
"issuer": "https://auth.northwind.example",
"poppy_domains": ["northwind.example"],
"token_endpoint": "https://auth.northwind.example/token",
"revocation_endpoint": "https://auth.northwind.example/revoke",
"authorization_endpoint": "https://auth.northwind.example/authorize",
"device_authorization_endpoint": "https://auth.northwind.example/device",
"authorization_servers": ["https://auth.northwind.example"],
}
GRANT = {"scopes": ["poppy:read", "orders.read"]}
JWT_OK = {"iat": 1000, "exp": 1250, "jwk_url": "https://pa.example/jwks.json"}
DELEG_OK = {"sub": {"id": "opaque-9f2c", "kind": "opaque"},
"pa": "https://pa.example", "brand": "northwind.example",
"scopes": ["poppy:read", "orders.read"], "exp_in": 1800,
"jti": "d-7f31"}
RECEIPT_OK = {"grantId": "g-42", "user": "opaque-9f2c", "pa": "https://pa.example",
"brand": "northwind.example", "scopesUsed": ["orders.read"],
"actions": [{"name": "orders.read", "argsHash": "sha256:1a2b"}],
"ts": "2026-10-13T02:10:00Z", "receipt_kept_by": "business"}
# 1) 合规样本
run_case("合规样本(Draft 0.1 最小可用 + 完整委托链)",
GOOD, GOOD_META, "northwind.example", GRANT, JWT_OK, DELEG_OK, RECEIPT_OK)
# 2) 子域/品牌冒名 + 取不到 issuer 元数据
bad1 = json.loads(json.dumps(GOOD))
bad1["organization"]["domain"] = "northwind-global.example"
run_case("冒名样本:organization.domain 写成集团主域 + issuer 元数据取不到",
bad1, None, "northwind.example")
# 3) 保留前缀 + 软 404 + MCP 三份文档不对齐
bad2 = json.loads(json.dumps(GOOD))
bad2["auth"]["custom_scopes"] = {"poppy:refund": "退款", "gift-wrap": "礼品包装"}
bad2["apis"] = [{"type": "mcpx", "url": "http://mcp.northwind.example/mcp",
"description": "Product search"}]
bad2["extensions"] = {"giftwrap": {"version": "0.1"}}
bad2_meta = dict(GOOD_META)
bad2_meta["authorization_servers"] = ["https://other-idp.example"]
bad2_meta["poppy_domains"] = ["northwind.example", "sister-brand.example"]
run_case("错误样本:保留前缀被占用 / 路由非 HTTPS / 扩展命名空间 / MCP 三份不对齐",
bad2, bad2_meta, "northwind.example")
# 4) 委托链越权 + receipt 缺字段 + 无撤销端点
bad_deleg = {"sub": {"id": "zhang.san@northwind.example", "kind": "pii"},
"pa": "https://pa.example", "brand": "northwind.example",
"scopes": ["poppy:read", "orders.read", "orders.cancel"], "exp_in": 86400}
bad_receipt = {"grantId": "g-42", "user": "opaque-9f2c", "pa": "https://pa.example",
"brand": "northwind.example", "scopesUsed": ["poppy:read", "orders.cancel"],
"actions": [{"name": "orders.cancel"}], "ts": "2026-10-13T02:10:00Z"}
bad_jwt = {"iat": 1000, "exp": 1000 + 3600}
meta_no_revoke = dict(GOOD_META)
meta_no_revoke.pop("revocation_endpoint")
run_case("越权样本:委托令牌 24h + user 由邮箱派生 + receipt 用了未授权 scope + 无撤销端点",
GOOD, meta_no_revoke, "northwind.example", GRANT, bad_jwt, bad_deleg, bad_receipt)
print("=" * 68)
print("审计完成:共 4 组样本。判定规则固定为「取不到即不通过」。")
三、实机运行结果(Python 3.13.12,两次运行 diff 完全一致)
ini
====================================================================
CASE: 合规样本(Draft 0.1 最小可用 + 完整委托链)
通过:未发现问题
====================================================================
CASE: 冒名样本:organization.domain 写成集团主域 + issuer 元数据取不到
[HIGH ] PAP-005 organization.domain(northwind-global.example)与请求域名(northwind.example)不一致 ------ 这份文件在替别的域名说话
[HIGH ] ISS-000 取不到 issuer 元数据 -> fail-closed,判定为不通过(不要写成 if meta: check(),那是一条静默放行通道)
-> 共 2 项问题(HIGH 2 / MEDIUM 0)
====================================================================
CASE: 错误样本:保留前缀被占用 / 路由非 HTTPS / 扩展命名空间 / MCP 三份不对齐
[HIGH ] PAP-010 自定义 scope「poppy:refund」占用了保留前缀 poppy:
[HIGH ] PAP-013 apis[].url 不是 HTTPS
[MEDIUM] PAP-012 apis[].type 只能是 openapi 或 mcp
[MEDIUM] PAP-015 扩展名「giftwrap」未用 域名/功能名 形式(协议内置的 operations 除外)
-> 共 4 项问题(HIGH 2 / MEDIUM 2)
====================================================================
CASE: 越权样本:委托令牌 24h + user 由邮箱派生 + receipt 用了未授权 scope + 无撤销端点
[HIGH ] ISS-004 元数据缺 revocation_endpoint(PAP 要求公布,缺了就无法撤销)
[HIGH ] PACT-101 平台身份 JWT 有效期 3600s > 300s 上限
[HIGH ] PACT-112 委托令牌有效期 86400s > 3600s 上限
[HIGH ] PACT-114 user 标识疑似由姓名/邮箱/手机号派生 ------ 规范禁止(含加盐哈希)
[HIGH ] PACT-122 receipt 记录了未授权 scope:['orders.cancel']
[MEDIUM] PACT-102 未给出 JWKS 地址,签名无法独立复核
[MEDIUM] PACT-113 委托令牌缺 jti -> 无法跟踪重放(规范里 jti 是可选项,provider「无需」跟踪重放,这一条只能你自己加)
[MEDIUM] PACT-123 receipt.actions[0] 无 argsHash ------ 只能证明「调用过」,证不了「改了什么」
[MEDIUM] PACT-124 receipt 由个人 Agent 侧留存 ------ 而签字方(业务方)恰恰是规范没要求留存任何东西的一方
-> 共 9 项问题(HIGH 5 / MEDIUM 4)
====================================================================
审计完成:共 4 组样本。判定规则固定为「取不到即不通过」。
四、三条真实踩坑(都是设计过程中撞出来的)
1. if meta: 就是一条静默放行通道(最重要的坑)
第一版 check_issuer 我本能地写成了:
python
def check_issuer(poppy_doc, meta):
if meta: # ← 这里就是坑
if meta.get("issuer") != declared: ...
if org_domain not in meta.get("poppy_domains", []): ...
看起来没问题------有元数据才检查,没元数据就跳过。但"取不到元数据"恰恰是攻击者最容易制造的状态:让发现请求返回 404、超时、或者一份畸形 JSON,这条守卫就放行了。
这和本月的 MCP Python SDK 漏洞是同一个结构 :那条回退路径上 issuer 校验只在"已经存在授权服务器 URL"时执行,回退时该值为空 → 校验被跳过而不是失败。正确写法是把"取不到"变成一条显式的失败:
python
if meta is None:
add("HIGH", "ISS-000", "取不到 issuer 元数据 -> fail-closed,判定为不通过")
return
经验:任何"如果拿得到就校验"的守卫,都要先问一句"拿不到时会发生什么"。答案必须是"拒绝",不能是"继续"。
2. 保留前缀检查:天真的 startswith 会误伤两个合法 scope
第二版我写成了:
python
if name.startswith("poppy:"):
add("HIGH", "PAP-010", ...)
结果是合规样本里的 poppy:read 和 poppy:write 被自己判成了违规 。规范里 poppy: 是保留前缀,但恰好这两个词是合法的------所以判定要先排除白名单:
python
ALLOWED_RESERVED = {"poppy:read", "poppy:write"}
...
for name in custom:
if name in ALLOWED_RESERVED:
continue
if name.startswith(RESERVED_PREFIX):
add("HIGH", "PAP-010", ...)
经验:凡是"禁止某前缀"的规则,务必先把该前缀下被显式允许的例外列出来。否则你的校验器会在第一份合规样本上自我打脸。
3. 缺失有效期要按"超限"处理,不能按"不适用"处理
委托令牌的默认值我一开始想写成 delegation.get("exp_in", 0)------"没写有效期就当它没问题"。这又是一个 fail-open。最后改成:
python
if delegation.get("exp_in", DELEG_MAX + 1) > DELEG_MAX:
没写有效期 → 用一个必然越界的默认值 → 直接判不通过。 同样思路用在 jti、argsHash、receipt_kept_by 上:缺字段本身就是一个发现,而不是"规范没要求所以忽略"。
五、这个脚本明确的局限(别把它当全部)
- 它不联网 。
meta是作为参数传进来的。这是刻意的------决定 fail-closed 的那一行代码,必须住在真正发起请求、真正感知到"取不到"的那一层。审计器负责的是"拿到之后怎么判"。 - 它不解析签名 。JWT 只检查
exp/iat/是否给了 JWKS,不做真正的验签。生产里必须用成熟的 JWT 库,并且算法白名单 要对(不能只信 token 自己声明的alg)。 - 它不覆盖 scope 语义 。规范里
poppy:write的"做变更"是一个类别,不是一个契约------含义由每个公司自己定义。脚本只能查"用了没授权的 scope",查不出"授权的那个 scope 是不是太宽"。 - 它不替你决定撤销 。PACT 没有撤销端点,脚本只能把"缺
revocation_endpoint"报出来。撤销流程要你自己设计,包括"撤销时派生的凭据、缓存、委托状态要不要一起作废"。
一句话:这个脚本能替你干掉 90% 的机械检查,但"该不该给这个 Agent 写权限"永远是你的决定。
六、抄作业清单(PR 描述可以直接贴)
- 所有校验函数入口先做"输入是否存在"的 fail-closed 断言,禁止
if x:式守卫。 organization.domain与请求主机名做归一化后精确比较(统一去www.、统一小写)。- 保留前缀类规则必须先声明白名单,再判违规。
- 所有"缺字段"的默认值一律取"必然越界"的值,而不是 0 / 空 / None。
- 有效期上限写成常量(
JWT_MAX=300、DELEG_MAX=3600)且集中定义,便于随规范改版统一调整。 scopesUsed ⊆ granted必须作为断言,而不是日志。argsHash缺失要有独立告警码------它是"证明确改了什么"的唯一抓手。- receipt 的留存方与留存期要写进设计文档,并明确由业务方补齐(规范只给了 SHOULD,且在个人 Agent 一侧)。
- 列出 MCP 接口的项目,必须同时校验三份文档的 issuer 一致性(poppy.json / issuer 元数据 / MCP 资源元数据)。
- 所有校验输出都带稳定错误码(
PAP-xxx/ISS-xxx/PACT-xxx),方便进 CI 做基线对比。 - 校验器自带多组样本(合规 / 冒名 / 字段错误 / 越权),并断言样本 1 必须零问题------这是校验器自身的回归测试。
小结
写完这个脚本最大的收获不是代码,是它把两份规范里最容易被忽略的一句话变成了固定的判定规则:取不到即不通过。
PAP 给了你发现文件,PACT 给了你 receipt;但把"信任锚""fail-closed""台账"这三件事补上的,从来是工程侧,不是协议侧。
协议定义线格式,你定义边界。
域名与数据均为虚构示意;涉及漏洞的部分基于公开安全公告,不含任何可复现的操作细节。