Skip to content

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

Merged
vybe merged 26 commits into
devfrom
feature/879-aauth-resource-client
Oct 13, 2026
Merged

vybe merged 26 commits into
devfrom
feature/879-aauth-resource-client

Conversation

@vybe

@vybe vybe commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

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

  • Resource client + challenge ladder — 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), answers 401 requirement=agent-token | person-token | auth-token, step-up once, expired-token re-authorisation once, resource-side 202 (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 reported possibly_executed; AAuth-Capabilities: interaction sent; AAuth-Budget parsed and returned; every token scrubbed from bodies, logs and errors.
  • Resource-token verification — a security sink — 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 from iss, 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.
  • PS auth-token exchange + caches — services/aauth/ps_client.py, auth_token.py: signed with the agent token carrying presented_token; the answer inspected (cnf = our key, aud = the resource, ps = our PS, lifetime ≤ 1 h, capped at the presented token's exp), cached in Redis per (agent, resource) until exp − 5 min; pending records share ent#878's handle namespace and its per-agent budget of 3.
  • Allow-list — a security sink — services/aauth/resource_allowlist.py: operator setting aauth_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.
  • One route, one tool — POST /api/agents/{name}/aauth/resource-call (flag off → 404 before body validation; only the agent's own key — humans, siblings and system get 403 aauth_agent_only; effect_guard with dedup_label, released on every non-terminal outcome) and the self-acting MCP tool call_aauth_resource(resource, method, path, body, justification, dedup_label | pending_handle) → outcome ∈ {result, interaction_required, pending, denied, expired, refusal} + next_step naming the actor — never a token.
  • Audit — EXECUTION aauth_resource_call after egress, AUTHORIZATION aauth_resource_refused before it; agent, resource issuer, path, token kind, outcome, budget cost — never a token.
  • Demo harness — scripts/dev/aauth_prototype/demo.sh steps 7 bind (Hellō link + code via bind.sh) and 8 sanctions check (search_entities against https://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), a rehearse mode (1–8 without pauses), settings wired in wire/down; senzing_check.py runs from the laptop with the agent's MCP key.
  • Config — the four AAUTH_* levers threaded into docker-compose.yml, .prod.yml and .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 — docs/memory/requirements/mcp.md §32.10; flow doc Flow 6 + refusal rows + stated limits 10–14; catalog lines in api-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_resource with 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.py as 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 defaults DEMO_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 in 8939c55e (pending budget checked before saving; auth-token ps + ≤ 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, resume re-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; ps check removed; assert_own_key removed → 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 504 possibly_executed but releases the effect claim, so a same-label redelivery would send again.

Tests

test_ent879_aauth_resource_client 189 · test_ent879_aauth_resource_route 19 · test_ent878_aauth_person_token 115 · test_ent623_aauth_core 79 · route census 66 · registry 51 · auth wiring 12 · compose parity 14 + 8 · node aauth.test.ts + visibility + access-wiring 88 · tsc --noEmit clean. 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.py from the demo laptop after the Hellō binding is the proof.

Before merge

Handoffs

🤖 Generated with Claude Code

vybe and others added 23 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>
…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>
… 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 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 (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: the agent_jkt check made tautological → 3 failed
    • resource_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_router in main.py) are all red. That pins the production route registration as well as the handler.
  • The route test drives the real effect_guard over a fake store, so these are executing tests, not source-text tests.

Non-blocking items, carried forward

  1. _is_control_path (resource_client.py:277) is case-sensitive and refuses exact matches only. A resource whose server is case-insensitive would accept /Authorize for 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.
  2. The pending budget is check-then-SADD. This is the same TOCTOU as #878; concurrent calls can overshoot 3 by a small amount.
  3. A possibly_executed POST releases the effect claim instead of completing it, so a re-delivery with the same dedup_label can 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.
  4. 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.

@vybe
vybe marked this pull request as ready for review October 13, 2026 16:46
@vybe
vybe changed the base branch from feature/878-aauth-person-token to dev October 13, 2026 19:33
@vybe

vybe commented Oct 13, 2026

Copy link
Copy Markdown
Contributor Author

merge-train (#3671, green): #2956 (4bc8561) and #3659 (18d02a1) squash-merged to dev; this PR retargeted to dev. Pushed a merge of dev onto this branch: the pre-stack dev tip (ac508c1) merged normally, then both squashes recorded with -s ours (their content is already on this branch). Diff vs dev is exactly this PR's own 37 files; three of them (models.py, tests/registry.json, api-endpoints.md) carry auto-merged dev-side changes.

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