a credential can be narrower than its holder

a commander is an agent working unattended for months, and the only
credential in the tree lived in the MCP tool server's environment where
the agent's own shell cannot see it. so the first one built here curled
the whisper container directly on localhost: right answer, wrong door, and
a door the platform does not control.

api_keys.permissions. null is what every key a human minted still is —
their full authority, exactly what the password that made it could do. a
list narrows: the credential reaches only those permission keys, and still
only where the account itself could. intersection, never union.

checked BEFORE the owner bypass, which is the whole point. a Super Admin
consults no grants table, so a token carrying the owner's identity is
unbounded by construction — fine for a browser session somebody is sitting
in front of, not fine for a process that runs for months. this is the only
thing in the platform that can bound them. verified against the running
server: an owner key scoped to [account] gets 200 on /api/user/settings
and 403 on /api/users, /api/tasks and /api/plugins.

it propagates. a narrowed credential can write a task and run it, so the
token buildTaskApiEnv hands that script carries the same narrowing —
otherwise the script comes back through the API with full authority and
the limit bought nothing. the gate stashes it on the request for the one
kind of route that passes authority onward.

POST /tasks/:name/run: runs a script task, waits, returns code and output.
both existing runners need something watching them — the socket needs a
live connection, a job needs somebody to open Jobs. a commander has
neither. deliberately not a job: transcribing a memo takes seconds and
produces a file, and a queue plus a row plus a progress bar is a screen
nobody opened between the request and the result.

script-run.ts is the third thing this needed and the one worth having on
its own. the socket runner, the job runner and now this each carried the
same twenty lines — materialize, INPUT_ env, positional args, resolve cwd,
mint the api env — identical, with nothing saying they had to stay that
way. they differ in how they REPORT, which is real and stays with each of
them. what the script SEES is now decided once.

a commander gets its key at creation, in officer.env at 0600 inside its own
directory, and CLAUDE.md tells it where and says plainly that a sidecar
listening on localhost with no auth is still the wrong door. a file rather
than an env var because the detached loop a commander arms is spawned by
the agent and inherits nothing the platform chose. deleting the commander
revokes the row — the directory removal takes the secret, not the thing
that makes it work.

default scope is everything except `admin`: a commander that can reach
nothing gets widened in a hurry rather than thought about, and installing
plugins, managing users and changing server settings are never part of a
watcher's job. it is a default, stored per key, and for a member it
narrows itself against their role.

also fixed: POST /command-hq/commanders/new never awaited
createCommanderDirectory, so a turn could spawn before its CLAUDE.md
existed — the exact ordering that redesign was for — and the cwd it
returned was a Promise serialising as {}.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-24 13:40:42 +00:00
co-authored by Claude Opus 5
parent 247291eeb9
commit e29a4ee380
17 changed files with 631 additions and 190 deletions
@@ -3,6 +3,7 @@ export {
createApiKey,
listApiKeys,
revokeApiKey,
revokeApiKeysByName,
deleteApiKey,
touchApiKey,
type ApiKeyIdentity,
@@ -14,6 +14,8 @@ export type ApiKeyIdentity = {
email: string;
username: string | null;
lastUsedAt: Date | null;
/** Permission keys this credential may reach, or null for the holder's full authority. */
permissions: string[] | null;
};
/**
@@ -31,6 +33,7 @@ export async function findLiveApiKeyByHash(keyHash: string): Promise<ApiKeyIdent
email: users.email,
username: users.username,
lastUsedAt: apiKeys.lastUsedAt,
permissions: apiKeys.permissions,
})
.from(apiKeys)
.innerJoin(users, eq(users.id, apiKeys.userId))
@@ -51,6 +54,8 @@ type CreateApiKeyParams = {
keyHash: string;
prefix: string;
expiresAt?: Date | null;
/** Omit or pass null for the holder's full authority; a list narrows the key to those permission keys. */
permissions?: string[] | null;
};
export async function createApiKey(params: CreateApiKeyParams): Promise<ApiKeySelect> {
@@ -62,6 +67,7 @@ export async function createApiKey(params: CreateApiKeyParams): Promise<ApiKeySe
keyHash: params.keyHash,
prefix: params.prefix,
expiresAt: params.expiresAt ?? null,
permissions: params.permissions ?? null,
})
.returning();
return row!;
@@ -75,6 +81,7 @@ export async function listApiKeys(userId: number): Promise<Omit<ApiKeySelect, 'k
userId: apiKeys.userId,
name: apiKeys.name,
prefix: apiKeys.prefix,
permissions: apiKeys.permissions,
lastUsedAt: apiKeys.lastUsedAt,
expiresAt: apiKeys.expiresAt,
revokedAt: apiKeys.revokedAt,
@@ -85,6 +92,24 @@ export async function listApiKeys(userId: number): Promise<Omit<ApiKeySelect, 'k
.orderBy(sql`${apiKeys.createdAt} desc`);
}
/**
* Revoke every live key a user holds under one name.
*
* For credentials the PLATFORM mints on someone's behalf rather than the user minting for themselves — a
* commander's key is named `commander:<dirName>`, so deleting that commander revokes exactly its own and
* recreating one under a reused directory name cannot leave a live key nothing points at.
*
* Revokes rather than deletes, the same as the by-id path: the row is what the audit reads afterwards.
*/
export async function revokeApiKeysByName(userId: number, name: string): Promise<number> {
const rows = await db
.update(apiKeys)
.set({ revokedAt: new Date() })
.where(and(eq(apiKeys.userId, userId), eq(apiKeys.name, name), isNull(apiKeys.revokedAt)))
.returning({ id: apiKeys.id });
return rows.length;
}
/**
* Revoke one of the caller's own keys. Scoped by userId in the same statement as the id, so a caller
* naming someone else's key id revokes nothing and is told the same "not found" as for an id that never
@@ -21,10 +21,17 @@ import { users } from '../auth/schema';
//
// ── Authority ──
//
// A key currently carries its owner's full authority — deliberately, as the first cut. That is not a
// hole: it is exactly what the password that made it could already do, so a key is never an escalation.
// A `scopes` column is the intended next step, and the resolver is written so adding one touches the
// resolver and this table, nothing else.
// A key carries its owner's authority, NARROWED by `permissions` when that column is set. Null means
// unnarrowed — what every key minted by a human still is, and what the mobile apps hold: exactly what the
// password that made it could already do, so a key is never an escalation.
//
// A list means the key may reach only those permission keys, and still only where the account itself
// could. Intersection, never union — see `permissions/authorize.ts` → `decideApiRequest`.
//
// **The owner is the reason this exists.** A Super Admin bypasses every role check, so a credential
// carrying the owner's identity is unbounded by construction, and a commander is an agent holding one
// unattended for months. The narrowing is applied BEFORE the owner bypass, which makes it the only thing
// in the platform that can bound them.
export const apiKeys = pgTable(
'api_keys',
{
@@ -38,6 +45,13 @@ export const apiKeys = pgTable(
keyHash: text('key_hash').notNull(),
/** First characters of the secret, in the clear, for display only. */
prefix: text('prefix').notNull(),
/**
* Permission keys this credential may reach, or null for the holder's full authority.
*
* An empty array is NOT the same as null and is not a mistake to guard against: it is a key that can
* reach nothing, which is a coherent thing to mint and a safe thing to default to.
*/
permissions: text('permissions').array(),
/**
* Updated on use, but at most once a minute — see touchApiKey. Without the debounce every
* authenticated request would carry a write, which is a lot of churn to answer "is this still in use".