make both env guards able to fire, and follow the symlink
host was right twice, including about his own advice. Two dead guards had shipped here, both for the same reason: written inside the spawn closure, where the only way to reach them is to spawn — and the passing path spawns sudo. So nothing ever demonstrated either one firing. THE SUBSET CHECK WAS ALSO TAUTOLOGICAL. `permitted` came from the same constants memberEnv builds childEnv from, so it was empty under every edit where that holds — the exact criticism that retired NEVER_ENV. Worse, it lost the one live trigger the denylist had: a credential added to ALLOWED_ENV used to throw, and under the subset check widened the permitted set in the same motion and passed silently. That is the realistic future edit and it was the one left unguarded. Now both, and the denylist tests the LIST rather than the instance, so it fires on exactly that edit. Extracted as `assertEnvSafe` so a test can pass a poisoned allowlist — the guards being untestable in place is why they were decorative twice. THE BINARY CHECK WOULD HAVE THROWN ON EVERY TURN. Anthropic's installer puts a symlink at ~/.local/bin/claude into a versioned directory; resolve() does not follow symlinks, so the string compare matched only while `command` arrived as the symlink spelling, and would have failed the moment anything upstream normalised it — at exactly the point the hook gets wired. Compared through realpathSync on both sides now, per turn and never cached, since `claude update` moves the target. Nine tests pin all of it: a poisoned allowlist, a stray key, each NEVER_ENV name, and the symlink/target/missing-path cases. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
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';
|
||||
|
||||
// Pins the two guards standing between a member's agent turn and the owner's credential.
|
||||
//
|
||||
// Both of them shipped dead before this test existed, and in both cases the reason was the same: they were
|
||||
// written inside the spawn closure, where the only way to reach them is to spawn, and the passing path spawns
|
||||
// `sudo`. So nothing ever demonstrated them firing, and "it looks right" carried the weight. That is what
|
||||
// these tests are for — not the happy path, which is obvious, but the two edits a future reader will actually
|
||||
// make.
|
||||
|
||||
const OWNER_CRED = 'ANTHROPIC_API_KEY';
|
||||
|
||||
describe('assertEnvSafe', () => {
|
||||
test('the shipping lists are clean', () => {
|
||||
// The regression this exists to catch: somebody adds a credential to ALLOWED_ENV.
|
||||
expect(() => assertEnvSafe(PERMITTED_ENV, { HOME: '/home/x' }, 'x')).not.toThrow();
|
||||
});
|
||||
|
||||
test('throws when a credential is in the allowlist — guards the constant', () => {
|
||||
// The realistic dangerous edit. Note the credential is NOT in childEnv: the point is that the *list*
|
||||
// permits it, so the next call that inherits one would pass it through silently.
|
||||
const poisoned = new Set([...PERMITTED_ENV, OWNER_CRED]);
|
||||
expect(() => assertEnvSafe(poisoned, { HOME: '/home/x' }, 'green')).toThrow(/is in the allowlist/);
|
||||
});
|
||||
|
||||
test('throws when the built env carries an unvetted key — guards the construction', () => {
|
||||
expect(() => assertEnvSafe(PERMITTED_ENV, { HOME: '/home/x', POSTGRES_URL: 'postgres://…' }, 'green')).toThrow(
|
||||
/unvetted env/,
|
||||
);
|
||||
});
|
||||
|
||||
test('names the offending variable, so the error is actionable', () => {
|
||||
expect(() => assertEnvSafe(PERMITTED_ENV, { JWT_SECRET: 'x' }, 'green')).toThrow(/JWT_SECRET/);
|
||||
});
|
||||
|
||||
test('every NEVER_ENV name would be caught if it were permitted', () => {
|
||||
// Guards against the list being quietly narrowed as well as the allowlist being widened.
|
||||
for (const name of ['ANTHROPIC_BASE_URL', 'CLAUDE_CODE_OAUTH_TOKEN', 'POSTGRES_URL', 'JWT_SECRET']) {
|
||||
expect(() => assertEnvSafe(new Set([...PERMITTED_ENV, name]), {}, 'green')).toThrow(name);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 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