diff --git a/packages/mosaic/src/commands/claudex-proxy.spec.ts b/packages/mosaic/src/commands/claudex-proxy.spec.ts index 5b43650..f5c076f 100644 --- a/packages/mosaic/src/commands/claudex-proxy.spec.ts +++ b/packages/mosaic/src/commands/claudex-proxy.spec.ts @@ -376,13 +376,17 @@ describe('installSystemdUnit', () => { }); describe('runProxyPreflight', () => { - it('is ok when binary present, auth valid, and proxy live', async () => { + const trustedListener = () => 'ok' as const; + + it('is ok when binary present, auth valid, proxy live, and listener identity-verified', async () => { const report = await runProxyPreflight({ checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }), checkAuth: () => ({ state: 'valid' }), probe: async () => true, + verifyListener: trustedListener, }); expect(report.ok).toBe(true); + expect(report.listenerVerdict).toBe('ok'); expect(report.problems).toEqual([]); }); @@ -391,6 +395,7 @@ describe('runProxyPreflight', () => { checkBinary: () => ({ present: false, path: null }), checkAuth: () => ({ state: 'valid' }), probe: async () => true, + verifyListener: trustedListener, }); expect(report.ok).toBe(false); expect(report.problems.some((p) => /binary/i.test(p))).toBe(true); @@ -401,6 +406,7 @@ describe('runProxyPreflight', () => { checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }), checkAuth: () => ({ state: 'expired' }), probe: async () => true, + verifyListener: trustedListener, }); expect(report.ok).toBe(false); expect(report.needsReauth).toBe(true); @@ -412,6 +418,7 @@ describe('runProxyPreflight', () => { checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }), checkAuth: () => ({ state: 'valid' }), probe: async () => false, + verifyListener: trustedListener, }); expect(report.ok).toBe(false); expect(report.live).toBe(false); @@ -422,17 +429,51 @@ describe('runProxyPreflight', () => { checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }), checkAuth: () => ({ state: 'unknown' }), probe: async () => true, + verifyListener: trustedListener, }); expect(report.ok).toBe(false); expect(report.needsReauth).toBe(false); expect(report.problems.some((p) => /could not determine/i.test(p))).toBe(true); }); + it('does NOT pass preflight when the live responder fails identity verification (F2b)', async () => { + // A squatter answering /healthz-2xx must not yield ok:true just because the + // binary is installed and OAuth is valid — the identity gate holds here too. + for (const verdict of ['foreign-user', 'wrong-exe', 'unknown'] as const) { + const report = await runProxyPreflight({ + checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }), + checkAuth: () => ({ state: 'valid' }), + probe: async () => true, + verifyListener: () => verdict, + }); + expect(report.ok).toBe(false); + expect(report.live).toBe(true); + expect(report.listenerVerdict).toBe(verdict); + expect(report.problems.some((p) => /identity could not be verified/i.test(p))).toBe(true); + // The identity problem is non-sensitive: port + verdict only, no token. + expect(JSON.stringify(report)).not.toMatch(/token|sk-|auth\.json/i); + } + }); + + it('does not verify listener identity when the proxy is dead (no listener to trust)', async () => { + const verifyListener = vi.fn(() => 'ok' as const); + const report = await runProxyPreflight({ + checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }), + checkAuth: () => ({ state: 'valid' }), + probe: async () => false, + verifyListener, + }); + expect(verifyListener).not.toHaveBeenCalled(); + expect(report.listenerVerdict).toBe('unknown'); + expect(report.ok).toBe(false); + }); + it('does not leak token material for any auth state', async () => { const report = await runProxyPreflight({ checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }), checkAuth: () => ({ state: 'expired' }), probe: async () => false, + verifyListener: trustedListener, }); expect(JSON.stringify(report)).not.toMatch(/token|sk-|auth\.json/i); }); @@ -537,23 +578,68 @@ describe('verifyListenerIdentity (finding #2 — OS-level listener identity, CWE uid: 1000, exePath: '/home/me/.local/bin/claude-code-proxy', }; + // Identity canonicalize for tests: fake paths don't exist on disk, so we map + // each path to itself and exercise symlink resolution explicitly where needed. + const idc = (p: string) => p; it('accepts a listener owned by the current uid whose exe is the expected proxy path', () => { const verdict = verifyListenerIdentity({ identify: () => me, currentUid: () => 1000, expectedExe: () => '/home/me/.local/bin/claude-code-proxy', + canonicalize: idc, }); expect(verdict).toBe('ok'); }); - it('accepts by basename when the expected path is unknown but the exe is claude-code-proxy', () => { + it('resolves symlinks on BOTH sides before comparing (canonical match → ok)', () => { + // The listener exe and our resolved binary reach the same real file via + // different symlink paths — a canonical comparison must accept it. + const canon: Record = { + '/var/run/proxy.link': '/opt/proxy/bin/claude-code-proxy', + '/home/me/.local/bin/claude-code-proxy': '/opt/proxy/bin/claude-code-proxy', + }; + const verdict = verifyListenerIdentity({ + identify: () => ({ ...me, exePath: '/var/run/proxy.link' }), + currentUid: () => 1000, + expectedExe: () => '/home/me/.local/bin/claude-code-proxy', + canonicalize: (p) => canon[p] ?? null, + }); + expect(verdict).toBe('ok'); + }); + + it('does NOT trust a same-uid process at the WRONG path with the right basename (F2a)', () => { + // The squatter vector on a shared-uid host: right basename, wrong path. The + // basename must NEVER be a trust signal when an expected exact path resolved. + const verdict = verifyListenerIdentity({ + identify: () => ({ ...me, exePath: '/tmp/claude-code-proxy' }), + currentUid: () => 1000, + expectedExe: () => '/home/me/.local/bin/claude-code-proxy', + canonicalize: idc, + }); + expect(verdict).toBe('wrong-exe'); + }); + + it('fails closed (unknown) when our own proxy binary path cannot be resolved (F2a)', () => { + // No expected path → we cannot assert identity → refuse to trust (no basename + // acceptance). Previously this returned `ok` by basename; that was a bypass. const verdict = verifyListenerIdentity({ identify: () => me, currentUid: () => 1000, expectedExe: () => null, + canonicalize: idc, }); - expect(verdict).toBe('ok'); + expect(verdict).toBe('unknown'); + }); + + it('fails closed (unknown) when a path cannot be canonicalized (F2a)', () => { + const verdict = verifyListenerIdentity({ + identify: () => me, + currentUid: () => 1000, + expectedExe: () => '/home/me/.local/bin/claude-code-proxy', + canonicalize: () => null, // e.g. binary deleted out from under the listener + }); + expect(verdict).toBe('unknown'); }); it('rejects a listener owned by a DIFFERENT uid (foreign-user) — fail closed', () => { @@ -561,6 +647,7 @@ describe('verifyListenerIdentity (finding #2 — OS-level listener identity, CWE identify: () => ({ ...me, uid: 0 }), currentUid: () => 1000, expectedExe: () => '/home/me/.local/bin/claude-code-proxy', + canonicalize: idc, }); expect(verdict).toBe('foreign-user'); }); @@ -570,6 +657,7 @@ describe('verifyListenerIdentity (finding #2 — OS-level listener identity, CWE identify: () => ({ ...me, exePath: '/usr/bin/nc' }), currentUid: () => 1000, expectedExe: () => '/home/me/.local/bin/claude-code-proxy', + canonicalize: idc, }); expect(verdict).toBe('wrong-exe'); }); @@ -579,6 +667,7 @@ describe('verifyListenerIdentity (finding #2 — OS-level listener identity, CWE identify: () => null, currentUid: () => 1000, expectedExe: () => '/home/me/.local/bin/claude-code-proxy', + canonicalize: idc, }); expect(verdict).toBe('unknown'); }); @@ -588,6 +677,7 @@ describe('verifyListenerIdentity (finding #2 — OS-level listener identity, CWE identify: () => me, currentUid: () => -1, expectedExe: () => '/home/me/.local/bin/claude-code-proxy', + canonicalize: idc, }); expect(verdict).toBe('unknown'); }); @@ -597,6 +687,7 @@ describe('verifyListenerIdentity (finding #2 — OS-level listener identity, CWE identify: () => ({ ...me, exePath: null }), currentUid: () => 1000, expectedExe: () => '/home/me/.local/bin/claude-code-proxy', + canonicalize: idc, }); expect(verdict).toBe('unknown'); }); diff --git a/packages/mosaic/src/commands/claudex-proxy.ts b/packages/mosaic/src/commands/claudex-proxy.ts index a749899..cd867ec 100644 --- a/packages/mosaic/src/commands/claudex-proxy.ts +++ b/packages/mosaic/src/commands/claudex-proxy.ts @@ -17,9 +17,9 @@ */ import { execFileSync, spawn, spawnSync } from 'node:child_process'; -import { mkdirSync, readFileSync, readlinkSync, writeFileSync } from 'node:fs'; +import { mkdirSync, readFileSync, readlinkSync, realpathSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; -import { basename, dirname, join } from 'node:path'; +import { dirname, join } from 'node:path'; // ─── Endpoint / command constants (spec table) ────────────────────────────── @@ -256,6 +256,17 @@ export interface VerifyListenerDeps { currentUid?: () => number; /** The expected proxy executable path (null when it can't be resolved). */ expectedExe?: () => string | null; + /** Canonicalize a path (resolve symlinks); null when it can't be resolved. */ + canonicalize?: (p: string) => string | null; +} + +/** Resolve a path through symlinks to its canonical form; null on any failure. */ +function defaultCanonicalize(p: string): string | null { + try { + return realpathSync(p); + } catch { + return null; + } } /** @@ -292,16 +303,25 @@ function defaultIdentifyListener(port: number = CLAUDEX_PROXY_PORT): ListenerIde * Verify that the process owning :18765 is genuinely OUR proxy before trusting * it. The proxy binds loopback with NO client authentication, so on a shared * host any local process could squat the port and a liveness 2xx alone does not - * prove identity (CWE-345). We check the OS-level identity of the listener — it - * must be owned by the current uid and be the expected proxy executable — and - * FAIL CLOSED (`unknown`) whenever identity cannot be established. This needs no - * upstream shared-secret/unix-socket support from `claude-code-proxy`. + * prove identity (CWE-345). We FAIL CLOSED (`unknown`) whenever identity cannot + * be established. This needs no upstream shared-secret/unix-socket support from + * `claude-code-proxy`. + * + * The executable path is the trust boundary that matters: on a shared-uid host + * (every agent session runs as the same operator) same-uid is NOT sufficient, so + * we require an EXACT canonical-path match against our resolved proxy binary and + * canonicalize both sides for symlinks. There is deliberately NO basename + * fallback — a same-uid process running `/tmp/claude-code-proxy` (right name, + * wrong path) must never be trusted. If our own binary path can't be resolved, + * or either path can't be canonicalized, we fail closed rather than downgrade to + * a weaker check. */ export function verifyListenerIdentity(deps: VerifyListenerDeps = {}): ListenerVerdict { const identify = deps.identify ?? (() => defaultIdentifyListener()); const currentUid = deps.currentUid ?? (() => (typeof process.getuid === 'function' ? process.getuid() : -1)); const expectedExe = deps.expectedExe ?? (() => checkProxyBinary().path); + const canonicalize = deps.canonicalize ?? defaultCanonicalize; const id = identify(); if (!id) return 'unknown'; // can't see the listener → don't trust it @@ -311,9 +331,11 @@ export function verifyListenerIdentity(deps: VerifyListenerDeps = {}): ListenerV if (!id.exePath) return 'unknown'; // can't confirm the executable → fail closed const expected = expectedExe(); - if (expected && id.exePath === expected) return 'ok'; - if (basename(id.exePath) === CLAUDEX_PROXY_BINARY) return 'ok'; - return 'wrong-exe'; + if (!expected) return 'unknown'; // can't resolve our own binary → fail closed + const expectedReal = canonicalize(expected); + const actualReal = canonicalize(id.exePath); + if (!expectedReal || !actualReal) return 'unknown'; // uncanonicalizable → fail closed + return actualReal === expectedReal ? 'ok' : 'wrong-exe'; } // ─── systemd user unit ─────────────────────────────────────────────────────── @@ -421,6 +443,8 @@ export interface PreflightReport { binaryPath: string | null; auth: AuthStatus; live: boolean; + /** OS-level identity verdict for the :18765 listener (`unknown` when dead). */ + listenerVerdict: ListenerVerdict; needsReauth: boolean; ok: boolean; problems: string[]; @@ -430,20 +454,32 @@ export interface PreflightDeps { checkBinary?: () => { present: boolean; path: string | null }; checkAuth?: () => AuthStatus; probe?: () => Promise; + verifyListener?: () => ListenerVerdict; } /** - * Compose the three preflight checks into a single structured report. `ok` is - * true only when the binary is present, OAuth is valid, and the proxy responds. + * Compose the preflight checks into a single structured report. `ok` is true + * only when the binary is present, OAuth is valid, the proxy responds, AND the + * responding listener's OS-level identity verifies as our proxy. + * + * The identity gate lives here too, not only in {@link ensureProxyRunning}: any + * consumer of this report (notably the phase-2 launch path) would otherwise + * treat a `/healthz`-2xx squatter as healthy and route Claude traffic to it + * (CWE-345). A liveness 2xx is necessary but not sufficient — a live responder + * that fails identity fails the preflight. */ export async function runProxyPreflight(deps: PreflightDeps = {}): Promise { const checkBinary = deps.checkBinary ?? (() => checkProxyBinary()); const checkAuth = deps.checkAuth ?? (() => checkAuthStatus()); const probe = deps.probe ?? (() => probeLiveness()); + const verifyListener = deps.verifyListener ?? (() => verifyListenerIdentity()); const bin = checkBinary(); const auth = checkAuth(); const live = await probe(); + // Only meaningful when something is actually responding; a dead port has no + // listener identity to establish. + const listenerVerdict: ListenerVerdict = live ? verifyListener() : 'unknown'; const problems: string[] = []; if (!bin.present) { @@ -461,14 +497,21 @@ export async function runProxyPreflight(deps: PreflightDeps = {}): Promise