Files
platform/src/servers/sidecar/claude/spawn-as-member.ts
T
pastilhasandClaude Opus 5 538a623bb8 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
2026-08-25 09:06:29 +00:00

212 lines
12 KiB
TypeScript

import type { SpawnOptions, SpawnedProcess } from '@anthropic-ai/claude-agent-sdk';
import { spawn } from 'node:child_process';
import { join, resolve } from 'node:path';
import { runAsArgv } from '@@/os-user';
import { claudeBinIn } from '@@/os-user-claude';
// Running a member's agent turn as the member, without a second sidecar.
//
// ── Why the sidecar stays as the service user and only the CLI drops privileges ──
//
// The obvious reading of "each member runs their own Claude" is a second `officer-agent` running under their
// uid. That does not work, and the reason is worth writing down because it looks like an implementation
// detail and is actually a boundary. This sidecar needs `POSTGRES_URL` (it imports `officerdb`) and the JWT
// signing secret — it mints a 30-day owner token for the MCP tools. A process running as a member with those
// two values in its environment can read every account's data and sign a token as the owner, which is
// strictly more than a member's shell can do. `docs/per-user-linux-accounts.md` already forbids exactly this:
// `.env` is 600 and a boot check refuses to start with OS users enabled while it is readable.
//
// So the split is: the platform glue stays the service user's, and the thing that runs the member's code and
// holds the member's credential — `claude` itself — is theirs. The member's agent process genuinely is their
// own, in their home, with their login and their binary. Only the harness around it is shared, and the
// harness is the part that must not be.
//
// This is the pty sidecar's shape, which is the established one here: one process, per-request identity,
// privileges dropped at the point where the member's code starts (`sidecar/pty/sessions.mjs:85-93`).
//
// ── Why an allowlist and not a filter ──
//
// `runAsArgv` uses `setpriv --reset-env`, so nothing crosses into the member's process unless it is written
// into the argv (`os-user.ts:120-124`). That is the whole safety property here, and it points one way: build
// the child's environment from an allowlist rather than by subtracting the dangerous names from this
// process's. A denylist has to be right about every variable that exists now and every one added later —
// including `POSTGRES_URL`, the JWT secret, and the owner's `ANTHROPIC_API_KEY`, all of which are sitting in
// this process's environment as it makes this call. An allowlist is wrong in the direction that fails safe.
/** Everything needed to run a turn as one member. Resolved by the platform, never taken from a client. */
export type MemberRun = {
/** Their Linux account, from `users.osUser`. */
osUser: string;
/** Their home — `DATA_PATH/<email>/home`, per `resolveHomeDir`. */
home: string;
};
/**
* The variables a member's `claude` is allowed to inherit.
*
* Deliberately short. `setpriv --init-groups --reset-env` already supplies HOME, USER, LOGNAME, SHELL and
* PATH from their passwd entry, so this list is only what the harness itself needs on top of that.
*
* **Nothing here may ever be a secret.** These are passed as `env K=V …` inside the argv, which means every
* one of them is visible in `/proc/<pid>/cmdline` to every account on the box. That is fine for locale and
* terminal settings and is not fine for a token — a credential belongs in the member's own `~/.claude`, which
* is theirs and mode 600, not on a command line.
*/
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.
*
* 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.**
*
* Two dead checks were written here before this one worked, and the distinction is the whole lesson. A
* denylist tested against `childEnv` cannot fire, because `memberEnv` builds that object *from* `ALLOWED_ENV`.
* Inverting it to a subset test cannot fire either, for the same reason — and it is strictly worse, because
* widening `ALLOWED_ENV` widens the permitted set in the same motion, so the one realistic dangerous edit
* (somebody adds a credential to the allowlist) stops throwing and starts passing silently.
*
* Tested against the *list*, it fires on exactly that edit. Being incomplete is then survivable: a name this
* misses degrades to the status quo rather than to false confidence. The Anthropic and Claude entries are what
* the installed SDK reads; the last two are what make this process more privileged than a member's shell.
*/
const NEVER_ENV = [
'ANTHROPIC_BASE_URL',
'ANTHROPIC_API_KEY',
'ANTHROPIC_AUTH_TOKEN',
'ANTHROPIC_FOUNDRY_API_KEY',
'_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL',
'CLAUDE_CODE_OAUTH_TOKEN',
'CLAUDE_CODE_OAUTH_REFRESH_TOKEN',
'CLAUDE_CODE_SESSION_ACCESS_TOKEN',
'CLAUDE_CODE_CLIENT_KEY',
'CLAUDE_API_KEY',
'POSTGRES_URL',
'JWT_SECRET',
];
/**
* The `claude` config directory for a member.
*
* `--reset-env` already sets HOME to their home, so `~/.claude` would resolve correctly on its own. This is
* set explicitly anyway because "which account's credential did this turn use" is the single most important
* question in this file, and it should be answerable by reading one line rather than by reasoning about what
* `setpriv` does to HOME.
*/
export const memberClaudeConfigDir = (home: string): string => join(home, '.claude');
/** Everything a member's turn may carry. Exported so a test can assert the shipping lists are clean. */
export const PERMITTED_ENV: ReadonlySet<string> = new Set<string>([...ALLOWED_ENV, ...ALSO_ALLOWED]);
/**
* The two env guards, as one pure function so they can be tested.
*
* They were unreachable from a test while they lived inside the spawn closure — the only way to exercise them
* was to spawn, and the passing path spawns `sudo`. Which is how both earlier versions of this guard shipped
* dead: nothing could demonstrate them firing.
*
* @param permitted the names allowed for this turn — parameterised so a test can pass a poisoned list
* @param childEnv what `memberEnv` actually produced
*/
export function assertEnvSafe(permitted: ReadonlySet<string>, childEnv: Record<string, string>, who: string): void {
// Guards the constant: a credential added to the allowlist throws rather than reaching a member.
const leaked = NEVER_ENV.filter((name) => permitted.has(name));
if (leaked.length) throw new Error(`refusing to run ${who}'s agent: ${leaked.join(', ')} is in the allowlist`);
// Guards the construction: a key `memberEnv` invents that nobody vetted.
const unexpected = Object.keys(childEnv).filter((name) => !permitted.has(name));
if (unexpected.length) throw new Error(`refusing to run ${who}'s agent with unvetted env: ${unexpected.join(', ')}`);
}
/** Build the child environment for a member's turn: allowlist in, everything else absent. */
function memberEnv(run: MemberRun, inherited: Record<string, string | undefined>): Record<string, string> {
const env: Record<string, string> = {};
for (const name of ALLOWED_ENV) {
const value = inherited[name];
if (value !== undefined) env[name] = value;
}
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;
}
/**
* The SDK's spawn hook, bound to one member.
*
* `spawnClaudeCodeProcess` (`sdk.d.ts:951`, "use this to run Claude Code in VMs, containers, or remote
* environments") is what makes this possible at all. The plan of record assumed the SDK had nowhere to put a
* uid and that a member's turn therefore had to become its own process — a change of shape rather than a
* flag. It is a flag.
*
* `node:child_process` rather than `Bun.spawn`, for two reasons: its return value already satisfies
* `SpawnedProcess` (a Writable stdin, a Readable stdout, `kill`, `on('exit')`), which Bun's does not — Bun
* gives web streams and no emitter — and `Bun.spawn` silently ignores `uid`/`gid` anyway, which is why
* `runAs` exists and why `os-user.test.ts` pins that behaviour.
*/
export function spawnClaudeAsMember(run: MemberRun): (options: SpawnOptions) => SpawnedProcess {
return ({ command, args, cwd, env, signal }: SpawnOptions): SpawnedProcess => {
const childEnv = memberEnv(run, env);
assertEnvSafe(PERMITTED_ENV, childEnv, run.osUser);
// The binary must be theirs — established without touching their filesystem.
//
// Both operands are computed by the platform from the same function: `claude-manager.ts` sets
// `pathToClaudeCodeExecutable: claudeBinIn(member.home)`, and this recomputes it. So string equality
// establishes exactly what the check is for, and if the SDK ever normalises the path it fails closed and
// loudly rather than silently.
//
// This was `realpathSync` on both sides for one commit, to survive an upstream that resolves symlinks —
// and there is no such upstream, because the platform controls both ends. The cost was total: a member's
// home is 700, the platform is `other`, so `realpathSync` threw EACCES, the catch turned that into
// "not their binary", and EVERY member turn would have been refused forever the moment the gates moved.
// Verified on the production host against a byte-identical path. Failing closed was the right direction
// and it made the feature impossible rather than unsafe.
//
// The alternative — granting the service user traverse — needs `x` on `.local`, `.local/share`,
// `.local/share/claude` and `versions/`, which reopens a decision made deliberately in `16` across a whole
// subtree rather than one directory.
const expectedBin = claudeBinIn(run.home);
if (resolve(command) !== expectedBin) {
throw new Error(`refusing to run ${command} as ${run.osUser}; expected their own ${expectedBin}`);
}
// `env K=V …` inside the argv, because `--reset-env` clears anything handed to `setpriv` itself. This is
// the only channel through which a variable can reach the member's process.
const assignments = Object.entries(childEnv).map(([name, value]) => `${name}=${value}`);
const [launcher, ...launcherArgs] = runAsArgv(run.osUser, ['env', ...assignments, command, ...args]);
// Unreachable — `runAsArgv` always returns `sudo` first and throws on an empty command. Written rather
// than asserted away because the alternative under `noUncheckedIndexedAccess` is a default that would
// silently pick a launcher, and a wrong launcher here means a turn running as the wrong user.
if (!launcher) throw new Error('runAsArgv returned an empty argv');
const child = spawn(launcher, launcherArgs, {
// Their home, not this process's. A cwd outside it would be readable by the turn only if the member
// could read it anyway — the kernel is the check here, not this line — but defaulting to their home is
// what makes an unqualified turn behave like their shell.
cwd: cwd ?? run.home,
stdio: ['pipe', 'pipe', 'pipe'],
signal,
});
// Non-null by construction: 'pipe' on all three above. The SDK's interface wants them non-nullable and
// node types them as possibly-null because other stdio modes exist.
return child as unknown as SpawnedProcess;
};
}