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:
@@ -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 member’s 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);
|
||||
});
|
||||
Reference in New Issue
Block a user