Repository navigation
feat(aauth): person-token binding through a person server — an agent acts for its owner (abilityai/trinity-enterprise#878) - #3659
Conversation
…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>
…prototype onto current dev (#3644)
…pricing reads after the dev merge (#3644) dev's inbound door now asks db.get_a2a_scope (trinity-enterprise#838) and, for a bearer-less caller, db.get_nevermined_config_with_key (ent#679), and it resolves its principal through get_user_or_anonymous — which calls get_current_user instead of depending on it, so the fixture's override has to sit on the optional dependency. 17 of 30 tests in this file failed on the merged tree for exactly those three reasons; no product code changes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…agent key (abilityai/trinity-enterprise#878) Checkpoint A of the person-token binding leg (Hellō person server). * services/aauth/person_token.py — person_token()/resume()/held_token(): signed POST (#623 profile) to the PS person_token_endpoint, 200 → held per (our_issuer, ps_issuer, agent, resource) until exp − 5 min and only while cnf is the agent's current key; 202 → Redis pending record (hashed handle, per-agent cap 3, no token stored) + url/code/link; the draft's deferred-response state machine on resume (202/200/403/408/410/ 429, cross-key reissue); audit_details() never carries token/handle/url/code. * services/aauth/person_server.py — aauth-person.json discovery: four required fields, issuer equality, token endpoints same-origin, jwks_uri may be off-host; per-worker caches, single-flight. * services/aauth/requirement.py — AAuth-Requirement (RFC 8941 dictionary), Retry-After, Crockford code and interaction-URL rules. * services/aauth/egress.py — request_capped(): every status with headers and a capped body behind the same pinned/no-redirect/identity/wire-cap guards as the A2A reader; a2a_client.py untouched (T5). * services/aauth/signer.py — T6: the per-agent key is HKDF-derived from the instance seed per (issuer, agent, hour window) so both workers hold one key; min_remaining keeps a challenge chain on one key; current_agent_key() exposes key + thumbprint for ent#879. * services/aauth/config.py — aauth_person_server / AAUTH_PERSON_SERVER; keys.instance_seed(); .env.example AAuth block. * tests/unit/test_ent878_aauth_person_token.py — 105 tests on recorded Hellō shapes; the signed POST replays through verifier.verify_request. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… rows (abilityai/trinity-enterprise#878)
Checkpoint B of the person-token binding leg.
* routers/aauth_person.py (`# mcp: tools/aauth.ts`) —
POST /api/agents/{name}/aauth/person-token: uniform 404 unless AAuth is
live; the agent's OWN key only (403 aauth_agent_only — a human principal
may not make the backend sign as the agent); separate POST/poll/fleet
rate budgets; audit row on state change only (issued / pending / denied /
refused) under AuditEventType.CREDENTIALS with token_jti, execution_id
and trigger_source — never the token, handle, url or code; configuration
refusals as 409/422/502/503, protocol outcomes as 200 `refused`.
* models.py — PersonTokenRequest / PersonTokenInteraction /
PersonTokenResponse (no field can carry the token; pinned by test).
* main.py — router registered after the #623 discovery router.
* MCP: tools/aauth.ts `request_person_token(resource, pending_handle?,
wait?)` — self-acting, agent scope only, returns outcome + jti/aud/sub/exp/
cached + interaction{url, code, link, pending_handle, retry_after} +
next_step; description tells the agent to relay the link privately to
its OWNER (whoever approves first becomes the person it acts for) and to
use ask_operator when no person is in the conversation (T7). Always
registered under operatorOnly (T3): flag off → backend 404 → the tool
reports `aauth_disabled: true` — advertised but inert. access.ts policy
row, client.ts requestPersonToken, tool-visibility.test.ts case.
* tests: route tests (404 inert, 403 human/sibling/system, 409/422/503
named, issued+audited, pending→resume→issued, denial as outcome,
response field set, main.py wiring pin) + aauth.test.ts (self-acting
gate, passthrough, token-field strip, flag mapping); the new route is
classified in tests/unit/_route_census.py.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e dev merge (#3644) - integrations.py: the settings-change audit row carried `credential_cleared` twice after the dev merge; Python keeps the last key, so switching a bearer endpoint to `aauth` recorded `false` while the store dropped the secret. The ent#623 line stays; the stale dev line goes. Router-level test proves the row says `true` on the switch (red against the duplicate). - a2a.py: the three `request.url.path` reads become `request.scope["path"]` (#3102 fence); the signature base keeps `raw_path` first. - .gitleaksignore: fingerprint for the `private_key: Ed25519PrivateKey = field(repr=False)` dataclass field at keys.py:43 (generic-api-key false positive). The range scan re-flags the adding commit, so an inline `gitleaks:allow` on the head cannot clear it; the fingerprint can. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…on forwards the mapped scheme (#3644) - _route_census.py: the three ent#623 discovery documents (/.well-known/aauth-agent.json, -jwks.json, -resource.json) are OWN_AUTH — anonymous by design, public material only, uniform 404 while the prototype flag is off. The AAuth route class on POST /a2a/{name} is the same registered route the census already lists; nothing new to classify there. - nginx.conf: the /.well-known/aauth- location sent `X-Forwarded-Proto $scheme`, which is always http behind the TLS terminator (#3215). It now mirrors the /a2a/ block's headers (mapped $fwd_proto); the sibling count moves 3 → 4. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ettings are unreadable (#3644) (d) `test_3215_public_base_url` drives the real card route with a settings stand-in that knows only the public URL. The branch made dev's single card builder (ent#180) consult `aauth_config.active_issuer()`, whose flag read called `settings_service._resolve_bool_flag` unguarded, so the card 500ed and the four card/door tests that follow it failed. `is_enabled()` now treats an unreadable flag as OFF (AAuth inert, dev's bytes), the direction `get_issuer` already takes; regression test on the real card route. I1: two tests execute the merge-added ent#838 gate on the AAuth door — an internal-scope agent answers an outside AAuth caller with the keyed caller's uniform 404 (no fetch, no audit row), and admits a trusted one as its verified identity, never its address. I2: comment on `fetch_card_for` naming its overlap with ent#838's own-path-then-origin card order; code unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…it allowlists (#3644) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cedes body validation (abilityai/trinity-enterprise#878) Review F1 (HIGH): `signer.agent_token_info` reused a cached window-N key for a chain asking for more remaining life than the window had left, so the key retired at the boundary mid-chain (T6 ruling: a whole chain finishes on one key; ent#879 would pay an extra consent near window edges). A cache hit now satisfies both bounds a miss would — `now + min_remaining < window_end` as well as the expiry margin — pinned by a test that starts from a POPULATED cache (the existing test only covered an empty one, where the bounds coincide), including the exact boundary (the key retires AT window_end). Review I1: a 429 on the initial POST is a refusal with no handle, so its `next_step` no longer points at `interaction.retry_after` (`slow_down_new`). Review I2: when the refreshed record cannot be persisted, the answer itself carries the back-off (`status=slow_down` with the PS's interval) instead of relying on the un-saved early-poll guard; the 429 arm logs instead of `pass`. Review I3: `rate_limiter.redis_client()` is the public seam; `person_token` no longer reaches a sibling's private through a from-import alias (Invariant #1(b)). Review I4: `_require_live` is a `Depends` declared after `get_current_user`, so the flag gate runs BEFORE body validation — with the flag off a malformed body answers the uniform 404 instead of a 422 that reveals the route (test: flag off + four bad bodies → 404; live again → 422). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…range scan still sees (#3644) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…(abilityai/trinity-enterprise#878) Checkpoint C of the person-token binding leg: - `scripts/dev/aauth_prototype/bind.sh <agent> <resource>` — binds one agent against the real person server with the agent's OWN MCP key (demo.sh's `agent_key()`), prints link + code for the owner, polls with the handle honouring `retry_after`, exits 0 on `issued` printing jti/aud/sub/exp — never the token (the response has no field for it). - `demo.sh`: `up`/`wire` set `aauth_person_server` (AAUTH_PERSON_SERVER, default the Hellō person server); runbook note + `rewire` warning: a new issuer host is a new agent to the PS, so do not rewire after binding. - Flow doc `a2a-aauth-agent-identity.md`: Flow 5 (binding), its refusal table, the ent#878 layers / surface / config rows, and the stated limits — binding hijack (relay only to your owner, privately; owner-only delivery is the productization fix), the instance key binds the instance's name for the agent, per-worker hold, PS signature not verified here, and the tool is advertised but inert while the flag is off (T3 deviation). - `requirements/mcp.md` §32.9 (FR-1..8 + out of scope / stated limits), chained after §32.8; ent#879 will be §32.10. - Catalog lines: `api-endpoints.md` (the route), `backend.md` (router + the four new `aauth/` modules and the HKDF signer change), `mcp-server.md` (`aauth.ts` / `request_person_token`); `feature-flows.md` index. - `tests/registry.json` entry for `test_ent878_aauth_person_token.py`. Debt entries (owner-only link delivery / T7, dynamic tool registration via the #846 reconciler / T3, RS256/ES256 verification of PS-issued tokens / #558 task G) are recorded in the private DEBT_INBOX. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…laration pattern; fingerprint signer.py:56 (abilityai/trinity-enterprise#878) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
trinity-ability
left a comment
There was a problem hiding this comment.
/validate-pr — APPROVE (stacked on #2956)
Validated against the stacked base feature/623-aauth-agent-identity, not dev. Tip f6eb3df3 · 31 files (+3326/−28) · Lane B (by path: no auth-boundary, compose, workflow or Dockerfile file touched; security-sensitive in substance, so the gates were checked by hand below) · no schema change.
CI: every check on the tip has finished and passed (Build (typecheck) + unit, gitleaks, guard ×3, prod-image-smoke, verify-non-root, schema-parity, compose/gid checks; e2e/report/auto-merge skipped). Because the base is a feature branch, this is the reduced set. backend-unit-test / pytest (head) + regression diff do not run on a feature-branch base. The first full Tier 1 run comes when the stack retargets dev, and it has to be green before merge.
What I ran (PR worktree, ~/tests/.venv)
| Run | Result |
|---|---|
test_ent878_aauth_person_token + test_ent623_aauth_core + test_ent623_aauth_a2a + guards test_2996_human_only_routes, test_1310_auth_wiring, test_models_centralized, test_186_enumeration_uniformity, test_293_admin_gate_rejects_agent_keys |
363 passed. test_2996 rule 1 is now green, so the earlier review's I5 (base routes not classified) is fixed |
Mutation: remove the min_remaining window bound in signer.py cache-hit (review F1) |
red (1 failed) |
Mutation: remove _assert_own_key from the route |
red |
Mutation: comment out app.include_router(aauth_person_router) in main.py |
red, so the composition root is pinned |
Execution coverage (§5.4)
| changed symbol | executed by | live consumer | verdict |
|---|---|---|---|
routers/aauth_person.py::request_person_token |
route tests via TestClient | main.py:1428 (pinned, mutation red) |
✅ |
signer cache-hit window bound (F1 fix) |
test_ent878 |
sign_request, person_token._signed_request |
✅ mutation red |
_require_live as a dependency (I4 fix) |
route tests (flag off + bad body → 404) | route signature | ✅ |
person_token / resume / egress.request_capped / requirement.* |
service tests through httpx.MockTransport + FakeRedis |
route; ent#879 | ✅ |
tools/aauth.ts |
aauth.test.ts + tool-visibility.test.ts (CI Build (typecheck) + unit green) |
server.ts registration |
✅ |
Checks
- Security: no keys, real emails or public IPs.
93.184.216.34is the example.com fixture, in tests only. No.env, no credential files, no symlinks or mode changes, no host paths. The.gitleaks.tomlallowlist is anchored narrowly to\w+: Ed25519PrivateKey = field(repr=False), and the.gitleaksignorefingerprint is commit-pinned. Both are acceptable. The enterprise-docs-guard pattern has 0 hits in the added lines. - Gate order:
AuthorizedAgentByName(uniform 404) →get_current_user→_require_live(404 before the body is validated) → own key only (403aauth_agent_only) → rate limits. The token never appears in the response model, the audit details or the MCP output. - Earlier review findings: F1 fixed (
signer.pycache hit now requiresnow + min_remaining < window_end). I1 fixed (a 429 on the POST maps toslow_down_newwith its ownnext_step). I4 fixed. I5 green. I2 and I3 remain informational. - Docs: closing keyword is present, written cross-tracker as
Fixes abilityai/trinity-enterprise#878. Requirements §32.9, Flow 5 and catalog lines are all included.
Warnings (do not block the stack, fix before it retargets dev)
⚠️ Config packaging (#1056 class). This PR's.env.exampledocumentsAAUTH_PERSON_SERVER(withAAUTH_PROTOTYPE_ENABLEDandAAUTH_ISSUER) as an env lever thatconfig.pyreads. None of the three is inbackend.environment:ofdocker-compose.ymlordocker-compose.prod.yml, so inside the container the env leg does nothing. It fails closed (person_server_unconfigured), and the demo path (demo.sh→system_settings) is not affected. Fix: wire all three into both compose files (two of them come from #2956, so do it on the base), or remove the env fallback from.env.example.⚠️ Stated limits carried as debt (T3 always-registered/inert, T7 first approver becomes the person): both are documented in Flow 5 and DEBT_INBOX. That is acceptable for the prototype, and the operator should know about them.- ℹ️ Not proven here: live premises P1 ("a bound agent gets 200") and P2 (cross-worker poll) can only be shown by the
bind.shrehearsal against Hellō. I judged the code and tests only, as the task asked. - ℹ️ The issue is in another repo, so merging does not auto-promote it. Set
status-in-devon abilityai/trinity-enterprise#878 by hand after merge.
Recommendation: APPROVE. Merge as part of the stack in the order the operator chooses (#2956 → this → ent#879), after a green full Tier 1 run once it targets dev.
|
merge-train (#3671, green): #2956 squash-merged to dev as 4bc8561 and this PR was retargeted to |
Fixes abilityai/trinity-enterprise#878 — the second step of the AAuth Night chain (trinity-enterprise#652): after agent identity (#2956), the agent can say whom it acts for. Stacked on #2956 — base is
feature/623-aauth-agent-identity; the stack merges together (operator call). Draft until the chain is in.What
aauth_person_server, Hellōhttps://person.hello.coopfor the demo); metadata from/.well-known/aauth-person.json(issuer must match, four required fields,jwks_urimay live on another host), cached like refactor(frontend): sweep cross-cutting + chat + file-mgr + process + misc (#554) #623's agent metadata —services/aauth/person_server.py.(agent, resource)untilexp − 5 min, the draft's full202 requirement=interaction/ pending-URL state machine (200/202/403/408/410/429), handles unguessable and agent-scoped, Redis-backed and fail-closed —services/aauth/person_token.py,requirement.py,egress.py.cnf; the window honoursmin_remainingso a whole challenge chain finishes on one key —services/aauth/signer.py,keys.py.POST /api/agents/{name}/aauth/person-token(flag off → 404 before any work; only the agent's own key may trigger a signature as that agent — humans get 403aauth_agent_only) and the self-acting MCP toolrequest_person_token(resource, pending_handle?)returning outcome +jti/aud/sub/exp+interaction{url, code, link, pending_handle, retry_after}+next_step— never the token.jti, execution id — never the token.scripts/dev/aauth_prototype/bind.sh <agent> <resource>prints the Hellō link + code and polls toissued;demo.shsets the PS and warns not torewireafter binding (a new issuer host is a new agent to the PS).docs/memory/requirements/mcp.md§32.9; catalog lines inapi-endpoints.md,backend.md,mcp-server.md.Rulings carried (orchestrator, on the operator's behalf — plan file
~/.claude/plans/ent-878-aauth-person-token.md)T1 Redis pending state · T2 new self-acting tool
request_person_token· T3 deviation: the tool is always registered and inert when the flag is off (backend 404 →aauth_disabled) instead of dynamic registration through the #846 reconciler — two days to the demo, flag on throughout; dynamic registration is a recorded debt · T4 no RS256/ES256 verification of tokens we merely carry (Hellō's keys; the resource verifies) · T5services/aauth/egress.py,a2a_client.pyuntouched · T6 HKDF per-agent key · T7 stated limit: the binding link is relayed in the owner's own chat; whoever approves first becomes the agent's person — owner-only delivery is the productization fix (debt) · T8Prefer: wait5 s / 15 s · T9 tool name. Builder deviations accepted: a POST-side 429 →pending/slow_down; interaction codes accepted at 4–32 Crockford symbols (correlation-only, judged safe by review); heldexpclamped to the agent token's.Review + security
/review(Fable, report-only): MERGEABLE AFTER FIXES — ten rider checks pass (own-key gate order, token never leaves the worker, PS-chosen strings validated before any fetch/relay/storage, HKDF binds issuer+agent+window, Redis fails closed, signature profile replayed through the verifier, MCP self-acting with a 1,025-char description). One functional finding fixed in0fb605bc: the key cache-hit now requiresnow + min_remaining < window_end(strict); plus the flag gate runs before body validation (flag off + bad body → 404)./cso --diff: no findings at any severity. Evidence sweep (Opus): GREEN — 20 most-direct files / 1,027 tests at seed 12345, the new file after its nine neighbours in one process (442), both mutation checks red (window condition removed → red; body before gate → red). Informational: a body that is not JSON answers422 json_invalidbefore anyDepends(FastAPI-wide; the route path is public anyway).Tests
test_ent878_aauth_person_token.py115 ·test_ent623_aauth_core79 ·test_ent623_aauth_a2a33 · route census 66 · registry 51 ·test_321548 · nodeaauth.test.ts+tool-visibility46 ·tsc --noEmitclean. Not run here: the full unit island (CI) · the live Hellō binding (bind.shfrom the demo laptop — premises P1 "a bound agent gets 200 directly" and P2 "cross-worker poll accepted" are proven only live).Before merge
backend-unit-testdoes not run) — expect the first full run when it retargetsdev. Merge order: feat(a2a): AAuth agent identity across instances, behind an off-by-default flag (abilityai/trinity-enterprise#623) #2956 → this → chore: add Codex project context #879.bind.sh) against Hellō with Eugene's account; a stable issuer hostname before binding.dev(checked: fix(audit): keep inclusive time bounds precise across ISO timestamp forms (#3555) #3573, feat(auth): change the admin password from the UI (abilityai/trinity-enterprise#709) #3516, fix(agent-server): price turns run through OpenRouter at OpenRouter prices #3490, fix(mcp): a sequential chat_with_agent call that ends in a receipt reports back to the caller's conversation (#3295) #3469, feat: allow PDF uploads with explicit deployment opt-in #3283, feat(workspace): the autonomy dial — what a companion may do unprompted (ent#641) #2984, feat(workspace): a stage advance reaches the Work card on the write, not the next poll (ent#533) #2713, feat(workspace): the rail's Canvas/Files dot lights on the write, not the next refetch (ent#532) #2709): only feat(auth): change the admin password from the UI (abilityai/trinity-enterprise#709) #3516 (feature/ent709-admin-password-change) —src/backend/routers/settings/generic.py, and that conflict comes from the refactor(frontend): sweep cross-cutting + chat + file-mgr + process + misc (#554) #623 base (the signing-key settings guard), not from this PR's own files; the operator's merge-order call.trinity-devmain(8bf2f00): owner-only link delivery, feat: per-agent MCP exposure flag — dynamic dedicated tool per exposed agent #846 dynamic registration, feat(schedules): preRun / postRun lifecycle hooks #558 task-G RS256 note.Handoffs
person_token() / resume() / held_token() / held_token_by_jti()andsigner.current_agent_key()(next PR in the stack).tools/aauth.ts) for the gate-consolidation work.🤖 Generated with Claude Code