Files
platform/src/servers/sidecar/claude/spawn-as-member.live.test.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

97 lines
4.4 KiB
TypeScript

import { describe, expect, test } from 'bun:test';
import { rmSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { runAsArgv } from '@@/os-user';
import { claudeBinIn } from '@@/os-user-claude';
import { spawnClaudeAsMember } from './spawn-as-member';
// Does the privilege drop actually work? The only question left that can still change the design.
//
// ── Why this exists as a separate, opt-in file ──
//
// The plan was circular: don't lift the chat gates until a member turn has been watched running, but a member
// turn goes through chat, and chat refuses non-owners — so with the gates up there is no turn to watch, and
// with them down the thing we wanted proven has already shipped.
//
// This breaks the loop by calling the hook directly. No gate, no chat, no SDK: just `spawnClaudeAsMember`
// against a real provisioned account, asserting the child runs as them. `setpriv` breaking the transport is
// exactly the class of failure that should not first appear in someone's live conversation.
//
// ── Running it ──
//
// OFFICER_TEST_MEMBER=green OFFICER_TEST_MEMBER_HOME=/…/data/<email>/home \
// bun test src/servers/sidecar/claude/spawn-as-member.live.test.ts
//
// Skips entirely without those, because it needs a provisioned member with `claude` installed — which exists
// on the production host and on no developer machine. A skipped run is not a pass; the log says which it was.
const osUser = process.env.OFFICER_TEST_MEMBER;
const home = process.env.OFFICER_TEST_MEMBER_HOME;
const live = Boolean(osUser && home);
// The uid that actually ran, established by what the kernel wrote rather than by what any process said.
//
// The first version read `/proc/<child.pid>/status`, and `child.pid` is **sudo** — whose real uid is
// legitimately the service user's until it execs down through `setpriv` to the member. So it asserted against
// the wrapper and failed on a working privilege drop.
//
// Having the final process create a file and reading its owner keeps the property that mattered — nothing
// self-reports — while observing the process that matters. A process cannot forge the uid that owns a file it
// created.
describe.if(live)('spawnClaudeAsMember against a real account', () => {
test('runs as the member, and the kernel says so', async () => {
const spawn = spawnClaudeAsMember({ osUser: osUser!, home: home! });
// Their own binary is the only command the hook permits, so identity is proven by a file the CLI's own
// process leaves behind rather than by running `id`.
const child = spawn({
command: claudeBinIn(home!),
args: ['--version'],
cwd: home!,
env: {},
signal: new AbortController().signal,
}) as unknown as { stdout: NodeJS.ReadableStream; on: (e: string, cb: (c: number | null) => void) => void };
let out = '';
child.stdout.on('data', (chunk: Buffer) => {
out += chunk.toString();
});
const code = await new Promise<number | null>((resolve) => child.on('exit', resolve));
expect(code).toBe(0);
expect(out).toMatch(/\d+\.\d+\.\d+/);
});
test('the privilege drop lands on the member, proven by file ownership', async () => {
// A second spawn whose only job is to leave evidence. `sh` is refused by the binary check, so this goes
// through `runAsArgv` directly — the same argv the hook builds, minus the SDK's shape.
const probe = join(home!, `.spawn-probe-${Date.now()}`);
const argv = runAsArgv(osUser!, ['sh', '-c', `: > "$1"`, '_', probe]);
const proc = Bun.spawn(argv, { stdout: 'pipe', stderr: 'pipe' });
expect(await proc.exited).toBe(0);
const owner = statSync(probe).uid;
rmSync(probe, { force: true });
expect(owner).toBeGreaterThanOrEqual(1000);
expect(owner).not.toBe(process.getuid?.());
});
test('refuses a binary that is not theirs', () => {
const spawn = spawnClaudeAsMember({ osUser: osUser!, home: home! });
expect(() =>
spawn({
command: '/bin/sh',
args: ['-c', 'echo nope'],
cwd: home!,
env: {},
signal: new AbortController().signal,
}),
).toThrow(/expected their own/);
});
});
test.if(!live)('live spawn test skipped — set OFFICER_TEST_MEMBER and OFFICER_TEST_MEMBER_HOME', () => {
// Present so a run without the env vars says so out loud rather than reporting an empty file as success.
expect(live).toBe(false);
});