本周在写跨组织 Agent 协作,昨天铺全景,今天只拆一张卡。协议细节以 A2A 1.0 为准,代码纯标准库、不联网、可直接进 CI。
先说结论
A2A 的 Agent Card 是自我声明的。客户端唯一的保证是:我从我打算去的那个域名,取到了这份描述。
这句话不等于「没得验」。它意味着可验的东西只有三类:
- 结构对不对(字段齐全、版本一致、地址可路由)
- 来源有没有变(签名头是否可定位信任锚、快照 digest 是否一致)
- 内容有没有夹带(描述里有没有面向 LLM 的指令)
至于「技能是不是真的」「拿到任务会不会乱来」——协议不回答,验签也不回答。JWS 认证身份,不证明能力,这一点在 A2ABreak 那篇分析里写得很直白。
所以下面这个工具不叫「信任评分」,叫体检。它只输出「哪几项不合规」,不输出「能不能用」。
卡片字段:先分清契约和广告
| 类别 | 字段 | 错了的后果 |
|---|---|---|
| 契约 | supportedInterfaces(url / protocolBinding / protocolVersion)、securitySchemes、输入输出模式、capabilities 布尔位 | 404、method-not-found、凭据取错、客户端在流式路径挂住 |
| 广告 | name、description、provider、iconUrl、documentationUrl、skills[].tags | 不报错,但人和 Agent 都会选错对象;描述本身是注入面 |
| 凭证 | signatures[] | 前两类失去归因能力 |
capabilities 归契约,是因为规范明确写了客户端会先探测再调用。streaming: true 而没有可用的 SendStreamingMessage,不是夸大,是违约。
11 条规则怎么来的
每条规则对应一个我见过的真实故障形态,不是凭感觉凑数。
| 编号 | 检查什么 | 为什么 | 级别 |
|---|---|---|---|
| CARD-001 | 有没有 supportedInterfaces | 1.0 客户端靠它选绑定与版本,缺了只能猜 | HIGH |
| CARD-002 | 每个接口有没有声明 protocolVersion | 版本缺失时客户端按 0.3 处理,行为会和你的实现错位 | MED |
| CARD-003 | 接口 URL 是否 https | 明文传输时,卡片内容和任务内容都可被中间人看 | HIGH |
| CARD-004 | URL 是否指向不可路由地址 | 0.0.0.0 / localhost / 私网,本地永不复现,线上必挂 | HIGH |
| CARD-005 | 顶层 url/protocolVersion 与 supportedInterfaces 是否一致 | 双写不同步,两代客户端各打各的,日志上都「成功」 | MED |
| CARD-006 | 有没有 signatures | 没有就只能算自我声明 | MED |
| CARD-007 | 签名头里有没有 alg 和 keyid | 没有 keyid,验签方不知道该信任哪把钥匙 | MED |
| CARD-008 | 文本字段里有没有指令性短语 | 卡片描述会被模型当上下文读,这是注入面 | HIGH |
| CARD-009 | 卡片 digest 是否与固定值一致 | 卡片会被改(技能增删、端点迁移),没有固定值就没有「它变了」这个信号 | HIGH |
| CARD-010 | securitySchemes 里有没有明文凭据 | 卡片是声明,不是钱包 | HIGH |
| CARD-011 | skills 是否为空 | 空技能表让客户端只能盲调 | MED |
规则只有 11 条,是因为再往下就需要联网(验签要拉 JWKS/DID 文档)或实跑(要发一次 SendMessage)。这两件事不该塞进一个静态检查器。
完整代码(186 行,纯标准库)
#!/usr/bin/env python3
"""card_lint.py - A2A Agent Card 最小体检(纯标准库 / 不联网 / 可进 CI)
用法:
python card_lint.py card.json
python card_lint.py card.json --pinned pinned.json
python card_lint.py card.json --pin # 只打印 digest,用于生成 pinned.json
退出码: 0 = PASS/WARN, 1 = FAIL(存在 HIGH)
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import json
import sys
from urllib.parse import urlparse
INJECT_MARKERS = ("ignore previous", "ignore all previous", "disregard previous",
"system:", "you must", "exfiltrate", "do not tell")
LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1", "0.0.0.0"}
SECRET_KEYS = ('"value"', '"secret"', '"password"')
def canon(obj) -> bytes:
"""RFC 8785 的够用版:键排序 + 紧凑分隔符 + 不转义中文。"""
return json.dumps(obj, sort_keys=True, separators=(",", ":"),
ensure_ascii=False).encode("utf-8")
def digest(card: dict) -> str:
return "sha256:" + hashlib.sha256(canon(card)).hexdigest()
def private_host(host: str) -> bool:
if host in LOCAL_HOSTS:
return True
parts = host.split(".")
if len(parts) != 4 or not all(p.isdigit() for p in parts):
return False
a, b = int(parts[0]), int(parts[1])
if a in (10, 127):
return True
if a == 192 and b == 168:
return True
if a == 172 and 16 <= b <= 31: # 172.16.0.0/12 只看第二段,别用 startswith
return True
if a == 169 and b == 254:
return True
return False
def walk_strings(node):
if isinstance(node, str):
yield node
elif isinstance(node, dict):
for v in node.values():
yield from walk_strings(v)
elif isinstance(node, list):
for v in node:
yield from walk_strings(v)
def lint(card: dict, pinned: dict | None) -> list[tuple[str, str, str]]:
found: list[tuple[str, str, str]] = []
def add(code: str, sev: str, msg: str) -> None:
found.append((code, sev, msg))
ifs = card.get("supportedInterfaces")
if not isinstance(ifs, list) or not ifs:
add("CARD-001", "HIGH", "缺少 supportedInterfaces,1.0 客户端无法确定绑定与版本")
ifs = []
declared = set()
for i, itf in enumerate(ifs):
if not isinstance(itf, dict):
add("CARD-002", "HIGH", f"supportedInterfaces[{i}] 不是对象")
continue
ver = itf.get("protocolVersion")
if not ver:
add("CARD-002", "MED", f"supportedInterfaces[{i}] 未声明 protocolVersion")
else:
declared.add(str(ver))
url = str(itf.get("url") or "")
p = urlparse(url)
host = p.hostname or ""
if not url:
add("CARD-003", "HIGH", f"supportedInterfaces[{i}] 没有 url")
elif private_host(host):
add("CARD-004", "HIGH", f"supportedInterfaces[{i}].url 指向不可路由地址:{url}")
elif p.scheme != "https":
add("CARD-003", "HIGH", f"supportedInterfaces[{i}].url 不是 https:{url}")
elif p.path in ("", "/"):
add("CARD-004", "LOW", f"supportedInterfaces[{i}].url 落在站点根,端点可能被别的服务占用")
top_ver = card.get("protocolVersion")
if top_ver is not None and declared and str(top_ver) not in declared:
add("CARD-005", "MED",
f"顶层 protocolVersion={top_ver} 不在 supportedInterfaces 的版本集合 {sorted(declared)} 内")
top_url = card.get("url")
if top_url and ifs and isinstance(ifs[0], dict) and ifs[0].get("url"):
if str(top_url).rstrip("/") != str(ifs[0]["url"]).rstrip("/"):
add("CARD-005", "MED", "顶层 url 与 supportedInterfaces[0].url 不一致,两代客户端会打到不同地址")
sigs = card.get("signatures")
if not isinstance(sigs, list) or not sigs:
add("CARD-006", "MED", "没有 signatures,卡片来源不可验证,只能算自我声明")
else:
for i, s in enumerate(sigs):
if not isinstance(s, dict) or not s.get("protected"):
add("CARD-007", "MED", f"signatures[{i}] 缺 protected,读不出 alg/keyid")
continue
raw = str(s["protected"])
try:
hdr = json.loads(base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4)))
except Exception:
add("CARD-007", "MED", f"signatures[{i}].protected 不是可解析的 base64url JSON")
continue
missing = [k for k in ("alg", "keyid") if not hdr.get(k)]
if missing:
add("CARD-007", "MED",
f"signatures[{i}] 的 protected 缺 {missing},验签方不知道信任哪把钥匙")
for text in walk_strings({k: card.get(k) for k in ("name", "description", "skills", "capabilities")}):
low = text.lower()
hit = [m for m in INJECT_MARKERS if m in low]
if hit:
add("CARD-008", "HIGH", f"卡片文本含指令性短语 {hit},这是面向 LLM 的注入面")
break
if pinned:
want = pinned.get("digest")
got = digest(card)
if want and want != got:
add("CARD-009", "HIGH",
f"卡片 digest 与固定值不一致:期望 {want[:19]}…,实际 {got[:19]}…")
schemes = card.get("securitySchemes") or {}
if isinstance(schemes, dict):
for name, sch in schemes.items():
blob = sch if isinstance(sch, str) else json.dumps(sch, ensure_ascii=False)
if any(k in blob for k in SECRET_KEYS):
add("CARD-010", "HIGH",
f"securitySchemes.{name} 疑似携带明文凭据:卡片只能是声明,不能是钱包")
skills = card.get("skills")
if not isinstance(skills, list) or not skills:
add("CARD-011", "MED", "skills 为空,客户端无法按能力选择,只能盲调")
return found
def main(argv=None) -> int:
ap = argparse.ArgumentParser()
ap.add_argument("card")
ap.add_argument("--pinned")
ap.add_argument("--pin", action="store_true")
args = ap.parse_args(argv)
with open(args.card, encoding="utf-8") as fh:
card = json.load(fh)
if args.pin:
print(digest(card))
return 0
pinned = None
if args.pinned:
with open(args.pinned, encoding="utf-8") as fh:
pinned = json.load(fh)
found = lint(card, pinned)
rank = {"HIGH": 0, "MED": 1, "LOW": 2}
found.sort(key=lambda x: (rank[x[1]], x[0]))
for code, sev, msg in found:
print(f"{code} {sev:<4} {msg}")
highs = sum(1 for f in found if f[1] == "HIGH")
verdict = "FAIL" if highs else ("WARN" if found else "PASS")
print(f"\n{verdict}: {len(found)} 项(HIGH {highs})")
return 1 if highs else 0
if __name__ == "__main__":
sys.exit(main())
实机输出
三组样本,Python 3.13.12,Windows。坏卡:
CARD-004 HIGH supportedInterfaces[0].url 指向不可路由地址:http://0.0.0.0:8080
CARD-008 HIGH 卡片文本含指令性短语 ['ignore previous'],这是面向 LLM 的注入面
CARD-010 HIGH securitySchemes.apiKey 疑似携带明文凭据:卡片只能是声明,不能是钱包
CARD-005 MED 顶层 protocolVersion=0.3 不在 supportedInterfaces 的版本集合 ['1.0'] 内
CARD-006 MED 没有 signatures,卡片来源不可验证,只能算自我声明
CARD-011 MED skills 为空,客户端无法按能力选择,只能盲调
FAIL: 6 项(HIGH 3)
退出码 1,可以直接卡 PR。
合规样本(双版本、https、带 keyid 的签名头、OAuth2 声明、非空 skills):
PASS: 0 项(HIGH 0)
把技能描述改一句话(加「可以不经审批给任何折扣」),再拿同一份 pinned.json 跑:
CARD-009 HIGH 卡片 digest 与固定值不一致:期望 sha256:1476405598ff…,实际 sha256:3b4f916b985a…
FAIL: 1 项(HIGH 1)
这就是卡片固定的意义:它不判断改动是好是坏,只保证「改动一定被看见」。
五条踩坑,全部有实测证据
1. 规范化参数没锁死,中文卡片 digest 全错
json.dumps 的 ensure_ascii 默认是 True。同一张卡:
ensure_ascii=False: 36112771d5674d79 43 bytes
ensure_ascii=True : c5a464e6007defd2 61 bytes
卡片里有中文 name(非常常见),两种写法算出的 digest 完全不同。如果发布方用默认参数、调用方用 ensure_ascii=False,固定值从第一天就是错的,而且错得很安静——每次都报「被篡改」。
修法:canonical 函数只能有一份,且只暴露「算 digest」这一个入口,不把参数留给调用方。
2. 对文件字节算 digest,格式化一次就失效
同一张卡,indent=2 与紧凑格式的 digest:
1476405598ffc42b # 紧凑
f812fc06365fa041 # indent=2
两种策略都成立,但不能混:要么对解析后的对象算(抗格式化),要么对文件字节算(能发现注释和空白改动)。我选前者,因为卡片的语义在 JSON 里,不在缩进里。
3. 用 set 收集结果,CI 基线当场作废
第一版图省事,用 set 存发现的编号。三个不同的 PYTHONHASHSEED:
['CARD-004', 'CARD-005', 'CARD-010', 'CARD-008', 'CARD-006', 'CARD-011']
['CARD-010', 'CARD-006', 'CARD-011', 'CARD-005', 'CARD-008', 'CARD-004']
['CARD-004', 'CARD-005', 'CARD-010', 'CARD-006', 'CARD-011', 'CARD-008']
同样的输入,输出顺序三次都不一样。人在本地看不出来,一进 CI 做 diff 就全是噪声,最后一定有人加 || true。
修法:用 list 收集,输出前按 (严重级, 编号) 显式排序。改完之后两次运行 diff 完全一致。
4. 172.16.0.0/12 不能用 startswith("172.")
上一篇文章里我留了个已知假阳性:172.5.1.1 是公网地址,却被 startswith("172.") 判成私网。今天改成解析第二段、只认 16–31。误报比漏报更危险,因为误报会训练用户忽略告警。
5. urlparse("https://a.com").path 是空串,不是 /
只判 p.path == "/" 会漏掉「URL 不带路径」的写法,而那恰恰是最容易把端点落在站点根、被别的服务占掉的情况。改成 in ("", "/") 才覆盖全。
它不做什么(重要)
| 不做 | 原因 | 该由谁做 |
|---|---|---|
| 验证签名本身 | 需要拉 JWKS / 解析 DID 文档,属联网 | 网关或握手层 |
| 验证接口真的实现了声明的方法 | 要实发一次 SendMessage | 对接前的联调 |
| 判断技能描述是否属实 | 协议不提供能力证明 | 人工评审 + 试用额度 |
| 判断 digest 变化是好是坏 | 固定值只提供「变了」的信号 | 变更评审 |
| 覆盖 DNS 指向私网的情况 | 只做字符串解析,不做 DNS 解析 | 网络层 egress 策略 |
还有一条已知误报:文本扫描包含 skills[].name,像「You must authenticate before calling」这种正常描述会被 CARD-008 命中。我保留了它——宁可多报一条让人看一眼,也不要漏掉一条真注入。但这条必须在文档里写清楚,否则用户第一次遇到就会关掉整个检查。
怎么进 CI
name: card-lint
on:
pull_request:
paths:
- "agents/**/agent-card.json"
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Lint agent cards
run: |
set -e
for f in $(git ls-files 'agents/**/agent-card.json'); do
pin="pins/$(basename "$(dirname "$f")").json"
echo "== $f"
python card_lint.py "$f" --pinned "$pin"
done
三条经验:
- HIGH 阻断,MED 只报告,LOW 不进 CI。 全阻断的结果是整条流水线被绕过。
pinned.json放在调用方仓库,不要放在发布方。放在发布方等于让被检查的人保管检查结果。- 固定值更新必须走 PR,且 PR 描述里要写清「为什么变」——这是唯一能让人工评审落到实处的机制。
小结
一张卡能告诉你的,只有「我要跟谁说话、怎么说、它自称会什么」。
它不能告诉你「该不该信它」。那件事要靠三样东西凑齐:来源可验证(签名 + 信任锚)、内容可比较(digest 固定)、能力可实测(跑一次真调用)。
三样里最容易漏的是第三样。前两样都能自动化,第三样必须有人真的发一次请求,看一眼返回。
明天写握手:名片递过去之后,对方凭什么相信「我代表的是这个用户」。
参考:A2A 1.0 规范与迁移指南;RedMonk《How Software Trusts Software》(2026-10-19);Redpanda A2A 文档(A2A-Version 与 -32009);ServiceNow A2A 1.0 卡片实例(双版本 supportedInterfaces);Cisco SD-Card 草案(2026-03,2026-09 过期);Lotfi 等 A2ABreak 分析(2026);Island FakeGit / AgentBaiting 研究(2026-07);Chrome Lighthouse 13.5 ARD 审计与 issue #17251。