vaid-adk 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.
- vaid_adk-0.1.0/.gitignore +30 -0
- vaid_adk-0.1.0/PKG-INFO +265 -0
- vaid_adk-0.1.0/README.md +239 -0
- vaid_adk-0.1.0/examples/two_agent_delegation.py +189 -0
- vaid_adk-0.1.0/pyproject.toml +69 -0
- vaid_adk-0.1.0/tests/test_adk_verify.py +196 -0
- vaid_adk-0.1.0/tests/test_plugin.py +132 -0
- vaid_adk-0.1.0/tests/test_trust_domain_binding.py +565 -0
- vaid_adk-0.1.0/vaid_adk/__init__.py +70 -0
- vaid_adk-0.1.0/vaid_adk/_verify.py +512 -0
- vaid_adk-0.1.0/vaid_adk/conformance.py +373 -0
- vaid_adk-0.1.0/vaid_adk/plugin.py +196 -0
|
@@ -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
|
vaid_adk-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: vaid-adk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: VAID verification for Google ADK: a BasePlugin that checks a presented VAID's signature, expiry, delegation-chain attenuation and revocation before an agent runs, is delegated to, or calls a tool, denying fail-closed.
|
|
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-adk
|
|
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: adk,agent-identity,attenuation,delegation,google-adk,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: google-adk<3,>=2.0
|
|
21
|
+
Requires-Dist: vaid-mint<0.10,>=0.9.0
|
|
22
|
+
Provides-Extra: test
|
|
23
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
|
|
24
|
+
Requires-Dist: pytest>=8.0; extra == 'test'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# vaid-adk
|
|
28
|
+
|
|
29
|
+
VAID verification for Google's Agent Development Kit (ADK): a `BasePlugin`
|
|
30
|
+
that checks a presented VAID's signature, expiry, delegation-chain
|
|
31
|
+
attenuation and revocation status before an agent runs, before it is
|
|
32
|
+
delegated to as a sub-agent, and before it calls a tool. It denies
|
|
33
|
+
fail-closed whenever any of that cannot be positively confirmed.
|
|
34
|
+
|
|
35
|
+
ADK has no built-in mechanism for this (google/adk-python#4992, #6551). This
|
|
36
|
+
package adds it for an agent that already carries a VAID
|
|
37
|
+
(https://github.com/solara-associates/vaid), using only `vaid_mint`'s
|
|
38
|
+
existing signature, chain and revocation primitives. It does not mint
|
|
39
|
+
credentials; minting an attenuated child VAID for a sub-agent is the
|
|
40
|
+
caller's job, via `vaid_mint.mint.MintService.mint_child`, exactly what
|
|
41
|
+
`examples/two_agent_delegation.py` does before wiring the plugin.
|
|
42
|
+
|
|
43
|
+
## Why a plugin, not a per-agent callback
|
|
44
|
+
|
|
45
|
+
ADK's `BaseAgent`/`LlmAgent` accept `before_agent_callback` and
|
|
46
|
+
`before_tool_callback` directly, and that is the hook an ADK adapter built
|
|
47
|
+
in June 2026 (`forge-agents` commit `85fdaa8`, `synthera` commit `dd0503c`)
|
|
48
|
+
used for its governed-delegation gate: `before_agent_callback` closing over
|
|
49
|
+
one sub-agent's `mint_child` call. That shape, enforce before an agent
|
|
50
|
+
runs and before a tool is called, is reused here, but `vaid-adk` registers
|
|
51
|
+
through `BasePlugin` instead of per-agent fields, for one reason: a
|
|
52
|
+
per-agent callback is opt-in per agent, so an agent constructed without one
|
|
53
|
+
runs ungoverned by omission. A `BasePlugin` is registered once on the
|
|
54
|
+
`Runner`/`App`, and ADK's `plugin_manager` consults it for every agent and
|
|
55
|
+
every tool call in the invocation tree, including a sub-agent added after
|
|
56
|
+
the gate was wired, and including ADK's own native delegation
|
|
57
|
+
(`transfer_to_agent` / `sub_agents`) with no further code at the call site.
|
|
58
|
+
|
|
59
|
+
Confirmed directly against the ADK source (`google-adk==2.11.0`):
|
|
60
|
+
|
|
61
|
+
- `BaseAgent._run_with_lifecycle` calls `self._handle_before_agent_callback(ctx)`
|
|
62
|
+
on **every** `run_async`, which runs `ctx.plugin_manager.run_before_agent_callback`
|
|
63
|
+
before the agent's own canonical callbacks; returning `Content` sets
|
|
64
|
+
`ctx.end_invocation`, skipping the agent entirely, including a sub-agent
|
|
65
|
+
reached through ADK's native delegation loop.
|
|
66
|
+
- `_caller.py`'s `_call_tool_async` runs
|
|
67
|
+
`invocation_context.plugin_manager.run_before_tool_callback(tool=tool,
|
|
68
|
+
tool_args=function_args, tool_context=tool_context)` before any canonical
|
|
69
|
+
callback; a non-`None` return becomes the tool's function response
|
|
70
|
+
directly, and the tool itself is never called.
|
|
71
|
+
|
|
72
|
+
## Usage
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from vaid_adk import (
|
|
76
|
+
ToolAuthorization,
|
|
77
|
+
TrustedIssuers,
|
|
78
|
+
VaidAuthPlugin,
|
|
79
|
+
VaidPresentation,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
plugin = VaidAuthPlugin(
|
|
83
|
+
# Each trusted kernel key is bound to the ONE trust domain it is trusted
|
|
84
|
+
# for. Read from an `AgentCard`-shaped config instead with
|
|
85
|
+
# `TrustedIssuers([{"trustDomain": ..., "kernelPublicKey": "<base64url>"}])`.
|
|
86
|
+
keys=TrustedIssuers.from_raw_keys([("vaid.example", issuer_kernel_public_key)]),
|
|
87
|
+
revocation=my_revocation_check, # see "Production note" below
|
|
88
|
+
tool_authorizations={
|
|
89
|
+
"lookup_order": ToolAuthorization(capability="read", resource="data.orders"),
|
|
90
|
+
"issue_refund": ToolAuthorization(capability="write", resource="data.payments"),
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Register the VAID each agent presents: its own signed document (`leaf`)
|
|
95
|
+
# plus the ancestor chain back to a trusted root (`chain`), not including
|
|
96
|
+
# the leaf itself.
|
|
97
|
+
plugin.register("coordinator", VaidPresentation(leaf=root_vaid))
|
|
98
|
+
plugin.register("worker", VaidPresentation(leaf=worker_child_vaid, chain=(root_vaid,)))
|
|
99
|
+
|
|
100
|
+
# runner = Runner(..., plugins=[plugin])
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
A tool with no entry in `tool_authorizations` is **denied**, never allowed
|
|
104
|
+
by omission. An agent with no registered presentation is denied the same
|
|
105
|
+
way: it is indistinguishable from one that was simply never given an
|
|
106
|
+
identity.
|
|
107
|
+
|
|
108
|
+
## Run the example
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
pip install -e python/vaid-mint
|
|
112
|
+
pip install -e python/vaid-adk[test]
|
|
113
|
+
python python/vaid-adk/examples/two_agent_delegation.py
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
A coordinator mints an attenuated child VAID for a worker sub-agent (scope
|
|
117
|
+
`data.orders` only, capability `read` only, narrower than the coordinator's
|
|
118
|
+
own `data.orders` + `data.payments`, `read` + `write`), registers both
|
|
119
|
+
agents' presentations with the plugin, and runs them through a real ADK
|
|
120
|
+
`Runner`. The worker then attempts two tool calls:
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
[coordinator] delegating to worker
|
|
124
|
+
[worker] lookup_order: ALLOWED
|
|
125
|
+
[worker] issue_refund: DENIED (out_of_scope)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`lookup_order` is within the worker's attenuated `data.orders`/`read`
|
|
129
|
+
bounds; `issue_refund` needs `data.payments`/`write`, which the worker's
|
|
130
|
+
VAID never held, so the plugin denies it before the tool runs.
|
|
131
|
+
|
|
132
|
+
## Trust configuration: a key is trusted for one domain
|
|
133
|
+
|
|
134
|
+
`keys` is a `TrustedIssuers`, and each kernel key in it is bound to the **one
|
|
135
|
+
trust domain it is trusted for**. A document signed by a trusted key is
|
|
136
|
+
accepted only if it also claims that key's domain.
|
|
137
|
+
|
|
138
|
+
Binding matters because without it a trusted key vouches for any domain a
|
|
139
|
+
document cares to claim. Given two legitimately trusted issuers, A and B, a
|
|
140
|
+
document signed by B's key while claiming A's `trust_domain` verifies
|
|
141
|
+
perfectly against B's key and was accepted: B's key vouching for A's
|
|
142
|
+
identity, with no forged signature and no compromised key anywhere in the
|
|
143
|
+
attack (GHSA-hfjh-v2x2-r58v). A key is trusted for the domain it is paired
|
|
144
|
+
with, never for a domain a document asserts.
|
|
145
|
+
|
|
146
|
+
Two spellings of the same configuration:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
# From an AgentCard-shaped config: the VAID A2A extension's `trustedIssuers`
|
|
150
|
+
# shape (docs/a2a/v1/extension.md section 2.1), so one agent's trust
|
|
151
|
+
# configuration reads the same whether it serves A2A or runs under ADK.
|
|
152
|
+
keys = TrustedIssuers([
|
|
153
|
+
{"trustDomain": "vaid.example", "kernelPublicKey": "IVL40Zt5HSRF..."},
|
|
154
|
+
{"trustDomain": "other.example", "kernelPublicKey": "My6-jSfLcyOz..."},
|
|
155
|
+
])
|
|
156
|
+
|
|
157
|
+
# From key bytes already held in the process (a local issuer, a key just
|
|
158
|
+
# fetched from a KMS).
|
|
159
|
+
keys = TrustedIssuers.from_raw_keys([("vaid.example", issuer_kernel_public_key)])
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
`kernelPublicKey` is decoded as **strict unpadded base64url to exactly 32
|
|
163
|
+
bytes**. A padded, non-canonical, out-of-alphabet, empty or wrong-length
|
|
164
|
+
value raises `TrustedIssuersConfigError` rather than being repaired or
|
|
165
|
+
skipped, as do a non-object entry, an entry with no `trustDomain`, and the
|
|
166
|
+
same key listed under two domains. One domain may hold several keys (an
|
|
167
|
+
issuer rotating); one key may not hold two domains.
|
|
168
|
+
|
|
169
|
+
A bare `vaid_mint.chain.KernelKeyMap` or `SingleKernelKey` is **not** accepted
|
|
170
|
+
as `keys`. Those resolve a key by thumbprint and cannot say which domain it is
|
|
171
|
+
trusted for, so the binding could not be checked at all; `VaidAuthPlugin`
|
|
172
|
+
refuses one in its constructor, so the gap cannot be left open by omission.
|
|
173
|
+
Any resolver answering both `resolve_key` and `trust_domain_for` is accepted.
|
|
174
|
+
|
|
175
|
+
## What this package checks, in order
|
|
176
|
+
|
|
177
|
+
1. The leaf VAID's own trust-domain binding — its claimed `trust_domain`
|
|
178
|
+
equals the domain its `kernel_key_thumbprint` is bound to in `keys` — and
|
|
179
|
+
only then its kernel signature, against the key that thumbprint resolved
|
|
180
|
+
to. The binding is checked first so an unrecognised or misbound issuer
|
|
181
|
+
(`issuer_mismatch`) stays distinguishable from a correctly-bound issuer
|
|
182
|
+
whose signature does not verify (`inauthentic`).
|
|
183
|
+
2. The leaf's own `expires_at`.
|
|
184
|
+
3. Each presented ancestor's own trust-domain binding, by the same rule and
|
|
185
|
+
reporting the same code, and then the presented chain's integrity and
|
|
186
|
+
attenuation back to a trusted root (`vaid_mint.chain.verify_chain_at`):
|
|
187
|
+
scope, capabilities, tenant and expiry contained at every hop.
|
|
188
|
+
4. The specific tool call's mapped `resource`/`capability` against the
|
|
189
|
+
leaf's own `scope_boundary`/`capability_set`.
|
|
190
|
+
5. Revocation of every VAID in the assembled lineage, via the
|
|
191
|
+
`RevocationCheck` passed to `VaidAuthPlugin`.
|
|
192
|
+
|
|
193
|
+
The order is load-bearing, not incidental. Step 1 reaches its verdict on the
|
|
194
|
+
leaf alone before step 3 examines any presented document, so a leaf whose own
|
|
195
|
+
signature does not verify is `inauthentic` regardless of what else was
|
|
196
|
+
presented alongside it — a defect found in the chain never pre-empts a verdict
|
|
197
|
+
step 1 would already have reached.
|
|
198
|
+
|
|
199
|
+
Any step that cannot be positively confirmed denies the call. "Could not
|
|
200
|
+
determine" (an unassembled chain, an unavailable revocation store) is never
|
|
201
|
+
read as "not revoked"; see `vaid_mint.revocation.RevocationStatus` and
|
|
202
|
+
`vaid_mint.verify.VaidVerdict`, whose fail-closed rule this package inherits
|
|
203
|
+
rather than re-deciding.
|
|
204
|
+
|
|
205
|
+
## Production note: wire a real RevocationCheck
|
|
206
|
+
|
|
207
|
+
`VaidAuthPlugin` takes `revocation` as a **required** constructor argument
|
|
208
|
+
with no default. The example above uses
|
|
209
|
+
`InMemoryRevocationList.assume_nothing_revoked()` only because it has no
|
|
210
|
+
production revocation store to call. That store is non-durable and fails
|
|
211
|
+
*open* after a restart (a VAID revoked before the restart verifies clean
|
|
212
|
+
again), which is fine for a demo and wrong for anything that must survive
|
|
213
|
+
one. For production, inject a durable `RevocationCheck` backed by your own
|
|
214
|
+
revoked-credential store (see `vaid_mint.revocation.RevocationBackend` for
|
|
215
|
+
the reference shape: both the revoked-set half and the lineage-resolver half
|
|
216
|
+
durable together, never one without the other) and let it report
|
|
217
|
+
`RevocationStatus.UNAVAILABLE` whenever it cannot be reached; this plugin
|
|
218
|
+
already denies on that outcome.
|
|
219
|
+
|
|
220
|
+
## What this package does not do
|
|
221
|
+
|
|
222
|
+
- **It does not mint.** Issuing a root VAID or an attenuated child VAID for
|
|
223
|
+
a sub-agent is ordinary `vaid_mint` usage (`MintService.mint_root` /
|
|
224
|
+
`mint_child`), done by the caller before `VaidAuthPlugin.register` is
|
|
225
|
+
called.
|
|
226
|
+
- **It does not depend on `forge-agents` or `synthera`.** The June 2026 ADK
|
|
227
|
+
adapters in those repos are prior art for the hook choice; this package
|
|
228
|
+
reuses only `vaid_mint`.
|
|
229
|
+
- **It does not depend on `vaid-a2a`.** The verification order in
|
|
230
|
+
`vaid_adk._verify` mirrors `vaid_a2a.verifier.verify_a2a_message`'s order
|
|
231
|
+
deliberately (both are "how do you check a presented VAID end to end"),
|
|
232
|
+
but each is self-contained against `vaid_mint` rather than one importing
|
|
233
|
+
the other.
|
|
234
|
+
|
|
235
|
+
## Packaged conformance firewall
|
|
236
|
+
|
|
237
|
+
An installed copy can be checked without this repository:
|
|
238
|
+
|
|
239
|
+
```
|
|
240
|
+
vaid-adk-conformance # exit 0 = PASS, 1 = BLOCKER
|
|
241
|
+
python -m vaid_adk.conformance # same
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
It verifies the frozen three-hop delegation chain bundled in `vaid-mint`
|
|
245
|
+
(`chain_v1.json`), at the instant that vector names rather than the wall clock,
|
|
246
|
+
and asserts eight outcomes: one that must be **allowed**, and seven that must
|
|
247
|
+
each be **denied with a different code** (cross-domain key, out-of-scope
|
|
248
|
+
resource, missing capability, no VAID, withheld ancestry, revoked ancestor,
|
|
249
|
+
revocation unavailable). It also asserts that a trust resolver which cannot bind
|
|
250
|
+
a domain to a key is refused outright, which is the half of
|
|
251
|
+
GHSA-hfjh-v2x2-r58v no single request can demonstrate.
|
|
252
|
+
|
|
253
|
+
The allowed case is load-bearing: without it, a build that denied everything
|
|
254
|
+
would satisfy every other check. It pins **verdicts**, not bytes - this package
|
|
255
|
+
signs nothing of its own.
|
|
256
|
+
|
|
257
|
+
## Install
|
|
258
|
+
|
|
259
|
+
**Local dev only**, from a repo checkout:
|
|
260
|
+
|
|
261
|
+
```
|
|
262
|
+
pip install -e python/vaid-pop
|
|
263
|
+
pip install -e python/vaid-mint
|
|
264
|
+
pip install -e python/vaid-adk[test]
|
|
265
|
+
```
|
vaid_adk-0.1.0/README.md
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# vaid-adk
|
|
2
|
+
|
|
3
|
+
VAID verification for Google's Agent Development Kit (ADK): a `BasePlugin`
|
|
4
|
+
that checks a presented VAID's signature, expiry, delegation-chain
|
|
5
|
+
attenuation and revocation status before an agent runs, before it is
|
|
6
|
+
delegated to as a sub-agent, and before it calls a tool. It denies
|
|
7
|
+
fail-closed whenever any of that cannot be positively confirmed.
|
|
8
|
+
|
|
9
|
+
ADK has no built-in mechanism for this (google/adk-python#4992, #6551). This
|
|
10
|
+
package adds it for an agent that already carries a VAID
|
|
11
|
+
(https://github.com/solara-associates/vaid), using only `vaid_mint`'s
|
|
12
|
+
existing signature, chain and revocation primitives. It does not mint
|
|
13
|
+
credentials; minting an attenuated child VAID for a sub-agent is the
|
|
14
|
+
caller's job, via `vaid_mint.mint.MintService.mint_child`, exactly what
|
|
15
|
+
`examples/two_agent_delegation.py` does before wiring the plugin.
|
|
16
|
+
|
|
17
|
+
## Why a plugin, not a per-agent callback
|
|
18
|
+
|
|
19
|
+
ADK's `BaseAgent`/`LlmAgent` accept `before_agent_callback` and
|
|
20
|
+
`before_tool_callback` directly, and that is the hook an ADK adapter built
|
|
21
|
+
in June 2026 (`forge-agents` commit `85fdaa8`, `synthera` commit `dd0503c`)
|
|
22
|
+
used for its governed-delegation gate: `before_agent_callback` closing over
|
|
23
|
+
one sub-agent's `mint_child` call. That shape, enforce before an agent
|
|
24
|
+
runs and before a tool is called, is reused here, but `vaid-adk` registers
|
|
25
|
+
through `BasePlugin` instead of per-agent fields, for one reason: a
|
|
26
|
+
per-agent callback is opt-in per agent, so an agent constructed without one
|
|
27
|
+
runs ungoverned by omission. A `BasePlugin` is registered once on the
|
|
28
|
+
`Runner`/`App`, and ADK's `plugin_manager` consults it for every agent and
|
|
29
|
+
every tool call in the invocation tree, including a sub-agent added after
|
|
30
|
+
the gate was wired, and including ADK's own native delegation
|
|
31
|
+
(`transfer_to_agent` / `sub_agents`) with no further code at the call site.
|
|
32
|
+
|
|
33
|
+
Confirmed directly against the ADK source (`google-adk==2.11.0`):
|
|
34
|
+
|
|
35
|
+
- `BaseAgent._run_with_lifecycle` calls `self._handle_before_agent_callback(ctx)`
|
|
36
|
+
on **every** `run_async`, which runs `ctx.plugin_manager.run_before_agent_callback`
|
|
37
|
+
before the agent's own canonical callbacks; returning `Content` sets
|
|
38
|
+
`ctx.end_invocation`, skipping the agent entirely, including a sub-agent
|
|
39
|
+
reached through ADK's native delegation loop.
|
|
40
|
+
- `_caller.py`'s `_call_tool_async` runs
|
|
41
|
+
`invocation_context.plugin_manager.run_before_tool_callback(tool=tool,
|
|
42
|
+
tool_args=function_args, tool_context=tool_context)` before any canonical
|
|
43
|
+
callback; a non-`None` return becomes the tool's function response
|
|
44
|
+
directly, and the tool itself is never called.
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from vaid_adk import (
|
|
50
|
+
ToolAuthorization,
|
|
51
|
+
TrustedIssuers,
|
|
52
|
+
VaidAuthPlugin,
|
|
53
|
+
VaidPresentation,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
plugin = VaidAuthPlugin(
|
|
57
|
+
# Each trusted kernel key is bound to the ONE trust domain it is trusted
|
|
58
|
+
# for. Read from an `AgentCard`-shaped config instead with
|
|
59
|
+
# `TrustedIssuers([{"trustDomain": ..., "kernelPublicKey": "<base64url>"}])`.
|
|
60
|
+
keys=TrustedIssuers.from_raw_keys([("vaid.example", issuer_kernel_public_key)]),
|
|
61
|
+
revocation=my_revocation_check, # see "Production note" below
|
|
62
|
+
tool_authorizations={
|
|
63
|
+
"lookup_order": ToolAuthorization(capability="read", resource="data.orders"),
|
|
64
|
+
"issue_refund": ToolAuthorization(capability="write", resource="data.payments"),
|
|
65
|
+
},
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Register the VAID each agent presents: its own signed document (`leaf`)
|
|
69
|
+
# plus the ancestor chain back to a trusted root (`chain`), not including
|
|
70
|
+
# the leaf itself.
|
|
71
|
+
plugin.register("coordinator", VaidPresentation(leaf=root_vaid))
|
|
72
|
+
plugin.register("worker", VaidPresentation(leaf=worker_child_vaid, chain=(root_vaid,)))
|
|
73
|
+
|
|
74
|
+
# runner = Runner(..., plugins=[plugin])
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
A tool with no entry in `tool_authorizations` is **denied**, never allowed
|
|
78
|
+
by omission. An agent with no registered presentation is denied the same
|
|
79
|
+
way: it is indistinguishable from one that was simply never given an
|
|
80
|
+
identity.
|
|
81
|
+
|
|
82
|
+
## Run the example
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
pip install -e python/vaid-mint
|
|
86
|
+
pip install -e python/vaid-adk[test]
|
|
87
|
+
python python/vaid-adk/examples/two_agent_delegation.py
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
A coordinator mints an attenuated child VAID for a worker sub-agent (scope
|
|
91
|
+
`data.orders` only, capability `read` only, narrower than the coordinator's
|
|
92
|
+
own `data.orders` + `data.payments`, `read` + `write`), registers both
|
|
93
|
+
agents' presentations with the plugin, and runs them through a real ADK
|
|
94
|
+
`Runner`. The worker then attempts two tool calls:
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
[coordinator] delegating to worker
|
|
98
|
+
[worker] lookup_order: ALLOWED
|
|
99
|
+
[worker] issue_refund: DENIED (out_of_scope)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`lookup_order` is within the worker's attenuated `data.orders`/`read`
|
|
103
|
+
bounds; `issue_refund` needs `data.payments`/`write`, which the worker's
|
|
104
|
+
VAID never held, so the plugin denies it before the tool runs.
|
|
105
|
+
|
|
106
|
+
## Trust configuration: a key is trusted for one domain
|
|
107
|
+
|
|
108
|
+
`keys` is a `TrustedIssuers`, and each kernel key in it is bound to the **one
|
|
109
|
+
trust domain it is trusted for**. A document signed by a trusted key is
|
|
110
|
+
accepted only if it also claims that key's domain.
|
|
111
|
+
|
|
112
|
+
Binding matters because without it a trusted key vouches for any domain a
|
|
113
|
+
document cares to claim. Given two legitimately trusted issuers, A and B, a
|
|
114
|
+
document signed by B's key while claiming A's `trust_domain` verifies
|
|
115
|
+
perfectly against B's key and was accepted: B's key vouching for A's
|
|
116
|
+
identity, with no forged signature and no compromised key anywhere in the
|
|
117
|
+
attack (GHSA-hfjh-v2x2-r58v). A key is trusted for the domain it is paired
|
|
118
|
+
with, never for a domain a document asserts.
|
|
119
|
+
|
|
120
|
+
Two spellings of the same configuration:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
# From an AgentCard-shaped config: the VAID A2A extension's `trustedIssuers`
|
|
124
|
+
# shape (docs/a2a/v1/extension.md section 2.1), so one agent's trust
|
|
125
|
+
# configuration reads the same whether it serves A2A or runs under ADK.
|
|
126
|
+
keys = TrustedIssuers([
|
|
127
|
+
{"trustDomain": "vaid.example", "kernelPublicKey": "IVL40Zt5HSRF..."},
|
|
128
|
+
{"trustDomain": "other.example", "kernelPublicKey": "My6-jSfLcyOz..."},
|
|
129
|
+
])
|
|
130
|
+
|
|
131
|
+
# From key bytes already held in the process (a local issuer, a key just
|
|
132
|
+
# fetched from a KMS).
|
|
133
|
+
keys = TrustedIssuers.from_raw_keys([("vaid.example", issuer_kernel_public_key)])
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`kernelPublicKey` is decoded as **strict unpadded base64url to exactly 32
|
|
137
|
+
bytes**. A padded, non-canonical, out-of-alphabet, empty or wrong-length
|
|
138
|
+
value raises `TrustedIssuersConfigError` rather than being repaired or
|
|
139
|
+
skipped, as do a non-object entry, an entry with no `trustDomain`, and the
|
|
140
|
+
same key listed under two domains. One domain may hold several keys (an
|
|
141
|
+
issuer rotating); one key may not hold two domains.
|
|
142
|
+
|
|
143
|
+
A bare `vaid_mint.chain.KernelKeyMap` or `SingleKernelKey` is **not** accepted
|
|
144
|
+
as `keys`. Those resolve a key by thumbprint and cannot say which domain it is
|
|
145
|
+
trusted for, so the binding could not be checked at all; `VaidAuthPlugin`
|
|
146
|
+
refuses one in its constructor, so the gap cannot be left open by omission.
|
|
147
|
+
Any resolver answering both `resolve_key` and `trust_domain_for` is accepted.
|
|
148
|
+
|
|
149
|
+
## What this package checks, in order
|
|
150
|
+
|
|
151
|
+
1. The leaf VAID's own trust-domain binding — its claimed `trust_domain`
|
|
152
|
+
equals the domain its `kernel_key_thumbprint` is bound to in `keys` — and
|
|
153
|
+
only then its kernel signature, against the key that thumbprint resolved
|
|
154
|
+
to. The binding is checked first so an unrecognised or misbound issuer
|
|
155
|
+
(`issuer_mismatch`) stays distinguishable from a correctly-bound issuer
|
|
156
|
+
whose signature does not verify (`inauthentic`).
|
|
157
|
+
2. The leaf's own `expires_at`.
|
|
158
|
+
3. Each presented ancestor's own trust-domain binding, by the same rule and
|
|
159
|
+
reporting the same code, and then the presented chain's integrity and
|
|
160
|
+
attenuation back to a trusted root (`vaid_mint.chain.verify_chain_at`):
|
|
161
|
+
scope, capabilities, tenant and expiry contained at every hop.
|
|
162
|
+
4. The specific tool call's mapped `resource`/`capability` against the
|
|
163
|
+
leaf's own `scope_boundary`/`capability_set`.
|
|
164
|
+
5. Revocation of every VAID in the assembled lineage, via the
|
|
165
|
+
`RevocationCheck` passed to `VaidAuthPlugin`.
|
|
166
|
+
|
|
167
|
+
The order is load-bearing, not incidental. Step 1 reaches its verdict on the
|
|
168
|
+
leaf alone before step 3 examines any presented document, so a leaf whose own
|
|
169
|
+
signature does not verify is `inauthentic` regardless of what else was
|
|
170
|
+
presented alongside it — a defect found in the chain never pre-empts a verdict
|
|
171
|
+
step 1 would already have reached.
|
|
172
|
+
|
|
173
|
+
Any step that cannot be positively confirmed denies the call. "Could not
|
|
174
|
+
determine" (an unassembled chain, an unavailable revocation store) is never
|
|
175
|
+
read as "not revoked"; see `vaid_mint.revocation.RevocationStatus` and
|
|
176
|
+
`vaid_mint.verify.VaidVerdict`, whose fail-closed rule this package inherits
|
|
177
|
+
rather than re-deciding.
|
|
178
|
+
|
|
179
|
+
## Production note: wire a real RevocationCheck
|
|
180
|
+
|
|
181
|
+
`VaidAuthPlugin` takes `revocation` as a **required** constructor argument
|
|
182
|
+
with no default. The example above uses
|
|
183
|
+
`InMemoryRevocationList.assume_nothing_revoked()` only because it has no
|
|
184
|
+
production revocation store to call. That store is non-durable and fails
|
|
185
|
+
*open* after a restart (a VAID revoked before the restart verifies clean
|
|
186
|
+
again), which is fine for a demo and wrong for anything that must survive
|
|
187
|
+
one. For production, inject a durable `RevocationCheck` backed by your own
|
|
188
|
+
revoked-credential store (see `vaid_mint.revocation.RevocationBackend` for
|
|
189
|
+
the reference shape: both the revoked-set half and the lineage-resolver half
|
|
190
|
+
durable together, never one without the other) and let it report
|
|
191
|
+
`RevocationStatus.UNAVAILABLE` whenever it cannot be reached; this plugin
|
|
192
|
+
already denies on that outcome.
|
|
193
|
+
|
|
194
|
+
## What this package does not do
|
|
195
|
+
|
|
196
|
+
- **It does not mint.** Issuing a root VAID or an attenuated child VAID for
|
|
197
|
+
a sub-agent is ordinary `vaid_mint` usage (`MintService.mint_root` /
|
|
198
|
+
`mint_child`), done by the caller before `VaidAuthPlugin.register` is
|
|
199
|
+
called.
|
|
200
|
+
- **It does not depend on `forge-agents` or `synthera`.** The June 2026 ADK
|
|
201
|
+
adapters in those repos are prior art for the hook choice; this package
|
|
202
|
+
reuses only `vaid_mint`.
|
|
203
|
+
- **It does not depend on `vaid-a2a`.** The verification order in
|
|
204
|
+
`vaid_adk._verify` mirrors `vaid_a2a.verifier.verify_a2a_message`'s order
|
|
205
|
+
deliberately (both are "how do you check a presented VAID end to end"),
|
|
206
|
+
but each is self-contained against `vaid_mint` rather than one importing
|
|
207
|
+
the other.
|
|
208
|
+
|
|
209
|
+
## Packaged conformance firewall
|
|
210
|
+
|
|
211
|
+
An installed copy can be checked without this repository:
|
|
212
|
+
|
|
213
|
+
```
|
|
214
|
+
vaid-adk-conformance # exit 0 = PASS, 1 = BLOCKER
|
|
215
|
+
python -m vaid_adk.conformance # same
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
It verifies the frozen three-hop delegation chain bundled in `vaid-mint`
|
|
219
|
+
(`chain_v1.json`), at the instant that vector names rather than the wall clock,
|
|
220
|
+
and asserts eight outcomes: one that must be **allowed**, and seven that must
|
|
221
|
+
each be **denied with a different code** (cross-domain key, out-of-scope
|
|
222
|
+
resource, missing capability, no VAID, withheld ancestry, revoked ancestor,
|
|
223
|
+
revocation unavailable). It also asserts that a trust resolver which cannot bind
|
|
224
|
+
a domain to a key is refused outright, which is the half of
|
|
225
|
+
GHSA-hfjh-v2x2-r58v no single request can demonstrate.
|
|
226
|
+
|
|
227
|
+
The allowed case is load-bearing: without it, a build that denied everything
|
|
228
|
+
would satisfy every other check. It pins **verdicts**, not bytes - this package
|
|
229
|
+
signs nothing of its own.
|
|
230
|
+
|
|
231
|
+
## Install
|
|
232
|
+
|
|
233
|
+
**Local dev only**, from a repo checkout:
|
|
234
|
+
|
|
235
|
+
```
|
|
236
|
+
pip install -e python/vaid-pop
|
|
237
|
+
pip install -e python/vaid-mint
|
|
238
|
+
pip install -e python/vaid-adk[test]
|
|
239
|
+
```
|