Agent execution auth — a credential for request-less agent turns
Status: doc-first SSOT (2026-07-24). The crux dependency of agent-teams.md S3 (teammates) / S4 (event bridge) / S5 (proactive): a teammate or a proactively-woken agent runs without a live HTTP request, so there is no forwarded user bearer for the control-plane MCP tools it calls. It needs its own credential. Builds on auth.md (the control plane owns all auth; every credential resolves to a
Principal).
Problem
Today the conversational agent (apps/agent) authenticates by forwarding the caller's bearer: the base
MCP client sends the user's token to the control plane, so every tool call runs as that human (their role,
their tenancy). That is exactly right for interactive chat — but a teammate (S3) or a proactively-woken
agent (S5) has no request, no header, no bearer. It still needs to call get_scorecard / run_scorecard /
send_message as some authenticated principal, scoped to its workspace and bounded in what it may do.
Principles
- A new credential kind, not a route special-case. Everdict already resolves several credential kinds to
one
PrincipalbehindcompositeAuthenticator(oidc · api-keyak_· runnerrnr_· github-actions). An autonomous agent credential is another kind —via: "agent", anagt_…token, one moreAuthenticator(therunnerAuthenticatorleast-privilege pattern is the template). No new tenancy axis, no bypass. - Acts AS its creator, never above. The token's
subjectis the human who created the teammate/proactive agent. Through the usual membership resolution (applyActiveWorkspace) it gets that person's current workspace role — so it can never exceed the creator, and a role change/removal takes effect immediately (no baked-in privilege). - Scoped tighter than a person. The token carries
scopes(the existing per-key scope:read|write|admin, intersected with the role bycan()). Default =write, which by the matrix excludes secrets, members, settings, keys, and destructive live-cluster control — an autonomous agent authors/runs eval content but never touches governance. (S6's eval-driving surface fits insidewrite.) - Fail-closed, hashed, revocable, audited. Only the SHA-256 hash is stored (plaintext returned once at
issuance, like
ak_). Unknown/expired ⇒undefined⇒ 401. Revoked when the teammate stops.via:"agent"makes every autonomous action distinguishable in logs from a human's.
Design
request-less turn (teammate wake / proactive event)
→ agent forwards Authorization: Bearer agt_… to /me + the MCP tools
→ agentTokenAuthenticator: agt_ prefix + hash → store → { workspace, subject(creator), scopes }
→ Principal{ via:"agent", subject:creator, workspace, scopes:["write"] }
→ applyActiveWorkspace → creator's membership role
→ can(principal, action) = role ∩ scopes (≤ creator, ≤ write)
- Token —
agt_<random>; SHA-256 hash in anAgentTokenStore(reuses theTenantKeyStoreshape:resolveByHash(hash) → { tenant, owner=creator, scopes }). Immutable (rotate = revoke + reissue). - Authenticator —
agentTokenAuthenticator({ resolve }):agt_prefix,hashKey, injectedresolve, fail-closed. ReturnsPrincipal{ via:"agent", subject, workspace, roles:["member"] (bootstrap default), scopes: resolved.scopes ?? ["write"] }. Placed in the composite chain. A1 = this slice. - Membership —
via:"agent"is NOT excluded fromapplyActiveWorkspacebootstrap (unlike runner/github-actions): the agent IS the creator, a real member, so it takes the creator's live role. (Itssubjectalready has a member row; nothing new is bootstrapped.)
Stages
- A1 — auth core.
Principal.via += "agent";agentTokenAuthenticator(pure, injected resolver); export + ready for the composite. Unit-tested (prefix match, fail-closed, scope default, bounded). ← this slice. - A2 — token store + issuance. LANDED.
agentTokenAuthenticatoris wired intobuildAuthenticator(resolvingagt_via the sharedTenantKeyStore);issueAgentToken(store, tenant, owner, scopes=["write"])mints + stores the hash (owner = creator,agt_prefix); the personal key list (GET /keys+list_api_keys) filters outagt_viaisAgentTokenPrefix. The control plane now ACCEPTS anagt_bearer as avia:"agent"principal. Chosen store approach (a) below (reuse, no migration). Store decision (resolved → a):- (a) reuse
TenantKeyStore—add(tenant, hash, { owner: creator, scopes: ["write"], prefix: "agt_" });agentTokenAuthenticator({ resolve: h => keyStore.resolveByHash(h) }). No migration. The prefix check keepsak_/agt_from cross-claiming (same hash table, different authenticator). Caveat: anagt_row would surface in the owner'slist_api_keysunless that list filtersprefix !== "agt_"— do that filter. - (b) dedicated
AgentTokenStore(new table + migration) — clean separation + teammate-tied lifecycle, no key-list leak, at the cost of a migration + a parallel store. Lean (a) + the list filter for the first cut (no migration); revisit (b) if agent-token lifecycle diverges.
- (a) reuse
- A3 — request-less turn auth. CORE LANDED.
apps/agentrunTeammateTurn(deps, authenticate, mailbox, sessionId, agentToken): authenticates via theagt_token (→ the agent principal), drains the teammate's mailbox, and runs the agent loop over the incoming messages — forwarding the SAME token to the MCP tools, so every tool call is authenticated + RBAC-bounded as the creator. Best-effort (a failed turn is logged, never thrown). Unit-tested (authenticated turn over an incoming message; empty-mailbox no-op). Last mile: wirerunTeammateTurnas theTeammateSupervisor'srunTurn+ aspawn_teammatepath that issues the token (issueAgentToken), creates the teammate session, and registers it — then S3 (peer collaboration) and S5 (proactive: an event wakes the same turn) are live end-to-end.
Non-goals / guardrails
- Not a super-user token — bounded by the creator's role AND the
writescope; never secrets/governance. - Not a second tenancy axis —
workspacestays the one trust-zone key; the token is workspace-scoped. - Not decode-without-verify — resolved only via the hashed store; unknown ⇒ 401.