Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions scripts/precedence.no-jest.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void>((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();
88 changes: 88 additions & 0 deletions scripts/relay-prod.no-jest.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
});
57 changes: 57 additions & 0 deletions src/lib/agent/__tests__/managed-settings-crossplatform.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});
18 changes: 13 additions & 5 deletions src/lib/agent/claude-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -125,7 +133,7 @@ export function checkAllSettingsConflicts(
}[] = [
{
source: 'managed',
paths: [MANAGED_SETTINGS_PATH],
paths: managedPaths,
writable: false,
},
{
Expand Down
66 changes: 66 additions & 0 deletions src/ui/__tests__/logging-ui-settings-guard.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading