a2a-doctor 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- a2a_doctor-0.1.0/.github/workflows/ci.yml +19 -0
- a2a_doctor-0.1.0/PKG-INFO +87 -0
- a2a_doctor-0.1.0/README.md +70 -0
- a2a_doctor-0.1.0/a2a_artifact_audit.py +208 -0
- a2a_doctor-0.1.0/a2a_card_lock.py +242 -0
- a2a_doctor-0.1.0/a2a_context_audit.py +162 -0
- a2a_doctor-0.1.0/a2a_doctor.py +362 -0
- a2a_doctor-0.1.0/a2a_reconnect_tax.py +226 -0
- a2a_doctor-0.1.0/a2a_stream_tax.py +244 -0
- a2a_doctor-0.1.0/a2a_task_state_audit.py +205 -0
- a2a_doctor-0.1.0/a2a_tax.py +269 -0
- a2a_doctor-0.1.0/a2a_timeline_replay.py +178 -0
- a2a_doctor-0.1.0/a2a_webhook_audit.py +204 -0
- a2a_doctor-0.1.0/pyproject.toml +28 -0
- a2a_doctor-0.1.0/sample_stream.jsonl +17 -0
- a2a_doctor-0.1.0/sample_timeline_stream.jsonl +17 -0
- a2a_doctor-0.1.0/sample_webhook_stream.jsonl +5 -0
- a2a_doctor-0.1.0/test_a2a_context_audit.py +118 -0
- a2a_doctor-0.1.0/test_a2a_doctor.py +164 -0
- a2a_doctor-0.1.0/test_a2a_timeline_replay.py +153 -0
- a2a_doctor-0.1.0/test_a2a_webhook_audit.py +140 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: actions/setup-python@v5
|
|
14
|
+
with:
|
|
15
|
+
python-version: "3.11"
|
|
16
|
+
- name: Run tests
|
|
17
|
+
run: |
|
|
18
|
+
python -m pip install pytest
|
|
19
|
+
python -m pytest test_a2a_doctor.py -q
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: a2a-doctor
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: One command, every A2A audit - replay recorded streams offline and health-check agent-to-agent traffic: token tax, artifact loss, task lifecycle, context correlation, webhook SSRF, MCP collisions.
|
|
5
|
+
Author: ZidoCode
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: a2a,agent,agent2agent,audit,llm,mcp,offline,replay,ssrf,token,webhook
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
14
|
+
Classifier: Topic :: Software Development :: Testing
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# a2a-doctor
|
|
19
|
+
|
|
20
|
+
**One command, every A2A audit in the family. Dumb math, honest numbers, zero dependencies.**
|
|
21
|
+
|
|
22
|
+
[](https://github.com/ZidoCode/a2a-doctor/actions/workflows/ci.yml) []()
|
|
23
|
+
|
|
24
|
+
The umbrella entry point for the ZidoCode A2A audit suite:
|
|
25
|
+
|
|
26
|
+
| Repo | What it audits |
|
|
27
|
+
|---|---|
|
|
28
|
+
| [a2a-tax](https://github.com/ZidoCode/a2a-tax) | token cost of the agent card + polling model |
|
|
29
|
+
| [a2a-stream-tax](https://github.com/ZidoCode/a2a-stream-tax) | token cost of a recorded SSE stream |
|
|
30
|
+
| [a2a-reconnect-tax](https://github.com/ZidoCode/a2a-reconnect-tax) | replayed-event tax across SSE reconnects |
|
|
31
|
+
| [a2a-artifact-audit](https://github.com/ZidoCode/a2a-artifact-audit) | chunk gaps, duplicates, writes after `last_chunk` |
|
|
32
|
+
| [a2a-task-state-audit](https://github.com/ZidoCode/a2a-task-state-audit) | task resurrection, illegal transitions, unknown states |
|
|
33
|
+
| [a2a-context-audit](https://github.com/ZidoCode/a2a-context-audit) | empty IDs, context mutation, orphan contexts ([A2A#1309](https://github.com/a2aproject/A2A/issues/1309)) |
|
|
34
|
+
| [a2a-webhook-audit](https://github.com/ZidoCode/a2a-webhook-audit) | webhook SSRF: metadata IPs, credentials, risky ports ([a2a-go#373](https://github.com/a2aproject/a2a-go/issues/373)) |
|
|
35
|
+
| [a2a-timeline-replay](https://github.com/ZidoCode/a2a-timeline-replay) | time-travel explorer — per-task timelines, event windows, raw JSON at any line |
|
|
36
|
+
| [a2a-card-lock](https://github.com/ZidoCode/a2a-card-lock) | agent-card pin + drift gate |
|
|
37
|
+
|
|
38
|
+
Each member works standalone. a2a-doctor runs them all in one pass and gives one verdict.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install a2a-doctor # or: copy the .py files (they are all stdlib-only)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
$ a2a-doctor check --card card.json --stream stream.jsonl --lock a2a-card.lock
|
|
50
|
+
a2a-doctor check — 7 section(s)
|
|
51
|
+
|
|
52
|
+
✓ card 418 tok estimated
|
|
53
|
+
✓ polling ~418 tok/task estimated
|
|
54
|
+
✓ stream 17 event(s) recorded
|
|
55
|
+
✗ artifacts 1 critical — event 14: write to 'a1' after last_chunk (artifact already closed)
|
|
56
|
+
✗ lifecycle 5 critical — line 7: task 'zombie-task' resumed after terminal state 'failed' -> 'working'
|
|
57
|
+
✓ reconnect no replayed events
|
|
58
|
+
✓ lock card unchanged — hash matches
|
|
59
|
+
|
|
60
|
+
verdict: FAIL (artifacts, lifecycle)
|
|
61
|
+
$ echo $?
|
|
62
|
+
2
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`check` is the CI gate: **exit 0** clean, **1** warnings only, **2** critical findings.
|
|
66
|
+
|
|
67
|
+
Individual sections:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
a2a-doctor tax card.json # card size + $ estimate
|
|
71
|
+
a2a-doctor stream stream.jsonl # token breakdown of a recorded stream
|
|
72
|
+
a2a-doctor reconnect stream.jsonl # replayed events across reconnects
|
|
73
|
+
a2a-doctor artifacts stream.jsonl # artifact integrity report
|
|
74
|
+
a2a-doctor lifecycle stream.jsonl # task state machine audit
|
|
75
|
+
a2a-doctor context stream.jsonl # context/task correlation audit
|
|
76
|
+
a2a-doctor webhook stream.jsonl # webhook SSRF audit (offline)
|
|
77
|
+
a2a-doctor timeline stream.jsonl # time-travel explorer: per-task state chains
|
|
78
|
+
a2a-doctor lock card.json a2a-card.lock # drift check
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Stream format
|
|
82
|
+
|
|
83
|
+
One JSON event per line — record your client's `message/stream` session, replay it anywhere, anytime. Deterministic: same input, same verdict, byte for byte.
|
|
84
|
+
|
|
85
|
+
## Why
|
|
86
|
+
|
|
87
|
+
Auditing an A2A endpoint once is easy. Continuously — in CI, on every deploy — is the point. a2a-doctor exists so there is one command to wire into the pipeline, not six. MIT license.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# a2a-doctor
|
|
2
|
+
|
|
3
|
+
**One command, every A2A audit in the family. Dumb math, honest numbers, zero dependencies.**
|
|
4
|
+
|
|
5
|
+
[](https://github.com/ZidoCode/a2a-doctor/actions/workflows/ci.yml) []()
|
|
6
|
+
|
|
7
|
+
The umbrella entry point for the ZidoCode A2A audit suite:
|
|
8
|
+
|
|
9
|
+
| Repo | What it audits |
|
|
10
|
+
|---|---|
|
|
11
|
+
| [a2a-tax](https://github.com/ZidoCode/a2a-tax) | token cost of the agent card + polling model |
|
|
12
|
+
| [a2a-stream-tax](https://github.com/ZidoCode/a2a-stream-tax) | token cost of a recorded SSE stream |
|
|
13
|
+
| [a2a-reconnect-tax](https://github.com/ZidoCode/a2a-reconnect-tax) | replayed-event tax across SSE reconnects |
|
|
14
|
+
| [a2a-artifact-audit](https://github.com/ZidoCode/a2a-artifact-audit) | chunk gaps, duplicates, writes after `last_chunk` |
|
|
15
|
+
| [a2a-task-state-audit](https://github.com/ZidoCode/a2a-task-state-audit) | task resurrection, illegal transitions, unknown states |
|
|
16
|
+
| [a2a-context-audit](https://github.com/ZidoCode/a2a-context-audit) | empty IDs, context mutation, orphan contexts ([A2A#1309](https://github.com/a2aproject/A2A/issues/1309)) |
|
|
17
|
+
| [a2a-webhook-audit](https://github.com/ZidoCode/a2a-webhook-audit) | webhook SSRF: metadata IPs, credentials, risky ports ([a2a-go#373](https://github.com/a2aproject/a2a-go/issues/373)) |
|
|
18
|
+
| [a2a-timeline-replay](https://github.com/ZidoCode/a2a-timeline-replay) | time-travel explorer — per-task timelines, event windows, raw JSON at any line |
|
|
19
|
+
| [a2a-card-lock](https://github.com/ZidoCode/a2a-card-lock) | agent-card pin + drift gate |
|
|
20
|
+
|
|
21
|
+
Each member works standalone. a2a-doctor runs them all in one pass and gives one verdict.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install a2a-doctor # or: copy the .py files (they are all stdlib-only)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
$ a2a-doctor check --card card.json --stream stream.jsonl --lock a2a-card.lock
|
|
33
|
+
a2a-doctor check — 7 section(s)
|
|
34
|
+
|
|
35
|
+
✓ card 418 tok estimated
|
|
36
|
+
✓ polling ~418 tok/task estimated
|
|
37
|
+
✓ stream 17 event(s) recorded
|
|
38
|
+
✗ artifacts 1 critical — event 14: write to 'a1' after last_chunk (artifact already closed)
|
|
39
|
+
✗ lifecycle 5 critical — line 7: task 'zombie-task' resumed after terminal state 'failed' -> 'working'
|
|
40
|
+
✓ reconnect no replayed events
|
|
41
|
+
✓ lock card unchanged — hash matches
|
|
42
|
+
|
|
43
|
+
verdict: FAIL (artifacts, lifecycle)
|
|
44
|
+
$ echo $?
|
|
45
|
+
2
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`check` is the CI gate: **exit 0** clean, **1** warnings only, **2** critical findings.
|
|
49
|
+
|
|
50
|
+
Individual sections:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
a2a-doctor tax card.json # card size + $ estimate
|
|
54
|
+
a2a-doctor stream stream.jsonl # token breakdown of a recorded stream
|
|
55
|
+
a2a-doctor reconnect stream.jsonl # replayed events across reconnects
|
|
56
|
+
a2a-doctor artifacts stream.jsonl # artifact integrity report
|
|
57
|
+
a2a-doctor lifecycle stream.jsonl # task state machine audit
|
|
58
|
+
a2a-doctor context stream.jsonl # context/task correlation audit
|
|
59
|
+
a2a-doctor webhook stream.jsonl # webhook SSRF audit (offline)
|
|
60
|
+
a2a-doctor timeline stream.jsonl # time-travel explorer: per-task state chains
|
|
61
|
+
a2a-doctor lock card.json a2a-card.lock # drift check
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Stream format
|
|
65
|
+
|
|
66
|
+
One JSON event per line — record your client's `message/stream` session, replay it anywhere, anytime. Deterministic: same input, same verdict, byte for byte.
|
|
67
|
+
|
|
68
|
+
## Why
|
|
69
|
+
|
|
70
|
+
Auditing an A2A endpoint once is easy. Continuously — in CI, on every deploy — is the point. a2a-doctor exists so there is one command to wire into the pipeline, not six. MIT license.
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""a2a-artifact-audit — audit A2A artifact streaming for silent data loss.
|
|
3
|
+
|
|
4
|
+
A2A artifacts stream in chunks: `artifact-update` events with append=true,
|
|
5
|
+
a last_chunk flag, and artifactIds the client is trusted to track. The spec
|
|
6
|
+
leaves the hard cases open — can an artifact be replaced after last_chunk
|
|
7
|
+
(a2aproject/A2A#1231)? Should append to an unknown artifactId fail loudly
|
|
8
|
+
(silent drop today: a2a-python#1038)? Is there any content integrity at all
|
|
9
|
+
(proposal #1140)?
|
|
10
|
+
|
|
11
|
+
a2a-artifact-audit replays a recorded artifact stream and reports:
|
|
12
|
+
- chunks appended to artifactIds that were never announced (silent drops)
|
|
13
|
+
- duplicate part deliveries (appended twice)
|
|
14
|
+
- events after last_chunk (post-completion writes)
|
|
15
|
+
- artifacts never closed (missing last_chunk)
|
|
16
|
+
- size accounting per artifact
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
python3 a2a_artifact_audit.py audit <stream.jsonl>
|
|
20
|
+
python3 a2a_artifact_audit.py report <stream.jsonl>
|
|
21
|
+
python3 a2a_artifact_audit.py simulate <card-url-or-file> [--artifacts N] [--chunks M]
|
|
22
|
+
|
|
23
|
+
Stream format: one JSON event per line (message/stream output).
|
|
24
|
+
|
|
25
|
+
Zero dependencies. Python 3.8+.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import argparse
|
|
29
|
+
import json
|
|
30
|
+
import sys
|
|
31
|
+
|
|
32
|
+
from a2a_tax import est_tokens, fetch_card
|
|
33
|
+
from a2a_stream_tax import event_tokens, load_stream
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------- audit
|
|
37
|
+
|
|
38
|
+
def audit(events):
|
|
39
|
+
"""Replay artifact-update events, detect data-integrity violations.
|
|
40
|
+
Returns (findings, artifacts). findings: list of (severity, msg)."""
|
|
41
|
+
CRIT, WARN, INFO = "CRITICAL", "WARNING", "INFO"
|
|
42
|
+
findings = []
|
|
43
|
+
artifacts = {} # artifactId -> {"chunks": n, "parts_tokens": t, "closed": bool,
|
|
44
|
+
# "seen_parts": set, "announced": bool}
|
|
45
|
+
order = 0
|
|
46
|
+
for ev in events:
|
|
47
|
+
order += 1
|
|
48
|
+
res = ev.get("result") if isinstance(ev.get("result"), dict) else {}
|
|
49
|
+
if res.get("kind") != "artifact-update":
|
|
50
|
+
continue
|
|
51
|
+
art = res.get("artifact") or {}
|
|
52
|
+
aid = art.get("artifactId")
|
|
53
|
+
if not aid:
|
|
54
|
+
findings.append((WARN, f"event {order}: artifact-update without artifactId"))
|
|
55
|
+
continue
|
|
56
|
+
append = bool(res.get("append"))
|
|
57
|
+
last = bool(res.get("last_chunk"))
|
|
58
|
+
parts = art.get("parts") or []
|
|
59
|
+
part_sig = json.dumps(parts, sort_keys=True, separators=(",", ":"))
|
|
60
|
+
ptoks = est_tokens(part_sig)
|
|
61
|
+
|
|
62
|
+
a = artifacts.setdefault(aid, {"chunks": 0, "parts_tokens": 0,
|
|
63
|
+
"closed": False, "seen_parts": [],
|
|
64
|
+
"announced": False})
|
|
65
|
+
# 1. protocol hygiene: first chunk should not be an append
|
|
66
|
+
# (server-side, append to an unannounced id is silently dropped — a2a-python#1038;
|
|
67
|
+
# from a client replay we flag it as hygiene, not data loss)
|
|
68
|
+
if append and a["chunks"] == 0:
|
|
69
|
+
findings.append((INFO, f"event {order}: first chunk of {aid!r} has append=true — "
|
|
70
|
+
"should be a fresh write (servers drop appends to unknown ids)"))
|
|
71
|
+
# 2. write after last_chunk (a2aproject/A2A#1231)
|
|
72
|
+
if a["closed"]:
|
|
73
|
+
findings.append((CRIT, f"event {order}: write to {aid!r} after last_chunk "
|
|
74
|
+
"(artifact already closed)"))
|
|
75
|
+
# 3. duplicate part delivery
|
|
76
|
+
if part_sig in a["seen_parts"]:
|
|
77
|
+
findings.append((WARN, f"event {order}: duplicate part delivered to {aid!r} "
|
|
78
|
+
f"(~{ptoks} tokens re-sent)"))
|
|
79
|
+
# 4. replacement without append after previously closed is #2; full
|
|
80
|
+
# replacement (append=false) after chunks is suspicious
|
|
81
|
+
if not append and a["chunks"] > 0:
|
|
82
|
+
findings.append((INFO, f"event {order}: artifact {aid!r} replaced wholesale "
|
|
83
|
+
f"({a['chunks']} earlier chunk(s) discarded)"))
|
|
84
|
+
a["chunks"] += 1
|
|
85
|
+
a["parts_tokens"] += ptoks
|
|
86
|
+
a["seen_parts"].append(part_sig)
|
|
87
|
+
a["announced"] = True
|
|
88
|
+
if last:
|
|
89
|
+
a["closed"] = True
|
|
90
|
+
|
|
91
|
+
# 5. never closed
|
|
92
|
+
for aid, a in sorted(artifacts.items()):
|
|
93
|
+
if not a["closed"]:
|
|
94
|
+
findings.append((WARN, f"artifact {aid!r} never closed — missing last_chunk "
|
|
95
|
+
f"({a['chunks']} chunk(s) held open)"))
|
|
96
|
+
return findings, artifacts
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------- simulate
|
|
100
|
+
|
|
101
|
+
def simulate(card, artifacts=3, chunks=5, gap=True):
|
|
102
|
+
"""Model an artifact stream: N artifacts x M chunks. With gap=True,
|
|
103
|
+
one append targets an unannounced artifact (the silent-drop class)."""
|
|
104
|
+
base = {"tokens": 0}
|
|
105
|
+
ev = {"jsonrpc": "2.0", "id": 1, "result": {
|
|
106
|
+
"kind": "artifact-update", "taskId": "t1", "contextId": "c1",
|
|
107
|
+
"append": True, "last_chunk": False,
|
|
108
|
+
"artifact": {"artifactId": "a", "parts": [{"kind": "text",
|
|
109
|
+
"text": "chunk payload — simulating artifact audit"}]}}}
|
|
110
|
+
per = event_tokens(ev)
|
|
111
|
+
total_events = artifacts * chunks
|
|
112
|
+
base["tokens"] = per * total_events
|
|
113
|
+
findings = 1 if gap else 0
|
|
114
|
+
return {"artifact_events": total_events, "tokens_total": base["tokens"],
|
|
115
|
+
"per_event_tokens": per, "simulated_findings": findings,
|
|
116
|
+
"note": "1 first-chunk has append=true (protocol hygiene)" if gap else ""}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------- report
|
|
120
|
+
|
|
121
|
+
def report(events):
|
|
122
|
+
findings, artifacts = audit(events)
|
|
123
|
+
crit = sum(1 for s, _ in findings if s == "CRITICAL")
|
|
124
|
+
warn = sum(1 for s, _ in findings if s == "WARNING")
|
|
125
|
+
info = sum(1 for s, _ in findings if s == "INFO")
|
|
126
|
+
lines = [f"a2a-artifact-audit report — {len(artifacts)} artifact(s), {len(findings)} finding(s)"]
|
|
127
|
+
lines.append("")
|
|
128
|
+
for aid, a in sorted(artifacts.items()):
|
|
129
|
+
state = "closed" if a["closed"] else "OPEN (no last_chunk)"
|
|
130
|
+
lines.append(f" {aid:20s} {a['chunks']:>3} chunks {a['parts_tokens']:>7,} tok {state}")
|
|
131
|
+
if findings:
|
|
132
|
+
lines.append("")
|
|
133
|
+
for s, m in findings:
|
|
134
|
+
lines.append(f" [{s:8s}] {m}")
|
|
135
|
+
lines.append("")
|
|
136
|
+
if crit:
|
|
137
|
+
lines.append(f" verdict: {crit} CRITICAL — chunks silently lost or written after close.")
|
|
138
|
+
else:
|
|
139
|
+
lines.append(" verdict: no critical loss — review warnings above.")
|
|
140
|
+
else:
|
|
141
|
+
lines.append(" clean — every chunk announced, ordered, and closed.")
|
|
142
|
+
return "\n".join(lines)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ---------------------------------------------------------------- CLI
|
|
146
|
+
|
|
147
|
+
def main(argv=None):
|
|
148
|
+
ap = argparse.ArgumentParser(prog="a2a-artifact-audit",
|
|
149
|
+
description="Audit A2A artifact streaming for silent data loss.")
|
|
150
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
151
|
+
|
|
152
|
+
p_a = sub.add_parser("audit", help="machine list of findings")
|
|
153
|
+
p_a.add_argument("path")
|
|
154
|
+
|
|
155
|
+
p_r = sub.add_parser("report", help="human-readable report")
|
|
156
|
+
p_r.add_argument("path")
|
|
157
|
+
|
|
158
|
+
p_s = sub.add_parser("simulate", help="model artifact stream from an AgentCard")
|
|
159
|
+
p_s.add_argument("source")
|
|
160
|
+
p_s.add_argument("--artifacts", type=int, default=3)
|
|
161
|
+
p_s.add_argument("--chunks", type=int, default=5)
|
|
162
|
+
p_s.add_argument("--no-gap", action="store_true", help="model a clean stream")
|
|
163
|
+
|
|
164
|
+
args = ap.parse_args(argv)
|
|
165
|
+
args.artifacts = max(0, getattr(args, "artifacts", 3))
|
|
166
|
+
args.chunks = max(0, getattr(args, "chunks", 5))
|
|
167
|
+
|
|
168
|
+
if args.cmd == "simulate":
|
|
169
|
+
try:
|
|
170
|
+
card, _ = fetch_card(args.source)
|
|
171
|
+
except Exception as e:
|
|
172
|
+
print(f"error: cannot load card from {args.source}: {e}", file=sys.stderr)
|
|
173
|
+
return 2
|
|
174
|
+
if not isinstance(card, dict):
|
|
175
|
+
print(f"error: {args.source} is not an AgentCard (expected a JSON object, "
|
|
176
|
+
f"got {type(card).__name__})", file=sys.stderr)
|
|
177
|
+
return 2
|
|
178
|
+
m = simulate(card, args.artifacts, args.chunks, not args.no_gap)
|
|
179
|
+
for k, v in m.items():
|
|
180
|
+
print(f"{k:22s}: {v:,}" if isinstance(v, int) else f"{k:22s}: {v}")
|
|
181
|
+
return 0
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
events, bad = load_stream(args.path)
|
|
185
|
+
except OSError as e:
|
|
186
|
+
print(f"error: cannot read stream file {args.path}: {e}", file=sys.stderr)
|
|
187
|
+
return 2
|
|
188
|
+
if bad:
|
|
189
|
+
print(f"note: skipped {len(bad)} unparseable line(s), first: line {bad[0][0]}: {bad[0][1]!r}",
|
|
190
|
+
file=sys.stderr)
|
|
191
|
+
if not events:
|
|
192
|
+
print(f"error: no valid events in {args.path}", file=sys.stderr)
|
|
193
|
+
return 2
|
|
194
|
+
|
|
195
|
+
if args.cmd == "audit":
|
|
196
|
+
findings, _ = audit(events)
|
|
197
|
+
for s, m in findings:
|
|
198
|
+
print(f"[{s:8s}] {m}")
|
|
199
|
+
if not findings:
|
|
200
|
+
print("clean — no artifact integrity violations")
|
|
201
|
+
return 2 if any(s == "CRITICAL" for s, _ in findings) else 0
|
|
202
|
+
|
|
203
|
+
print(report(events))
|
|
204
|
+
return 2 if any(s == "CRITICAL" for s, _ in audit(events)[0]) else 0
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
if __name__ == "__main__":
|
|
208
|
+
sys.exit(main())
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""a2a-card-lock — pin A2A Agent Cards, catch silent drift in CI.
|
|
3
|
+
|
|
4
|
+
An Agent Card is a trust anchor: you fetch it once, verify it, and build a
|
|
5
|
+
peering on top. But nothing stops the remote agent from quietly changing the
|
|
6
|
+
card tomorrow — a new skill with a different description, a rewritten URL, a
|
|
7
|
+
dropped auth scheme, a loosened security block. mcp-lock proved the pattern
|
|
8
|
+
for MCP tool chains; agent cards are the same attack surface and nobody pins
|
|
9
|
+
them.
|
|
10
|
+
|
|
11
|
+
a2a-card-lock hashes a card into a lockfile, diffs live cards against it,
|
|
12
|
+
and exits non-zero on breaking changes so CI catches drift before your
|
|
13
|
+
agents obey it.
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
python3 a2a_card_lock.py pin <card-url-or-file> [--out a2a-card.lock]
|
|
17
|
+
python3 a2a_card_lock.py diff <card-url-or-file> [--lock a2a-card.lock]
|
|
18
|
+
python3 a2a_card_lock.py report <card-url-or-file> [--lock a2a-card.lock]
|
|
19
|
+
|
|
20
|
+
Zero dependencies. Python 3.8+.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import sys
|
|
27
|
+
|
|
28
|
+
from a2a_tax import est_tokens, fetch_card
|
|
29
|
+
|
|
30
|
+
BREAKING = "CRITICAL"
|
|
31
|
+
WARNING = "WARNING"
|
|
32
|
+
INFO = "INFO"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------- hashing
|
|
36
|
+
|
|
37
|
+
def canonical(card):
|
|
38
|
+
"""Deterministic serialization: sort keys recursively, compact separators.
|
|
39
|
+
Two semantically identical cards must hash identically."""
|
|
40
|
+
return json.dumps(card, sort_keys=True, separators=(",", ":"))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def card_hash(card):
|
|
44
|
+
return hashlib.sha256(canonical(card).encode("utf-8")).hexdigest()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def field_hashes(card):
|
|
48
|
+
"""Per-field sha256, so the diff can name exactly what moved."""
|
|
49
|
+
out = {}
|
|
50
|
+
for k, v in card.items():
|
|
51
|
+
out[k] = hashlib.sha256(json.dumps(v, sort_keys=True,
|
|
52
|
+
separators=(",", ":")).encode("utf-8")).hexdigest()[:16]
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def skill_map(card):
|
|
57
|
+
"""skill id -> {name, description-hash, tags-hash}"""
|
|
58
|
+
out = {}
|
|
59
|
+
for s in card.get("skills") or []:
|
|
60
|
+
sid = s.get("id", s.get("name", "?"))
|
|
61
|
+
out[sid] = {
|
|
62
|
+
"name": s.get("name", ""),
|
|
63
|
+
"desc_hash": hashlib.sha256((s.get("description") or "").encode()).hexdigest()[:16],
|
|
64
|
+
"tags": sorted(s.get("tags") or []),
|
|
65
|
+
}
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------- pin
|
|
70
|
+
|
|
71
|
+
def pin(card):
|
|
72
|
+
"""Build a lockfile dict from a live card."""
|
|
73
|
+
return {
|
|
74
|
+
"lock_version": 1,
|
|
75
|
+
"card_hash": card_hash(card),
|
|
76
|
+
"protocolVersion": card.get("protocolVersion", ""),
|
|
77
|
+
"fields": field_hashes(card),
|
|
78
|
+
"skills": skill_map(card),
|
|
79
|
+
"pinned_at": "", # intentionally no timestamp: same card -> same lockfile
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def save_lock(lock, path):
|
|
84
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
85
|
+
json.dump(lock, f, indent=2, sort_keys=True)
|
|
86
|
+
f.write("\n")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def load_lock(path):
|
|
90
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
91
|
+
return json.load(f)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------- diff
|
|
95
|
+
|
|
96
|
+
def diff(card, lock):
|
|
97
|
+
"""Compare a live card against a pinned lock. Returns (findings, summary).
|
|
98
|
+
Breaking: card hash, protocolVersion, url/auth/security, skill removal,
|
|
99
|
+
skill description changes. Warnings: skill additions, new fields."""
|
|
100
|
+
findings = []
|
|
101
|
+
live = pin(card)
|
|
102
|
+
|
|
103
|
+
if live["card_hash"] == lock.get("card_hash"):
|
|
104
|
+
return [], "card unchanged — hash matches"
|
|
105
|
+
|
|
106
|
+
# protocolVersion
|
|
107
|
+
if live["protocolVersion"] != lock.get("protocolVersion"):
|
|
108
|
+
findings.append((BREAKING,
|
|
109
|
+
f"protocolVersion changed: {lock.get('protocolVersion')!r} -> {live['protocolVersion']!r}"))
|
|
110
|
+
|
|
111
|
+
# per-field: url / auth / security are breaking on any change
|
|
112
|
+
for key in ("url", "preferredTransport", "security", "securitySchemes", "provider"):
|
|
113
|
+
old = (lock.get("fields") or {}).get(key)
|
|
114
|
+
new = live["fields"].get(key)
|
|
115
|
+
if old and new and old != new:
|
|
116
|
+
findings.append((BREAKING, f"{key} changed (was pinned, now differs)"))
|
|
117
|
+
|
|
118
|
+
# skills
|
|
119
|
+
old_skills = lock.get("skills") or {}
|
|
120
|
+
new_skills = live["skills"]
|
|
121
|
+
for sid in sorted(set(old_skills) - set(new_skills)):
|
|
122
|
+
findings.append((BREAKING, f"skill removed: {sid}"))
|
|
123
|
+
for sid in sorted(set(new_skills) - set(old_skills)):
|
|
124
|
+
findings.append((WARNING, f"skill added: {sid} — review its description before trusting"))
|
|
125
|
+
for sid in sorted(set(old_skills) & set(new_skills)):
|
|
126
|
+
if old_skills[sid]["desc_hash"] != new_skills[sid]["desc_hash"]:
|
|
127
|
+
findings.append((BREAKING,
|
|
128
|
+
f"skill description changed: {sid} ({old_skills[sid]['name']!r})"))
|
|
129
|
+
elif old_skills[sid]["tags"] != new_skills[sid]["tags"]:
|
|
130
|
+
findings.append((INFO, f"skill tags changed: {sid}"))
|
|
131
|
+
|
|
132
|
+
# other fields (informational)
|
|
133
|
+
for k in sorted(set(live["fields"]) - set(lock.get("fields") or {})):
|
|
134
|
+
findings.append((INFO, f"new field: {k}"))
|
|
135
|
+
|
|
136
|
+
if not findings:
|
|
137
|
+
findings.append((INFO, "card changed but only in untracked fields"))
|
|
138
|
+
return findings, None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def classify_severity(findings):
|
|
142
|
+
for sev, _ in findings:
|
|
143
|
+
if sev == BREAKING:
|
|
144
|
+
return 2
|
|
145
|
+
for sev, _ in findings:
|
|
146
|
+
if sev == WARNING:
|
|
147
|
+
return 1
|
|
148
|
+
return 0
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ---------------------------------------------------------------- CLI
|
|
152
|
+
|
|
153
|
+
def main(argv=None):
|
|
154
|
+
ap = argparse.ArgumentParser(prog="a2a-card-lock",
|
|
155
|
+
description="Pin A2A Agent Cards, catch silent drift in CI.")
|
|
156
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
157
|
+
|
|
158
|
+
p_pin = sub.add_parser("pin", help="pin a card into a lockfile")
|
|
159
|
+
p_pin.add_argument("source")
|
|
160
|
+
p_pin.add_argument("--out", default="a2a-card.lock")
|
|
161
|
+
|
|
162
|
+
p_diff = sub.add_parser("diff", help="compare a live card against a lockfile")
|
|
163
|
+
p_diff.add_argument("source")
|
|
164
|
+
p_diff.add_argument("--lock", default="a2a-card.lock")
|
|
165
|
+
|
|
166
|
+
p_rep = sub.add_parser("report", help="human-readable drift report")
|
|
167
|
+
p_rep.add_argument("source")
|
|
168
|
+
p_rep.add_argument("--lock", default="a2a-card.lock")
|
|
169
|
+
|
|
170
|
+
args = ap.parse_args(argv)
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
card, src = fetch_card(args.source)
|
|
174
|
+
except Exception as e:
|
|
175
|
+
print(f"error: cannot load card from {args.source}: {e}", file=sys.stderr)
|
|
176
|
+
return 2
|
|
177
|
+
if not isinstance(card, dict):
|
|
178
|
+
print(f"error: {args.source} is not an AgentCard (expected a JSON object, "
|
|
179
|
+
f"got {type(card).__name__})", file=sys.stderr)
|
|
180
|
+
return 2
|
|
181
|
+
|
|
182
|
+
if args.cmd == "pin":
|
|
183
|
+
lock = pin(card)
|
|
184
|
+
save_lock(lock, args.out)
|
|
185
|
+
print(f"pinned {src}")
|
|
186
|
+
print(f" card hash : {lock['card_hash'][:32]}…")
|
|
187
|
+
print(f" skills : {len(lock['skills'])}")
|
|
188
|
+
print(f" lockfile : {args.out}")
|
|
189
|
+
return 0
|
|
190
|
+
|
|
191
|
+
# diff / report need a lockfile
|
|
192
|
+
try:
|
|
193
|
+
lock = load_lock(args.lock)
|
|
194
|
+
except OSError as e:
|
|
195
|
+
print(f"error: cannot read lockfile {args.lock}: {e}", file=sys.stderr)
|
|
196
|
+
return 2
|
|
197
|
+
except json.JSONDecodeError as e:
|
|
198
|
+
print(f"error: lockfile {args.lock} is not valid JSON: {e}", file=sys.stderr)
|
|
199
|
+
return 2
|
|
200
|
+
if not isinstance(lock, dict) or "card_hash" not in lock:
|
|
201
|
+
print(f"error: {args.lock} is not an a2a-card-lock lockfile", file=sys.stderr)
|
|
202
|
+
return 2
|
|
203
|
+
|
|
204
|
+
findings, unchanged = diff(card, lock)
|
|
205
|
+
if unchanged:
|
|
206
|
+
print(unchanged)
|
|
207
|
+
return 0
|
|
208
|
+
|
|
209
|
+
sev_max = classify_severity(findings)
|
|
210
|
+
if args.cmd == "diff":
|
|
211
|
+
icon = {BREAKING: "CRITICAL", WARNING: "WARNING", INFO: "INFO"}
|
|
212
|
+
for sev, msg in findings:
|
|
213
|
+
print(f" {icon[sev]:8s} {msg}")
|
|
214
|
+
return 2 if sev_max == 2 else (1 if sev_max == 1 else 0)
|
|
215
|
+
|
|
216
|
+
# report
|
|
217
|
+
def usd(_): # no pricing here; report is qualitative
|
|
218
|
+
return ""
|
|
219
|
+
print(f"a2a-card-lock report — {src}")
|
|
220
|
+
print(f" pinned hash : {lock.get('card_hash', '')[:32]}…")
|
|
221
|
+
print(f" live hash : {card_hash(card)[:32]}…")
|
|
222
|
+
print(f" skills now : {len(card.get('skills') or [])} (pinned: {len(lock.get('skills') or {})})")
|
|
223
|
+
print()
|
|
224
|
+
if not findings:
|
|
225
|
+
print(" no drift detected")
|
|
226
|
+
return 0
|
|
227
|
+
counts = {s: sum(1 for sv, _ in findings if sv == s) for s in (BREAKING, WARNING, INFO)}
|
|
228
|
+
print(f" {counts[BREAKING]} breaking, {counts[WARNING]} warning, {counts[INFO]} info")
|
|
229
|
+
for sev, msg in findings:
|
|
230
|
+
print(f" [{sev:8s}] {msg}")
|
|
231
|
+
print()
|
|
232
|
+
if counts[BREAKING]:
|
|
233
|
+
print(" verdict: BREAKING — re-verify this agent out-of-band before your next task.")
|
|
234
|
+
elif counts[WARNING]:
|
|
235
|
+
print(" verdict: CHANGED — review new skills before trusting them.")
|
|
236
|
+
else:
|
|
237
|
+
print(" verdict: COSMETIC — safe to re-pin.")
|
|
238
|
+
return 0
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
if __name__ == "__main__":
|
|
242
|
+
sys.exit(main())
|