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
+52 -4
View File
@@ -4,6 +4,7 @@ import { lookupOsUser } from '@@/os-user';
import { join } from 'node:path';
import { getCommanderDir, getCommanderStateDir } from '../../data-path';
import { loopPidPath, spawnedRecordPath } from './spawned';
import { writeCommanderCredential } from './credential';
/*
A commander's own directory, created once, up front — `docs/commander-directory.md`.
@@ -22,6 +23,8 @@ export type CommanderClaudeMdParams = {
referencePath: string;
loopPidPath: string;
spawnedPath: string;
/** Absolute path to this commander's `officer.env`, or null when minting it failed. */
credentialPath: string | null;
};
/**
@@ -64,6 +67,37 @@ export function commanderClaudeMd(params: CommanderClaudeMdParams): string {
'The platform cannot find this process and cannot recover a spawned agent id after it exits, so this',
'record is the only one. Without it, deleting this commander leaves its loop running.',
'',
'## Reaching the platform',
'',
...(params.credentialPath
? [
'This commander has its own credential, and it is the ONLY way it should reach anything:',
'',
'```',
`set -a; . ${params.credentialPath}; set +a`,
'curl -H "Authorization: Bearer $OFFICER_API_KEY" -H "Host: $OFFICER_API_HOST" \\',
' "$OFFICER_API_URL/api/..."',
'```',
'',
'Use the platform, never the thing behind it. A sidecar — whisper, a media server, anything a',
'plugin wraps — usually also listens on localhost with no authentication, so calling it directly',
'works and looks correct. It is still wrong: it skips every permission, every setting the owner',
'chose and every log, and it breaks silently the day that service moves.',
'',
'Prefer a TASK over a raw call where one exists. `GET /api/tasks` lists them and',
'`POST /api/tasks/<dirName>/run` runs one and returns what it did — one reviewed definition on',
'disk instead of a re-implementation living in a loop nobody reads.',
'',
'The credential is narrowed and revoked when this commander is deleted. A 403 is an answer, not a',
'problem to route around: it means this commander was not given that, and the owner is the one who',
'decides otherwise.',
'',
]
: [
'This commander has NO credential — minting one failed at creation. It cannot reach the platform',
'API, and it must not work around that by calling a sidecar directly. Tell the owner.',
'',
]),
'## Before arming anything',
'',
'Never arm a watcher on the first turn. The first turn is for settling what to watch and what to do',
@@ -119,11 +153,24 @@ async function giveToAccount(email: string, dir: string): Promise<string | null>
export async function createCommanderDirectory(
email: string,
dirName: string,
params: { name: string; referencePath: string },
): Promise<{ dir: string; ownershipError: string | null }> {
params: { name: string; referencePath: string; userId: number },
): Promise<{ dir: string; ownershipError: string | null; credentialError: string | null }> {
const dir = getCommanderDir(email, dirName);
mkdirSync(dir, { recursive: true });
mkdirSync(getCommanderStateDir(email, dirName), { recursive: true });
// Before CLAUDE.md, because CLAUDE.md tells the commander where its credential is and has to say
// truthfully whether there is one. Best effort for the same reason the chown is: a commander with no
// credential is degraded, and refusing to create it would be the worse trade — but it must be TOLD,
// or it will find its own way to the machine, which is how this started.
let credentialPath: string | null = null;
let credentialError: string | null = null;
try {
({ path: credentialPath } = await writeCommanderCredential({ userId: params.userId, email, dirName }));
} catch (err) {
credentialError = err instanceof Error ? err.message : 'could not mint a credential';
}
writeFileSync(
join(dir, 'CLAUDE.md'),
commanderClaudeMd({
@@ -131,10 +178,11 @@ export async function createCommanderDirectory(
referencePath: params.referencePath,
loopPidPath: loopPidPath(email, dirName),
spawnedPath: spawnedRecordPath(email, dirName),
credentialPath,
}),
);
// Last, so it covers CLAUDE.md and state/ as well as the directory itself.
// Last, so it covers CLAUDE.md, officer.env and state/ as well as the directory itself.
const ownershipError = await giveToAccount(email, dir);
return { dir, ownershipError };
return { dir, ownershipError, credentialError };
}