Skip to content

feat(aauth): person-token binding through a person server — an agent acts for its owner (abilityai/trinity-enterprise#878) - #3659

Merged
vybe merged 18 commits into
devfrom
feature/878-aauth-person-token
Oct 13, 2026
Merged

vybe merged 18 commits into
devfrom
feature/878-aauth-person-token

Conversation

@vybe

@vybe vybe commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

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

  • Person server — one operator-set issuer (aauth_person_server, Hellō https://person.hello.coop for the demo); metadata from /.well-known/aauth-person.json (issuer must match, four required fields, jwks_uri may 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.
  • Person token for a resource — a signed POST as the agent (the refactor(frontend): sweep cross-cutting + chat + file-mgr + process + misc (#554) #623 RFC 9421 profile, replayed through our own verifier in tests), one cached token per (agent, resource) until exp − 5 min, the draft's full 202 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.
  • Per-agent key derived from the instance seed (HKDF, hour window) so every worker holds the same cnf; the window honours min_remaining so a whole challenge chain finishes on one key — services/aauth/signer.py, keys.py.
  • One route, one tool — 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 403 aauth_agent_only) and the self-acting MCP tool request_person_token(resource, pending_handle?) returning outcome + jti/aud/sub/exp + interaction{url, code, link, pending_handle, retry_after} + next_step — never the token.
  • Audit rows on every issuance / handoff / terminal outcome naming agent, PS, resource, jti, execution id — never the token.
  • Demo harness — scripts/dev/aauth_prototype/bind.sh <agent> <resource> prints the Hellō link + code and polls to issued; demo.sh sets the PS and warns not to rewire after binding (a new issuer host is a new agent to the PS).
  • Docs — flow doc Flow 5 + refusal rows + stated limits 6–9; docs/memory/requirements/mcp.md §32.9; catalog lines in api-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) · T5 services/aauth/egress.py, a2a_client.py untouched · 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) · T8 Prefer: wait 5 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); held exp clamped 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 in 0fb605bc: the key cache-hit now requires now + 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 answers 422 json_invalid before any Depends (FastAPI-wide; the route path is public anyway).

Tests

test_ent878_aauth_person_token.py 115 · test_ent623_aauth_core 79 · test_ent623_aauth_a2a 33 · route census 66 · registry 51 · test_3215 48 · node aauth.test.ts + tool-visibility 46 · tsc --noEmit clean. Not run here: the full unit island (CI) · the live Hellō binding (bind.sh from the demo laptop — premises P1 "a bound agent gets 200 directly" and P2 "cross-worker poll accepted" are proven only live).

Before merge

Handoffs

🤖 Generated with Claude Code

vybe and others added 16 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>
…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 trinity-ability left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/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.34 is the example.com fixture, in tests only. No .env, no credential files, no symlinks or mode changes, no host paths. The .gitleaks.toml allowlist is anchored narrowly to \w+: Ed25519PrivateKey = field(repr=False), and the .gitleaksignore fingerprint 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 (403 aauth_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.py cache hit now requires now + min_remaining < window_end). I1 fixed (a 429 on the POST maps to slow_down_new with its own next_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)

  1. ⚠️ Config packaging (#1056 class). This PR's .env.example documents AAUTH_PERSON_SERVER (with AAUTH_PROTOTYPE_ENABLED and AAUTH_ISSUER) as an env lever that config.py reads. None of the three is in backend.environment: of docker-compose.yml or docker-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.
  2. ⚠️ 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.
  3. ℹ️ Not proven here: live premises P1 ("a bound agent gets 200") and P2 (cross-worker poll) can only be shown by the bind.sh rehearsal against Hellō. I judged the code and tests only, as the task asked.
  4. ℹ️ The issue is in another repo, so merging does not auto-promote it. Set status-in-dev on 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.

@vybe

vybe commented Oct 13, 2026

Copy link
Copy Markdown
Contributor Author

merge-train (#3671, green): #2956 squash-merged to dev as 4bc8561 and this PR was retargeted to dev. Pushed one merge of dev onto this branch — the pre-squash dev tip merged normally, then the #2956 squash recorded with -s ours (its tree was verified identical to dev + #2956's head, which this branch already contains). Diff vs dev is this PR's own 31 files.

@vybe
vybe merged commit 18d02a1 into dev Oct 13, 2026
27 checks passed
@vybe
vybe deleted the feature/878-aauth-person-token branch October 13, 2026 19:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants