A2A agents¶
passmcp a2a check verifies an agent that speaks the
Agent2Agent (A2A) protocol from its Agent
Card. It uses the same rules as an MCP run: every verdict cites the
requests behind it, passmcp only reads, and the result can be written as an
in-toto attestation that a registry or gateway verifies offline.
passmcp a2a check https://agent.example.com
passmcp a2a check https://agent.example.com --output json
passmcp a2a check https://agent.example.com --output attestation > a2a.intoto.json
The command exits with 0 when nothing failed, 2 when a check failed, and 1 when the URL cannot be checked at all.
What it checks against¶
The specification is A2A v1.0, read at commit
72b3761
of a2aproject/A2A (Apache-2.0):
- Where the card is: section 8.2 puts the Agent Card at
/.well-known/agent-card.jsonat the root of the agent's domain. passmcp fetches it from there whatever path the URL you give names. - What a card is: the
AgentCardmessage inspecification/a2a.protois the normative definition, rendered as JSON with proto3 JSON field names. A2A v1 publishes no JSON Schema for the card, so passmcp vendors none. The validator ininternal/a2a/schema.gotranscribes the proto message by message, including its required fields, itsoneofgroups and its enumerations. - How a card is signed: section 8.4 defines the signature. A signer
removes default values, excludes the
signaturesmember, canonicalises the rest with RFC 8785 (JCS), and signs the result as the payload of a JWS (RFC 7515). EachAgentCardSignaturecarries the protected header and the signature.
The checks¶
| Check | Passes when | Fails when |
|---|---|---|
a2a.transport |
the card was served over HTTPS | the card is served over plain http from a host that is not loopback (loopback is recorded as info, as net.scheme records it for MCP) |
a2a.card_schema |
the card is a valid A2A v1 AgentCard |
the card cannot be fetched, is not a JSON object, or departs from the schema. Every error names its JSON path, such as $.skills[0].tags: must be an array |
a2a.card_signature |
a signature verifies against the key its jku names |
no signature verifies: the card was altered, the key set cannot be fetched, or it has no key with the kid |
a2a.unauthenticated |
the card declares a scheme and the agent refuses a request with no credentials | the agent answers a request that carries no credentials, whether or not the card declares a scheme |
Each check also has the outcomes that are neither pass nor fail:
- An unsigned card is
info. Signing is optional in A2A v1, so an unsigned card breaks no rule. The finding records that nothing binds the card to its publisher. - A signature that verifies only against a
jwkembedded in its own protected header iswarn. It shows the card was not altered after signing, but not who signed it, because anyone can sign with a key they carry themselves. - A card whose signature names neither a
jkunor ajwkfails. passmcp keeps no trusted key store, so it has no way to resolve that key. - The unprotected
headerof a signature is never used to find a key. Nothing in it is signed. - An agent that refuses a request while its card declares no scheme is
warn. The agent is protected, but a client cannot learn from the card how to authenticate.
Signature verification¶
passmcp implements JCS and JWS with the Go standard library alone, with no dependency added for them. It accepts these algorithms:
EdDSA(Ed25519);ES256,ES384andES512;RS256,RS384andRS512;PS256,PS384andPS512.
It refuses RSA keys under 2048 bits and EC points that are not on their curve. The canonicaliser is tested against the RFC 8785 examples.
A jku is fetched only when the URL policy allows it: HTTPS to a public
host, or loopback. A card that names a key set inside your network is
refused unless you pass --insecure-allow-private-hosts, because a card
that could make passmcp fetch any URL it names would turn passmcp into a
probe of your network.
The one request that shows authentication¶
Showing whether the agent serves requests without credentials needs one
request. passmcp picks the least invasive method A2A has, ListTasks with
a page size of one, and sends it with no credentials to the first JSON-RPC
or HTTP+JSON interface the card declares:
- JSON-RPC:
POST <url>with{"jsonrpc":"2.0","id":"passmcp-a2a-1","method":"ListTasks","params":{"pageSize":1}} - HTTP+JSON:
GET <url>/tasks?pageSize=1
Both carry the A2A-Version header of the interface. The request reads,
creates and changes nothing. passmcp never sends a message and never
invokes a skill (ADR-0004). How the
answer counts:
- Served: a 2xx answer with a task list, or with a JSON-RPC
result. - Refused: 401 or 403.
- Neither: anything else, such as a JSON-RPC error, a 404 or a
redirect. The finding is
info, because it shows nothing either way.
passmcp skips the check, and says why, when the card declares only gRPC or names an interface the URL policy refuses.
The attestation¶
--output attestation writes an in-toto Statement v1 whose predicate is
passmcp-reporting's A2A evaluation (a2a package). The statement holds:
- the card's URL;
- the SHA-256 of its JCS form;
- whether the card was signed, and which key verified it;
- every verdict with its evidence, and the verdict counts.
The statement carries no score, because A2A has no rubric yet. A number with no rubric behind it could not be compared with anything.
A consumer verifies it offline:
st, err := a2a.Parse(b) // satellion.com/passmcp-reporting/a2a
if err != nil || !st.Covers("https://agent.example.com") {
// reject
}
--output json writes the full result, including every schema error.
The text output lists the schema errors in full when there are more than
the five the finding summarises.