diff --git a/src/web-server/services/codex-dashboard-service.ts b/src/web-server/services/codex-dashboard-service.ts index 8bba991f..fcfb71dc 100644 --- a/src/web-server/services/codex-dashboard-service.ts +++ b/src/web-server/services/codex-dashboard-service.ts @@ -90,6 +90,30 @@ function asNumber(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null; } +function redactCodexProviderBaseUrl(baseUrl: string | null): string | null { + if (!baseUrl) return null; + + try { + const parsed = new URL(baseUrl); + const scheme = parsed.protocol.replace(/:$/, '') || 'url'; + return `[redacted:${scheme}]`; + } catch { + return '[redacted:url]'; + } +} + +function redactCodexProviderEnvKey(name: string, envKey: string | null): string | null { + if (!envKey) return null; + return isBuiltInCodexModelProvider(name) ? envKey : '[set]'; +} + +function redactCodexProjectPath(projectPath: string): string { + const normalized = projectPath.trim().replace(/[\\/]+$/, ''); + if (!normalized) return '[unknown]'; + const basename = normalized.split(/[\\/]/).filter(Boolean).pop(); + return basename || '[root]'; +} + function hasOwn(obj: object, key: string): boolean { return Object.prototype.hasOwnProperty.call(obj, key); } @@ -567,8 +591,8 @@ export function summarizeCodexModelProviders(value: unknown): CodexModelProvider return { name, - baseUrl: asString(provider.base_url), - envKey: asString(provider.env_key), + baseUrl: redactCodexProviderBaseUrl(asString(provider.base_url)), + envKey: redactCodexProviderEnvKey(name, asString(provider.env_key)), wireApi: asString(provider.wire_api), requiresOpenaiAuth: provider.requires_openai_auth === true, supportsWebsockets: provider.supports_websockets === true, @@ -622,7 +646,10 @@ export function summarizeCodexProjectTrust(value: unknown): CodexProjectTrustDia const project = asObject(projectValue); const trustLevel = project ? asString(project.trust_level) : null; if (!trustLevel) return null; - return { path: projectPath, trustLevel } satisfies CodexProjectTrustDiagnostics; + return { + path: redactCodexProjectPath(projectPath), + trustLevel, + } satisfies CodexProjectTrustDiagnostics; }) .filter((project): project is CodexProjectTrustDiagnostics => project !== null) .sort((left, right) => left.path.localeCompare(right.path)); diff --git a/tests/unit/web-server/api-routes-remote-write-guard.test.ts b/tests/unit/web-server/api-routes-remote-write-guard.test.ts index e1cbe9d9..7a707b9b 100644 --- a/tests/unit/web-server/api-routes-remote-write-guard.test.ts +++ b/tests/unit/web-server/api-routes-remote-write-guard.test.ts @@ -20,6 +20,7 @@ describe('api-routes remote write guard', () => { let tempHome = ''; let originalDashboardAuthEnabled: string | undefined; let originalCcsHome: string | undefined; + let originalCodexHome: string | undefined; beforeAll(async () => { const app = express(); @@ -53,8 +54,10 @@ describe('api-routes remote write guard', () => { beforeEach(() => { originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED; originalCcsHome = process.env.CCS_HOME; + originalCodexHome = process.env.CODEX_HOME; tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-api-routes-remote-write-guard-')); process.env.CCS_HOME = tempHome; + process.env.CODEX_HOME = path.join(tempHome, '.codex'); process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false'; forcedRemoteAddress = '10.10.0.24'; }); @@ -72,6 +75,12 @@ describe('api-routes remote write guard', () => { delete process.env.CCS_HOME; } + if (originalCodexHome !== undefined) { + process.env.CODEX_HOME = originalCodexHome; + } else { + delete process.env.CODEX_HOME; + } + if (tempHome && fs.existsSync(tempHome)) { fs.rmSync(tempHome, { recursive: true, force: true }); tempHome = ''; @@ -99,6 +108,44 @@ describe('api-routes remote write guard', () => { expect(response.status).toBe(200); }); + it('redacts sensitive Codex diagnostics data while preserving remote access', async () => { + const codexHome = process.env.CODEX_HOME; + if (!codexHome) throw new Error('CODEX_HOME was not initialized'); + fs.mkdirSync(codexHome, { recursive: true }); + fs.writeFileSync( + path.join(codexHome, 'config.toml'), + `model_provider = "private" + +[model_providers.private] +base_url = "https://llm.internal.example.test/v1/responses?tenant=alpha" +env_key = "PRIVATE_PROVIDER_TOKEN" +wire_api = "responses" + +[projects."/Users/someone/CloudPersonal/private-workspace"] +trust_level = "trusted" +` + ); + + const response = await fetch(`${baseUrl}/api/codex/diagnostics`); + const body = await response.json(); + const serialized = JSON.stringify(body); + + expect(response.status).toBe(200); + expect(body.config.projectTrust).toEqual([ + { path: 'private-workspace', trustLevel: 'trusted' }, + ]); + expect(body.config.modelProviders).toEqual([ + expect.objectContaining({ + name: 'private', + baseUrl: '[redacted:https]', + envKey: '[set]', + }), + ]); + expect(serialized).not.toContain('/Users/someone'); + expect(serialized).not.toContain('llm.internal.example.test'); + expect(serialized).not.toContain('PRIVATE_PROVIDER_TOKEN'); + }); + it('blocks remote profile creation when dashboard auth is disabled', async () => { const response = await fetch(`${baseUrl}/api/profiles`, { method: 'POST', diff --git a/tests/unit/web-server/codex-dashboard-service.test.ts b/tests/unit/web-server/codex-dashboard-service.test.ts index 9ebeb999..586d4d6a 100644 --- a/tests/unit/web-server/codex-dashboard-service.test.ts +++ b/tests/unit/web-server/codex-dashboard-service.test.ts @@ -105,11 +105,33 @@ describe('codex-dashboard-service', () => { expect(summary.length).toBe(2); expect(summary[0].name).toBe('cliproxy'); - expect(summary[0].envKey).toBe('CLIPROXY_API_KEY'); + expect(summary[0].baseUrl).toBe('[redacted:http]'); + expect(summary[0].envKey).toBe('[set]'); expect(summary[0].hasHttpHeaders).toBe(true); expect(summary[1].usesExperimentalBearerToken).toBe(true); }); + it('redacts custom provider URLs and env var names from diagnostics summaries', () => { + const summary = summarizeCodexModelProviders({ + private: { + base_url: 'https://llm.internal.example.test/v1/responses?tenant=alpha', + env_key: 'PRIVATE_PROVIDER_TOKEN', + wire_api: 'responses', + }, + }); + + expect(summary).toEqual([ + expect.objectContaining({ + name: 'private', + baseUrl: '[redacted:https]', + envKey: '[set]', + wireApi: 'responses', + }), + ]); + expect(JSON.stringify(summary)).not.toContain('llm.internal.example.test'); + expect(JSON.stringify(summary)).not.toContain('PRIVATE_PROVIDER_TOKEN'); + }); + it('summarizes feature flags, project trust, and mcp servers', () => { const features = summarizeCodexFeatureFlags({ multi_agent: true, @@ -136,11 +158,26 @@ describe('codex-dashboard-service', () => { expect(features.disabled.map((feature) => feature.name)).toEqual(['shell_snapshot']); expect(features.all.find((feature) => feature.name === 'custom_mode')?.state).toBe('custom'); expect(projects.length).toBe(2); + expect(projects[0].path).toBe('a'); expect(projects[0].trustLevel).toBe('trusted'); expect(servers[0].transport).toBe('streamable-http'); expect(servers[0].usesInlineBearerToken).toBe(true); }); + it('redacts project trust paths to basenames in diagnostics summaries', () => { + const summary = summarizeCodexProjectTrust({ + '/Users/someone/CloudPersonal/private-workspace': { trust_level: 'trusted' }, + '/var/tmp/other-workspace/': { trust_level: 'untrusted' }, + }); + + expect(summary).toEqual([ + { path: 'other-workspace', trustLevel: 'untrusted' }, + { path: 'private-workspace', trustLevel: 'trusted' }, + ]); + expect(JSON.stringify(summary)).not.toContain('/Users/someone'); + expect(JSON.stringify(summary)).not.toContain('/var/tmp'); + }); + it('returns raw config payload for missing config.toml', async () => { const raw = await getCodexRawConfig(); @@ -404,7 +441,7 @@ bearer_token = "secret" expect(diagnostics.config.modelAutoCompactTokenLimit).toBe(700000); expect(diagnostics.config.toolOutputTokenLimit).toBe(12000); expect(diagnostics.config.personality).toBe('friendly'); - expect(diagnostics.config.projectTrust[0]?.path).toBe('/tmp/workspace-a'); + expect(diagnostics.config.projectTrust[0]?.path).toBe('workspace-a'); expect(result.rawText).toContain('model = "gpt-5.4"'); expect(result.rawText).toContain('model_context_window = 800000'); expect(result.rawText).toContain('model_auto_compact_token_limit = 700000');