Files
platform/src/servers/api/command-hq/credential.ts
T
pastilhasandClaude Opus 5 12a9605b01 a commander's bound is a rule, not a list
the allow list shipped an hour ago was a snapshot. plugin permissions
register at mount, so a plugin installed after a commander was created was
missing from its list and failed as a bare 403 with nothing saying why —
and every future thing a commander is meant to do is exactly that shape:
notify a device, send mail, spawn a sub-agent, hand a transcription to an
idle one, reach a plugin that does not exist yet.

so a credential now narrows two ways, and both only subtract:

  permissions   an ALLOW list. exact, a snapshot. right for a credential
                with one job.
  excludedKinds kinds it may never reach, resolved against the LIVE
                registry on every request. right for a credential whose
                job is open-ended.

a commander gets the second: every kind except admin. bounded away from
installing plugins, managing users and roles and changing server settings,
forever — and open to everything the platform learns to do, with nothing
to re-issue. still intersected with the account, so for a member it
narrows itself against their role.

verified against the running server with a commander-shaped key: tasks,
notify, file-browser and the transcribe PLUGIN all reach their routes;
/api/users, /api/plugins and /api/roles are 403. the plugin case is the
one an allow list could not have passed — it was mounted long after any
key was minted.

the commander's CLAUDE.md says what it cannot do in the rule's own terms,
and that a 403 is an answer rather than a problem to route around.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 13:47:46 +00:00

106 lines
5.1 KiB
TypeScript

import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { createApiKey, revokeApiKeysByName } from 'officerdb';
import { mintApiKey } from '@@/auth-token';
import { PORT, OFFICER_API_URL } from '../../officer-url.mjs';
import { getCommanderDir } from '../../data-path';
/*
A commander's own credential.
── Why it has one at all ──
A commander is an agent working while nobody watches, and it has to reach the platform to do anything
worth doing. Until now it could not: the only credential in the tree sits in the MCP tool server's
environment, which the agent's own shell cannot see. So the first commander built here did the
reasonable thing from where it was standing and curled the whisper container directly on localhost —
correct answer, wrong door, and a door the platform does not control.
── Why it is narrowed ──
The owner bypasses every permission check, so a credential carrying the owner's identity is unbounded.
That is fine for a browser session somebody is sitting in front of and wrong for a process that runs
for months unattended. `api_keys.permissions` is the only thing that can bound a Super Admin — see
`permissions/authorize.ts` → `decideApiRequest`, which applies it BEFORE the owner short-circuit.
── Why it excludes a KIND rather than listing what it allows ──
A commander's job is open-ended and grows. It should send a notification, send mail, spawn a sub-agent,
hand a transcription to an idle one — and reach a plugin installed six months from now without being
re-issued. What it should never do is administer the server it runs on.
An allow list cannot express that. A list is a snapshot of what existed on the day it was written, so a
plugin added afterwards is simply missing from it, and the failure arrives as a bare 403 with nothing
saying why. Every future capability would land on that.
So the default is a rule: every kind except `admin`. It is resolved against the live registry on each
request, which means it keeps meaning the same thing as the platform grows — bounded away from
installing plugins, managing users and roles and changing server settings, forever, and open to
everything else the platform learns to do.
A DEFAULT, not a policy: it is stored on the key row and can be tightened per commander with an explicit
allow list alongside it. And for a member it narrows itself, because the check is an intersection with
what their role holds.
*/
/** What a commander gets unless told otherwise: everything the platform can do, except administering it. */
export const COMMANDER_EXCLUDED_KINDS = ['admin'];
/** The env file's path, and the name the key is stored under — both derived, so revocation cannot miss one. */
export const commanderKeyName = (dirName: string) => `commander:${dirName}`;
export const commanderEnvPath = (email: string, dirName: string) =>
join(getCommanderDir(email, dirName), 'officer.env');
/**
* Mint a commander's key and write it into its directory.
*
* The secret is written to a FILE rather than injected into the agent's environment because the agent is
* not the only thing that needs it: a commander arms a detached loop, and that loop is spawned by the
* agent itself and inherits nothing the platform chose. A file both can read is the only shape that
* covers the interactive turn and the unattended loop with one credential.
*
* 0600, and `createCommanderDirectory` chowns the whole directory to the account afterwards — which is
* why this must run BEFORE that chown, not after.
*/
export async function writeCommanderCredential(params: {
userId: number;
email: string;
dirName: string;
/** An explicit allow list, to tighten one commander further. Absent leaves it at the default rule. */
permissions?: string[] | null;
}): Promise<{ path: string; excludedKinds: string[] }> {
const { secret, keyHash, prefix } = mintApiKey();
// Revoke first. A commander recreated under a directory name that has been used before would otherwise
// leave a live key nothing points at any more.
await revokeApiKeysByName(params.userId, commanderKeyName(params.dirName)).catch(() => {});
await createApiKey({
userId: params.userId,
name: commanderKeyName(params.dirName),
keyHash,
prefix,
permissions: params.permissions ?? null,
excludedKinds: COMMANDER_EXCLUDED_KINDS,
});
const path = commanderEnvPath(params.email, params.dirName);
mkdirSync(join(path, '..'), { recursive: true });
writeFileSync(
path,
[
"# This commander's own credential. Source it before calling Officer:",
'# set -a; . ./officer.env; set +a',
'#',
'# It is narrowed — see `permissions` on its api_keys row — and it is revoked when this commander is',
'# deleted. Do not copy it anywhere else; anything that needs one gets its own.',
`OFFICER_API_URL=${OFFICER_API_URL}`,
`OFFICER_API_HOST=${process.env.PUBLIC_URL ? new URL(process.env.PUBLIC_URL).host : `127.0.0.1:${PORT}`}`,
`OFFICER_API_KEY=${secret}`,
'',
].join('\n'),
{ mode: 0o600 },
);
return { path, excludedKinds: COMMANDER_EXCLUDED_KINDS };
}