diff --git a/src/commands/persist-command.ts b/src/commands/persist-command.ts index 911bde14..31dd11b0 100644 --- a/src/commands/persist-command.ts +++ b/src/commands/persist-command.ts @@ -18,6 +18,11 @@ import ProfileDetector from '../auth/profile-detector'; import { getClaudeConfigDir, getClaudeSettingsPath } from '../utils/claude-config-path'; import { extractOption, hasAnyFlag } from './arg-extractor'; import { resolveClaudeExtensionSetup } from '../shared/claude-extension-setup'; +import { + CODEX_TRANSLATOR_URL_MARKER, + findCodexTranslatorUrlPaths, + formatSettingsPathList, +} from '../shared/stale-codex-translator-settings'; interface PersistCommandArgs { profile?: string; @@ -64,7 +69,6 @@ const PERSIST_LOCK_STALE_MS = 10000; const PERSIST_LOCK_RETRIES = 5; const PERSIST_LOCK_RETRY_MIN_MS = 100; const PERSIST_LOCK_RETRY_MAX_MS = 500; -const CODEX_TRANSLATOR_URL_MARKER = '/api/provider/codex'; const NATIVE_CODEX_TARGETS = ['ccsxp', 'ccs codex --target codex']; type PermissionMode = (typeof VALID_PERMISSION_MODES)[number]; @@ -521,38 +525,6 @@ function isSensitiveEnvKey(key: string): boolean { ); } -function formatSettingsPathSegment(basePath: string, segment: string | number): string { - if (typeof segment === 'number') { - return `${basePath}[${segment}]`; - } - - if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(segment)) { - return basePath ? `${basePath}.${segment}` : segment; - } - - return `${basePath}[${JSON.stringify(segment)}]`; -} - -function findCodexTranslatorUrlPaths(value: unknown, path = ''): string[] { - if (typeof value === 'string') { - return value.includes(CODEX_TRANSLATOR_URL_MARKER) ? [path || '(root)'] : []; - } - - if (Array.isArray(value)) { - return value.flatMap((item, index) => - findCodexTranslatorUrlPaths(item, formatSettingsPathSegment(path, index)) - ); - } - - if (typeof value === 'object' && value !== null) { - return Object.entries(value).flatMap(([key, item]) => - findCodexTranslatorUrlPaths(item, formatSettingsPathSegment(path, key)) - ); - } - - return []; -} - function buildPersistReceipt( existingEnv: Record, existingSettings: Record, @@ -611,14 +583,6 @@ function formatKeyList(keys: string[]): string { return keys.length > 0 ? keys.join(', ') : 'none'; } -function formatSettingsPathList(paths: string[]): string { - const visiblePaths = paths.slice(0, 5); - const remainingCount = paths.length - visiblePaths.length; - return remainingCount > 0 - ? `${visiblePaths.join(', ')} (+${remainingCount} more)` - : visiblePaths.join(', '); -} - function printPersistReceipt(receipt: PersistReceipt): void { console.log(subheader('Config Receipt')); console.log(` Settings: ${getClaudeSettingsDisplayPath()}`); diff --git a/src/management/checks/config-check.ts b/src/management/checks/config-check.ts index ebf5293d..f3ac17e0 100644 --- a/src/management/checks/config-check.ts +++ b/src/management/checks/config-check.ts @@ -9,6 +9,11 @@ import { HealthCheck, IHealthChecker, createSpinner } from './types'; import { getClaudeConfigDir } from '../../utils/claude-config-path'; import { getCcsDir } from '../../config/config-loader-facade'; +import { + CODEX_TRANSLATOR_URL_MARKER, + findCodexTranslatorUrlPaths, + formatSettingsPathList, +} from '../../shared/stale-codex-translator-settings'; const ora = createSpinner(); @@ -219,20 +224,18 @@ export class ClaudeSettingsChecker implements IHealthChecker { // Validate JSON try { const content = fs.readFileSync(settingsPath, 'utf8'); - const settings = JSON.parse(content) as { - env?: Record; - }; - const baseUrl = - settings.env && typeof settings.env.ANTHROPIC_BASE_URL === 'string' - ? settings.env.ANTHROPIC_BASE_URL - : ''; - if (baseUrl.includes('/api/provider/codex')) { + const settings = JSON.parse(content) as unknown; + const codexTranslatorUrlPaths = findCodexTranslatorUrlPaths(settings); + if (codexTranslatorUrlPaths.length > 0) { + const formattedPaths = formatSettingsPathList(codexTranslatorUrlPaths); spinner.warn(); - console.log(` ${warn(settingsName.padEnd(22))} Codex CLIProxy bridge persisted`); + console.log( + ` ${warn(settingsName.padEnd(22))} Codex CLIProxy bridge persisted at ${formattedPaths}` + ); results.addCheck( 'Claude Settings', 'warning', - 'Claude settings route Claude Code through the Codex CLIProxy translator', + `Claude settings route Claude Code through the Codex CLIProxy translator at ${formattedPaths} (${CODEX_TRANSLATOR_URL_MARKER})`, 'Run: ccs persist default --yes; use ccsxp or ccs codex --target codex for Codex' ); return; diff --git a/src/shared/stale-codex-translator-settings.ts b/src/shared/stale-codex-translator-settings.ts new file mode 100644 index 00000000..81156690 --- /dev/null +++ b/src/shared/stale-codex-translator-settings.ts @@ -0,0 +1,41 @@ +export const CODEX_TRANSLATOR_URL_MARKER = '/api/provider/codex'; + +function formatSettingsPathSegment(basePath: string, segment: string | number): string { + if (typeof segment === 'number') { + return `${basePath}[${segment}]`; + } + + if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(segment)) { + return basePath ? `${basePath}.${segment}` : segment; + } + + return `${basePath}[${JSON.stringify(segment)}]`; +} + +export function findCodexTranslatorUrlPaths(value: unknown, path = ''): string[] { + if (typeof value === 'string') { + return value.includes(CODEX_TRANSLATOR_URL_MARKER) ? [path || '(root)'] : []; + } + + if (Array.isArray(value)) { + return value.flatMap((item, index) => + findCodexTranslatorUrlPaths(item, formatSettingsPathSegment(path, index)) + ); + } + + if (typeof value === 'object' && value !== null) { + return Object.entries(value).flatMap(([key, item]) => + findCodexTranslatorUrlPaths(item, formatSettingsPathSegment(path, key)) + ); + } + + return []; +} + +export function formatSettingsPathList(paths: string[]): string { + const visiblePaths = paths.slice(0, 5); + const remainingCount = paths.length - visiblePaths.length; + return remainingCount > 0 + ? `${visiblePaths.join(', ')} (+${remainingCount} more)` + : visiblePaths.join(', '); +} diff --git a/tests/unit/management/checks/config-check.test.ts b/tests/unit/management/checks/config-check.test.ts index f5e7a9f5..4a11a0ec 100644 --- a/tests/unit/management/checks/config-check.test.ts +++ b/tests/unit/management/checks/config-check.test.ts @@ -57,4 +57,61 @@ describe('ClaudeSettingsChecker', () => { fs.rmSync(tempRoot, { recursive: true, force: true }); } }); + + it('warns with safe path-level evidence for nested Codex translator settings', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-config-check-')); + originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + delete process.env.CLAUDE_CONFIG_DIR; + + try { + await runWithScopedCcsHome(tempRoot, async () => { + const staleUrl = 'https://proxy.example.com/api/provider/codex/v1/messages'; + const settingsPath = path.join(tempRoot, '.claude', 'settings.json'); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + KEEP_ME: 'safe', + }, + hooks: { + PostToolUse: [ + { + config: { + endpoint: staleUrl, + }, + }, + ], + }, + }, + null, + 2 + ) + '\n' + ); + + const originalConsoleLog = console.log; + const capturedLogs: string[] = []; + console.log = (...args: unknown[]) => { + capturedLogs.push(args.map((arg) => String(arg)).join(' ')); + }; + try { + const results = new HealthCheck(); + new ClaudeSettingsChecker().run(results); + + expect(results.warnings).toHaveLength(1); + expect(results.warnings[0].message).toContain( + 'hooks.PostToolUse[0].config.endpoint' + ); + expect(results.warnings[0].message).not.toContain(staleUrl); + expect(capturedLogs.join('\n')).toContain('hooks.PostToolUse[0].config.endpoint'); + expect(capturedLogs.join('\n')).not.toContain(staleUrl); + } finally { + console.log = originalConsoleLog; + } + }); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); }); diff --git a/tests/unit/shared/stale-codex-translator-settings.test.ts b/tests/unit/shared/stale-codex-translator-settings.test.ts new file mode 100644 index 00000000..be0cfd89 --- /dev/null +++ b/tests/unit/shared/stale-codex-translator-settings.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'bun:test'; + +import { findCodexTranslatorUrlPaths } from '../../../src/shared/stale-codex-translator-settings'; + +describe('stale Codex translator settings scanner', () => { + it('reports nested object and array paths without returning sensitive values', () => { + const settings = { + env: { + SAFE_VALUE: 'keep-me', + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', + }, + hooks: { + PostToolUse: [ + { + matcher: 'Read', + config: { + headers: { + Authorization: 'Bearer secret', + }, + endpoint: 'https://proxy.example.com/api/provider/codex/v1/messages', + }, + }, + ], + }, + 'custom-key': ['https://proxy.example.com/api/provider/codex'], + }; + + expect(findCodexTranslatorUrlPaths(settings)).toEqual([ + 'env.ANTHROPIC_BASE_URL', + 'hooks.PostToolUse[0].config.endpoint', + '["custom-key"][0]', + ]); + }); +});