vaid-a2a 0.1.1__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.
@@ -0,0 +1,30 @@
1
+ # Rust
2
+ /target
3
+ **/*.rs.bk
4
+ Cargo.lock.orig
5
+
6
+ # Backtraces
7
+ rustc-ice-*.txt
8
+
9
+ # Editor / OS
10
+ .DS_Store
11
+ *.swp
12
+ .idea/
13
+ .vscode/
14
+
15
+ # Python
16
+ __pycache__/
17
+ *.py[cod]
18
+ .pytest_cache/
19
+ *.egg-info/
20
+ dist/
21
+ build/
22
+ .venv/
23
+ .buildvenv/
24
+ .installtest/
25
+
26
+ # TypeScript / npm
27
+ node_modules/
28
+ typescript/*/dist/
29
+ typescript/*/test-dist/
30
+ *.tsbuildinfo
@@ -0,0 +1,216 @@
1
+ Metadata-Version: 2.5
2
+ Name: vaid-a2a
3
+ Version: 0.1.1
4
+ Summary: Reference verifier for the VAID A2A extension (docs/a2a/v1/extension.md): checks a VAID carried in an A2A Message against signature, expiry, chain attenuation, requested-action scope and revocation.
5
+ Project-URL: Homepage, https://github.com/solara-associates/vaid
6
+ Project-URL: Repository, https://github.com/solara-associates/vaid
7
+ Project-URL: Source, https://github.com/solara-associates/vaid/tree/main/python/vaid-a2a
8
+ Project-URL: Issues, https://github.com/solara-associates/vaid/issues
9
+ Author: solara.associates
10
+ Author-email: "solara.associates" <info@solara.associates>
11
+ License: Apache-2.0
12
+ Keywords: a2a,agent-identity,agent2agent,attenuation,delegation,vaid
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: Apache Software License
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: vaid-mint<0.10,>=0.9.0
21
+ Provides-Extra: test
22
+ Requires-Dist: pytest>=8.0; extra == 'test'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # vaid-a2a
26
+
27
+ Reference verifier for the VAID [A2A](https://a2a-protocol.org) extension
28
+ specified in [`docs/a2a/v1/extension.md`](../../docs/a2a/v1/extension.md)
29
+ (**draft**).
30
+
31
+ A2A lets agents delegate work to agents. [a2aproject/A2A#2028](https://github.com/a2aproject/A2A/issues/2028)
32
+ proposes `actorChain`, a caller-supplied record of who acted for whom, and is
33
+ explicit that a self-reported chain can narrow perfectly while proving no
34
+ actual authority. This extension supplies one concrete, offline-checkable
35
+ proof a hop can point at to fill that gap: a VAID — a signed, attenuated,
36
+ revocable delegation document this repo already mints and verifies in three
37
+ languages.
38
+
39
+ This package does not implement the extension's wire format; it implements
40
+ the **verification side**: given an A2A `Message` (as a plain `dict`) and a
41
+ trust configuration, it extracts any VAID(s) carried under the extension's
42
+ metadata keys and runs the ordered check the spec defines, returning a typed
43
+ allow/deny result with a reason — never a bare boolean, so a caller can log,
44
+ alert, or retry the right thing.
45
+
46
+ ## Install
47
+
48
+ ```sh
49
+ pip install -e python/vaid-pop -e python/vaid-mint -e python/vaid-a2a
50
+ ```
51
+
52
+ (No published release yet — this is a draft extension on a feature branch.
53
+ See `docs/a2a/drafts/a2a-2028-reply.md` and the branch's `REPORT-306.md`.)
54
+
55
+ ## Use
56
+
57
+ ```python
58
+ from datetime import datetime, timezone
59
+
60
+ from vaid_mint.revocation import InMemoryRevocationList
61
+ from vaid_a2a import verify_a2a_message, METADATA_VAID_KEY
62
+
63
+ trust_config = {
64
+ "trustedIssuers": [
65
+ {"trustDomain": "vaid.example", "kernelPublicKey": "<base64url, raw 32 bytes>"}
66
+ ]
67
+ }
68
+
69
+ message = {
70
+ "metadata": {
71
+ METADATA_VAID_KEY: leaf_vaid_document, # a dict, as produced by vaid_mint
72
+ # METADATA_CHAIN_KEY: [ancestor_vaid_1, ...], # optional, for chain checks
73
+ }
74
+ }
75
+
76
+ result = verify_a2a_message(
77
+ message,
78
+ trust_config=trust_config,
79
+ # A plain, never-populated InMemoryRevocationList() is UNAVAILABLE by
80
+ # design (R.4.5/R.4.6), so this example as written DENIES EVERY REQUEST
81
+ # with VerifyCode.INDETERMINATE, however valid the VAID is. That is
82
+ # deliberate: a copy-pasted revocation seam with nothing wired to it
83
+ # must fail closed, never read as "nothing is revoked". See "Configuring
84
+ # real revocation" below for what plugging in an actual store takes.
85
+ revocation=InMemoryRevocationList(),
86
+ requested_action="data.acme.orders",
87
+ now=datetime.now(timezone.utc),
88
+ )
89
+
90
+ if result.allowed:
91
+ ... # proceed
92
+ else:
93
+ ... # result.code (a VerifyCode) and result.reason explain why
94
+ ```
95
+
96
+ **Valid requests are refused as `indeterminate` until a real
97
+ `RevocationCheck` is configured.** This is not a bug to work around; it is
98
+ the fail-closed default R.4.5 requires. The copy-paste path above stays
99
+ closed on purpose, so a deployment cannot go live by accident with
100
+ revocation silently assumed clean.
101
+
102
+ See `examples/guard.py` for use as a guard in front of a request handler.
103
+
104
+ ### Configuring real revocation
105
+
106
+ Implement `vaid_mint.revocation.RevocationCheck` against whatever actually
107
+ tracks revoked VAIDs in your deployment (a database table, a feed, a cache
108
+ in front of either) and pass an instance of it instead of
109
+ `InMemoryRevocationList()`:
110
+
111
+ ```python
112
+ from vaid_mint.revocation import RevocationCheck, RevocationStatus
113
+
114
+ class DatabaseRevocationCheck:
115
+ """Minimal real RevocationCheck: ask the database once per lineage."""
116
+
117
+ def __init__(self, db):
118
+ self._db = db
119
+
120
+ def check_lineage(self, lineage: list[str]) -> RevocationStatus:
121
+ try:
122
+ revoked_ids = self._db.revoked_vaid_ids(lineage)
123
+ except Exception:
124
+ return RevocationStatus.UNAVAILABLE # fail closed, not NOT_REVOKED
125
+ if revoked_ids:
126
+ return RevocationStatus.REVOKED
127
+ return RevocationStatus.NOT_REVOKED
128
+
129
+ result = verify_a2a_message(
130
+ message,
131
+ trust_config=trust_config,
132
+ revocation=DatabaseRevocationCheck(db),
133
+ requested_action="data.acme.orders",
134
+ now=datetime.now(timezone.utc),
135
+ )
136
+ ```
137
+
138
+ `check_lineage` receives the full ordered lineage (root first, leaf last)
139
+ that `verify_a2a_message` already assembled; it is never asked to resolve
140
+ ancestry itself (R.4.1). `RevocationStatus` is three-valued on purpose: a
141
+ database error or an unreachable feed must report `UNAVAILABLE`, never
142
+ `NOT_REVOKED`.
143
+
144
+ ### Local demo only, never production
145
+
146
+ `InMemoryRevocationList.assume_nothing_revoked()` exists for local demos and
147
+ tests that have no revocation source to wire up and want to exercise the
148
+ rest of the check (signature, expiry, chain, scope) without every call
149
+ denying on revocation. It is **never** appropriate outside a local demo: it
150
+ makes every lineage read as not-revoked, unconditionally, regardless of
151
+ whether anything actually was.
152
+
153
+ ```python
154
+ # LOCAL DEMO ONLY, NEVER PRODUCTION.
155
+ from vaid_mint.revocation import InMemoryRevocationList
156
+
157
+ demo_result = verify_a2a_message(
158
+ message,
159
+ trust_config=trust_config,
160
+ revocation=InMemoryRevocationList.assume_nothing_revoked(),
161
+ requested_action="data.acme.orders",
162
+ now=datetime.now(timezone.utc),
163
+ )
164
+ ```
165
+
166
+ ## What this checks, and in what order
167
+
168
+ Exactly the order `docs/a2a/v1/extension.md` §5 specifies — signature, leaf
169
+ expiry, chain integrity, per-hop attenuation, requested-action scope,
170
+ revocation — fail closed at the first failing step. See the module docstring
171
+ in `vaid_a2a/verifier.py` for the full reasoning behind the order; it mirrors
172
+ `vaid_mint.verify` and `vaid_mint.chain`, which this package delegates to
173
+ rather than reimplements.
174
+
175
+ | step | delegates to |
176
+ |---|---|
177
+ | 1. signature / authenticity | `vaid_mint.verify.verify_vaid_authenticity_graded` |
178
+ | 2. leaf expiry | `vaid_mint.document.is_expired_at` |
179
+ | 3. chain integrity | `vaid_mint.chain.verify_chain_at` |
180
+ | 4. per-hop attenuation | `vaid_mint.chain.verify_chain_at` (same call) |
181
+ | 5. requested action in leaf scope | `vaid_mint.document.is_in_scope` |
182
+ | 6. revocation over the full lineage | the caller's own `vaid_mint.revocation.RevocationCheck` |
183
+
184
+ ## Test vectors
185
+
186
+ `tests/test_vectors.py` runs every vector in `docs/a2a/v1/vectors/*.json` and
187
+ asserts the expected result. Regenerate the vectors from `vaid-mint` tooling
188
+ with `docs/a2a/v1/vectors/generate_vectors.py`; see that directory's own README.
189
+
190
+ ## Packaged conformance firewall
191
+
192
+ The vectors above also ship **inside the wheel**, so an installed copy can be
193
+ checked without this repository:
194
+
195
+ ```
196
+ vaid-a2a-conformance # exit 0 = PASS, 1 = BLOCKER
197
+ python -m vaid_a2a.conformance # same
198
+ ```
199
+
200
+ It replays all 12 extension vectors through `verify_a2a_message` and asserts the
201
+ `(result, error_code)` pair `docs/a2a/v1/extension.md` section 5 specifies for
202
+ each, at the verification instant each vector names rather than the wall clock.
203
+ It pins **verdicts**, not bytes: this package signs and canonicalizes nothing of
204
+ its own, and the bytes beneath it are proven by `vaid-mint-conformance`. The
205
+ bundled vectors are byte-identical to `docs/a2a/v1/vectors/`, which CI checks
206
+ by comparing the file listings and then every file.
207
+
208
+ ## Non-goals
209
+
210
+ - Does not depend on `a2a-python` or any A2A SDK — `Message` is accepted as a
211
+ plain `dict`, so this works against any A2A server or client.
212
+ - Does not mint VAIDs (`vaid_mint.mint.MintService`) or implement revocation
213
+ transport (`docs/spec/revocation.md` R.1) — both out of scope here, as they
214
+ are for the extension itself.
215
+ - Does not implement `actorChain` (#2028) or require it to be present; see
216
+ the spec's §4.1 for how the two compose when both are active.
@@ -0,0 +1,192 @@
1
+ # vaid-a2a
2
+
3
+ Reference verifier for the VAID [A2A](https://a2a-protocol.org) extension
4
+ specified in [`docs/a2a/v1/extension.md`](../../docs/a2a/v1/extension.md)
5
+ (**draft**).
6
+
7
+ A2A lets agents delegate work to agents. [a2aproject/A2A#2028](https://github.com/a2aproject/A2A/issues/2028)
8
+ proposes `actorChain`, a caller-supplied record of who acted for whom, and is
9
+ explicit that a self-reported chain can narrow perfectly while proving no
10
+ actual authority. This extension supplies one concrete, offline-checkable
11
+ proof a hop can point at to fill that gap: a VAID — a signed, attenuated,
12
+ revocable delegation document this repo already mints and verifies in three
13
+ languages.
14
+
15
+ This package does not implement the extension's wire format; it implements
16
+ the **verification side**: given an A2A `Message` (as a plain `dict`) and a
17
+ trust configuration, it extracts any VAID(s) carried under the extension's
18
+ metadata keys and runs the ordered check the spec defines, returning a typed
19
+ allow/deny result with a reason — never a bare boolean, so a caller can log,
20
+ alert, or retry the right thing.
21
+
22
+ ## Install
23
+
24
+ ```sh
25
+ pip install -e python/vaid-pop -e python/vaid-mint -e python/vaid-a2a
26
+ ```
27
+
28
+ (No published release yet — this is a draft extension on a feature branch.
29
+ See `docs/a2a/drafts/a2a-2028-reply.md` and the branch's `REPORT-306.md`.)
30
+
31
+ ## Use
32
+
33
+ ```python
34
+ from datetime import datetime, timezone
35
+
36
+ from vaid_mint.revocation import InMemoryRevocationList
37
+ from vaid_a2a import verify_a2a_message, METADATA_VAID_KEY
38
+
39
+ trust_config = {
40
+ "trustedIssuers": [
41
+ {"trustDomain": "vaid.example", "kernelPublicKey": "<base64url, raw 32 bytes>"}
42
+ ]
43
+ }
44
+
45
+ message = {
46
+ "metadata": {
47
+ METADATA_VAID_KEY: leaf_vaid_document, # a dict, as produced by vaid_mint
48
+ # METADATA_CHAIN_KEY: [ancestor_vaid_1, ...], # optional, for chain checks
49
+ }
50
+ }
51
+
52
+ result = verify_a2a_message(
53
+ message,
54
+ trust_config=trust_config,
55
+ # A plain, never-populated InMemoryRevocationList() is UNAVAILABLE by
56
+ # design (R.4.5/R.4.6), so this example as written DENIES EVERY REQUEST
57
+ # with VerifyCode.INDETERMINATE, however valid the VAID is. That is
58
+ # deliberate: a copy-pasted revocation seam with nothing wired to it
59
+ # must fail closed, never read as "nothing is revoked". See "Configuring
60
+ # real revocation" below for what plugging in an actual store takes.
61
+ revocation=InMemoryRevocationList(),
62
+ requested_action="data.acme.orders",
63
+ now=datetime.now(timezone.utc),
64
+ )
65
+
66
+ if result.allowed:
67
+ ... # proceed
68
+ else:
69
+ ... # result.code (a VerifyCode) and result.reason explain why
70
+ ```
71
+
72
+ **Valid requests are refused as `indeterminate` until a real
73
+ `RevocationCheck` is configured.** This is not a bug to work around; it is
74
+ the fail-closed default R.4.5 requires. The copy-paste path above stays
75
+ closed on purpose, so a deployment cannot go live by accident with
76
+ revocation silently assumed clean.
77
+
78
+ See `examples/guard.py` for use as a guard in front of a request handler.
79
+
80
+ ### Configuring real revocation
81
+
82
+ Implement `vaid_mint.revocation.RevocationCheck` against whatever actually
83
+ tracks revoked VAIDs in your deployment (a database table, a feed, a cache
84
+ in front of either) and pass an instance of it instead of
85
+ `InMemoryRevocationList()`:
86
+
87
+ ```python
88
+ from vaid_mint.revocation import RevocationCheck, RevocationStatus
89
+
90
+ class DatabaseRevocationCheck:
91
+ """Minimal real RevocationCheck: ask the database once per lineage."""
92
+
93
+ def __init__(self, db):
94
+ self._db = db
95
+
96
+ def check_lineage(self, lineage: list[str]) -> RevocationStatus:
97
+ try:
98
+ revoked_ids = self._db.revoked_vaid_ids(lineage)
99
+ except Exception:
100
+ return RevocationStatus.UNAVAILABLE # fail closed, not NOT_REVOKED
101
+ if revoked_ids:
102
+ return RevocationStatus.REVOKED
103
+ return RevocationStatus.NOT_REVOKED
104
+
105
+ result = verify_a2a_message(
106
+ message,
107
+ trust_config=trust_config,
108
+ revocation=DatabaseRevocationCheck(db),
109
+ requested_action="data.acme.orders",
110
+ now=datetime.now(timezone.utc),
111
+ )
112
+ ```
113
+
114
+ `check_lineage` receives the full ordered lineage (root first, leaf last)
115
+ that `verify_a2a_message` already assembled; it is never asked to resolve
116
+ ancestry itself (R.4.1). `RevocationStatus` is three-valued on purpose: a
117
+ database error or an unreachable feed must report `UNAVAILABLE`, never
118
+ `NOT_REVOKED`.
119
+
120
+ ### Local demo only, never production
121
+
122
+ `InMemoryRevocationList.assume_nothing_revoked()` exists for local demos and
123
+ tests that have no revocation source to wire up and want to exercise the
124
+ rest of the check (signature, expiry, chain, scope) without every call
125
+ denying on revocation. It is **never** appropriate outside a local demo: it
126
+ makes every lineage read as not-revoked, unconditionally, regardless of
127
+ whether anything actually was.
128
+
129
+ ```python
130
+ # LOCAL DEMO ONLY, NEVER PRODUCTION.
131
+ from vaid_mint.revocation import InMemoryRevocationList
132
+
133
+ demo_result = verify_a2a_message(
134
+ message,
135
+ trust_config=trust_config,
136
+ revocation=InMemoryRevocationList.assume_nothing_revoked(),
137
+ requested_action="data.acme.orders",
138
+ now=datetime.now(timezone.utc),
139
+ )
140
+ ```
141
+
142
+ ## What this checks, and in what order
143
+
144
+ Exactly the order `docs/a2a/v1/extension.md` §5 specifies — signature, leaf
145
+ expiry, chain integrity, per-hop attenuation, requested-action scope,
146
+ revocation — fail closed at the first failing step. See the module docstring
147
+ in `vaid_a2a/verifier.py` for the full reasoning behind the order; it mirrors
148
+ `vaid_mint.verify` and `vaid_mint.chain`, which this package delegates to
149
+ rather than reimplements.
150
+
151
+ | step | delegates to |
152
+ |---|---|
153
+ | 1. signature / authenticity | `vaid_mint.verify.verify_vaid_authenticity_graded` |
154
+ | 2. leaf expiry | `vaid_mint.document.is_expired_at` |
155
+ | 3. chain integrity | `vaid_mint.chain.verify_chain_at` |
156
+ | 4. per-hop attenuation | `vaid_mint.chain.verify_chain_at` (same call) |
157
+ | 5. requested action in leaf scope | `vaid_mint.document.is_in_scope` |
158
+ | 6. revocation over the full lineage | the caller's own `vaid_mint.revocation.RevocationCheck` |
159
+
160
+ ## Test vectors
161
+
162
+ `tests/test_vectors.py` runs every vector in `docs/a2a/v1/vectors/*.json` and
163
+ asserts the expected result. Regenerate the vectors from `vaid-mint` tooling
164
+ with `docs/a2a/v1/vectors/generate_vectors.py`; see that directory's own README.
165
+
166
+ ## Packaged conformance firewall
167
+
168
+ The vectors above also ship **inside the wheel**, so an installed copy can be
169
+ checked without this repository:
170
+
171
+ ```
172
+ vaid-a2a-conformance # exit 0 = PASS, 1 = BLOCKER
173
+ python -m vaid_a2a.conformance # same
174
+ ```
175
+
176
+ It replays all 12 extension vectors through `verify_a2a_message` and asserts the
177
+ `(result, error_code)` pair `docs/a2a/v1/extension.md` section 5 specifies for
178
+ each, at the verification instant each vector names rather than the wall clock.
179
+ It pins **verdicts**, not bytes: this package signs and canonicalizes nothing of
180
+ its own, and the bytes beneath it are proven by `vaid-mint-conformance`. The
181
+ bundled vectors are byte-identical to `docs/a2a/v1/vectors/`, which CI checks
182
+ by comparing the file listings and then every file.
183
+
184
+ ## Non-goals
185
+
186
+ - Does not depend on `a2a-python` or any A2A SDK — `Message` is accepted as a
187
+ plain `dict`, so this works against any A2A server or client.
188
+ - Does not mint VAIDs (`vaid_mint.mint.MintService`) or implement revocation
189
+ transport (`docs/spec/revocation.md` R.1) — both out of scope here, as they
190
+ are for the extension itself.
191
+ - Does not implement `actorChain` (#2028) or require it to be present; see
192
+ the spec's §4.1 for how the two compose when both are active.
@@ -0,0 +1,54 @@
1
+ """A minimal example: use vaid_a2a.verify_a2a_message as a guard in front of
2
+ an A2A request handler. Not wired to any specific A2A server framework —
3
+ `handle_message` below is the shape any framework's message handler takes.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from vaid_a2a import METADATA_VAID_KEY, VerifyCode, verify_a2a_message
9
+ from vaid_mint.revocation import RevocationCheck
10
+
11
+ # Loaded once at startup from the AgentCard's own extension declaration.
12
+ TRUST_CONFIG = {
13
+ "trustedIssuers": [
14
+ {"trustDomain": "vaid.example", "kernelPublicKey": "<base64url, raw 32 bytes>"},
15
+ ]
16
+ }
17
+
18
+
19
+ def handle_message(message: dict, revocation: RevocationCheck) -> dict:
20
+ """A stand-in for a real A2A message handler. ``message`` is the incoming
21
+ A2A ``Message`` as a dict; ``action`` is whatever this handler is about
22
+ to do, used as the ``requested_action`` scope check."""
23
+ action = "data.acme.orders"
24
+
25
+ result = verify_a2a_message(
26
+ message,
27
+ trust_config=TRUST_CONFIG,
28
+ revocation=revocation,
29
+ requested_action=action,
30
+ )
31
+
32
+ if not result.allowed:
33
+ # result.code is a VerifyCode — map it to whatever error shape this
34
+ # handler's A2A transport expects (see docs/a2a/v1/extension.md §6).
35
+ return {
36
+ "error": {
37
+ "code": result.code.value,
38
+ "message": result.reason,
39
+ }
40
+ }
41
+
42
+ if result.code is VerifyCode.NO_VAID:
43
+ # The extension was not activated, or carried no VAID, and
44
+ # requireVaid was false — this handler's own policy decides whether
45
+ # that is acceptable for this action. Shown here for completeness;
46
+ # a handler that requires a VAID for every action should instead
47
+ # pass require_vaid=True above and never reach this branch.
48
+ pass
49
+
50
+ return {"result": f"handled action {action!r}"}
51
+
52
+
53
+ if __name__ == "__main__":
54
+ print(METADATA_VAID_KEY)
@@ -0,0 +1,68 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "vaid-a2a"
7
+ version = "0.1.1"
8
+ description = "Reference verifier for the VAID A2A extension (docs/a2a/v1/extension.md): checks a VAID carried in an A2A Message against signature, expiry, chain attenuation, requested-action scope and revocation."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "Apache-2.0" }
12
+ authors = [
13
+ # Two entries by design: PEP 621 routes any entry that has an email
14
+ # entirely into Author-email, leaving the core Author field empty (PyPI
15
+ # then renders "Author: None"). The name-only entry populates Author;
16
+ # the second preserves the existing Author-email verbatim.
17
+ { name = "solara.associates" },
18
+ { name = "solara.associates", email = "info@solara.associates" },
19
+ ]
20
+ keywords = ["vaid", "a2a", "agent-identity", "delegation", "attenuation", "agent2agent"]
21
+ classifiers = [
22
+ "Development Status :: 3 - Alpha",
23
+ "License :: OSI Approved :: Apache Software License",
24
+ "Intended Audience :: Developers",
25
+ "Programming Language :: Python :: 3.10",
26
+ "Programming Language :: Python :: 3.11",
27
+ "Programming Language :: Python :: 3.12",
28
+ ]
29
+ # vaid-mint supplies verification, chain-attenuation and revocation primitives
30
+ # (vaid_mint.verify, vaid_mint.chain, vaid_mint.document, vaid_mint.revocation) —
31
+ # this package is a thin extraction-and-ordering layer over them, not a second
32
+ # implementation. No dependency on a2a-python or any A2A SDK: Message is accepted
33
+ # as a plain dict so this works against any A2A server or client, per the brief.
34
+ dependencies = [
35
+ "vaid-mint>=0.9.0,<0.10",
36
+ ]
37
+
38
+ [project.scripts]
39
+ # The packaged firewall: an external consumer who installed ONLY the wheel can run
40
+ # `vaid-a2a-conformance` to prove the verifier they got reaches the verdict
41
+ # docs/a2a/v1/extension.md specifies for every published extension vector. It pins
42
+ # VERDICTS, not bytes - see the module docstring on why that is the normative
43
+ # surface for a verifier, and why it is a different claim rather than a weaker one.
44
+ vaid-a2a-conformance = "vaid_a2a.conformance:main"
45
+
46
+ [project.optional-dependencies]
47
+ test = [
48
+ "pytest>=8.0",
49
+ ]
50
+
51
+ [project.urls]
52
+ Homepage = "https://github.com/solara-associates/vaid"
53
+ Repository = "https://github.com/solara-associates/vaid"
54
+ Source = "https://github.com/solara-associates/vaid/tree/main/python/vaid-a2a"
55
+ Issues = "https://github.com/solara-associates/vaid/issues"
56
+
57
+ [tool.hatch.build.targets.wheel]
58
+ # The extension vectors ship inside the wheel (vaid_a2a/vectors/, vendored
59
+ # byte-identically from docs/a2a/v1/vectors/ and cmp-checked in CI) so a
60
+ # consumer's firewall runs against the exact bytes this verifier was proven
61
+ # against - the same reason vaid_pop and vaid_mint bundle theirs.
62
+ packages = ["vaid_a2a"]
63
+
64
+ [tool.hatch.build.targets.sdist]
65
+ include = ["vaid_a2a", "tests", "examples", "README.md"]
66
+
67
+ [tool.pytest.ini_options]
68
+ testpaths = ["tests"]
@@ -0,0 +1,94 @@
1
+ """Runs every vector in docs/a2a/v1/vectors/*.json against verify_a2a_message and
2
+ asserts the expected (result, error_code) pair.
3
+
4
+ The vectors live outside this package, under docs/a2a/v1/vectors/, because they
5
+ are the extension's vectors — not vaid-a2a's private fixtures — and
6
+ test_extension.py-equivalents in other languages would read the same files.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+
15
+ import pytest
16
+
17
+ from vaid_a2a import METADATA_CHAIN_KEY, METADATA_VAID_KEY, verify_a2a_message
18
+ from vaid_mint.revocation import InMemoryRevocationList
19
+
20
+ VECTORS_DIR = Path(__file__).resolve().parents[3] / "docs" / "a2a" / "v1" / "vectors"
21
+
22
+
23
+ def _parse_verification_time(raw: str) -> datetime:
24
+ """Parse a vector's own ``verification_time`` (RFC 3339, ``Z`` suffix) —
25
+ never the wall clock, per the vectors README."""
26
+ return datetime.fromisoformat(raw.replace("Z", "+00:00"))
27
+
28
+
29
+ def _vector_files() -> list[Path]:
30
+ files = sorted(VECTORS_DIR.glob("*.json"))
31
+ assert files, f"no vectors found under {VECTORS_DIR} — generator not run?"
32
+ return files
33
+
34
+
35
+ def _load(path: Path) -> dict:
36
+ return json.loads(path.read_text())
37
+
38
+
39
+ def _revocation_for(revoked_ids: list[str]) -> InMemoryRevocationList:
40
+ store = InMemoryRevocationList()
41
+ if not revoked_ids:
42
+ # An empty revocation list must still VOUCH for "nothing revoked",
43
+ # not read as UNAVAILABLE (R.4.5/R.4.6) — assume_nothing_revoked()
44
+ # is the explicit, named way to get that posture rather than relying
45
+ # on an absent store by accident.
46
+ return InMemoryRevocationList.assume_nothing_revoked()
47
+ for vaid_id in revoked_ids:
48
+ store.revoke(vaid_id)
49
+ return store
50
+
51
+
52
+ @pytest.mark.parametrize("vector_path", _vector_files(), ids=lambda p: p.stem)
53
+ def test_vector(vector_path: Path) -> None:
54
+ vector = _load(vector_path)
55
+
56
+ message = {
57
+ "metadata": {
58
+ METADATA_VAID_KEY: vector["inputs"]["leaf"],
59
+ METADATA_CHAIN_KEY: vector["inputs"]["chain"],
60
+ }
61
+ }
62
+ revocation = _revocation_for(vector["revoked_vaid_ids"])
63
+
64
+ result = verify_a2a_message(
65
+ message,
66
+ trust_config=vector["trust_config"],
67
+ revocation=revocation,
68
+ requested_action=vector.get("requested_action"),
69
+ now=_parse_verification_time(vector["verification_time"]),
70
+ )
71
+
72
+ expected_pass = vector["expected_result"] == "pass"
73
+ assert result.allowed is expected_pass, (
74
+ f"{vector_path.name}: expected allowed={expected_pass}, got "
75
+ f"{result.allowed} ({result.code.value}: {result.reason})"
76
+ )
77
+ if vector.get("expected_error_code") is not None:
78
+ assert result.code.value == vector["expected_error_code"], (
79
+ f"{vector_path.name}: expected code "
80
+ f"{vector['expected_error_code']!r}, got {result.code.value!r} "
81
+ f"({result.reason})"
82
+ )
83
+
84
+
85
+ def test_all_vectors_covered() -> None:
86
+ """A guard against the parametrized test silently collecting zero cases —
87
+ the exact "ALL-PASS, a workflow not started" shape: a suite with no
88
+ vectors loaded would otherwise report a clean, empty pass.
89
+
90
+ The floor tracks the number of vectors that actually exist. Left at a
91
+ stale lower number it stops being a guard against anything but total
92
+ absence: with 12 vectors published, a floor of 6 would let half of them
93
+ disappear and still report a clean pass."""
94
+ assert len(_vector_files()) >= 12