Skip to content

feat(a2a): AAuth agent identity across instances, behind an off-by-default flag (abilityai/trinity-enterprise#623) - #2956

Draft
vybe wants to merge 2 commits into
devfrom
feature/623-aauth-agent-identity
Draft

vybe wants to merge 2 commits into
devfrom
feature/623-aauth-agent-identity

Conversation

@vybe

@vybe vybe commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Draft — a time-boxed prototype, not a merge candidate yet. The gating decision (OSS-core seam vs entitlement-gated module) and the merge path are open; see "Open before this can merge" below.

Summary

An agent on one Trinity instance calls an agent on another and is authenticated as itself — by a key only it holds — instead of by a shared bearer secret. This is AAuth's smallest access mode (agent-identity-only, self-hosted bootstrap: no Person Server, no Access Server, no missions) applied to the A2A seam we already ship. The transport is unchanged: still A2A JSON-RPC over POST /a2a/{name}.

Everything is behind aauth_prototype_enabled, off by default. With the flag off, inbound, outbound and the served card behave as before.

What it replaces. Federation previously meant instance B minting a Trinity MCP API key and instance A storing it. That credential resolves to its owner, carrying the owner's role — the class behind six prior incidents — so the callee's answer to "who is calling?" was "somebody holding a key owned by a user here". Now it is aauth:<agent>@<instance-host>: per agent, no role attached, proved per request, revoked by deleting one line on the callee.

Changes

  • services/aauth/ — config (flag + issuer), jose (Ed25519 JWS; PyJWT only registers the polymorphic EdDSA, which the draft forbids verifiers to accept), httpsig (RFC 9421 profile + a strict RFC 8941 subset), keys (one instance key, AES-256-GCM in system_settings, insert-if-absent so two workers cannot fork it), signer, discovery, verifier, inbound, whoami (operator CLI).
  • routers/aauth.py — the three /.well-known/aauth-*.json documents; uniform 404 unless AAuth is live; per-IP limited.
  • routers/a2a.py — a route class sends a request carrying Signature-Key to a separate entry point, so the bearer dependency is not made conditional and the bearer path, its 401s and its dependency overrides are untouched. Both entry points share one JSON-RPC method table.
  • Outbound: auth_scheme on the endpoint registry (bearer default | aauth, which carries no credential), signer threaded to the RPC, and a path-relative card fetch for aauth endpoints.
  • a2a_gate.explicit_inbound_identities — the fail-closed allow-list accessor (inverse of the bearer gate beside it). Its provider half is abilityai/trinity-enterprise#677.
  • platform_audit_service — new external_agent actor type, so a remote caller is not recorded as the platform itself.

Ordering decisions worth reviewing, all with tests: the trusted-issuer pre-gate runs before any network I/O; a refusal is auditable only after the token's signature verifies; the body is read only after the signature covering its digest verifies; replay and the allow-list fail closed.

Test Plan

  • tests/unit/test_ent623_aauth_{core,a2a}.py — 136 new tests; mutation-checked (breaking the pre-gate, digest, replay, flag gate, allow-list membership or task scoping each fails a test)
  • Existing A2A suites unchanged (test_157_*, test_736_*, test_180_*, test_a2a_card_service) — 493 passing
  • Full unit suite: 16,390 passed (two order-dependent failures reproduced on clean dev — pre-existing)
  • Cross-implementation: a request signed by the JS reference @hellocoop/httpsig 2.6.0 verifies here (checked-in vector), and ours verifies there
  • Live between two independent instances: whoami.aauth.dev (a non-Trinity verifier) echoed the calling agent's identity; the remote task ran; an unlisted identity got 403; a tampered body got 401 before the envelope was parsed; a bearer call on the same endpoint still worked
  • /verify-local, /cso --diff — not run

Open before this can merge

  1. Gating decision — OSS-core seam vs entitlement-gated module (deferred to after the 2026-09-18 call).
  2. Trinity→Trinity A2A is broken independently of this PR — the outbound client reads a peer's card at the origin root while Trinity serves it under /a2a/{name}/, so bearer federation fails at the card fetch too. Fixed here for aauth endpoints only; the general fix wants its own issue.
  3. Stated prototype limits (also in the flow doc): one software-held instance key, no rotation; the backend signs on the agent's behalf; identity is keyed by agent name; inbound AAuth is inert without the private allow-list module; authorization stays coarse — an allow-list entry means the peer may ask the agent for anything it can do.
  4. src/frontend/nginx.conf gains one location for the discovery documents — add the ui label if the e2e gate is wanted before merge.

Docs: docs/memory/feature-flows/a2a-aauth-agent-identity.md, requirements docs/memory/requirements/mcp.md §32.6, architecture catalogs, and a learnings.md entry (a second Trinity backend on the same Docker daemon deletes the first one's agent volumes).

Refs abilityai/trinity-enterprise#623

🤖 Generated with Claude Code

vybe and others added 2 commits September 18, 2026 08:02
…fault flag (Abilityai/trinity-enterprise#623)

An agent on one Trinity calls an agent on another and is authenticated as
ITSELF — by its own key under AAuth's agent-identity-only mode — with no bearer
secret of either instance configured on the other. Prototype for the 2026-09-18
call with the protocol's author; `aauth_prototype_enabled` is OFF by default and
flag-off behaviour is unchanged.

Each instance is its own Agent Provider (AAuth self-hosted bootstrap): it
publishes `/.well-known/aauth-{agent,jwks,resource}.json`, self-issues
`aa-agent+jwt` tokens binding a per-agent key via `cnf.jwk`, and signs requests
per RFC 9421 with the key conveyed by `Signature-Key: sig=jwt;jwt="…"`.

Inbound (`POST /a2a/{name}`): a request carrying `Signature-Key` while AAuth is
live takes a separate entry point, so the bearer dependency — which 401s a
request with no token before any handler runs — is not made conditional and the
bearer path stays byte-identical. Verification runs before the body is parsed as
JSON-RPC, cheapest-and-local first: header shape → token claims → exposure +
trusted-issuer pre-gate → issuer metadata/JWKS (pinned public-HTTPS egress,
same-origin `jwks_uri`, one 8 s deadline) → token signature → HTTP signature
(which covers the Content-Digest HEADER) → body digest → replay claim. Only then
is the body read. An AAuth caller never becomes a Trinity `User`: access is
exposure plus an EXPLICIT allow-list entry, fail-closed, and no owner role
travels with it. Refusals answer RFC 9457 problem details with `Signature-Error`;
a verified-but-unlisted caller gets 403.

Two properties are load-bearing and easy to lose:

* B never fetches metadata for an issuer host no operator listed, so an
  unauthenticated caller cannot use this backend as a fetcher; and
* a refusal is only auditable once the token's own signature verifies —
  before that `sub` is a string the caller typed, and attributing an audit row
  to it would hand any stranger the audit table.

Outbound: a registered endpoint may be marked `auth_scheme: aauth` (no
credential — switching to it clears one, non-default ports refused). Only the
agent's OWN key may place such a call: `AuthorizedAgentByName` also admits the
owner, shared users and admins, and for AAuth that would let a human make the
backend sign as the agent. The request is signed over the exact bytes sent, to
the registered host rather than the pinned IP.

Also here: the served card declares `aauth` AFTER `bearerAuth` (an AAuth-unaware
client that takes the first entry is unaffected); a new `external_agent` audit
actor type, so a remote caller is not recorded as the platform itself; and a
path-relative card fetch for aauth endpoints, because a Trinity peer serves its
card under `/a2a/{name}/` while the client only ever looked at the origin root —
which is why Trinity-to-Trinity A2A did not work at all. The general fix for that
wants its own public issue.

Verified live between two independent instances (the second in its own Docker
daemon — see the learnings entry on why a shared daemon eats the first one's
agent volumes): `whoami.aauth.dev`, a verifier that is not ours, echoes the
calling agent's identity; the remote task runs; an unlisted identity is refused
403; a tampered body is refused 401; a bearer call to the same endpoint still
works. A request signed by the JS reference `@hellocoop/httpsig` verifies here
(checked-in vector), and ours verifies there.

Stated prototype limits: one software-held instance key (AES-256-GCM at rest, no
rotation), the backend signs on the agent's behalf, identity is keyed by agent
name, `message/stream` is refused for AAuth callers, and inbound AAuth is inert
without the private allow-list module. Requirement: docs/memory/requirements/mcp.md §32.6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…trinity-enterprise#623)

The end-to-end flow, written to be read by someone who did not build it: roles,
layers, public surface, config state, and the four flows (enablement, outbound
sign, inbound verify-and-decide, served card) with the ordering constraints
spelled out — why the trusted-issuer pre-gate runs before any network I/O, why a
refusal only becomes auditable once the token verifies, and why the body is read
after the signature that covers its digest rather than before.

Also states the trust model plainly: what the callee actually verifies (key
possession + this exact request), what it does not (who runs the peer), that the
unit of trust is the peer instance rather than one agent, and that authorization
stays coarse — an allow-list entry means the peer may ask this agent for anything
it can do. Closes with the prototype's limits and the diagrams worth drawing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

⚠️ Nightly unit-suite check skipped — merge conflict against dev.

Resolve by running git merge dev locally and pushing the result. The next nightly run will re-test once the conflict is gone.

@github-actions

Copy link
Copy Markdown

⚠️ Live-instance suite skipped — merge conflict against dev.

Resolve by merging dev locally and pushing the result; the next nightly re-tests.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant