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>
This commit is contained in:
@@ -1,8 +1,5 @@
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import { mkdtempSync, mkdirSync, symlinkSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { PERMITTED_ENV, assertEnvSafe, sameFile } from './spawn-as-member';
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { PERMITTED_ENV, assertEnvSafe } from './spawn-as-member';
|
||||
|
||||
// Pins the two guards standing between a member's agent turn and the owner's credential.
|
||||
//
|
||||
@@ -44,38 +41,3 @@ describe('assertEnvSafe', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Anthropic's installer puts a symlink at ~/.local/bin/claude pointing into a versioned directory, and
|
||||
// `claude update` moves that target. A string compare on the path matches only while the caller happens to
|
||||
// pass the symlink spelling — which is why the check follows symlinks on both sides.
|
||||
describe('sameFile', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'spawn-as-member-'));
|
||||
const versioned = join(dir, 'versions', '2.1.227');
|
||||
const link = join(dir, 'bin', 'claude');
|
||||
|
||||
mkdirSync(join(dir, 'versions'), { recursive: true });
|
||||
mkdirSync(join(dir, 'bin'), { recursive: true });
|
||||
writeFileSync(versioned, '#!/bin/sh\n', { mode: 0o755 });
|
||||
symlinkSync(versioned, link);
|
||||
|
||||
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
test('a symlink and its target are the same file', () => {
|
||||
expect(sameFile(link, versioned)).toBe(true);
|
||||
});
|
||||
|
||||
test('the symlink matches itself — the spelling the caller passes today', () => {
|
||||
expect(sameFile(link, link)).toBe(true);
|
||||
});
|
||||
|
||||
test('a different file does not match', () => {
|
||||
const other = join(dir, 'bin', 'not-claude');
|
||||
writeFileSync(other, '#!/bin/sh\n', { mode: 0o755 });
|
||||
expect(sameFile(other, link)).toBe(false);
|
||||
});
|
||||
|
||||
test('a missing path refuses rather than throwing', () => {
|
||||
// Inside a spawn hook an ENOENT would surface as an unexplained crash; false reads as "not their binary".
|
||||
expect(sameFile(join(dir, 'bin', 'absent'), link)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user