Files
platform/src/servers/sidecar/claude/spawn-as-member.ts
T
pastilhasandClaude Opus 5 c5522700ff compare the binary as a string, and prove the uid by file ownership
host ran the live test against green. Two results.

SETPRIV WORKS. The privilege drop lands on the member and the SDK spawn survives
it, so the design does not change shape and everything layered on the hook
stays. That was the last question that could have moved the architecture.

THE BINARY CHECK REFUSED A BYTE-IDENTICAL PATH. sameFile used realpathSync,
which has to stat inside a 700 home the platform is `other` to, so it threw
EACCES and the catch turned that into "not their binary" — every member turn
refused forever, the moment the gates moved. Failing closed was the right
direction and it made the feature impossible rather than unsafe.

Now resolve(command) !== claudeBinIn(run.home). Both operands are computed by
the platform from the same function, so string equality establishes exactly what
the check is for and needs no access to their home. sameFile and its tests are
deleted: a helper kept for a case that cannot arise is a trap for the next
reader. The realpath version was defending against an upstream that normalises
paths, and there is no such upstream — the platform controls both ends.

The fact that decides this is that the check runs in the PLATFORM process, not
the member's, and neither of us stated it until EACCES did.

THE TEST WATCHED THE WRONG PROCESS. child.pid is sudo, whose real uid is
legitimately the service user's until it execs down through setpriv, so
asserting on it fails on a working drop. Split: --version through the hook
proves their binary ran, and a second spawn creates a file in their home whose
owner the test stats. Nothing self-reports, and a process cannot forge the uid
that owns a file it created.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 01:03:17 +00:00

197 lines
11 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. */
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<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);
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;
};
}