every agent session gets a key, scoped to notify
a commander has officer.env and can reach the platform. an ordinary chat agent had 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 either could not, or asked to be handed a personal API key: the owner's full authority, pasted into a transcript. one refused tonight and was right to. minted per session, permissions ['notify'], expiring in a week, named session:<key> so re-arming replaces rather than accumulates and a survivor is findable rather than one of a hundred identical rows. "an agent may tell me things" is a far 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. widening this later is one entry in an array; starting wide and narrowing later never happens. in the environment rather than a file, because it dies with the process: nothing to leak, nothing to clean up after a crash, no file for a later session to find. the commander's credential is a file for the opposite reason — a detached loop reads it hours later. members get it too, through the allowlist rather than round it. those OFFICER_* names sit next to NEVER_ENV deliberately, and they are not that kind of thing: NEVER_ENV guards the OWNER'S credentials, which would make a member's shell more privileged than the member. this grants a member one capability they already have, over their own devices, and nobody else's. best effort throughout — a turn still starts if minting fails, and the agent is simply back where it was. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<AgentApiEnv | null> {
|
||||
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<void> {
|
||||
await revokeApiKeysByName(userId, sessionKeyName(sessionKey)).catch(() => {});
|
||||
}
|
||||
@@ -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<string, string | undefined>
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user