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:
2026-08-11 22:10:34 +00:00
co-authored by Claude Opus 5
parent 06bfcf9556
commit 6b7aad91db
2 changed files with 163 additions and 24 deletions
@@ -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);
});
});