diff --git a/.github/workflows/promote-release.yml b/.github/workflows/promote-release.yml index 7e0769c8..98aa9c60 100644 --- a/.github/workflows/promote-release.yml +++ b/.github/workflows/promote-release.yml @@ -40,11 +40,13 @@ jobs: steps: - name: Validate stable semver tag format + env: + TAG: ${{ inputs.tag }} run: | - if [[ "${{ inputs.tag }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "[OK] Tag format valid: ${{ inputs.tag }}" + if [[ "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "[OK] Tag format valid: ${TAG}" else - echo "[X] Expected vX.Y.Z format, got: ${{ inputs.tag }}" + echo "[X] Expected vX.Y.Z format, got: ${TAG}" echo " For rc tags use docker-release.yml directly with promote_to_latest=true." exit 1 fi @@ -52,26 +54,29 @@ jobs: - name: Verify release exists and is stable (not a prerelease) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ inputs.tag }} run: | - IS_PRERELEASE=$(gh release view "${{ inputs.tag }}" \ + IS_PRERELEASE=$(gh release view "${TAG}" \ --repo "${{ github.repository }}" \ --json isPrerelease --jq '.isPrerelease') if [[ "${IS_PRERELEASE}" == "true" ]]; then - echo "[X] Release ${{ inputs.tag }} is still a prerelease (isPrerelease=true)" + echo "[X] Release ${TAG} is still a prerelease (isPrerelease=true)" echo " Semantic-release publishes stable releases to main automatically." echo " Check that the tag was created from a main merge, not a dev prerelease." exit 1 fi if [[ "${IS_PRERELEASE}" != "false" ]]; then - echo "[X] Could not read release state for ${{ inputs.tag }} (got: ${IS_PRERELEASE})" - echo " Verify the tag exists: gh release view ${{ inputs.tag }} --repo ${{ github.repository }}" + echo "[X] Could not read release state for ${TAG} (got: ${IS_PRERELEASE})" + echo " Verify the tag exists: gh release view ${TAG} --repo ${{ github.repository }}" exit 1 fi - echo "[OK] Release ${{ inputs.tag }} is stable — proceeding with Docker mutable tag promotion" + echo "[OK] Release ${TAG} is stable — proceeding with Docker mutable tag promotion" - name: Verify immutable Docker image exists + env: + TAG: ${{ inputs.tag }} run: | - TAG_SANS_V="${{ inputs.tag }}" + TAG_SANS_V="${TAG}" TAG_SANS_V="${TAG_SANS_V#v}" OWNER_LOWER=$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]') IMAGE_REF="ghcr.io/${OWNER_LOWER}/ccs:${TAG_SANS_V}" @@ -86,13 +91,14 @@ jobs: - name: Dispatch Docker mutable tag promotion env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ inputs.tag }} run: | RUN_URL=$(gh workflow run "Publish Docker Image" \ --repo "${{ github.repository }}" \ - --field "tag=${{ inputs.tag }}" \ + --field "tag=${TAG}" \ --field "promote_to_latest=true" \ 2>&1) - echo "[OK] Dispatched docker-release.yml with tag=${{ inputs.tag }} promote_to_latest=true" + echo "[OK] Dispatched docker-release.yml with tag=${TAG} promote_to_latest=true" echo "[i] Monitor the run at: https://github.com/${{ github.repository }}/actions/workflows/docker-release.yml" echo "[i] After the run completes, verify with:" echo " docker buildx imagetools inspect ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/ccs:latest" diff --git a/package.json b/package.json index ae54e25d..5a100ae2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.0.0", + "version": "8.0.0-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", diff --git a/src/cliproxy/executor/claude-launcher.ts b/src/cliproxy/executor/claude-launcher.ts index 66663f05..ab74f0a4 100644 --- a/src/cliproxy/executor/claude-launcher.ts +++ b/src/cliproxy/executor/claude-launcher.ts @@ -163,9 +163,6 @@ export async function launchClaude(context: ClaudeLaunchContext): Promise boolean; -} - -export interface ShouldKeepSessionProxiesAliveOptions { - code: number | null; - signal: NodeJS.Signals | null; - backgroundKeepAliveBaseUrl?: string; - hasBackgroundWorkerUsingBaseUrl: (baseUrl: string) => boolean; -} - -const CLAUDE_BG_WORKER_ARGS = ['--bg-spare', '--bg-pty-host'] as const; - -export function hasClaudeBackgroundWorkerUsingBaseUrlInProcessList( - processList: string, - baseUrl: string -): boolean { - const envNeedle = `ANTHROPIC_BASE_URL=${baseUrl}`; - return processList - .split(/\r?\n/) - .some( - (line) => line.includes(envNeedle) && CLAUDE_BG_WORKER_ARGS.some((arg) => line.includes(arg)) - ); -} - -export function hasClaudeBackgroundWorkerUsingBaseUrl(baseUrl: string): boolean { - if (process.platform === 'win32') { - return false; - } - - for (const args of [['auxEww'], ['auxeww'], ['eww', '-axo', 'pid=,command=']]) { - try { - const processList = execFileSync('ps', args, { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - }); - if (hasClaudeBackgroundWorkerUsingBaseUrlInProcessList(processList, baseUrl)) { - return true; - } - } catch { - continue; - } - } - - return false; -} - -export function shouldKeepSessionProxiesAlive( - options: ShouldKeepSessionProxiesAliveOptions -): boolean { - if (options.code !== 0 || options.signal) { - return false; - } - - const baseUrl = options.backgroundKeepAliveBaseUrl; - if (!baseUrl) { - return false; - } - - return options.hasBackgroundWorkerUsingBaseUrl(baseUrl); -} - /** * Check for existing proxy and handle version mismatch, or determine if new spawn needed */ @@ -244,8 +180,7 @@ export function setupCleanupHandlers( codexReasoningProxy: unknown, toolSanitizationProxy: unknown, httpsTunnel: unknown, - verbose: boolean, - keepAliveOptions: SessionProxyKeepAliveOptions = {} + verbose: boolean ): void { const log = (msg: string) => { if (verbose) { @@ -275,19 +210,6 @@ export function setupCleanupHandlers( } }; - const backgroundProbe = - keepAliveOptions.hasBackgroundWorkerUsingBaseUrl ?? hasClaudeBackgroundWorkerUsingBaseUrl; - - const startKeepAliveWatcher = (baseUrl: string) => { - const timer = setInterval(() => { - if (backgroundProbe(baseUrl)) return; - log('No Claude bg worker is using the session proxy; cleaning up'); - stopSessionResources(); - process.exit(0); - }, keepAliveOptions.keepAlivePollIntervalMs ?? 30000); - timer.unref(); - }; - const cleanup = () => { log('Parent signal received, cleaning up'); stopSessionResources(); @@ -296,23 +218,6 @@ export function setupCleanupHandlers( claude.on('exit', (code, signal) => { log(`Claude exited: code=${code}, signal=${signal}`); - const backgroundKeepAliveBaseUrl = keepAliveOptions.backgroundKeepAliveBaseUrl; - - if ( - shouldKeepSessionProxiesAlive({ - code, - signal, - backgroundKeepAliveBaseUrl, - hasBackgroundWorkerUsingBaseUrl: backgroundProbe, - }) && - backgroundKeepAliveBaseUrl - ) { - stopQuotaMonitor(); - log(`Keeping session proxy alive for Claude bg worker: ${backgroundKeepAliveBaseUrl}`); - startKeepAliveWatcher(backgroundKeepAliveBaseUrl); - return; - } - stopSessionResources(); if (signal) { diff --git a/src/cliproxy/quota/__tests__/quota-fetcher-codex.test.ts b/src/cliproxy/quota/__tests__/quota-fetcher-codex.test.ts index 284f9b38..7a913713 100644 --- a/src/cliproxy/quota/__tests__/quota-fetcher-codex.test.ts +++ b/src/cliproxy/quota/__tests__/quota-fetcher-codex.test.ts @@ -342,6 +342,26 @@ describe('Codex Quota Fetcher', () => { expect(windows.find((w) => w.category === 'additional')).toBeUndefined(); }); + it('should coerce non-string additional limit_name values to fallback label', () => { + const response = { + additional_rate_limits: [ + { + limit_name: { unexpected: true }, + rate_limit: { + primary_window: { used_percent: 10, reset_after_seconds: 3600 }, + }, + }, + ], + }; + + const windows = buildCodexQuotaWindows(response as never); + + expect(windows).toHaveLength(1); + expect(windows[0].category).toBe('additional'); + expect(windows[0].featureLabel).toBe('Additional'); + expect(windows[0].label).toBe('Additional (Primary)'); + }); + it('should accept camelCase additionalRateLimits and rateLimit fields', () => { const response = { additionalRateLimits: [ diff --git a/src/cliproxy/quota/quota-fetcher-codex.ts b/src/cliproxy/quota/quota-fetcher-codex.ts index 82cf99ed..78ab225b 100644 --- a/src/cliproxy/quota/quota-fetcher-codex.ts +++ b/src/cliproxy/quota/quota-fetcher-codex.ts @@ -380,7 +380,11 @@ function buildCodexQuotaWindows(payload: CodexUsageResponse): CodexQuotaWindow[] const entryRateLimit = entry.rate_limit || entry.rateLimit; if (!entryRateLimit) continue; - const featureLabel = entry.limit_name || entry.limitName || 'Additional'; + const rawFeatureLabel = entry.limit_name ?? entry.limitName; + const featureLabel = + typeof rawFeatureLabel === 'string' && rawFeatureLabel.trim().length > 0 + ? rawFeatureLabel.trim() + : 'Additional'; addWindow( `${featureLabel} (Primary)`, entryRateLimit.primary_window || entryRateLimit.primaryWindow, diff --git a/src/codex-auth/shell-detect.ts b/src/codex-auth/shell-detect.ts index f1425f2d..d9046775 100644 --- a/src/codex-auth/shell-detect.ts +++ b/src/codex-auth/shell-detect.ts @@ -3,8 +3,6 @@ * Determines current shell to emit correct eval-safe export syntax. */ -import * as childProcess from 'child_process'; - export type Shell = 'bash' | 'zsh' | 'fish' | 'pwsh' | 'cmd'; /** @@ -20,7 +18,7 @@ export function detectShell( if (platform === 'win32') { return ( shellFromExecutable(env.SHELL) ?? - shellFromExecutable(parentProcessName ?? detectParentProcessName(platform)) ?? + shellFromExecutable(parentProcessName) ?? shellFromExecutable(env.ComSpec ?? env.COMSPEC) ?? 'cmd' ); @@ -31,26 +29,6 @@ export function detectShell( return 'bash'; // default for bash, sh, dash, ksh } -function detectParentProcessName(platform: string): string | undefined { - if (platform !== 'win32' || !Number.isInteger(process.ppid) || process.ppid <= 0) { - return undefined; - } - - const result = childProcess.spawnSync( - 'powershell.exe', - [ - '-NoProfile', - '-NonInteractive', - '-Command', - `(Get-Process -Id ${process.ppid} -ErrorAction Stop).ProcessName`, - ], - { encoding: 'utf8', timeout: 1500, windowsHide: true } - ); - - if (result.status !== 0 || !result.stdout) return undefined; - return result.stdout.trim().split(/\r?\n/).pop()?.trim(); -} - function shellFromExecutable(value: string | undefined): Shell | null { if (!value) return null; const base = value diff --git a/src/utils/claude-subcommand-detector.ts b/src/utils/claude-subcommand-detector.ts index 9f425e82..adcf6080 100644 --- a/src/utils/claude-subcommand-detector.ts +++ b/src/utils/claude-subcommand-detector.ts @@ -119,9 +119,11 @@ const SUBCOMMAND_ALLOWED_SESSION_FLAGS: Record> = { * * Heuristic: * 1. Walk args until the `--` terminator or end. - * 2. Skip known value-taking flags together with their next token. - * 3. Skip unknown `--flag=value` forms and bare `--flag` / `-x` tokens. - * 4. The first remaining positional token is the candidate. + * 2. If `--print` is present before the first positional token, treat as + * prompt/headless session mode (never a subcommand launch). + * 3. Skip known value-taking flags together with their next token. + * 4. Skip unknown `--flag=value` forms and bare `--flag` / `-x` tokens. + * 5. The first remaining positional token is the candidate. * 5. Match against CLAUDE_SUBCOMMANDS. * * Anything after the candidate is irrelevant — once a subcommand is in play, @@ -142,6 +144,8 @@ export function getClaudeSubcommandName(args: readonly string[]): string | null const arg = args[i]; if (arg === '--') return null; + if (arg === '--print' || arg === '-p') return null; + if (arg.startsWith('-')) { if (VALUE_TAKING_FLAGS.has(arg)) { // Skip the next token as the flag's value (when present and not another flag). diff --git a/src/web-server/models-dev/pricing-resolver.ts b/src/web-server/models-dev/pricing-resolver.ts index daad1c92..715e7600 100644 --- a/src/web-server/models-dev/pricing-resolver.ts +++ b/src/web-server/models-dev/pricing-resolver.ts @@ -36,6 +36,10 @@ function normalizeId(value: string): string { return value.trim().toLowerCase(); } +function isModelEntry(value: unknown): value is ModelsDevModel { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + export function normalizeModelsDevProviderId( provider: string | null | undefined ): string | undefined { @@ -91,6 +95,7 @@ function findModel(provider: ModelsDevProvider, model: string): ModelsDevModel | const normalizedEntries = new Map(); for (const [key, value] of Object.entries(models)) { + if (!isModelEntry(value)) continue; normalizedEntries.set(normalizeId(key), value); if (typeof value.id === 'string') normalizedEntries.set(normalizeId(value.id), value); } @@ -188,6 +193,7 @@ export function getKnownModelsDevModels(): string[] { const ids = new Set(); for (const provider of Object.values(registry)) { for (const model of Object.values(provider.models ?? {})) { + if (!model || typeof model !== 'object') continue; if (typeof model.id === 'string') ids.add(`${provider.id}/${model.id}`); } } diff --git a/src/web-server/models-dev/registry-cache.ts b/src/web-server/models-dev/registry-cache.ts index 650f41b4..4f5253e7 100644 --- a/src/web-server/models-dev/registry-cache.ts +++ b/src/web-server/models-dev/registry-cache.ts @@ -38,10 +38,20 @@ function normalizeRegistryPayload(payload: unknown): ModelsDevRegistry | null { for (const [key, value] of Object.entries(payload)) { if (!isPlainObject(value)) continue; const id = typeof value.id === 'string' && value.id.trim() ? value.id.trim() : key; - const models = isPlainObject(value.models) - ? (value.models as NonNullable) - : undefined; - if (!models || Object.keys(models).length === 0) continue; + const modelsPayload = isPlainObject(value.models) ? value.models : undefined; + if (!modelsPayload) continue; + + const models: NonNullable = {}; + for (const [modelKey, modelValue] of Object.entries(modelsPayload)) { + if (!isPlainObject(modelValue)) continue; + const modelId = + typeof modelValue.id === 'string' && modelValue.id.trim() ? modelValue.id.trim() : modelKey; + models[modelKey] = { + ...(modelValue as NonNullable[string]), + id: modelId, + }; + } + if (Object.keys(models).length === 0) continue; providers[id] = { ...(value as ModelsDevProvider), diff --git a/src/web-server/routes/proxy-routes.ts b/src/web-server/routes/proxy-routes.ts index a98867ed..8032149a 100644 --- a/src/web-server/routes/proxy-routes.ts +++ b/src/web-server/routes/proxy-routes.ts @@ -155,8 +155,9 @@ router.put('/backend', (req: Request, res: Response) => { // Pre-flight read: check running state before acquiring write lock const currentConfig = loadOrCreateUnifiedConfig(); const currentBackend = currentConfig.cliproxy?.backend ?? DEFAULT_BACKEND; - const localPort = - currentConfig.cliproxy_server?.local?.port ?? DEFAULT_CLIPROXY_SERVER_CONFIG.local.port; + const localPort = validatePort( + currentConfig.cliproxy_server?.local?.port ?? DEFAULT_CLIPROXY_SERVER_CONFIG.local.port + ); if (currentBackend !== backend && isProxyRunning() && !force) { res.status(409).json({ error: 'Proxy is running. Stop proxy first or use force=true to change backend.', diff --git a/src/web-server/usage/sqlite-cli.ts b/src/web-server/usage/sqlite-cli.ts index cde5afcb..ad4a151e 100644 --- a/src/web-server/usage/sqlite-cli.ts +++ b/src/web-server/usage/sqlite-cli.ts @@ -5,6 +5,44 @@ import { promisify } from 'util'; const execFileAsync = promisify(execFile); const SQLITE_JSON_MAX_BUFFER = 10 * 1024 * 1024; +// Trusted system paths per platform. These are fixed, non-user-writable +// locations managed by the OS or a system package manager. +// PATH-hijack threat model: we never resolve from $PATH; we only accept +// binaries whose realpath resolves under one of these prefixes. +const TRUSTED_SQLITE_PATHS_UNIX = [ + '/usr/bin/sqlite3', + '/usr/local/bin/sqlite3', + '/opt/homebrew/bin/sqlite3', +]; + +// Windows has no single canonical system install path for sqlite3 +// (winget, Chocolatey, and Scoop all use different locations). An empty +// list means Windows falls through to the CCS_SQLITE_BIN env-var path. +const TRUSTED_SQLITE_PATHS_WINDOWS: string[] = []; + +// Trusted path prefixes used to validate env-var overrides. A realpath that +// does not start with one of these prefixes is rejected to prevent users or +// CI from pointing CCS_SQLITE_BIN at a writable/untrusted location. +const TRUSTED_PREFIX_UNIX = [ + '/usr/bin/', + '/usr/local/bin/', + '/usr/sbin/', + '/usr/local/sbin/', + '/opt/homebrew/', + '/opt/local/', // MacPorts + '/nix/store/', // Nix / NixOS immutable store + '/run/current-system/', // NixOS system activation symlink target + '/snap/', // Snap packages +]; + +const TRUSTED_PREFIX_WINDOWS = [ + 'C:\\Program Files\\', + 'C:\\Program Files (x86)\\', + 'C:\\Windows\\System32\\', + 'C:\\Windows\\SysWOW64\\', + 'C:\\ProgramData\\chocolatey\\bin\\', // Chocolatey managed bin dir +]; + export type SqliteJsonRow = Record; function isCommandMissing(error: unknown): boolean { @@ -13,13 +51,116 @@ function isCommandMissing(error: unknown): boolean { return nodeError.code === 'ENOENT' || /not found/i.test(nodeError.message); } -export async function querySqliteJson(dbPath: string, sql: string): Promise { +function getPlatformTrustedPaths(): string[] { + return process.platform === 'win32' ? TRUSTED_SQLITE_PATHS_WINDOWS : TRUSTED_SQLITE_PATHS_UNIX; +} + +function getPlatformTrustedPrefixes(): string[] { + return process.platform === 'win32' ? TRUSTED_PREFIX_WINDOWS : TRUSTED_PREFIX_UNIX; +} + +/** + * Validate a CCS_SQLITE_BIN override path. + * + * Security invariant: the resolved (symlink-expanded) path must start with + * at least one trusted prefix. This prevents pointing at a binary in a + * user-writable location such as /tmp, $HOME/.local, or a relative PATH + * entry, which would reintroduce the PATH-hijack vector closed in #1347. + * + * Returns the validated path on success, or throws with an explanation. + */ +function validateEnvOverridePath(rawPath: string): string { + let resolved: string; + try { + resolved = fs.realpathSync(rawPath); + } catch { + throw new Error( + `CCS_SQLITE_BIN path "${rawPath}" could not be resolved: file not found or inaccessible` + ); + } + + // Verify executable bit (or file existence on Windows where X_OK is unreliable). + try { + if (process.platform === 'win32') { + fs.accessSync(resolved, fs.constants.F_OK); + } else { + fs.accessSync(resolved, fs.constants.X_OK); + } + } catch { + throw new Error(`CCS_SQLITE_BIN path "${resolved}" is not executable`); + } + + const normalizedResolved = process.platform === 'win32' ? resolved.toLowerCase() : resolved; + + const trusted = getPlatformTrustedPrefixes().some((prefix) => { + const normalizedPrefix = process.platform === 'win32' ? prefix.toLowerCase() : prefix; + return normalizedResolved.startsWith(normalizedPrefix); + }); + + if (!trusted) { + throw new Error( + `CCS_SQLITE_BIN path "${resolved}" does not resolve under a trusted system prefix. ` + + `Paths under user-writable locations (e.g. /tmp, $HOME/.local) are rejected ` + + `to prevent PATH-hijack attacks.` + ); + } + + return resolved; +} + +/** + * Resolve the sqlite3 binary to use. + * + * Resolution order: + * 1. CCS_SQLITE_BIN env var override (validated against trusted prefixes) + * 2. First accessible path from the platform's hardcoded trusted list + * 3. Throw "sqlite3 command not available" + */ +function resolveTrustedSqlitePath(env: NodeJS.ProcessEnv = process.env): string { + const envOverride = env['CCS_SQLITE_BIN']; + if (envOverride && envOverride.trim().length > 0) { + // May throw — caller surfaces the error. + return validateEnvOverridePath(envOverride.trim()); + } + + const trustedPath = getPlatformTrustedPaths().find((candidate) => { + try { + // Resolve symlinks so the check is on the real binary. + const real = fs.realpathSync(candidate); + fs.accessSync(real, fs.constants.X_OK); + return true; + } catch { + return false; + } + }); + + if (!trustedPath) { + throw new Error('sqlite3 command not available'); + } + + // Return the realpath to avoid double-hop symlink confusion at exec time. + return fs.realpathSync(trustedPath); +} + +export async function querySqliteJson( + dbPath: string, + sql: string, + env: NodeJS.ProcessEnv = process.env +): Promise { if (!fs.existsSync(dbPath)) { return []; } + let sqlitePath: string; try { - const { stdout } = await execFileAsync('sqlite3', ['-json', dbPath, sql], { + sqlitePath = resolveTrustedSqlitePath(env); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(message); + } + + try { + const { stdout } = await execFileAsync(sqlitePath, ['-json', dbPath, sql], { maxBuffer: SQLITE_JSON_MAX_BUFFER, }); const trimmed = stdout.trim(); @@ -38,3 +179,6 @@ export async function querySqliteJson(dbPath: string, sql: string): Promise { - it('keeps per-session proxies alive after a clean Claude exit when a bg worker inherited the base URL', () => { - expect( - shouldKeepSessionProxiesAlive({ - code: 0, - signal: null, - backgroundKeepAliveBaseUrl: 'http://127.0.0.1:64314/api/provider/codex', - hasBackgroundWorkerUsingBaseUrl: () => true, - }) - ).toBe(true); - }); - - it('cleans up per-session proxies when no bg worker inherited the base URL', () => { - expect( - shouldKeepSessionProxiesAlive({ - code: 0, - signal: null, - backgroundKeepAliveBaseUrl: 'http://127.0.0.1:64314/api/provider/codex', - hasBackgroundWorkerUsingBaseUrl: () => false, - }) - ).toBe(false); - }); - - it('cleans up per-session proxies for signaled Claude exits', () => { - expect( - shouldKeepSessionProxiesAlive({ - code: null, - signal: 'SIGTERM', - backgroundKeepAliveBaseUrl: 'http://127.0.0.1:64314/api/provider/codex', - hasBackgroundWorkerUsingBaseUrl: () => true, - }) - ).toBe(false); - }); - - it('detects Claude bg workers that inherited the exact base URL', () => { - const baseUrl = 'http://127.0.0.1:64314/api/provider/codex'; - const processList = ` -123 /opt/claude/claude.exe --bg-spare /tmp/claim.sock ANTHROPIC_BASE_URL=${baseUrl} ANTHROPIC_AUTH_TOKEN=ccs-internal-managed -456 /opt/claude/claude.exe --bg-spare /tmp/claim.sock ANTHROPIC_BASE_URL=http://127.0.0.1:62075/api/provider/codex -789 /opt/claude/claude.exe --print ANTHROPIC_BASE_URL=${baseUrl} -`; - - expect(hasClaudeBackgroundWorkerUsingBaseUrlInProcessList(processList, baseUrl)).toBe(true); - expect( - hasClaudeBackgroundWorkerUsingBaseUrlInProcessList( - processList, - 'http://127.0.0.1:65535/api/provider/codex' - ) - ).toBe(false); + it('bg keepalive wiring removed — no dead code to test', () => { + // No-op: feature scaffold removed per red-team finding on #1340. }); }); diff --git a/tests/unit/model-pricing.test.ts b/tests/unit/model-pricing.test.ts index 779dfac9..9e6eca84 100644 --- a/tests/unit/model-pricing.test.ts +++ b/tests/unit/model-pricing.test.ts @@ -392,6 +392,26 @@ describe('model-pricing', () => { expect(pricing.outputPerMillion).toBe(0); }); + it('ignores malformed models.dev entries during provider-aware lookups', () => { + setCachedModelsDevRegistry({ + openai: { + id: 'openai', + name: 'OpenAI', + models: { + bad: null as unknown as never, + 'gpt-4o': { + id: 'gpt-4o', + name: 'GPT-4o', + cost: { input: 2.5, output: 10 }, + }, + }, + }, + }); + + expect(() => getModelPricing('gpt-4o', { provider: 'openai' })).not.toThrow(); + expect(getModelPricing('gpt-4o', { provider: 'openai' }).inputPerMillion).toBe(2.5); + }); + it('prefers provider-aware models.dev pricing over exact static table matches', () => { const pricing = getModelPricing('gpt-4o', { provider: 'github-copilot' }); expect(pricing.inputPerMillion).toBe(0); @@ -447,5 +467,22 @@ describe('model-pricing', () => { expect(calculateCost(usage, 'gpt-5.5', { provider: 'openai' })).toBe(35.5); expect(calculateCost(usage, 'gpt-5.5', { provider: 'ghcp' })).toBe(0); }); + + it('gracefully ignores malformed cached model entries', () => { + setCachedModelsDevRegistry( + { + openai: { + id: 'openai', + models: { + 'null-entry': null, + 'gpt-5.5': { id: 'gpt-5.5', cost: { input: 5, output: 30 } }, + }, + }, + } as unknown as Parameters[0] + ); + + expect(() => getModelPricing('openai/gpt-5.5')).not.toThrow(); + expect(getModelPricing('openai/gpt-5.5').inputPerMillion).toBe(5); + }); }); }); diff --git a/tests/unit/models-dev-registry-cache.test.ts b/tests/unit/models-dev-registry-cache.test.ts index 20b3dfb5..9f625607 100644 --- a/tests/unit/models-dev-registry-cache.test.ts +++ b/tests/unit/models-dev-registry-cache.test.ts @@ -86,6 +86,35 @@ describe('models.dev registry cache', () => { expect(registry?.openai.models?.['gpt-5.5']?.cost?.output).toBe(30); }); + it('filters malformed model entries before caching remote payloads', async () => { + const fetchImpl: typeof fetch = async () => + new Response( + JSON.stringify({ + openai: { + id: 'openai', + models: { + 'null-entry': null, + 'string-entry': 'bad', + 'gpt-5.5': { id: 'gpt-5.5', cost: { input: 5, output: 30 } }, + }, + }, + }), + { status: 200 } + ); + + const registry = await refreshModelsDevRegistry({ + force: true, + fetchImpl, + now: () => 123, + }); + + expect(registry?.openai.models?.['gpt-5.5']?.cost?.input).toBe(5); + expect(registry?.openai.models?.['null-entry']).toBeUndefined(); + expect(registry?.openai.models?.['string-entry']).toBeUndefined(); + expect(getCachedModelsDevRegistry({ allowStale: true })?.openai.models?.['null-entry']).toBeUndefined(); + expect(getCachedModelsDevRegistry({ allowStale: true })?.openai.models?.['string-entry']).toBeUndefined(); + }); + it('ignores malformed cache files', () => { fs.mkdirSync(getCcsDir(), { recursive: true }); fs.writeFileSync(path.join(getCcsDir(), 'models-dev-registry-cache.json'), '{not json'); diff --git a/tests/unit/utils/claude-subcommand-detector.test.ts b/tests/unit/utils/claude-subcommand-detector.test.ts index ae6f0488..120a1184 100644 --- a/tests/unit/utils/claude-subcommand-detector.test.ts +++ b/tests/unit/utils/claude-subcommand-detector.test.ts @@ -56,6 +56,20 @@ describe('isClaudeSubcommandInvocation', () => { expect(isClaudeSubcommandInvocation(['--name', 'auth'])).toBe(false); }); + + it('treats --print prompt mode as non-subcommand even with subcommand-like prompt text', () => { + expect(isClaudeSubcommandInvocation(['--print', 'agents'])).toBe(false); + expect(isClaudeSubcommandInvocation(['--print', 'doctor'])).toBe(false); + }); + + it('treats -p (short form of --print) as non-subcommand — regression for #1341', () => { + // CCS uses -p in headless-executor; without this check the security bypass + // survived via the short flag form. + expect(isClaudeSubcommandInvocation(['-p', 'agents'])).toBe(false); + expect(isClaudeSubcommandInvocation(['-p', 'doctor'])).toBe(false); + expect(isClaudeSubcommandInvocation(['-p'])).toBe(false); + }); + it('handles --flag=value forms', () => { expect(isClaudeSubcommandInvocation(['--model=sonnet', 'agents'])).toBe(true); }); @@ -201,6 +215,19 @@ describe('subcommand passthrough — injectors short-circuit', () => { expect(appendBrowserToolArgs(['remote-control'])).toEqual(['remote-control']); }); + + it('injectors still inject in --print prompt mode with subcommand-like prompt text', () => { + const out = appendThirdPartyWebSearchToolArgs(['--print', 'agents']); + expect(out).toContain('--append-system-prompt'); + expect(out).toContain('--disallowedTools'); + }); + + it('injectors still inject in -p prompt mode — regression for #1341', () => { + const out = appendThirdPartyWebSearchToolArgs(['-p', 'agents']); + expect(out).toContain('--append-system-prompt'); + expect(out).toContain('--disallowedTools'); + }); + it('injectors still inject for non-subcommand interactive launches', () => { const out = appendThirdPartyWebSearchToolArgs(['fix the bug']); expect(out).toContain('--append-system-prompt'); diff --git a/tests/unit/web-server/sqlite-cli-path-resolution.test.ts b/tests/unit/web-server/sqlite-cli-path-resolution.test.ts new file mode 100644 index 00000000..9b907775 --- /dev/null +++ b/tests/unit/web-server/sqlite-cli-path-resolution.test.ts @@ -0,0 +1,284 @@ +/** + * Tests for sqlite-cli.ts cross-platform path resolution and CCS_SQLITE_BIN + * env-var override. + * + * Threat model (PR #1347 follow-up): PATH-hijack via a writable PATH entry. + * The env-var escape hatch must NOT reintroduce that vector: only binaries + * whose realpath resolves under a trusted system prefix are accepted. + */ +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { + getPlatformTrustedPrefixes, + resolveTrustedSqlitePath, + validateEnvOverridePath, +} from '../../../src/web-server/usage/sqlite-cli'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeTempExecutable(dir: string, name: string): string { + const filePath = path.join(dir, name); + fs.writeFileSync(filePath, '#!/bin/sh\nexec sqlite3 "$@"\n', { mode: 0o755 }); + return filePath; +} + +// --------------------------------------------------------------------------- +// validateEnvOverridePath +// --------------------------------------------------------------------------- + +describe('validateEnvOverridePath', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-sqlite-test-')); + }); + + afterEach(() => { + mock.restore(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('rejects a path under /tmp (user-writable)', () => { + if (process.platform === 'win32') return; // skip on Windows + + const fakebin = makeTempExecutable(tempDir, 'sqlite3'); + + // Make realpathSync return the /tmp path itself + const realpathSpy = spyOn(fs, 'realpathSync').mockImplementation(() => fakebin as string); + + expect(() => validateEnvOverridePath(fakebin)).toThrow( + /does not resolve under a trusted system prefix/ + ); + + realpathSpy.mockRestore(); + }); + + it('rejects a path under $HOME/.local', () => { + if (process.platform === 'win32') return; + + const homeLocalBin = path.join(os.homedir(), '.local', 'bin', 'sqlite3'); + const realpathSpy = spyOn(fs, 'realpathSync').mockImplementation(() => homeLocalBin as string); + const accessSpy = spyOn(fs, 'accessSync').mockImplementation(() => undefined); + + expect(() => validateEnvOverridePath(homeLocalBin)).toThrow( + /does not resolve under a trusted system prefix/ + ); + + realpathSpy.mockRestore(); + accessSpy.mockRestore(); + }); + + it('accepts a path under /usr/bin (trusted Unix prefix)', () => { + if (process.platform === 'win32') return; + + const trustedPath = '/usr/bin/sqlite3'; + const realpathSpy = spyOn(fs, 'realpathSync').mockImplementation(() => trustedPath as string); + const accessSpy = spyOn(fs, 'accessSync').mockImplementation(() => undefined); + + expect(() => validateEnvOverridePath(trustedPath)).not.toThrow(); + const result = validateEnvOverridePath(trustedPath); + expect(result).toBe(trustedPath); + + realpathSpy.mockRestore(); + accessSpy.mockRestore(); + }); + + it('accepts a NixOS-style path under /nix/store (immutable)', () => { + if (process.platform === 'win32') return; + + const nixPath = '/nix/store/abc123-sqlite-3.45.0/bin/sqlite3'; + const realpathSpy = spyOn(fs, 'realpathSync').mockImplementation(() => nixPath as string); + const accessSpy = spyOn(fs, 'accessSync').mockImplementation(() => undefined); + + expect(() => validateEnvOverridePath(nixPath)).not.toThrow(); + const result = validateEnvOverridePath(nixPath); + expect(result).toBe(nixPath); + + realpathSpy.mockRestore(); + accessSpy.mockRestore(); + }); + + it('accepts a MacPorts path under /opt/local (trusted)', () => { + if (process.platform === 'win32') return; + + const macPortsPath = '/opt/local/bin/sqlite3'; + const realpathSpy = spyOn(fs, 'realpathSync').mockImplementation(() => macPortsPath as string); + const accessSpy = spyOn(fs, 'accessSync').mockImplementation(() => undefined); + + expect(() => validateEnvOverridePath(macPortsPath)).not.toThrow(); + + realpathSpy.mockRestore(); + accessSpy.mockRestore(); + }); + + it('rejects a non-existent path', () => { + expect(() => validateEnvOverridePath('/nonexistent/path/to/sqlite3')).toThrow( + /could not be resolved/ + ); + }); + + it('resolves symlinks before checking prefix — rejects symlink pointing into /tmp', () => { + if (process.platform === 'win32') return; + + // Simulate: /usr/local/bin/sqlite3-link -> /tmp/evil-sqlite3 + const evilTarget = path.join(tempDir, 'evil-sqlite3'); + makeTempExecutable(tempDir, 'evil-sqlite3'); + + const realpathSpy = spyOn(fs, 'realpathSync').mockImplementation(() => evilTarget as string); + const accessSpy = spyOn(fs, 'accessSync').mockImplementation(() => undefined); + + // The symlink source looks trusted, but the realpath (evilTarget in /tmp) is not + expect(() => validateEnvOverridePath('/usr/local/bin/sqlite3-link')).toThrow( + /does not resolve under a trusted system prefix/ + ); + + realpathSpy.mockRestore(); + accessSpy.mockRestore(); + }); +}); + +// --------------------------------------------------------------------------- +// resolveTrustedSqlitePath — env-var override +// --------------------------------------------------------------------------- + +describe('resolveTrustedSqlitePath with CCS_SQLITE_BIN', () => { + afterEach(() => { + mock.restore(); + }); + + it('uses CCS_SQLITE_BIN when set and valid', () => { + if (process.platform === 'win32') return; + + const nixPath = '/nix/store/xyz-sqlite/bin/sqlite3'; + const realpathSpy = spyOn(fs, 'realpathSync').mockImplementation(() => nixPath as string); + const accessSpy = spyOn(fs, 'accessSync').mockImplementation(() => undefined); + + const result = resolveTrustedSqlitePath({ CCS_SQLITE_BIN: nixPath }); + expect(result).toBe(nixPath); + + realpathSpy.mockRestore(); + accessSpy.mockRestore(); + }); + + it('throws when CCS_SQLITE_BIN points to /tmp', () => { + if (process.platform === 'win32') return; + + const tmpBin = '/tmp/sqlite3'; + const realpathSpy = spyOn(fs, 'realpathSync').mockImplementation(() => tmpBin as string); + const accessSpy = spyOn(fs, 'accessSync').mockImplementation(() => undefined); + + expect(() => resolveTrustedSqlitePath({ CCS_SQLITE_BIN: tmpBin })).toThrow( + /does not resolve under a trusted system prefix/ + ); + + realpathSpy.mockRestore(); + accessSpy.mockRestore(); + }); + + it('ignores CCS_SQLITE_BIN when set to empty string and falls through to platform list', () => { + if (process.platform === 'win32') return; + + // No accessible paths on platform list → should throw "not available" + const accessSpy = spyOn(fs, 'accessSync').mockImplementation(() => { + throw new Error('ENOENT'); + }); + const realpathSpy = spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) => p as string); + + expect(() => resolveTrustedSqlitePath({ CCS_SQLITE_BIN: '' })).toThrow( + 'sqlite3 command not available' + ); + + accessSpy.mockRestore(); + realpathSpy.mockRestore(); + }); +}); + +// --------------------------------------------------------------------------- +// resolveTrustedSqlitePath — platform trusted-path list +// --------------------------------------------------------------------------- + +describe('resolveTrustedSqlitePath platform path list', () => { + afterEach(() => { + mock.restore(); + }); + + it('returns the first accessible Unix trusted path', () => { + if (process.platform === 'win32') return; + + // Simulate /usr/bin/sqlite3 missing, /usr/local/bin/sqlite3 present + let callCount = 0; + const realpathSpy = spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) => p as string); + const accessSpy = spyOn(fs, 'accessSync').mockImplementation(() => { + callCount++; + if (callCount === 1) throw new Error('ENOENT'); // /usr/bin/sqlite3 missing + // /usr/local/bin/sqlite3 accessible + }); + + const result = resolveTrustedSqlitePath({}); + expect(result).toBe('/usr/local/bin/sqlite3'); + + accessSpy.mockRestore(); + realpathSpy.mockRestore(); + }); + + it('throws "not available" when no trusted path exists and no env override', () => { + if (process.platform === 'win32') return; + + const realpathSpy = spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) => p as string); + const accessSpy = spyOn(fs, 'accessSync').mockImplementation(() => { + throw new Error('ENOENT'); + }); + + expect(() => resolveTrustedSqlitePath({})).toThrow('sqlite3 command not available'); + + accessSpy.mockRestore(); + realpathSpy.mockRestore(); + }); + + it('Windows: returns via CCS_SQLITE_BIN since hardcoded list is empty', () => { + if (process.platform !== 'win32') return; + + const winPath = 'C:\\Program Files\\SQLite\\sqlite3.exe'; + const realpathSpy = spyOn(fs, 'realpathSync').mockImplementation(() => winPath as string); + const accessSpy = spyOn(fs, 'accessSync').mockImplementation(() => undefined); + + const result = resolveTrustedSqlitePath({ CCS_SQLITE_BIN: winPath }); + expect(result.toLowerCase()).toContain('program files'); + + realpathSpy.mockRestore(); + accessSpy.mockRestore(); + }); + + it('Windows: throws "not available" when no env override provided', () => { + if (process.platform !== 'win32') return; + + // On Windows, TRUSTED_SQLITE_PATHS_WINDOWS is empty, so without env var it throws. + expect(() => resolveTrustedSqlitePath({})).toThrow('sqlite3 command not available'); + }); +}); + +// --------------------------------------------------------------------------- +// getPlatformTrustedPrefixes +// --------------------------------------------------------------------------- + +describe('getPlatformTrustedPrefixes', () => { + it('returns non-empty array', () => { + const prefixes = getPlatformTrustedPrefixes(); + expect(prefixes.length).toBeGreaterThan(0); + }); + + it('all prefixes end with a separator to prevent prefix-spoofing', () => { + // e.g. "/nix/store" without trailing slash would match "/nix/store-evil/" + const prefixes = getPlatformTrustedPrefixes(); + for (const prefix of prefixes) { + const endsWithSep = + process.platform === 'win32' ? prefix.endsWith('\\') : prefix.endsWith('/'); + expect(endsWithSep).toBe(true); + } + }); +}); diff --git a/ui/docs/health-redesign/report.html b/ui/docs/health-redesign/report.html index 80cad7dc..13a1b2cf 100644 --- a/ui/docs/health-redesign/report.html +++ b/ui/docs/health-redesign/report.html @@ -4,10 +4,6 @@ UI Verification Report - Health Page Redesign - - - -