diff --git a/src/servers/sidecar/claude/claude-manager.ts b/src/servers/sidecar/claude/claude-manager.ts index f8feeb28..22a5011f 100644 --- a/src/servers/sidecar/claude/claude-manager.ts +++ b/src/servers/sidecar/claude/claude-manager.ts @@ -11,6 +11,7 @@ import { setClaudeSession, clearClaudeSession, getClaudeSession } from './state' import { countsAsOutput, diagnoseExit, stderrReason } from './exit-diagnosis'; import { createParseState, processMessage } from './stream-parser'; import { spawnClaudeAsMember } from './spawn-as-member'; +import { mintSessionCredential } from './session-credential'; import { claudeBinIn } from '@@/os-user-claude'; const SEND_TIMEOUT_MS = 30 * 60 * 1000; @@ -592,6 +593,19 @@ async function createSession( // Strip the nested-session guard vars so the SDK can spawn `claude` (mirrors the old spawn env clean). const cleanEnv = envWithoutParentSession(); + /* + This session's own credential, scoped to notifications. + + In the environment rather than a file: it dies with the process, there is no path to leak and nothing + to clean up after a crash. The agent's Bash inherits it as `$OFFICER_AGENT_KEY`, which is the whole + point — until now the only token in the tree was the MCP server's, which the agent's shell cannot see, + so an agent asked to tell its owner something had no door at all. + + Null when minting failed. The agent then has no key, which is exactly where everything was before. + */ + const agentEnv = await mintSessionCredential(params.userId, sessionKey); + if (agentEnv) Object.assign(cleanEnv, agentEnv); + const resumeId = getClaudeSession(sessionKey, params.userId) ?? params.resumeSessionId; /* Where that transcript actually lives, because `--resume` is scoped to the WORKING DIRECTORY. diff --git a/src/servers/sidecar/claude/session-credential.ts b/src/servers/sidecar/claude/session-credential.ts new file mode 100644 index 00000000..a0c9024d --- /dev/null +++ b/src/servers/sidecar/claude/session-credential.ts @@ -0,0 +1,88 @@ +import { createApiKey, revokeApiKeysByName } from 'officerdb'; +import { mintApiKey } from '@@/auth-token'; +import { PORT, OFFICER_API_URL } from '../../officer-url.mjs'; + +/** + * A credential for one agent session, narrowed to notifications. + * + * ── The gap it closes ── + * + * A commander has `officer.env` and can reach the platform. An ordinary chat agent has nothing: the only + * token in the tree lives in the MCP server's environment, which the agent's own Bash cannot see. So an + * 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 ── + * + * "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. + * + * Widening it later is one entry in this array. Starting wide and narrowing later never happens. + * + * ── Why an env var rather than a file ── + * + * It dies with the process. There is no path to leak, nothing to clean up after a crash, and no file for a + * later session to find. The commander's own credential is a file precisely because it must OUTLIVE the + * turn that made it — a detached loop reads it hours later. A chat agent has no such need. + * + * ── Lifetime ── + * + * Named for the session, so re-arming the same conversation replaces its key rather than accumulating one + * per turn, and a key outliving its session is findable by name rather than being one of a hundred + * 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']; + +/** A week. Long enough for a conversation somebody returns to, short enough that a leaked one dies. */ +const SESSION_KEY_DAYS = 7; + +export const sessionKeyName = (sessionKey: string) => `session:${sessionKey}`; + +export type AgentApiEnv = { + OFFICER_API_URL: string; + OFFICER_API_HOST: string; + OFFICER_AGENT_KEY: string; +}; + +/** + * Mint one, replacing any previous key for this session. + * + * Best effort by contract: a turn must still start if the database refuses. The agent then simply has no + * key, which is the state everything was in before this existed — degradation rather than failure. + */ +export async function mintSessionCredential(userId: number, sessionKey: string): Promise { + try { + const name = sessionKeyName(sessionKey); + await revokeApiKeysByName(userId, name); + + 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 }); + + const publicHost = (() => { + try { + return process.env.PUBLIC_URL ? new URL(process.env.PUBLIC_URL.replace(/^"|"$/g, '')).host : undefined; + } catch { + return undefined; + } + })(); + + return { + OFFICER_API_URL, + // Requests go to 127.0.0.1, but origin validation matches Host against PUBLIC_URL — so a bare + // loopback request is refused without this. Same reason `task-api-env.ts` carries it. + OFFICER_API_HOST: publicHost ?? `127.0.0.1:${PORT}`, + OFFICER_AGENT_KEY: secret, + }; + } catch (err) { + console.error('[claude] could not mint a session credential — the agent will have no API key', err); + return null; + } +} + +/** Drop it when the session ends. Never fatal: the expiry above is the backstop. */ +export async function revokeSessionCredential(userId: number, sessionKey: string): Promise { + await revokeApiKeysByName(userId, sessionKeyName(sessionKey)).catch(() => {}); +} diff --git a/src/servers/sidecar/claude/spawn-as-member.ts b/src/servers/sidecar/claude/spawn-as-member.ts index 7d67da91..1f8434c8 100644 --- a/src/servers/sidecar/claude/spawn-as-member.ts +++ b/src/servers/sidecar/claude/spawn-as-member.ts @@ -54,8 +54,17 @@ export type MemberRun = { */ const ALLOWED_ENV = ['LANG', 'LC_ALL', 'TERM', 'TZ', 'NO_COLOR', 'CLAUDE_CODE_ENTRYPOINT'] as const; -/** Set by `memberEnv` itself rather than inherited, so vetted the same way. */ -const ALSO_ALLOWED = ['HOME', 'CLAUDE_CONFIG_DIR'] as const; +/** + * Set by `memberEnv` itself rather than inherited, so vetted the same way. + * + * The three `OFFICER_*` entries are this member's OWN session credential, minted per turn and narrowed to + * `notify`. They belong here deliberately, and they are not the kind of thing `NEVER_ENV` below guards + * against: those are the OWNER'S credentials, which would make a member's shell more privileged than the + * member. This one grants a member exactly one capability they already have — telling their own devices + * something — and grants it to nobody else. Without it a member's agent has no way to reach the platform + * at all, which is a worse answer than a narrow key. + */ +const ALSO_ALLOWED = ['HOME', 'CLAUDE_CONFIG_DIR', 'OFFICER_API_URL', 'OFFICER_API_HOST', 'OFFICER_AGENT_KEY'] as const; /** * Credential names that must never appear in the allowlist. **This guards the constant, not the instance.** @@ -127,6 +136,12 @@ function memberEnv(run: MemberRun, inherited: Record } env.HOME = run.home; env.CLAUDE_CONFIG_DIR = memberClaudeConfigDir(run.home); + // The member's own session credential, when one was minted. Absent is fine: the agent then has no key, + // which is where every member's agent was before this existed. + for (const name of ['OFFICER_API_URL', 'OFFICER_API_HOST', 'OFFICER_AGENT_KEY'] as const) { + const value = inherited[name]; + if (value !== undefined) env[name] = value; + } return env; }