写了个 Agent Card 体检器:一张「自我声明」的名片,能验到什么程度

本周在写跨组织 Agent 协作,昨天铺全景,今天只拆一张卡。协议细节以 A2A 1.0 为准,代码纯标准库、不联网、可直接进 CI。

先说结论

A2A 的 Agent Card 是自我声明的。客户端唯一的保证是:我从我打算去的那个域名,取到了这份描述。

这句话不等于「没得验」。它意味着可验的东西只有三类:

  1. 结构对不对(字段齐全、版本一致、地址可路由)
  2. 来源有没有变(签名头是否可定位信任锚、快照 digest 是否一致)
  3. 内容有没有夹带(描述里有没有面向 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 行,纯标准库)

python 复制代码
#!/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。坏卡:

objectivec 复制代码
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):

arduino 复制代码
PASS: 0 项(HIGH 0)

把技能描述改一句话(加「可以不经审批给任何折扣」),再拿同一份 pinned.json 跑:

arduino 复制代码
CARD-009 HIGH 卡片 digest 与固定值不一致:期望 sha256:1476405598ff...,实际 sha256:3b4f916b985a...

FAIL: 1 项(HIGH 1)

这就是卡片固定的意义:它不判断改动是好是坏,只保证「改动一定被看见」。

五条踩坑,全部有实测证据

1. 规范化参数没锁死,中文卡片 digest 全错

json.dumps 的 ensure_ascii 默认是 True。同一张卡:

ini 复制代码
ensure_ascii=False: 36112771d5674d79 43 bytes
ensure_ascii=True : c5a464e6007defd2 61 bytes

卡片里有中文 name(非常常见),两种写法算出的 digest 完全不同。如果发布方用默认参数、调用方用 ensure_ascii=False,固定值从第一天就是错的,而且错得很安静------每次都报「被篡改」。

修法:canonical 函数只能有一份,且只暴露「算 digest」这一个入口,不把参数留给调用方。

2. 对文件字节算 digest,格式化一次就失效

同一张卡,indent=2 与紧凑格式的 digest:

bash 复制代码
1476405598ffc42b   # 紧凑
f812fc06365fa041   # indent=2

两种策略都成立,但不能混:要么对解析后的对象 算(抗格式化),要么对文件字节算(能发现注释和空白改动)。我选前者,因为卡片的语义在 JSON 里,不在缩进里。

3. 用 set 收集结果,CI 基线当场作废

第一版图省事,用 set 存发现的编号。三个不同的 PYTHONHASHSEED:

css 复制代码
['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

yaml 复制代码
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

三条经验:

  1. HIGH 阻断,MED 只报告,LOW 不进 CI。 全阻断的结果是整条流水线被绕过。
  2. pinned.json 放在调用方仓库,不要放在发布方。放在发布方等于让被检查的人保管检查结果。
  3. 固定值更新必须走 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。

相关推荐
这个DBA有点耶5 小时前
库内语义构建 vs 外挂向量库:业务语义缺失,才是RAG“胡说八道”的根源
数据库·mysql·aigc
Dawson Zhu5 小时前
从空间 Scaling 到时间 Scaling
人工智能·语言模型·架构·aigc·agi
阿里云云原生5 小时前
AgentCore + Codex 构建生产级 Agent 的原理与实践
agent
ServBay5 小时前
OpenAI大规模封号?ChatGPT 用户该如何应对
chatgpt·openai·ai编程
DigitalOcean7 小时前
Codex 如何接入 Claude 等其它模型?使用 CC Switch 接入 FluxLane API 教程
llm·agent
OpsEye7 小时前
混合云 AI 架构,如何统一做敏感内容拦截?
javascript·ai编程
七夜zippoe7 小时前
Agent 并发控制:多用户、多会话与资源隔离策略
ai·agent·并发控制·资源隔离·多用户·多会话
Spurs蒋7 小时前
【大模型推理工程 03】GPU 推理性能:显存、带宽、算力与算术强度
aigc
沉默王二7 小时前
Hy4正式发布一手实测,腾讯这波起飞了。
openai·agent·vibecoding