A2A
permdock/a2a emits A2A Agent Cards whose skills carry security requirements derived from permissions and filters the authenticated extended card by the caller's grants.
permdock/a2a maps A2A skills to permission references. From that map it emits the public Agent Card with securitySchemes and per-skill securityRequirements, and it builds the authenticated extended card for a specific caller by keeping only skills the caller may invoke. It is the A2A counterpart of list_tools filtering in permdock/mcp.
Purpose
A2A 1.0 (specification) describes agents through Agent Cards: a public card lists skills and the security schemes required to call them, and an authenticated extended card may expose different skills per caller. Cards can be signed. Without a permission layer, the skill list is static and every caller sees the same card. With permdock/a2a, each skill's security requirement is the permission's scope, and the extended card is a per-caller view computed from the same policy that guards the skill at execution time.
API
import { createPermDock } from "permdock/a2a";
const { agentCard, extendedAgentCard, protectSkill } = createPermDock(policy, {
subject: (auth) => userFrom(auth), // caller principal from the A2A request auth
card: {
name: "Posts agent",
url: "https://agent.example.com/a2a",
version: "1.0.0",
},
securitySchemes: {
oauth: {
type: "oauth2",
oauth2MetadataUrl:
"https://auth.example.com/.well-known/oauth-authorization-server",
},
},
skills: {
summarise: {
permission: permissions.post.read,
description: "Summarise a post",
},
publish: {
permission: permissions.post.publish,
data: (task) => loadPost(task.postId),
},
},
});
app.get("/.well-known/agent-card.json", (c) => c.json(agentCard()));
app.get("/a2a/extended-card", (c) => c.json(extendedAgentCard(c.get("auth"))));
app.post(
"/a2a/tasks",
protectSkill((task) => task.skillId),
handleTask,
);skillsmaps skill ids to a permission and optional metadata;dataresolves the resource for instance-level actions from the task payload.agentCard()returns the public card in the A2A 1.0 wire form, which validates against the publisheda2a.json:card.urlbecomes the firstsupportedInterfacesentry (protocolBindingdefaults toJSONRPC,protocolVersionis1.0),descriptiondefaults toname,defaultInputModesanddefaultOutputModesdefault to['text/plain'], andcapabilities.extendedAgentCardistrue.securitySchemestakes OpenAPI-style input (typeoauth2,http,openIdConnect,mutualTLSorapiKey) and emits the A2A one-of form ({ oauth2SecurityScheme: { oauth2MetadataUrl } }). Each skill carriestags(default: the permission's resource) andsecurityRequirementsof the form{ schemes: { <scheme>: { list: [scope] } } }, naming the configured scheme and the permission'sscopealone (post:read,post:publish), with no coarse agent scope added.extendedAgentCard(auth)builds a request-scopedPermDockand returns only skills with a grant for the caller;securityRequirementsare unchanged. A skill with instance-level conditions stays listed when any grant could match, as with MCPtools/list, and filtered skills carry noalternativeshints.protectSkill(selector)is middleware for the task endpoint: it resolves the skill id, runsdecide, and rejects the task when the outcome is notgranted.- Card signing stays with the host's key material:
sign(card, signPayload)passes the RFC 8785 canonical JSON of the card withoutsignaturesto your callback, which returns a compact JWS over it (for example joseCompactSign); the adapter checks the payload, detaches it and appends{ protected, signature }tosignatures(A2A 1.0 section 8.4), so the filtered extended card can be signed per response without the adapter holding a key.
Request lifecycle
- Discovery: a client fetches the public card. No authentication; scopes per skill are visible so the client can request them during OAuth.
- Authenticated discovery: the client fetches the extended card with a bearer token. The adapter resolves the subject, fills
actorfrom the token's client identity anddelegationfrom scopes orauthorization_details, and filters skills withcan(permission)(collection) or any-grant (instance). - Task submission:
protectSkillmaps the task to a skill and permission, runs the skill'sdataloader, validates what it returns against the resource schema, and callsdecide. A loader that throws, or returns nothing for an instance permission, is a denial. - Outcome:
grantedruns the task;deniedandapproval-requiredanswer with an A2A task failure carrying the Decision (below). on('decision')records the discovery filter and the task decision withactoranddelegation.
What it validates
- The row a
dataloader returns, against the resource schema (validate: 'boundary'); the task body is untrusted and is never the row itself. - A skill whose permission is instance-level must declare
data;createPermDockthrows aTypeErrorat startup otherwise. - Scope presence: the caller's token must carry the skill's
scope; a missing scope is reported as a401/403withWWW-Authenticatenaming the required scope, matching the security requirement in the card. - Card consistency: in development, the adapter asserts that every skill declares exactly one permission and that the
securitySchemesreferenced bysecurityRequirementsexist. - Caller identity is never taken from the task body; it comes from the transport authentication only.
How denials surface
- In discovery: the skill is absent from the extended card. The public card is never filtered, so a caller can always learn what scopes to request.
- At execution: an A2A task in the failed state whose error is an RFC 9457 Problem Details object with
permission,denialsandalternatives.statusfollows the Problem Details status matrix:401with aBearerwwwAuthenticatechallenge for a caller with no subject,429for a rate limit,503when the limit store is unreachable,403otherwise. A loader that throws, or returns nothing for an instance permission, is a403denial with reasonvalidation; a subject resolver that throws makes the caller anonymous. approval-required: the task enters an input-required state with theDecision.tokenand a human-readable prompt in the message, and the pending request is written to thestore. When the caller continues the task, the adapter recomputes the token (or reads the one the verified token carries inauth.extra.approval) and resumes only if the store holds an approved record for the same permission, resource, principal and client; the approval is consumed, so a second run fails withapproval-consumed. A token issued to another client never matches.- An unknown skill id, including
__proto__,constructorand other inherited names, fails with403; skills are looked up by own key only. - Missing scope: standard OAuth step-up (
insufficient_scope), not a denial.
Example app
apps/examples/a2a-agent: Hono on 127.0.0.1:3471 with GET /health, public /.well-known/agent-card.json, and POST /a2a/tasks. Summarise is granted for a member; publish is denied.
Related standards
- A2A: Agent Card,
securitySchemes,securityRequirements, signed cards, authenticated extended card. - OAuth agent delegation: scopes and
authorization_detailsas delegated authority. - OpenAPI 3.2: the same
oauth2MetadataUrlsecurity scheme shape. - Problem Details: task error body.
Last updated on
WebMCP
permdock/webmcp registers browser-exposed WebMCP tools only for actions the current snapshot allows, with hints from action metadata and automatic unregistration when permissions change.
AuthZEN
permdock/authzen serves the OpenID AuthZEN Authorization API 1.0 (evaluation, evaluations, search, discovery) from a PermDock policy so the decision endpoint is a standard PDP.