PermDock
Adapters

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,
);
  • skills maps skill ids to a permission and optional metadata; data resolves 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 published a2a.json: card.url becomes the first supportedInterfaces entry (protocolBinding defaults to JSONRPC, protocolVersion is 1.0), description defaults to name, defaultInputModes and defaultOutputModes default to ['text/plain'], and capabilities.extendedAgentCard is true. securitySchemes takes OpenAPI-style input (type oauth2, http, openIdConnect, mutualTLS or apiKey) and emits the A2A one-of form ({ oauth2SecurityScheme: { oauth2MetadataUrl } }). Each skill carries tags (default: the permission's resource) and securityRequirements of the form { schemes: { <scheme>: { list: [scope] } } }, naming the configured scheme and the permission's scope alone (post:read, post:publish), with no coarse agent scope added.
  • extendedAgentCard(auth) builds a request-scoped PermDock and returns only skills with a grant for the caller; securityRequirements are unchanged. A skill with instance-level conditions stays listed when any grant could match, as with MCP tools/list, and filtered skills carry no alternatives hints.
  • protectSkill(selector) is middleware for the task endpoint: it resolves the skill id, runs decide, and rejects the task when the outcome is not granted.
  • Card signing stays with the host's key material: sign(card, signPayload) passes the RFC 8785 canonical JSON of the card without signatures to your callback, which returns a compact JWS over it (for example jose CompactSign); the adapter checks the payload, detaches it and appends { protected, signature } to signatures (A2A 1.0 section 8.4), so the filtered extended card can be signed per response without the adapter holding a key.

Request lifecycle

  1. Discovery: a client fetches the public card. No authentication; scopes per skill are visible so the client can request them during OAuth.
  2. Authenticated discovery: the client fetches the extended card with a bearer token. The adapter resolves the subject, fills actor from the token's client identity and delegation from scopes or authorization_details, and filters skills with can(permission) (collection) or any-grant (instance).
  3. Task submission: protectSkill maps the task to a skill and permission, runs the skill's data loader, validates what it returns against the resource schema, and calls decide. A loader that throws, or returns nothing for an instance permission, is a denial.
  4. Outcome: granted runs the task; denied and approval-required answer with an A2A task failure carrying the Decision (below).
  5. on('decision') records the discovery filter and the task decision with actor and delegation.

What it validates

  • The row a data loader 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; createPermDock throws a TypeError at startup otherwise.
  • Scope presence: the caller's token must carry the skill's scope; a missing scope is reported as a 401/403 with WWW-Authenticate naming 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 securitySchemes referenced by securityRequirements exist.
  • 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, denials and alternatives. status follows the Problem Details status matrix: 401 with a Bearer wwwAuthenticate challenge for a caller with no subject, 429 for a rate limit, 503 when the limit store is unreachable, 403 otherwise. A loader that throws, or returns nothing for an instance permission, is a 403 denial with reason validation; a subject resolver that throws makes the caller anonymous.
  • approval-required: the task enters an input-required state with the Decision.token and a human-readable prompt in the message, and the pending request is written to the store. When the caller continues the task, the adapter recomputes the token (or reads the one the verified token carries in auth.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 with approval-consumed. A token issued to another client never matches.
  • An unknown skill id, including __proto__, constructor and other inherited names, fails with 403; 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.

  • A2A: Agent Card, securitySchemes, securityRequirements, signed cards, authenticated extended card.
  • OAuth agent delegation: scopes and authorization_details as delegated authority.
  • OpenAPI 3.2: the same oauth2MetadataUrl security scheme shape.
  • Problem Details: task error body.

Last updated on

On this page