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//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//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. */ const ALSO_ALLOWED = ['HOME', 'CLAUDE_CONFIG_DIR'] 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 = new Set([...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, childEnv: Record, 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): Record { const env: Record = {}; 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); 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; }; }