diff --git a/src/servers/sidecar/claude/session-credential.ts b/src/servers/sidecar/claude/session-credential.ts index a0c9024d..e5c1cc57 100644 --- a/src/servers/sidecar/claude/session-credential.ts +++ b/src/servers/sidecar/claude/session-credential.ts @@ -3,7 +3,7 @@ import { mintApiKey } from '@@/auth-token'; import { PORT, OFFICER_API_URL } from '../../officer-url.mjs'; /** - * A credential for one agent session, narrowed to notifications. + * A credential for one agent session, carrying its holder's authority minus administration. * * ── The gap it closes ── * @@ -12,13 +12,39 @@ import { PORT, OFFICER_API_URL } from '../../officer-url.mjs'; * agent asked to tell its owner something had no door at all — it either could not, or it asked to be * handed a personal API key, which is the owner's full authority pasted into a transcript. * - * ── Why `notify` and nothing else ── + * ── It was `['notify']` until 2026-08-25, and the reason it stopped being enough ── * - * "An agent may tell me things" is a much smaller thing to hand out than "an agent may act as me", and it - * is the whole of what was being asked for. Everything else an agent needs, it already has: it runs as - * somebody, in their home, with their files. This is only the one capability that has no other route. + * The original argument was that "an agent may tell me things" is far smaller than "an agent may act as + * me", and that everything else an agent needs it already has, because it runs as somebody, in their home, + * with their files. Both halves were true. The second half is also what undid the first. * - * Widening it later is one entry in this array. Starting wide and narrowing later never happens. + * An owner's agent has a shell AS THE OWNER, unsandboxed. It can read `.env`, query Postgres, decrypt a + * plugin's stored credential, `docker exec` into a container. So a key scoped to `notify` never made that + * agent safer — it made it unable to use the platform POLITELY while every impolite route stayed open. The + * moment that became visible: a chat asked to take a proxy host out of service correctly reported it could + * not, having read this file, while sitting one `docker exec` away from the same container. + * + * A route that goes through the platform is authorised, logged, and bounded by a permission. One that goes + * around it is none of those. Withholding the first does not prevent the second; it only chooses which one + * gets used. + * + * ── What it carries now ── + * + * Its holder's own authority, minus `admin`. Not a list of permissions — a rule, resolved against the live + * registry at request time, so a plugin installed next year works from a chat without this file changing. + * The alternative on offer was adding each plugin's key to an array here, which puts plugin names in a core + * file and hands every plugin to every chat, one line at a time, forever. + * + * `execution` needs no exclusion: it is never grantable at any level and `authorize.ts` strips it + * regardless. `admin` is the one worth naming — an agent may use the server, and may not administer it. + * Creating accounts, granting permissions and installing plugins stay with a person. + * + * ── This is narrower for a MEMBER, and that is where it matters ── + * + * A member's agent runs as their own Linux user, confined by the kernel, without the owner's environment. + * For them this key is a real boundary rather than a courtesy: it resolves to exactly what their role was + * granted, so a chat can reach what its owner allowed and nothing else. That is the shape the owner asked + * for — "the features provided by the platform, including the ones that I allow for each user". * * ── Why an env var rather than a file ── * @@ -33,7 +59,12 @@ import { PORT, OFFICER_API_URL } from '../../officer-url.mjs'; * identical rows. Also expires on its own: a session that ends without cleanup — a crash, a restart — must * not leave a live credential behind for a month. */ -const SESSION_KEY_PERMISSIONS = ['notify']; +/** + * Kinds this key may never reach, resolved against the registry on every request rather than expanded + * here. See `api_keys.excludedKinds` — it is checked BEFORE the owner bypass, which is what makes it the + * only thing capable of bounding a Super Admin's agent at all. + */ +const SESSION_KEY_EXCLUDED_KINDS = ['admin']; /** A week. Long enough for a conversation somebody returns to, short enough that a leaked one dies. */ const SESSION_KEY_DAYS = 7; @@ -59,7 +90,17 @@ export async function mintSessionCredential(userId: number, sessionKey: string): const { secret, keyHash, prefix } = mintApiKey(); const expiresAt = new Date(Date.now() + SESSION_KEY_DAYS * 24 * 60 * 60 * 1000); - await createApiKey({ userId, name, keyHash, prefix, permissions: SESSION_KEY_PERMISSIONS, expiresAt }); + // `permissions: null` — no allow list. The narrowing is the kind rule below, so this key follows its + // holder's grants as they change rather than freezing a snapshot of them at mint time. + await createApiKey({ + userId, + name, + keyHash, + prefix, + permissions: null, + excludedKinds: SESSION_KEY_EXCLUDED_KINDS, + expiresAt, + }); const publicHost = (() => { try {