attenu-guard × A2A (Agent2Agent protocol)
Tested against a2a-sdk 1.1.2 (Apache-2.0) and the A2A specification of August 2026, on Python 3.12+.
A2A is a protocol rather than a framework: the calling agent and the remote agent are separate
processes, each with its own framework, its own tools and its own ledger. So the delegation moment is
the hop — message:send — and this adapter is two halves that meet on the wire.
What A2A already settles
A2A's transport security is careful. Every agent authenticates the caller (§7), the Agent Card declares security schemes, credentials for in-task authorization are meant to travel out of band, and §7.6.3 warns that in-band credentials passing through a chain of agents are exposed to each agent in it. That is the right foundation, and this recipe stands on it.
What this adapter adds
A2A §7.6.4, "In-Task Authorization Scope", says where the boundary is:
The A2A protocol does not define the scope, representation, validity, or revocation semantics of the authorization decision or credential obtained in response to this state.
and:
If an implementation requires authorization for specific operations, it is responsible for defining how the authorized operation is identified and how that authorization is checked before the operation is performed.
That is what this supplies, through A2A's own extension mechanism (§4.6) rather than beside it:
- The caller grants less than it holds, and says so on the wire.
parent.delegate(...)mints the child, and the resulting Delegation Chain — signed JWT-shaped tokens,attenu_guard.wire— rides on the outgoing message underhttps://attenu.io/a2a/delegation-chain/v1. - The remote agent verifies it offline and narrows again. Signatures, the parent-hash byte commitment at every hop, chain depth, child ⊆ parent at every hop, expiry — no authorization server in the path. The served permissions are the meet of the verified leaf and what the remote deployment decided this task needs, so neither side can widen the other.
- Every tool call is checked before its body, in the remote process, and the refusal is on a hash-chained ledger.
- The record verifies afterwards — the caller's ledger, the remote agent's ledger, and the tokens that bind them, from those inputs alone.
What it hooks
| step | A2A API |
|---|---|
| attach the chain to an outgoing hop | ClientCallInterceptor.before(BeforeArgs) (a2a/client/interceptors.py:46), run for every call by BaseClient._intercept_before (a2a/client/base_client.py:460) — the same seam AuthInterceptor uses for bearer tokens |
| carry it | Message.extensions + Message.metadata[<uri>] (spec §4.6.2), plus the A2A-Extensions request header (§4.6.1) via ClientCallContext.service_parameters |
| read it, gate the remote agent | AgentExecutor.execute(context, event_queue) (a2a/server/agent_execution/agent_executor.py:15) — the boundary every binding funnels an inbound task through, reached from DefaultRequestHandlerV2.on_message_send (a2a/server/request_handlers/default_request_handler_v2.py:240) |
| check before a tool body | guarded_tool(fn, scope=…) on the remote agent's tools, reading the request's Guard from a ContextVar |
| declare the extension | agent_extension() → AgentCard.capabilities.extensions (§4.6.1), required=True |
Both hook points are public ABCs. Nothing is monkeypatched and nothing private is touched.
Run it
pip install "a2a-sdk>=1.1" attenu-guard
python examples/integrations/a2a/demo.py
# the same story over real HTTP (Starlette + uvicorn, still no API key):
pip install "a2a-sdk[http-server]" uvicorn
RUN_LIVE=1 python examples/integrations/a2a/live_smoke.py
demo.py runs both agents in one process over InProcessTransport, an implementation of the SDK's
public ClientTransport ABC (a2a/client/transports/base.py:28) that hands the request to the server's
request handler instead of to a socket. Everything either side does is what it does over the wire.
live_smoke.py removes that caveat: it boots a Starlette A2A server, resolves the Agent Card over HTTP
and posts JSON-RPC. Point it elsewhere with A2A_AGENT_URL=… to exercise the client half against
someone else's agent.
What you'll see
An orchestrator holding {crm.read, crm.export, mail.send} (100 000 rows, egress "any") sends a
summarising task to a remote summariser and grants it {crm.read} (5 000 rows, no egress, 15 minutes).
The remote deployment narrows again to 2 000 rows. The remote agent has been poisoned:
- it reads 1 800 CRM rows — ALLOW;
- it tries to export to
s3://attacker-bucket/…— DENY (scope_not_granted), before the body, in the remote process; the control run with no guard exports; - a 4 200-row read the caller's grant would have permitted — DENY (
ceiling_exceeded), because the remote end narrowed further; - a forged chain, a spliced chain, a widened chain, an expired chain and a missing chain are each refused at the boundary, before the remote agent's logic starts;
- the client half refuses to send a hop for which no permissions resolved;
- both ledgers and the tokens verify offline, and
attenu-guard verifyre-checks the remote ledger from the file.
Ends with RESULT: OK, exit 0.
Trust boundary
The check lives at the A2A boundary, inside the remote agent's process, before the agent's own logic
runs — and again before each tool body. Inside that boundary: a denied hop never reaches
inner.execute; a denied tool never runs its body; a chain that will not verify is a refusal, and so is
any exception raised while deciding; a tool reached by a path that did not pass through
GuardedAgentExecutor raises rather than running.
Outside it: a direct call to the underlying Python function inside the remote process, any other route to the resource behind a tool, and anything the remote agent does that is not a guarded tool. Transport security, agent authentication and per-hop credentials stay A2A's — this sits on top of them, not instead of them.
Extension negotiation is a declaration, not enforcement. A server that does not know the extension
ignores it (§4.6.3) and is unguarded; that is why a guarded deployment declares it required=True and
why the guard, not the card, is what refuses.
Revocation across the hop
Not solved here, and the shape of the gap is worth stating. wire.load does not consult a Token Status
List (that step of the offline verification algorithm is out of scope for the wire format), so a token
revoked in the caller's process after it was minted keeps verifying until it expires. What is enforced:
an expired chain is refused, and revocation_check= is the seam where a deployment plugs its own
status list or revocation feed — it runs before anything is minted. Keep TTLs short. Cross-process
revocation propagation is tracked as separate work.
What verifies from where
Two processes, two ledgers, one token chain between them — so be exact about what each proves:
| input | what it proves |
|---|---|
| the caller's bundle | the caller's own chain, root → the child it minted for the remote agent, and that the child ⊆ the caller |
| the remote agent's bundle | its served node ⊆ the permissions it was handed, plus every allow and deny its tools produced |
| the tokens | they bind the two: the remote ledger's continuation root holds the leaf token's permissions exactly, under the leaf's subject, and its chain_id is derived from that leaf token's bytes |
verify_hop(tokens, signer, client_bundle=…, server_bundle=…) checks all three from those inputs alone.
Neither bundle on its own proves the hop, and a bundle that is not supplied is reported "not checked",
never as passing. Two hops whose leaf tokens are byte-identical land in the same ledger id — give chains
distinct chain_ids if you need to tell such hops apart.
Evidence manifest
| Claim | Pinned to | Test |
|---|---|---|
| Both hook points are public and shaped as the adapter binds to them | a2a-sdk==1.1.2 |
test_compat_a2a_hook_points_are_public_and_unchanged |
| Interceptors run before the transport sees the request | BaseClient._execute_with_interceptors |
test_compat_interceptors_are_run_before_the_transport |
| The remote agent is served a strict subset of the caller's permissions | this recipe | test_remote_agent_is_served_a_strict_subset_of_the_callers_permissions |
| A denied tool body did not run; the unguarded control's did | side-effect oracle | test_the_allowed_read_runs_and_the_export_body_never_does, test_the_unguarded_control_does_export |
| Forged / spliced / widened / expired / absent chains are all refused before the agent's logic | this recipe | test_a_bad_chain_is_refused_before_the_remote_agents_logic_starts, test_a_spliced_chain_is_refused |
| A bug while deciding denies rather than serving | this recipe | test_a_deciding_bug_denies_rather_than_serving |
| Both ledgers plus the tokens verify offline; each tamper fails | evidence.verify_bundle, verify_hop |
test_both_ledgers_and_the_tokens_verify_offline_together, test_verify_hop_catches_*, test_a_tampered_server_ledger_fails |
Related: OWASP Top 10 for Agentic Applications 2026 — ASI03, ASI07, ASI08 ·
docs/DENIAL-CONTRACT.md ·
docs/THREAT-MODEL.md · the Delegation Token wire format in
docs/.
What remains A2A's
The transport, agent authentication, the Agent Card, the task lifecycle, and the eventual standard answer to §7.6.4. If A2A defines a field for delegated authority, this adapter reads it from there instead of from the extension slot; the verification does not change.