feat(auth): broaden agent API-key utility in the CLI; authorize per-method on the user RPC (TNT-139)#101
Conversation
d21889e to
1bab79f
Compare
There was a problem hiding this comment.
Pull request overview
This PR broadens agent API-key usability in the CLI by changing the user RPC (/api/v1/user/rpc) from a blanket “reject agent keys” policy to “authenticate any principal, authorize per-method,” allowing agents to call safe account-scoped reads (whoami, space.list) while keeping all account-management methods user-only.
Changes:
- Admit agent API keys on the user RPC at authentication time, carrying through
kind: "u" | "a"andemail: string | null. - Enforce default-deny agent authorization on the user RPC via a centralized allow-list gate (
whoami,space.list). - Extend
whoamiprotocol + CLI output to includekindand omit email for agents; update integration/e2e tests and auth design docs accordingly.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/server/middleware/authenticate-user.ts | Removes the “agent-key door bar”; authenticates api keys to their real principal kind and sets nullable email for agents. |
| packages/server/router.ts | Limits lazy first-login provisioning to user callers only; threads kind through the user-RPC context. |
| packages/server/rpc/user/index.ts | Adds centralized per-method agent gating with an explicit allow-list (whoami, space.list). |
| packages/server/rpc/user/types.ts | Extends user-RPC context with kind and nullable email; introduces requireUserCaller helper for user-only methods. |
| packages/server/rpc/user/whoami.ts | Returns {id, kind, email, name} from the validated credential context. |
| packages/protocol/user/whoami.ts | Updates the whoami schema/result to include kind and make email nullable. |
| packages/cli/commands/whoami.ts | Prints Kind: and suppresses email for agent identities. |
| packages/server/wiring.test.ts | Updates wiring test expectations for whoami to include kind. |
| packages/server/middleware/authenticate-user.integration.test.ts | Updates auth integration coverage to assert agent api keys are admitted as kind "a" (no email). |
| packages/server/rpc/user/api-key.integration.test.ts | Sets kind: "u" in the test context (tests user-PAT behavior). |
| packages/server/rpc/user/agent.integration.test.ts | Adds agent-caller coverage for whoami/space.list and verifies all account-management methods are FORBIDDEN. |
| e2e/cli.e2e.test.ts | End-to-end verification that agent keys can run me whoami/me space list but are denied me agent list. |
| AUTH_DESIGN.md | Documents the new “admit any principal, authorize per-method” policy for the user RPC. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Agents have no email; only humans do. | ||
| if (identity.email) console.log(` Email: ${identity.email}`); |
| const body = (await response.json()) as { | ||
| jsonrpc: string; | ||
| result: { id: string; email: string; name: string }; | ||
| result: { id: string; kind: string; email: string; name: string }; | ||
| }; |
|
Non-blocking observation on the per-method gate ordering ( In production, the RPC dispatcher validates params before invoking the handler, but the agent denial (
So the error an agent sees on a gated method depends on whether its params happen to be valid:
This is cosmetic, not a security issue: in both branches the agent never reaches the handler body, so account management stays denied — only the error code/message differs. Worth noting the integration tests do not surface this because the If we ever want an agent to always get the "user-only" message regardless of param validity, the fix is to run the |
1bab79f to
5821f85
Compare
|
Thanks — addressed all three in @jgpruitt's ordering observation (the substantive one): you're right that authorization should precede param validation, so I made the agent denial a per-method
So an agent now gets the same Copilot nits:
|
| /** The user's email (powers whoami + lazy provisioning); null for an agent. */ | ||
| email: string | null; | ||
| /** The principal's display name (the agent's name, for an agent). */ | ||
| name: string; |
| /** The caller's email (powers whoami); null for an agent (no email). */ | ||
| email: string | null; | ||
| /** The caller's display name (the agent's name, for an agent). */ | ||
| name: string; |
| const handlerContext: HandlerContext = { request, ...context }; | ||
|
|
||
| // Authorize before validating params: a caller that may not invoke this | ||
| // method shouldn't have its input parsed — it gets a consistent | ||
| // authorization error rather than an INVALID_PARAMS that leaks the param | ||
| // schema. Throws an AppError on denial (mapped below). | ||
| method.authorize?.(handlerContext); | ||
|
|
…NT-139)
The user RPC barred all agent keys at the door (403, "agents can't manage
the account") — conflating authentication (who) with authorization (what).
That blunt bar lumped harmless account-scoped reads (whoami, space.list) in
with real account mutations, so an agent acting with ME_API_KEY couldn't even
report its own identity.
Flip it to "admit any authenticated principal, authorize per-method", with the
server as the single source of truth:
- authenticate-user: drop the kind!='u' bar; an api key now carries its
principal's real kind ('u'|'a'). Agents get email=null, emailVerified=false.
- router: run first-login provisioning for users only (agents are
pre-provisioned; provisioning is user+email keyed).
- user RPC gate (gateAgentAccess + AGENT_ALLOWED in rpc/user/index): an
ALLOW-LIST. whoami and space.list are open to any principal; every other
method denies a non-user caller via requireUserCaller. Default-deny: a
newly-added method is off-limits to agents unless explicitly allow-listed,
so a forgotten annotation denies agents rather than exposing the account.
- the denial is a per-method `authorize` hook run by the dispatcher BEFORE
param validation, so an agent always gets the same FORBIDDEN (never an
INVALID_PARAMS that would leak the schema), and input is never parsed for a
call the caller may not make.
- whoami gains a `kind` field and a nullable email; `me whoami` renders
"Kind: agent" and omits the email line for agents (cf. TNT-101).
This makes the CLI long tail (me whoami, me space list, me group mine, me
access *, me principal resolve) usable for agents — exactly what their grants +
kind authorize — while admin/account-mutation stays denied as before.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5821f85 to
642388b
Compare
|
Addressed the latest Copilot pass in
|
Fixes TNT-139.
Problem
Agent API keys are first-class credentials, but the user RPC (
/api/v1/user/rpc) barred all agent keys at the door (403, "agents can't manage the account"). That conflated authentication (who you are) with authorization (what you may do): the blunt door bar lumped harmless account-scoped reads (whoami,space.list) together with genuine account mutations (agent.*,apiKey.*,space.create/rename/delete). So an agent acting withME_API_KEYcouldn't even report its own identity, and the CLI long tail (me whoami,me space list,me group mine, …) was closed to agents even where the server would authorize them.Approach
Flip the user RPC from "bar all agent keys" to "admit any authenticated principal, authorize per-method" — with the server as the single source of truth.
authenticate-user.ts— drop thekind !== "u"403 bar. An api key now carries its principal's realkind("u"|"a"); agents getemail: null,emailVerified: false.router.ts— first-login provisioning runs for users only (agents are pre-provisioned by their owner; provisioning is user+email keyed).rpc/user/index.ts— a per-method allow-list (gateAgentAccess+AGENT_ALLOWED).whoamiandspace.listare open to any principal; every other method is denied to a non-user caller (requireUserCaller). This is default-deny: a newly-added user-RPC method is off-limits to agents unless explicitly allow-listed, so a forgotten annotation denies agents rather than exposing the account. The policy lives in one auditable place instead of being scattered across handlers.whoamiresult gains akindfield and a nullableemail;me whoamirendersKind: agentand omits the email line for agents (serves TNT-101's intent).Net: an agent can use the CLI for anything its grants + kind authorize (
me whoami,me space list,me group mine,me access *,me principal resolve), while admin / account-mutation stays denied — same policy as before, now enforced where it belongs.Note on the CLI bucket
The ticket's "Bucket 1" (drop
requireSessionfromme access/me group) already landed via PR #93 — those commands already gate with a thinrequireAuthand lean on the server'sFORBIDDEN.me group minebecomes correct for agents automatically once the door is fixed (it callswhoamithengroup.listForMember, which already allows self).Agent authorization on the user RPC
whoamispace.listspace.create/space.rename/space.deleteagent.create/list/spaces/rename/deleteapiKey.create/list/get/deleteTests
./bun run checkgreen: typecheck, lint, 634 unit tests.authenticate-user,agent,api-key(user RPC), andserver(HTTP) all pass. New agent-caller cases: admitted askind "a"; canwhoami/space.list; denied all 12 account-management methods (FORBIDDEN).cli.e2e.test.tstest 7): an agent key drivesme whoami/me space listand is FORBIDDEN onme agent list. The e2e suite skips locally (noOPENAI_API_KEY); runs in CI underTEST_CI=1.AUTH_DESIGN.mdupdated to describe the new policy.🤖 Generated with Claude Code