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
106 lines
5.1 KiB
TypeScript
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 };
|
|
}
|