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:
@@ -214,3 +214,51 @@ describe('deciding a websocket', () => {
|
||||
`api/users/permissions-routes.ts` calls the invalidator — is a claim about call sites, not about logic,
|
||||
and a test here could only re-state it.
|
||||
*/
|
||||
|
||||
/*
|
||||
── A credential narrower than its holder ──
|
||||
|
||||
The owner bypasses every role check, so a token carrying the owner's identity is unbounded by
|
||||
construction. That is right for a browser session somebody is sitting in front of, and wrong for an
|
||||
agent holding one unattended for months — a commander. The narrowing on an API key is the only thing
|
||||
that can bound a Super Admin, which is why it is checked BEFORE the bypass rather than folded into the
|
||||
grants map (a Super Admin never reads that map).
|
||||
*/
|
||||
describe('a narrowed credential', () => {
|
||||
const owner: EffectivePermissions = { isOwner: true, grants: new Map() };
|
||||
|
||||
test('bounds the owner, who nothing else bounds', () => {
|
||||
expect(decideApiRequest(owner, 'POST', '/api/tasks', ['tasks']).allowed).toBe(true);
|
||||
expect(decideApiRequest(owner, 'GET', '/api/users', ['tasks']).allowed).toBe(false);
|
||||
expect(decideWsProvider(owner, 'terminal', ['tasks'])).toBe(false);
|
||||
});
|
||||
|
||||
test('null and undefined leave the holder unnarrowed', () => {
|
||||
expect(decideApiRequest(owner, 'GET', '/api/users', null).allowed).toBe(true);
|
||||
expect(decideApiRequest(owner, 'GET', '/api/users').allowed).toBe(true);
|
||||
});
|
||||
|
||||
// An empty list is a coherent thing to mint, and the safe default for a credential nobody has decided
|
||||
// about yet. It must not read as "no narrowing".
|
||||
test('an empty list reaches nothing', () => {
|
||||
expect(decideApiRequest(owner, 'GET', '/api/tasks', []).allowed).toBe(false);
|
||||
});
|
||||
|
||||
// Intersection, never union: naming a permission does not grant it.
|
||||
test('it cannot widen an account that was never granted it', () => {
|
||||
const d = decideApiRequest(resolve(member()), 'GET', '/api/file-browser', ['files']);
|
||||
expect(d.allowed).toBe(false);
|
||||
});
|
||||
|
||||
test('it narrows an account that was granted it', () => {
|
||||
const held = resolve(member(), grants(['files', 'write']));
|
||||
expect(decideApiRequest(held, 'GET', '/api/file-browser', ['files']).allowed).toBe(true);
|
||||
expect(decideApiRequest(held, 'GET', '/api/file-browser', ['tasks']).allowed).toBe(false);
|
||||
});
|
||||
|
||||
// Same rule the unnarrowed path already has, one step earlier: a path nothing serves must not be
|
||||
// reachable by failing to resolve.
|
||||
test('a path no permission covers is refused', () => {
|
||||
expect(decideApiRequest(owner, 'GET', '/api/nothing-serves-this', ['tasks']).allowed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -141,17 +141,41 @@ export async function isApiRequestAllowed(
|
||||
userId: number | undefined,
|
||||
method: string,
|
||||
path: string,
|
||||
credential?: string[] | null,
|
||||
): Promise<{ allowed: boolean; reason?: string }> {
|
||||
return decideApiRequest(await getEffectivePermissions(userId), method, path);
|
||||
return decideApiRequest(await getEffectivePermissions(userId), method, path, credential);
|
||||
}
|
||||
|
||||
/** The decision, given an already-resolved account. Pure, and where every rule below actually lives. */
|
||||
/**
|
||||
* The decision, given an already-resolved account. Pure, and where every rule below actually lives.
|
||||
*
|
||||
* `credential` is the narrowing carried by an API key — the permission keys THAT key may reach, or
|
||||
* null/undefined for one that carries its holder's full authority (which is every key a human has minted
|
||||
* and every session JWT). It is an intersection, never a union: a key cannot reach what its account
|
||||
* could not, and its account cannot reach, through this key, what the key does not name.
|
||||
*
|
||||
* **It is checked before the owner bypass, and that is the whole point.** A Super Admin consults no
|
||||
* grants table, so a credential carrying the owner's identity is unbounded by construction — which is
|
||||
* fine for a browser session the owner is sitting in front of, and not fine for an agent holding one
|
||||
* unattended for months. This line is the only thing in the platform that can bound them.
|
||||
*/
|
||||
export function decideApiRequest(
|
||||
effective: EffectivePermissions,
|
||||
method: string,
|
||||
path: string,
|
||||
credential?: string[] | null,
|
||||
): { allowed: boolean; reason?: string } {
|
||||
const { isOwner, grants } = effective;
|
||||
|
||||
if (credential) {
|
||||
const scoped = permissionForApiPath(path);
|
||||
// Same deny-the-unmapped rule as below, one step earlier: a narrowed credential naming nothing that
|
||||
// covers this path cannot be allowed onto it by failing to resolve the path.
|
||||
if (!scoped || !credential.includes(scoped.key)) {
|
||||
return { allowed: false, reason: 'this credential is not permitted to use that' };
|
||||
}
|
||||
}
|
||||
|
||||
if (isOwner) return { allowed: true };
|
||||
|
||||
const permission = permissionForApiPath(path);
|
||||
@@ -197,13 +221,29 @@ export function decideApiRequest(
|
||||
* provider reaching here today belongs to an `execution` permission and is refused above, structurally,
|
||||
* rather than by being left off a list. The rule stays because the first plugin to own a socket needs it.
|
||||
*/
|
||||
export async function isWsProviderAllowed(userId: number | undefined, provider: string): Promise<boolean> {
|
||||
return decideWsProvider(await getEffectivePermissions(userId), provider);
|
||||
export async function isWsProviderAllowed(
|
||||
userId: number | undefined,
|
||||
provider: string,
|
||||
credential?: string[] | null,
|
||||
): Promise<boolean> {
|
||||
return decideWsProvider(await getEffectivePermissions(userId), provider, credential);
|
||||
}
|
||||
|
||||
/** The decision, given an already-resolved account. See `isWsProviderAllowed` for why level is not read. */
|
||||
export function decideWsProvider(effective: EffectivePermissions, provider: string): boolean {
|
||||
export function decideWsProvider(
|
||||
effective: EffectivePermissions,
|
||||
provider: string,
|
||||
credential?: string[] | null,
|
||||
): boolean {
|
||||
const { isOwner, grants } = effective;
|
||||
|
||||
// Before the owner bypass, for the reason given in `decideApiRequest`. A socket is the one door a
|
||||
// narrowed credential must not walk through just because it authenticated.
|
||||
if (credential) {
|
||||
const scoped = permissionForWsProvider(provider);
|
||||
if (!scoped || !credential.includes(scoped.key)) return false;
|
||||
}
|
||||
|
||||
if (isOwner) return true;
|
||||
|
||||
const permission = permissionForWsProvider(provider);
|
||||
|
||||
Reference in New Issue
Block a user