Skip to content

feat(auth): broaden agent API-key utility in the CLI; authorize per-method on the user RPC (TNT-139)#101

Merged
cevian merged 1 commit into
mainfrom
mat/tnt-139-broaden-agent-api-key-utility-in-the-cli
Jun 25, 2026
Merged

feat(auth): broaden agent API-key utility in the CLI; authorize per-method on the user RPC (TNT-139)#101
cevian merged 1 commit into
mainfrom
mat/tnt-139-broaden-agent-api-key-utility-in-the-cli

Conversation

@cevian

@cevian cevian commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

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 with ME_API_KEY couldn'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 the kind !== "u" 403 bar. An api key now carries its principal's real kind ("u" | "a"); agents get email: 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). whoami and space.list are 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.
  • whoami result gains a kind field and a nullable email; me whoami renders Kind: agent and 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 requireSession from me access / me group) already landed via PR #93 — those commands already gate with a thin requireAuth and lean on the server's FORBIDDEN. me group mine becomes correct for agents automatically once the door is fixed (it calls whoami then group.listForMember, which already allows self).

Agent authorization on the user RPC

Method Agent allowed?
whoami ✅ (allow-list)
space.list ✅ (allow-list)
space.create / space.rename / space.delete
agent.create / list / spaces / rename / delete
apiKey.create / list / get / delete

Tests

  • ./bun run check green: typecheck, lint, 634 unit tests.
  • Integration (local Postgres): authenticate-user, agent, api-key (user RPC), and server (HTTP) all pass. New agent-caller cases: admitted as kind "a"; can whoami / space.list; denied all 12 account-management methods (FORBIDDEN).
  • e2e (cli.e2e.test.ts test 7): an agent key drives me whoami / me space list and is FORBIDDEN on me agent list. The e2e suite skips locally (no OPENAI_API_KEY); runs in CI under TEST_CI=1.
  • AUTH_DESIGN.md updated to describe the new policy.

🤖 Generated with Claude Code

@cevian cevian force-pushed the mat/tnt-139-broaden-agent-api-key-utility-in-the-cli branch from d21889e to 1bab79f Compare June 25, 2026 13:12
@cevian cevian requested a review from Copilot June 25, 2026 13:19
@cevian cevian marked this pull request as ready for review June 25, 2026 13:19
@cevian cevian requested a review from jgpruitt as a code owner June 25, 2026 13:19

Copilot AI 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.

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" and email: string | null.
  • Enforce default-deny agent authorization on the user RPC via a centralized allow-list gate (whoami, space.list).
  • Extend whoami protocol + CLI output to include kind and 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.

Comment thread packages/cli/commands/whoami.ts Outdated
Comment on lines +35 to +36
// Agents have no email; only humans do.
if (identity.email) console.log(` Email: ${identity.email}`);
Comment on lines 186 to 189
const body = (await response.json()) as {
jsonrpc: string;
result: { id: string; email: string; name: string };
result: { id: string; kind: string; email: string; name: string };
};
@jgpruitt

Copy link
Copy Markdown
Collaborator

Non-blocking observation on the per-method gate ordering (gateAgentAccess):

In production, the RPC dispatcher validates params before invoking the handler, but the agent denial (requireUserCallerFORBIDDEN) lives inside the wrapped handler:

  • packages/server/rpc/handler.ts:140-143method.schema.safeParse(params) runs first; on failure it returns INVALID_PARAMS and never calls the handler.
  • packages/server/rpc/handler.ts:178 — only then is method.handler(...) invoked.
  • packages/server/rpc/user/index.ts:45-50 — the gate keeps the original schema and puts requireUserCaller(ctx) inside the handler.

So the error an agent sees on a gated method depends on whether its params happen to be valid:

Agent sends First failing check Error
valid params requireUserCaller (handler) FORBIDDEN ("user-only…")
invalid/missing params method.schema (handler.ts:140) INVALID_PARAMS

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 call() helper invokes registered.handler(...) directly (packages/server/rpc/user/agent.integration.test.ts:37,53), bypassing the schema.safeParse step — hence the "handlers run here without schema validation" comment at line 144. The tests prove the gate denies agents, but not the production ordering.

If we ever want an agent to always get the "user-only" message regardless of param validity, the fix is to run the kind check before schema validation (e.g. in the auth/context layer, which already knows kind). Not necessary for correctness.

@cevian cevian force-pushed the mat/tnt-139-broaden-agent-api-key-utility-in-the-cli branch from 1bab79f to 5821f85 Compare June 25, 2026 14:42
@cevian

cevian commented Jun 25, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — addressed all three in 5821f85.

@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 authorize hook that the dispatcher runs before schema.safeParse, rather than a check inside the wrapped handler:

  • RegisteredMethod.authorize?(ctx) — new optional hook (rpc/types.ts).
  • handler.ts — calls method.authorize?.(handlerContext) right after method lookup, before safeParse. A denial throws AppError(FORBIDDEN), mapped as usual.
  • rpc/user/index.tsgateAgentAccess now sets authorize on non-allow-listed methods instead of wrapping the handler.

So an agent now gets the same FORBIDDEN ("…user-only…") regardless of param validity, and its input is never parsed for a call it may not make (no INVALID_PARAMS schema leak). New regression test drives the real dispatcher with deliberately-invalid params as an agent and asserts the user-only error wins (agent.integration.test.ts → "through the dispatcher, a gated method denies an agent BEFORE param validation"). The call() helper also now invokes authorize before the handler, mirroring production ordering.

Copilot nits:

  • whoami.ts — email line now guarded with !== null (not truthy).
  • wiring.test.ts — response-body type updated to email: string | null.

./bun run check + the user-RPC / server integration suites are green.

Copilot AI 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.

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated 3 comments.

Comment on lines +30 to 33
/** 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;
Comment on lines +19 to 22
/** 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;
Comment on lines +139 to +146
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>
@cevian cevian force-pushed the mat/tnt-139-broaden-agent-api-key-utility-in-the-cli branch from 5821f85 to 642388b Compare June 25, 2026 14:58
@cevian

cevian commented Jun 25, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the latest Copilot pass in 642388b.

name doc accuracy (authenticate-user.ts, types.ts) — correct, the doc overstated it. Clarified both comments: name is a human display name from a session/OAuth token, but on the api-key path it's the core principal's name — the user's email for a user PAT, the agent's name for an agent. No behavior change.

authorize not captured by the rpc.* span (handler.ts) — leaving as-is, by design. The rpc.* span is created after param validation, so every pre-dispatch rejection is already untraced there: METHOD_NOT_FOUND and INVALID_PARAMS both return before the span exists. Putting authorize in that same pre-span position is consistent, not a new gap. Access decisions are already observable a layer up — authenticateUser/authenticateSpace run inside auth.* spans and debug-log every admit/deny. Broadening tracing to cover all pre-dispatch rejections would be a separate, dispatcher-wide change rather than something specific to this PR. Happy to do it as a follow-up if we want pre-dispatch rejections traced uniformly.

@cevian cevian merged commit 17574cc into main Jun 25, 2026
4 checks passed
@cevian cevian deleted the mat/tnt-139-broaden-agent-api-key-utility-in-the-cli branch June 25, 2026 15:33
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.

3 participants