From 355fcaf1e58653acebf17323911c4dc0e2809406 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 25 Aug 2026 18:04:38 +0000 Subject: [PATCH] a chat's key follows its holder, minus administration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit it was ['notify'], on the argument 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 true. the second is what undid the first. 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 scoping its key to notify never made it safer — it made it unable to use the platform POLITELY while every impolite route stayed open. that became visible today: 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 from the same container. a route through the platform is authorised, logged and bounded by a permission. one around it is none of those. withholding the first does not prevent the second, it only chooses which gets used. so: the holder's own authority, minus admin. a RULE against the live registry, not a list — a plugin installed next year works from a chat without this file changing. the alternative on the table was adding each plugin's key to an array here, which puts plugin names in core and hands every plugin to every chat, one line at a time, forever. execution needs no exclusion; authorize.ts strips it whatever a row says. admin is the one worth naming: an agent may use the server and may not administer it. accounts, grants and plugin installs stay with a person. and it is NARROWER for a member, which is where it counts — their agent runs as their own linux user with no owner environment, so this key is a real boundary resolving to exactly what their role was granted. Co-Authored-By: Claude Opus 5 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- .../sidecar/claude/session-credential.ts | 57 ++++++++++++++++--- 1 file changed, 49 insertions(+), 8 deletions(-) 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 {