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
This commit is contained in:
2026-08-24 13:47:46 +00:00
co-authored by Claude Opus 5
parent e29a4ee380
commit 12a9605b01
13 changed files with 197 additions and 69 deletions
+24 -17
View File
@@ -2,7 +2,6 @@ import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { createApiKey, revokeApiKeysByName } from 'officerdb';
import { mintApiKey } from '@@/auth-token';
import { PERMISSION_BY_KEY } from '@@/permissions/registry';
import { PORT, OFFICER_API_URL } from '../../officer-url.mjs';
import { getCommanderDir } from '../../data-path';
@@ -24,21 +23,28 @@ import { getCommanderDir } from '../../data-path';
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 the default is "everything except admin" ──
── Why it excludes a KIND rather than listing what it allows ──
A commander that can reach nothing is useless on the day it is created, and a default nobody can work
with gets widened in a hurry rather than thought about. So the default removes the class that is never
part of a watcher's job — installing plugins, managing users and roles, changing server settings — and
leaves the rest.
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.
It is a DEFAULT, not a policy: the list is stored on the key row and can be narrowed per commander.
And for a member it narrows itself, because the check is an intersection with what their role holds.
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.
*/
/** Permission keys a commander gets unless told otherwise: everything the platform does that is not administration. */
export function defaultCommanderPermissions(): string[] {
return [...PERMISSION_BY_KEY.values()].filter((p) => p.kind !== 'admin').map((p) => p.key);
}
/** 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}`;
@@ -60,9 +66,9 @@ export async function writeCommanderCredential(params: {
userId: number;
email: string;
dirName: string;
permissions?: string[];
}): Promise<{ path: string; permissions: string[] }> {
const permissions = params.permissions ?? defaultCommanderPermissions();
/** 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
@@ -73,7 +79,8 @@ export async function writeCommanderCredential(params: {
name: commanderKeyName(params.dirName),
keyHash,
prefix,
permissions,
permissions: params.permissions ?? null,
excludedKinds: COMMANDER_EXCLUDED_KINDS,
});
const path = commanderEnvPath(params.email, params.dirName);
@@ -94,5 +101,5 @@ export async function writeCommanderCredential(params: {
{ mode: 0o600 },
);
return { path, permissions };
return { path, excludedKinds: COMMANDER_EXCLUDED_KINDS };
}