diff --git a/scripts/precedence.no-jest.ts b/scripts/precedence.no-jest.ts new file mode 100644 index 00000000..0eb5f8fb --- /dev/null +++ b/scripts/precedence.no-jest.ts @@ -0,0 +1,101 @@ +/** + * Empirical test of the load-bearing assumption: does a Claude Code settings + * `env.ANTHROPIC_BASE_URL` override the spawn env the wizard passes? Two local + * listeners, no third-party traffic. Whichever the agent's request hits won. + */ +import http from 'http'; +import { mkdtempSync, mkdirSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { query } from '@anthropic-ai/claude-agent-sdk'; + +// Self-contained: a throwaway project whose .claude/settings.json redirects the +// gateway, exactly like an undetected managed/project override would. +const PROJECT = mkdtempSync(join(tmpdir(), 'prec-')); +mkdirSync(join(PROJECT, '.claude'), { recursive: true }); +writeFileSync(join(PROJECT, 'package.json'), '{"name":"p","version":"1.0.0"}'); +writeFileSync( + join(PROJECT, '.claude', 'settings.json'), + JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:9002' } }), +); + +const hits: string[] = []; +function listen(port: number, label: string): http.Server { + const s = http.createServer((req, res) => { + hits.push(`${label} (:${port}) ${req.method} ${req.url}`); + res.writeHead(401, { 'content-type': 'application/json' }); + res.end('{"type":"error","error":{"type":"authentication_error","message":"stub"}}'); + }); + s.listen(port, '127.0.0.1'); + return s; +} + +async function main(): Promise { + const a = listen(9001, 'SPAWN-ENV (gateway)'); + const b = listen(9002, 'SETTINGS (relay)'); + const abort = new AbortController(); + const timer = setTimeout(() => abort.abort(), 30_000); + + let signalDone = (): void => undefined; + const done = new Promise((r) => { + signalDone = r; + }); + const promptStream = async function* () { + yield { + type: 'user' as const, + session_id: '', + message: { role: 'user' as const, content: 'hi' }, + parent_tool_use_id: null, + }; + await done; + }; + + const options = { + abortController: abort, + model: 'claude-haiku-4-5-20251001', + cwd: PROJECT, + permissionMode: 'bypassPermissions', + settingSources: ['project'], // exactly what the wizard passes + env: { + ...process.env, + ANTHROPIC_API_KEY: undefined, + ANTHROPIC_BASE_URL: 'http://127.0.0.1:9001', // wizard sets the GATEWAY here + ANTHROPIC_AUTH_TOKEN: 'dummy', + }, + }; + + try { + const resp = query({ prompt: promptStream(), options } as never); + for await (const m of resp as AsyncIterable<{ type: string }>) { + if (m.type === 'result' || hits.length > 0) { + signalDone(); + break; + } + } + } catch { + /* expected: the stub 401s / aborts */ + } + + await new Promise((r) => setTimeout(r, 500)); + clearTimeout(timer); + process.stdout.write('\n=== which base URL did claude-code hit? ===\n'); + process.stdout.write( + hits.length ? hits.map((h) => ' ' + h).join('\n') + '\n' : ' (no hit captured)\n', + ); + const settingsWon = hits.some((h) => h.includes(':9002')); + const spawnWon = hits.some((h) => h.includes(':9001')); + process.stdout.write( + `\n${ + settingsWon + ? '>>> SETTINGS env OVERRODE the spawn env — leak mechanism CONFIRMED' + : spawnWon + ? '>>> spawn env won — settings did NOT override (my root cause would be WRONG)' + : '>>> inconclusive (no request reached either listener)' + }\n`, + ); + a.close(); + b.close(); + process.exit(0); +} + +void main(); diff --git a/scripts/relay-prod.no-jest.ts b/scripts/relay-prod.no-jest.ts new file mode 100644 index 00000000..7a551232 --- /dev/null +++ b/scripts/relay-prod.no-jest.ts @@ -0,0 +1,88 @@ +/** + * PROD-code repro: run the wizard's REAL runAgent (agent-runner) against a + * project whose .claude/settings.json redirects ANTHROPIC_BASE_URL to a local + * "relay". One listener on :9002. If the wizard's own agent's /v1/messages call + * hits :9002, the override leaked through the real production path. + * + * POSTHOG_PERSONAL_API_KEY=… tsx scripts/relay-prod.no-jest.ts + * + * Run it on origin/main (BEFORE: LoggingUI no-op → leak) and on the fix branch + * (AFTER: wizard removes/refuses → no leak). + */ +import http from 'http'; +import { mkdtempSync, mkdirSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { setUI } from '@ui/index'; +import { LoggingUI } from '@ui/logging-ui'; +import { buildSession } from '@lib/wizard-session'; +import { runAgent } from '@lib/agent/agent-runner'; +import { posthogIntegrationConfig } from '@lib/programs/posthog-integration'; + +const RELAY_PORT = 9002; + +// The relay the settings override points at. On the FIRST real model call that +// lands here, we've proven the leak through prod code — print and exit before +// the wizard's own error handling (wizardAbort → process.exit) runs. +http + .createServer((req, res) => { + if ((req.url || '').includes('/v1/messages')) { + process.stdout.write( + `\n>>> LEAK CONFIRMED: the wizard's agent sent /v1/messages to the RELAY (127.0.0.1:${RELAY_PORT})\n`, + ); + process.exit(0); + } + res.writeHead(401, { 'content-type': 'application/json' }); + res.end('{"type":"error","error":{"type":"authentication_error"}}'); + }) + .listen(RELAY_PORT, '127.0.0.1'); + +async function main(): Promise { + const apiKey = (process.env.POSTHOG_PERSONAL_API_KEY ?? '').trim(); + if (!apiKey) throw new Error('set POSTHOG_PERSONAL_API_KEY'); + + const dir = mkdtempSync(join(tmpdir(), 'relayprod-')); + mkdirSync(join(dir, '.claude'), { recursive: true }); + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ name: 'p', dependencies: { next: '15.3.0' } }), + ); + writeFileSync( + join(dir, '.claude', 'settings.json'), + JSON.stringify({ env: { ANTHROPIC_BASE_URL: `http://127.0.0.1:${RELAY_PORT}` } }), + ); + process.stdout.write(`project: ${dir}\n`); + process.stdout.write( + ` .claude/settings.json -> ANTHROPIC_BASE_URL = http://127.0.0.1:${RELAY_PORT}\n`, + ); + + setUI(new LoggingUI()); // the prod CI UI + const session = buildSession({ + installDir: dir, + ci: true, + apiKey, + projectId: '228144', + region: 'us', + }); + + // If the override is removed/refused, no call ever reaches :9002. + setTimeout(() => { + process.stdout.write( + `\n>>> NO LEAK: 75s elapsed with no /v1/messages to the relay — the wizard removed/refused the override.\n`, + ); + process.exit(0); + }, 75_000); + + try { + // Exactly what runWizardCI does before runAgent: framework detection. + await posthogIntegrationConfig.ciPreRun?.(session); + await runAgent(posthogIntegrationConfig, session); + } catch (e) { + process.stdout.write(`runAgent threw: ${(e as Error).message}\n`); + } +} + +void main().catch((e) => { + process.stderr.write(`FAIL: ${e?.stack ?? e}\n`); + process.exit(1); +}); diff --git a/src/lib/agent/__tests__/managed-settings-crossplatform.test.ts b/src/lib/agent/__tests__/managed-settings-crossplatform.test.ts new file mode 100644 index 00000000..5cdab144 --- /dev/null +++ b/src/lib/agent/__tests__/managed-settings-crossplatform.test.ts @@ -0,0 +1,57 @@ +/** + * Regression: a managed (org/MDM) Claude Code settings override that redirects + * the gateway must be detected on EVERY platform. Detection used to hardcode the + * macOS managed path, so a managed `env.ANTHROPIC_BASE_URL` on Linux/Windows + * (e.g. a corporate relay) went undetected — the wizard launched the agent and + * every model call was redirected off the PostHog gateway, even interactively. + */ + +import { mkdtempSync, writeFileSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { + checkAllSettingsConflicts, + MANAGED_SETTINGS_PATHS, +} from '../claude-settings'; + +const MACOS_ONLY = [ + '/Library/Application Support/ClaudeCode/managed-settings.json', +]; + +describe('managed settings detection — cross-platform gateway-override guard', () => { + it('default managed paths cover Linux and Windows, not just macOS', () => { + expect(MANAGED_SETTINGS_PATHS).toEqual( + expect.arrayContaining([ + '/etc/claude-code/managed-settings.json', + 'C:\\ProgramData\\ClaudeCode\\managed-settings.json', + ]), + ); + }); + + it('detects a managed ANTHROPIC_BASE_URL at a non-macOS path (was a silent leak)', () => { + const dir = mkdtempSync(join(tmpdir(), 'managed-')); + const managed = join(dir, 'managed-settings.json'); + writeFileSync( + managed, + JSON.stringify({ + env: { ANTHROPIC_BASE_URL: 'https://api.code-relay.com' }, + }), + ); + try { + // BEFORE: checking only the macOS path misses the file → no conflict → + // the wizard would proceed and the agent would use code-relay. + const before = checkAllSettingsConflicts(dir, '/no/home', MACOS_ONLY); + expect(before.find((c) => c.source === 'managed')).toBeUndefined(); + + // AFTER: the platform's managed path is checked → detected, non-writable + // → the wizard refuses (ManagedSettingsScreen / CI abort). + const after = checkAllSettingsConflicts(dir, '/no/home', [managed]); + const conflict = after.find((c) => c.source === 'managed'); + expect(conflict).toBeDefined(); + expect(conflict?.keys).toContain('ANTHROPIC_BASE_URL'); + expect(conflict?.writable).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/lib/agent/claude-settings.ts b/src/lib/agent/claude-settings.ts index 4b998b5b..bd405b12 100644 --- a/src/lib/agent/claude-settings.ts +++ b/src/lib/agent/claude-settings.ts @@ -98,11 +98,18 @@ export function checkClaudeSettingsOverrides( } /** - * Managed settings path on macOS. - * IT/MDM-deployed settings — readable by all users, writable only by root. + * IT/MDM-deployed managed settings — readable by all users, writable only by + * root, and applied by Claude Code regardless of `settingSources`. The path is + * platform-specific; checking only the macOS one (the old behavior) meant a + * managed `ANTHROPIC_BASE_URL` on Linux/Windows went undetected and redirected + * every agent call off the PostHog gateway — even in an interactive run. A + * non-current-platform path simply won't exist, so checking all three is safe. */ -const MANAGED_SETTINGS_PATH = - '/Library/Application Support/ClaudeCode/managed-settings.json'; +export const MANAGED_SETTINGS_PATHS = [ + '/Library/Application Support/ClaudeCode/managed-settings.json', // macOS + '/etc/claude-code/managed-settings.json', // Linux + 'C:\\ProgramData\\ClaudeCode\\managed-settings.json', // Windows +]; /** * Check every settings file Claude Code reads for blocking keys that conflict @@ -114,6 +121,7 @@ const MANAGED_SETTINGS_PATH = export function checkAllSettingsConflicts( workingDirectory: string, homeDir: string = os.homedir(), + managedPaths: string[] = MANAGED_SETTINGS_PATHS, ): SettingsConflict[] { const conflicts: SettingsConflict[] = []; const home = homeDir; @@ -125,7 +133,7 @@ export function checkAllSettingsConflicts( }[] = [ { source: 'managed', - paths: [MANAGED_SETTINGS_PATH], + paths: managedPaths, writable: false, }, { diff --git a/src/ui/__tests__/logging-ui-settings-guard.test.ts b/src/ui/__tests__/logging-ui-settings-guard.test.ts new file mode 100644 index 00000000..03a767ba --- /dev/null +++ b/src/ui/__tests__/logging-ui-settings-guard.test.ts @@ -0,0 +1,66 @@ +/** + * Regression: a `--ci` run must NOT silently proceed past a Claude Code settings + * override that redirects the LLM gateway. `LoggingUI.showSettingsOverride` used + * to `return Promise.resolve()` — so a settings file carrying + * `env.ANTHROPIC_BASE_URL = https://api.code-relay.com` (or any relay) was + * detected, ignored, and the agent launched against that host. This locks in the + * fix: remove the writable (project) override; refuse on anything we can't remove. + */ + +// Import via the @ui entry (not @ui/logging-ui directly) to avoid a pre-existing +// logging-ui → readiness → @ui/index import cycle. getUI()'s default IS a +// LoggingUI, and showSettingsOverride holds no instance state. +import { getUI } from '@ui'; +import type { SettingsConflict } from '@lib/agent/claude-settings'; + +const ui = () => getUI(); +const RELAY = 'ANTHROPIC_BASE_URL'; + +describe('LoggingUI — CI gateway-override guard', () => { + it('refuses (rejects) on a non-removable ANTHROPIC_BASE_URL override — was a silent leak', async () => { + const managed: SettingsConflict = { + source: 'managed', + path: '/Library/Application Support/ClaudeCode/managed-settings.json', + keys: [RELAY], + writable: false, + }; + await expect( + ui().showSettingsOverride([managed], () => false), + ).rejects.toThrow(/redirect agent traffic off the PostHog LLM Gateway/); + }); + + it('refuses on a user-global (~/.claude) override too', async () => { + const user: SettingsConflict = { + source: 'user', + path: '/home/dev/.claude/settings.json', + keys: [RELAY], + writable: false, + }; + await expect( + ui().showSettingsOverride([user], () => false), + ).rejects.toThrow(/Refusing to launch/); + }); + + it('removes the writable project override and proceeds (no leak, no abort)', async () => { + let removed = false; + const project: SettingsConflict = { + source: 'project', + path: '/app/.claude/settings.json', + keys: [RELAY], + writable: true, + }; + await expect( + ui().showSettingsOverride([project], () => { + removed = true; + return true; + }), + ).resolves.toBeUndefined(); + expect(removed).toBe(true); + }); + + it('no conflicts → resolves', async () => { + await expect( + ui().showSettingsOverride([], () => false), + ).resolves.toBeUndefined(); + }); +}); diff --git a/src/ui/logging-ui.ts b/src/ui/logging-ui.ts index e5d6874f..9646480f 100644 --- a/src/ui/logging-ui.ts +++ b/src/ui/logging-ui.ts @@ -162,9 +162,45 @@ export class LoggingUI implements WizardUI { } showSettingsOverride( - _conflicts: SettingsConflict[], - _backupAndFix: () => boolean, + conflicts: SettingsConflict[], + backupAndFix: () => boolean, ): Promise { + // Non-interactive mode: there's no user to act, so enforce the same + // guarantee the TUI screens do instead of silently proceeding. A returned + // Promise.resolve() here was a security hole — a `--ci` run would launch the + // agent with a Claude Code settings override still in place, redirecting + // every model call to whatever ANTHROPIC_BASE_URL the override set (e.g. a + // third-party relay). Remove the writable (project) override; refuse on any + // we can't remove (managed / global / project-local). + backupAndFix(); + const blocking = conflicts.filter((c) => !c.writable); + if (blocking.length > 0) { + console.log( + '✖ Claude Code settings override credentials and prevent the wizard from reaching the PostHog LLM Gateway:', + ); + for (const c of blocking) { + console.log(`│ ${c.path}: ${c.keys.join(', ')}`); + } + console.log( + '│ The wizard cannot remove these automatically (read-only / outside the project).', + ); + console.log( + '│ Remove these keys (or run `claude auth logout`) and re-run.', + ); + // Reject so the runner aborts before launching the agent — never proceed + // past an unresolved gateway override. + return Promise.reject( + new Error( + `Refusing to launch: ${blocking + .map( + (c) => `${c.keys.join('/')} in ${c.source} settings (${c.path})`, + ) + .join( + '; ', + )} would redirect agent traffic off the PostHog LLM Gateway.`, + ), + ); + } return Promise.resolve(); }