Repository navigation
feat(aauth): outbound resource client + call_aauth_resource — an agent calls an external AAuth resource through the full challenge ladder (abilityai/trinity-enterprise#879) - #3667
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>
…fication (abilityai/trinity-enterprise#879) Checkpoint A of the outbound AAuth resource client, stacked on ent#878. New under services/aauth/: - resource_meta.py — aauth-resource.json (issuer equality, same-origin jwks and control endpoints, access_mode, additional_signature_components) with the person_server fetch/cache discipline, and a tolerant AAuth-Budget parser. - resource_token.py — the security sink: every claim of a 401 auth-token challenge's resource token is checked against what WE presented (iss, dwk, aud == our PS, agent_jkt, ps, sub, presented_jti, timestamps, skew, lifetime, typ/alg/header params, kid → key under aauth-resource.json, signature) before the PS is contacted; one named check per failure. - auth_token.py — the exchange outcome types and the PS-answer inspection (T5: claims + cnf binding, no PS signature check). - resource_client.py — the ladder: token choice honouring access_mode, every 401/202/4xx transition (agent-token retry, person-token via ent#878, auth-token verify→exchange, step-up once, expired_jwt re-auth once, expired_presented fresh chain once, resource-side 202 → handle, never polled inline), bounds (≤6 sends, ≤3 exchanges, 25 s budget, possibly_executed on a POST timeout), and token scrubbing on every body/error. The Redis cache/pending/exchange seams are named and answer "unavailable" until checkpoint B wires ps_client. Additive edits to the stacked modules: - discovery: fetch_issuer_keys/resolve_key take dwk (cache keyed by (issuer, dwk)). - httpsig: components_for/sign accept extra_components (content-type/-digest only). - signer: sign_request(presented_token=, expected_key_thumbprint=, extra_components=) + KeyRotated; the agent key always signs. - egress.request_capped(peer=) so a resource-leg error names the resource. - person_token.forget(agent, resource) for the fresh-chain path. tests/unit/test_ent879_aauth_resource_client.py: 125 tests — one red per resource-token check, every ladder transition through the real signer → egress → MockTransport path with the scripted resource reading what was signed. ent#878 (115) and ent#623 (79) suites stay green. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t, resume (abilityai/trinity-enterprise#879)
Checkpoint B of the outbound AAuth resource client.
- services/aauth/ps_client.py — the draft §Auth Token Endpoint leg: a POST to
the PS auth_token_endpoint signed with the AGENT token carrying
{resource_token, presented_token, justification, capabilities} + Prefer: wait;
the answer inspected (T5) and mapped to AuthTokenIssued / ExchangePending /
ExchangeRefused with the PS's own error vocabulary preserved. The auth-token
cache lives in Redis under aauth:at:{sha256(our_issuer|ps|agent|jkt|resource)}
(TTL to expiry, served only with ≥ 5 min left and the caller's key; unreadable
→ "not held"). Pending records (ps_interaction / ps_approval /
resource_polling) share ent#878's handle namespace through its now-public
save_record/load_record/delete_record; each tool answers pending_unknown for
the other's kinds; a record carries the stored request and never a token.
- services/aauth/resource_allowlist.py — the sink between an LLM-supplied URL
and a signed request: aauth_resource_allowlist (system_settings → env →
nothing), origin EQUALITY against strict server identifiers, and fail closed
on every unreadable state — unset, empty, malformed JSON, a non-list, or a
single bad entry empties the whole list.
- resource_client: the allow-list gate before any egress; seams wired to
ps_client; resume(agent, handle) — one signed GET of the pending URL (PS
exchange or resource-side deferral) and, when the PS mints the auth token,
the STORED request is re-sent (never caller-supplied args); terminal /
transient / slow-down / early-poll states; key_rotated and handle_unknown
fail closed.
- person_token (ent#878): signed_ps_request, save/load/delete_record and
DENIAL_CODES made public (no test pinned the privates); records carry
kind=person_token and resume() refuses other kinds.
- .env.example: AAUTH_RESOURCE_ALLOWLIST.
tests/unit/test_ent879_aauth_resource_client.py: 185 tests (A+B) — the
allow-list fail-closed matrix (empty, unreadable, malformed, origin mismatch,
prefix, port, path), the exchange mapping, the cache, the records, every
resume state, and the acceptance beat: the second call presents the cached
auth token with zero PS trips. ent#878 (115) and ent#623 (79) stay green.
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>
…h-resource-client
… rows (abilityai/trinity-enterprise#879)
Checkpoint C — the auth boundary over the ent#879 ladder.
Route `POST /api/agents/{name}/aauth/resource-call` (routers/aauth_resource.py,
`# mcp: tools/aauth.ts`, registered after aauth_person): ent#878's own gates
reused, not copied — `require_live` as a Depends declared after
get_current_user (flag off → uniform 404 before body validation) and
`assert_own_key` (human / sibling / system key → 403 aauth_agent_only) BEFORE
the allow-list is consulted; the two helpers go public on aauth_person with a
`what=` message parameter. Discriminated request (models.py): a new call
(resource, method, path, body, justification, REQUIRED dedup_label) XOR a
resume (pending_handle) via a callable Discriminator; no request or response
field can carry a token (token_kind is a three-word Literal; pinned).
Protocol outcomes are 200 with `outcome`; refusals are mapped HTTP errors
(403 / 402 / 409 / 422 / 429 / 503 / 504, 502 default) carrying
possibly_executed on a POST timeout (#914).
Service seam services/aauth/resource_call.py: the ladder under the #1084
effect guard with identity {resource, method, path} + dedup_label (never the
body); only result / denied complete the claim, every other outcome leaves
via _NotTerminal so the claim is released and the post-consent retry runs
for real; a resume never enters the guard; in-flight → 409, unguarded on a
pull agent → 422 (#2392). Audit: EXECUTION aauth_resource_call after egress,
AUTHORIZATION aauth_resource_refused before it, details allow-listed — agent,
resource, method, path, token kind, outcome, status, budget cost, challenges;
never a token, the body, the justification, a handle, a url or a code.
Activity row direction aauth_resource (host only), fail-open.
MCP `call_aauth_resource(resource, method, path, body, justification,
dedup_label | pending_handle)` in tools/aauth.ts through the same self-acting
seam (agent scope only), always registered under operatorOnly (ent#879 T4,
the ent#878 mechanism); returns the backend union verbatim plus `agent`,
strips any token-shaped field, and turns an HTTP refusal into
{outcome: refusal, refusal{reason, message, http_status, retry_after?,
challenges?, possibly_executed?}, do_not_retry, next_step} naming the actor.
client.ts callAAuthResource, access.ts policy row, server.ts comment,
tool-visibility.test.ts both tools, aauth.test.ts cases; one census entry.
Tests: tests/unit/test_ent879_aauth_resource_route.py (19) over the real
ladder + TestClient — gate order, discriminated body, result / replay /
pending-release / resume / denied / expired / refusal mapping / 409 / 422 /
timeout, audit allow-list, activity shape, main.py order, catch-all resolve,
gate reuse; MCP aauth.test.ts 88 incl. description < 2048 chars.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…bilityai/trinity-enterprise#879) Checkpoint D — the live proof and the docs. demo.sh: step 7 `bind` (bind.sh <agent> https://senzing.aauth.dev — prints the Hellō link + code, waits for `issued`) and step 8 `sanctions check` (senzing_check.py through the agent's OWN key against POST /v1/search_entities {"FEATURES": [{"NAME_FULL": …}, {"ADDR_COUNTRY": …}]}: the identity used, the challenge sequence actually seen, the top candidates' match_key / match_level, the AAuth-Budget cost, then a second call that must show challenges: [] and token_kind: auth — zero PS trips — then A's aauth_resource_* audit rows). `rehearse` runs 1–8 with no pause (step 7 asserts the binding is present, step 8 prints a consent link instead of polling). `wire` sets aauth_person_server + aauth_resource_allowlist (Senzing + whoami, origin equality); `down` deletes both keys. Step 8 carries execution_id + a per-call dedup_label; DEMO_EXECUTION_ID defaults to `manual` and the header says why: the effect guard arms only for a real execution of the agent (the MCP path), an unknown id would be refused on a pull pilot (#2392). scripts/dev/aauth_prototype/senzing_check.py: stdlib-only, runs from a laptop with the agent's MCP key; handles interaction_required (link for the OWNER, polls honouring retry_after; --no-wait prints and exits 3), prints nothing that is a token. Docs: requirements/mcp.md §32.10 (FR-1..FR-9 + out of scope + stated limits, chained after §32.9); feature-flows/a2a-aauth-agent-identity.md Flow 6 (the ladder, nine steps + bounds), its refusal table, public-surface and configuration rows, stated limits 10–14 (allow-list is a settings key; auth tokens + pending records in Redis under the transient-secret posture; PS signature inspected not verified; four-party aud deferred; the guard arms only for a real execution), Related; catalog lines in architecture/{api-endpoints,mcp-server,backend}.md; tests/registry.json entries for test_ent879_aauth_resource_client.py, test_ent879_aauth_resource_route.py and mcp-server/src/tools/aauth.test.ts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…etime bounds, path normalisation (abilityai/trinity-enterprise#879) - M1: ps_client.save_pending checks ent#878's per-agent open-interaction bound (MAX_PENDING_PER_AGENT, shared set) before writing; the ladder maps it to the same too_many_pending refusal person_token() returns. - M2: inspect_auth_token checks ps == our PS and caps exp - iat at 1 h. - L1: a control-endpoint path matches with one trailing slash stripped. - L2: ResourceToken.token is repr=False like its three siblings. - L3: the 25 s call budget starts at entry (covers discovery), both in call_resource and resume. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…iew lows (abilityai/trinity-enterprise#879) - L4/L7: AAUTH_PROTOTYPE_ENABLED, AAUTH_ISSUER, AAUTH_PERSON_SERVER and AAUTH_RESOURCE_ALLOWLIST forwarded in backend.environment of docker-compose.yml and docker-compose.prod.yml (#1056 class), and mirrored into docker-compose.hosted.yml, which the #2280 parity guard requires to equal prod. - L6: a POST whose connection drops after the write (egress 'unreachable') reports possibly_executed, like a timeout (#914). 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 (validated against the stacked base feature/878-aauth-person-token, not dev)
Tip 10e22f1 · Lane C (docker-compose*.yml touched), no schema flag · 37 files, +5666/−104 · stack #2956 → #3659 → this, merging together on the operator's call.
| Category | Status | Notes |
|---|---|---|
| Base branch | ✅ | feature/878-aauth-person-token, intentional for a stacked PR |
| Issue link | ✅ | Fixes abilityai/trinity-enterprise#879 resolves (P1, type-feature, theme-security). Cross-tracker, so promotion is manual: the issue currently reads status-ready and needs status-in-dev set by hand when the stack lands |
| Commits | ✅ | 7 conventional commits (feat/docs/fix/merge) |
| Requirements / architecture / flow | ✅ | requirements/mcp.md §32.10, flow doc Flow 6 + limits 10–14, catalog lines in api-endpoints.md / mcp-server.md / backend.md |
| Enterprise-docs disclosure | ✅ | the guard pattern finds nothing in the added lines |
| Security greps §4.1–4.10 | ✅ | no keys, emails, symlinks or host paths. The only IP is the 93.184.216.34 fixture, and it is in tests/ |
| Config packaging | ✅ | all four AAUTH_* levers are in docker-compose.yml, .prod.yml, .hosted.yml and .env.example (AAUTH_RESOURCE_ALLOWLIST is read through a constant, so the regex grep misses it; I checked it by hand) |
| Infrastructure | ✅ | compose edits are env wiring only, and the PR body explains them |
| Structural review | ✅ | /review gave MERGEABLE AFTER FIXES; the fixes are in 8939c55 and 10e22f1. Scoped /cso --diff found nothing |
| Merge gate | Every check on the tip finished green. It is the reduced stacked set: pytest (head) / regression diff / backend-unit-test 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 any merge. Lane C also needs journey-smoke + frontend-e2e on the PR at that point (label ui) |
Execution coverage (§5.4), run independently in the PR worktree at 10e22f1
test_ent879_aauth_resource_client+_route+test_ent878_aauth_person_token: 323 passed- My own mutations, separate from the sweep's four. Each turned the suite red and each was reverted:
resource_token.py:125: theagent_jktcheck made tautological → 3 failedresource_allowlist.is_allowed: equality loosened to a prefix match → 1 failed (the origin-equality sink is pinned)resource_client.py:738: the allow-list re-check on resume removed → 1 failed
- The sweep's mutations (pending budget, auth-token
ps,assert_own_key,include_routerinmain.py) are all red. That pins the production route registration as well as the handler. - The route test drives the real
effect_guardover a fake store, so these are executing tests, not source-text tests.
Non-blocking items, carried forward
_is_control_path(resource_client.py:277) is case-sensitive and refuses exact matches only. A resource whose server is case-insensitive would accept/Authorizefor its declared/authorize. The impact is limited: the target is still an allow-listed origin, so this is not a new destination. Worth a follow-up issue.- The pending budget is check-then-
SADD. This is the same TOCTOU as #878; concurrent calls can overshoot 3 by a small amount. - A
possibly_executedPOST releases the effect claim instead of completing it, so a re-delivery with the samededup_labelcan re-send a POST that may already have run. This is a deliberate choice and it is flagged in the 504 detail and the audit row. It is still weaker than one effect per label. - Review L4 (deferred + consent resume re-sends the POST) and L8 are already listed as follow-ups in the PR body.
Not judged here: the live Senzing call. Its proof is demo.sh rehearse / senzing_check.py from the demo laptop.
Recommendation: APPROVE for the stack, on the condition that the full Tier 1 run (plus journey-smoke / frontend-e2e) is green once it retargets dev.
|
merge-train (#3671, green): #2956 (4bc8561) and #3659 (18d02a1) squash-merged to dev; this PR retargeted to |
Fixes abilityai/trinity-enterprise#879 — the third and last step of the AAuth Night chain (trinity-enterprise#652): the agent calls an external AAuth resource — Senzing sanctions screening — through the full challenge ladder, with no shared secret anywhere. Stacked on #3659 (which stacks on #2956) — base is
feature/878-aauth-person-token; the stack merges together (operator call). Draft until the chain is in.What
services/aauth/resource_client.py,resource_meta.py: reads/.well-known/aauth-resource.json(issuer must match), presents the best token held (auth > person > agent), answers401 requirement=agent-token | person-token | auth-token, step-up once, expired-token re-authorisation once, resource-side202(deferred delivery) → a resumable handle with no inline polling; bounds ≤ 6 resource sends / ≤ 3 PS exchanges / 25 s from entry; a POST that times out or drops is reportedpossibly_executed;AAuth-Capabilities: interactionsent;AAuth-Budgetparsed and returned; every token scrubbed from bodies, logs and errors.services/aauth/resource_token.py: every check in the draft's Resource Challenge Verification before anything goes to the person server (typ,dwk, signature against the resource's JWKS resolved fromiss,iss= the resource called,agent_jkt= RFC 7638 thumbprint of our key,ps= our PS,sub+presented_jti= the token we presented,exp,aud= our PS), one red test per check.services/aauth/ps_client.py,auth_token.py: signed with the agent token carryingpresented_token; the answer inspected (cnf= our key,aud= the resource,ps= our PS, lifetime ≤ 1 h, capped at the presented token'sexp), cached in Redis per(agent, resource)untilexp − 5 min; pending records share ent#878's handle namespace and its per-agent budget of 3.services/aauth/resource_allowlist.py: operator settingaauth_resource_allowlist, fail-closed on empty / unreadable / malformed, origin equality (scheme + host + port) plus path rules (…aauth.dev.evil.example,…aauth.dev@evil,/authorize/refused), every dialled URL also through refactor(frontend): sweep cross-cutting + chat + file-mgr + process + misc (#554) #623's public-URL validator.POST /api/agents/{name}/aauth/resource-call(flag off → 404 before body validation; only the agent's own key — humans, siblings andsystemget 403aauth_agent_only;effect_guardwithdedup_label, released on every non-terminal outcome) and the self-acting MCP toolcall_aauth_resource(resource, method, path, body, justification, dedup_label | pending_handle)→outcome ∈ {result, interaction_required, pending, denied, expired, refusal}+next_stepnaming the actor — never a token.EXECUTION aauth_resource_callafter egress,AUTHORIZATION aauth_resource_refusedbefore it; agent, resource issuer, path, token kind, outcome, budget cost — never a token.scripts/dev/aauth_prototype/demo.shsteps 7 bind (Hellō link + code viabind.sh) and 8 sanctions check (search_entitiesagainsthttps://senzing.aauth.dev, prints identity, the challenge sequence seen, top candidates'match_key/match_level, the budget cost, then a second call with zero PS trips), arehearsemode (1–8 without pauses), settings wired inwire/down;senzing_check.pyruns from the laptop with the agent's MCP key.AAUTH_*levers threaded intodocker-compose.yml,.prod.ymland.hosted.yml(the feat(aauth): person-token binding through a person server — an agent acts for its owner (abilityai/trinity-enterprise#878) #3659 validation warning).docs/memory/requirements/mcp.md§32.10; flow doc Flow 6 + refusal rows + stated limits 10–14; catalog lines inapi-endpoints.md,mcp-server.md,backend.md; registry entries.Rulings carried (orchestrator, on the operator's behalf — plan file
~/.claude/plans/ent-879-aauth-resource-client.md)T1 Redis for cache + pending records · T2 one tool
call_aauth_resourcewith resume by handle · T3 one route · T4 always registered, inert when the flag is off (same as #3659) · T5 PS-issued tokens inspected, not signature-verified · T6 allow-list = settings key (UI is recorded debt) · T7 reuse ent#878's gate helper · T8 challenge-driven only (no pre-authorisation) · T9 fallback not needed — step-up, expired re-auth, resource-side 202 and extra signature components are implemented · T10 justification not audited. Builder deviations accepted:auth_token.pyas its own module; auth-token cache keyed by resource (scope only known after the challenge);jti-less resource tokens accepted (the draft does not require it); the demo defaultsDEMO_EXECUTION_ID=manual(a synthetic id cannot arm the effect guard — stated limit 14); ent#878's gate helpers made public on this branch.Review + security
/review(Fable, report-only, A+B): MERGEABLE AFTER FIXES — both sinks hold (adversarial allow-list probes refused; every resource-token check red when broken); two mediums fixed in8939c55e(pending budget checked before saving; auth-tokenps+ ≤ 1 h), lows L1–L3 + L6 fixed (8939c55e,10e22f1e). Scoped/cso --diff: no findings. Not fixed, recorded: review L4 — when the resource defers and the PS then asks for consent,resumere-sends the original POST instead of polling the held request (a resume-path redesign; follow-up issue); L8 (low confidence) a non-UTF-8 body may raise while saving a pending record. Evidence sweep (Opus, 10e22f1): GREEN — 20 most-direct files / 1,050 tests at seed 12345, the two new files after their ten neighbours in one process (650), four mutation checks red (budget check removed;pscheck removed;assert_own_keyremoved → an admin reached the allow-list; router registration commented out → census). Three minor items noted, none blocking: the control-endpoint check is case-sensitive and ignores sub-paths (/Authorize,/authorize/x); the pending-budget check and save are two Redis steps (a race can leave four records — same gap as #878, records expire); a POST that times out after being sent returns 504possibly_executedbut releases the effect claim, so a same-label redelivery would send again.Tests
test_ent879_aauth_resource_client189 ·test_ent879_aauth_resource_route19 ·test_ent878_aauth_person_token115 ·test_ent623_aauth_core79 · route census 66 · registry 51 · auth wiring 12 · compose parity 14 + 8 · nodeaauth.test.ts+ visibility + access-wiring 88 ·tsc --noEmitclean. Not run here: the full unit island (CI) ·docker compose config(no compose CLI on the engineer; YAML-parsed + parity tests) · the live Senzing call —demo.sh rehearse/senzing_check.pyfrom the demo laptop after the Hellō binding is the proof.Before merge
dev. Merge order: feat(a2a): AAuth agent identity across instances, behind an off-by-default flag (abilityai/trinity-enterprise#623) #2956 → feat(aauth): person-token binding through a person server — an agent acts for its owner (abilityai/trinity-enterprise#878) #3659 → this.possibly_executed); Settings UI foraauth_resource_allowlist; auth tokens in Redis unenveloped under the transient-secret posture (Invariant fix: Add ARM64 Alpine compatibility for frontend Docker build #12); four-partyaud;authorization_endpointpre-authorisation; PS signature verification.dev: only feat(auth): change the admin password from the UI (abilityai/trinity-enterprise#709) #3516 onsrc/backend/routers/settings/generic.py— inherited from the refactor(frontend): sweep cross-cutting + chat + file-mgr + process + misc (#554) #623 base, not this PR's files (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).Handoffs
tools/aauth.ts) for the gate-consolidation work.aauth_prototype_enabled.🤖 Generated with Claude Code