写了个 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。

相关推荐
xn71331 小时前
LiteRT Tensor API C++ 怎么用?XNNPACK、GPU 与 NPU 路线如何选择
ai编程
狒狒AI1 小时前
Remotion / GSAP+Three / HyperFrames / Skia 四栈实测:代码渲染知识视频的选栈方法(附开源技能)
前端·人工智能·aigc
三轩说1 小时前
停更 5 年,它本该被淘汰 —— 直到 Workbuddy 让它跑在新 M6 上
ai编程
白日放歌须纵久1 小时前
Claude Skills 装了就等于能用吗?给 Claude Code Skills 建一套首跑验收流程
ai编程
用户201528693981 小时前
大厂老兵从零学——Agent开发
agent
stormzhangV1 小时前
AI 平权后,最该重视的 8 种能力
人工智能·aigc·产品
沛东的认知和实践1 小时前
Agentic RAG 的入库设计:从 HTML 到可检索、可复现的快照
agent
Topskys1 小时前
大模型原理之投机解码
agent
柯南46681 小时前
【AI工程师精讲】15:DPO:不训奖励模型、不做强化学习,也能完成对齐
人工智能·ai编程