test the privilege drop without lifting a gate

host caught a circularity I had written twice: do not lift the chat gates until
a member turn has been watched running, but a member turn goes through chat and
chat refuses non-owners. With the gates up there is nothing to watch; with them
down the thing we wanted proven has already shipped.

spawn-as-member.live.test.ts calls spawnClaudeAsMember directly against a real
provisioned account — no gate, no chat, no SDK. The child's uid is read from
/proc/<pid>/status, so it is the kernel's answer rather than anything the child
chose to say, and it asserts >=1000 and not this process's uid: a failed
privilege drop cannot pass by running as the service user. It also asserts the
binary exited 0 having printed a version, which proves their install ran rather
than merely being spawned, plus a negative that /bin/sh through the same hook
throws.

Opt-in via OFFICER_TEST_MEMBER and OFFICER_TEST_MEMBER_HOME, because it needs a
provisioned member with claude installed — which exists on the production host
and on no developer machine. A run without them skips loudly rather than
reporting an empty file as a pass.

Also adopted host's NO REPLY NEEDED terminator: "reply to everything" had no
exit condition and cost the owner two agents being polite at each other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 00:57:18 +00:00
co-authored by Claude Opus 5
parent bae3ebf7ba
commit 9bab67aca5
2 changed files with 138 additions and 0 deletions
@@ -0,0 +1,87 @@
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
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 child's real uid, read from the kernel rather than from anything the child chose to say. */
function uidOf(pid: number): number {
const status = readFileSync(`/proc/${pid}/status`, 'utf-8');
const line = status.split('\n').find((l) => l.startsWith('Uid:'));
return Number(line!.split(/\s+/)[1]);
}
describe.if(live)('spawnClaudeAsMember against a real account', () => {
test('runs the members own claude, as the member', async () => {
const spawn = spawnClaudeAsMember({ osUser: osUser!, home: home! });
// `--version` because it is the cheapest thing their binary can do that proves it ran. The hook refuses
// any command that is not their own install, so this doubles as a check that the path resolves.
const child = spawn({
command: claudeBinIn(home!),
args: ['--version'],
cwd: home!,
env: {},
signal: new AbortController().signal,
}) as unknown as {
pid: number;
stdout: NodeJS.ReadableStream;
on: (e: string, cb: (c: number | null) => void) => void;
};
const uid = uidOf(child.pid);
let out = '';
child.stdout.on('data', (chunk: Buffer) => {
out += chunk.toString();
});
const code = await new Promise<number | null>((resolve) => child.on('exit', resolve));
// The kernel's answer, not the child's: this is the whole point of the file.
expect(uid).toBeGreaterThanOrEqual(1000);
expect(uid).not.toBe(process.getuid?.());
expect(code).toBe(0);
expect(out).toMatch(/\d+\.\d+\.\d+/);
});
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);
});