mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 00:22:34 +00:00
feat(release): promote dev to main — security hardening batch (#1351)
* fix(cliproxy): disable spoofable default bg keepalive probe (#1340) * fix(dispatcher): treat --print as non-subcommand Claude session (#1341) * fix(dispatcher): treat --print as non-subcommand Claude session * style: apply prettier formatting * fix(dispatcher): correct return type in subcommand detector getClaudeSubcommandName returns string | null; return false (boolean) was invalid after dev refactored isClaudeSubcommandInvocation to delegate to it. Change to return null to preserve the --print safety fix intent. * fix(cliproxy): sanitize local port before config regeneration (#1342) * fix(pricing): guard malformed models.dev model entries (#1344) * fix(pricing): guard malformed models.dev model entries * style: apply prettier formatting * fix(codex-quota): guard feature label type in quota windows (#1346) * fix(analytics): harden sqlite3 invocation for native Droid usage scan (#1347) * fix(analytics): use trusted sqlite3 binary path * style: apply prettier formatting * fix(codex-auth): stop spawning powershell in shell detection (#1348) * fix(ui-docs): remove third-party runtime assets from health report (#1349) * fix(ui-docs): remove third-party runtime assets from health report * style: apply prettier formatting * fix(ci): prevent promote-release tag command injection (#1350) * chore(release): 8.0.0-dev.1 * fix(models-dev): sanitize models.dev registry entries and harden pricing resolver (#1345) * fix(models-dev): sanitize cached model entries before pricing lookup * style: apply prettier formatting * chore(release): 8.0.0-dev.2 * refactor(cliproxy): remove orphaned bg keepalive wiring (#1353) Red-team review of #1340 found that backgroundProbe / shouldKeepSessionProxiesAlive / startKeepAliveWatcher and related types were never reachable: the sole caller (claude-launcher.ts) passed no hasBackgroundWorkerUsingBaseUrl detector, so backgroundProbe was always undefined and the keepalive branch never executed. Remove the unreachable scaffold: - Delete CLAUDE_BG_WORKER_ARGS, hasClaudeBackgroundWorkerUsingBaseUrl{,InProcessList}, shouldKeepSessionProxiesAlive, ShouldKeepSessionProxiesAliveOptions, and the SessionProxyKeepAliveOptions interface from session-bridge.ts - Remove the backgroundProbe/startKeepAliveWatcher block and keepalive exit branch from setupCleanupHandlers; the function now always calls stopSessionResources on Claude exit - Remove backgroundKeepAliveBaseUrl wiring from claude-launcher.ts - Remove the execFileSync import (was only used by the deleted ps-based detector) - Update test file: remove tests for deleted exports, keep placeholder A trusted bg-worker detector can be re-added in a future PR if the feature is actually needed; the keepalive logic in shouldKeepSessionProxiesAlive can be restored then. * fix(analytics): support sqlite3 path resolution on Windows and NixOS (#1354) - Add CCS_SQLITE_BIN env-var override; validated with fs.realpathSync so symlinks are fully resolved before prefix check - Reject any override whose realpath does not start under a trusted system prefix (prevents PATH-hijack reintroduction from #1347) - Add TRUSTED_PREFIX_UNIX covering /nix/store/, /opt/local/ (MacPorts), /snap/, /run/current-system/ in addition to existing /usr/* and /opt/homebrew/ entries - Add TRUSTED_PREFIX_WINDOWS covering Program Files, System32, and the Chocolatey managed bin dir (no canonical winget/Scoop path exists) - Keep TRUSTED_SQLITE_PATHS_WINDOWS empty — Windows users set CCS_SQLITE_BIN - Pass env as optional third param to querySqliteJson (backward compatible) - Add 16-test suite covering env-var acceptance, /tmp rejection, symlink traversal, NixOS paths, MacPorts, Windows fallback, and prefix safety * fix(dispatcher): also treat -p as non-subcommand short for --print (#1352) Red-team follow-up to #1341: the original fix only guarded --print but Claude accepts -p as the short form, and CCS itself passes -p in headless-executor.ts. Without this check the security bypass survived via the short flag. Added regression tests: ['-p', 'agents'], ['-p', 'doctor'], ['-p'] alone, and injector short-circuit verification for -p form. * chore(release): 8.0.0-dev.3 --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
github-actions[bot] <github-actions[bot]@users.noreply.github.com>
commit
aed942f51b
@@ -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"
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -163,9 +163,6 @@ export async function launchClaude(context: ClaudeLaunchContext): Promise<ChildP
|
||||
}
|
||||
}
|
||||
|
||||
const hasSessionProxy = Boolean(codexReasoningProxy || toolSanitizationProxy || httpsTunnel);
|
||||
const backgroundKeepAliveBaseUrl = hasSessionProxy ? tracedEnv.ANTHROPIC_BASE_URL : undefined;
|
||||
|
||||
// Wire cleanup handlers (process exit, SIGINT, SIGTERM, proxy teardown)
|
||||
setupCleanupHandlers(
|
||||
claude,
|
||||
@@ -174,8 +171,7 @@ export async function launchClaude(context: ClaudeLaunchContext): Promise<ChildP
|
||||
codexReasoningProxy,
|
||||
toolSanitizationProxy,
|
||||
httpsTunnel,
|
||||
verbose,
|
||||
{ backgroundKeepAliveBaseUrl }
|
||||
verbose
|
||||
);
|
||||
|
||||
return claude;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* - Startup lock coordination
|
||||
*/
|
||||
|
||||
import { ChildProcess, execFileSync } from 'child_process';
|
||||
import { ChildProcess } from 'child_process';
|
||||
import { info, warn } from '../../utils/ui';
|
||||
import { getInstalledCliproxyVersion } from '../binary-manager';
|
||||
import { CLIProxyBackend } from '../types';
|
||||
@@ -33,70 +33,6 @@ export interface ProxySessionResult {
|
||||
shouldSpawn: boolean;
|
||||
}
|
||||
|
||||
export interface SessionProxyKeepAliveOptions {
|
||||
backgroundKeepAliveBaseUrl?: string;
|
||||
keepAlivePollIntervalMs?: number;
|
||||
hasBackgroundWorkerUsingBaseUrl?: (baseUrl: string) => 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) {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -119,9 +119,11 @@ const SUBCOMMAND_ALLOWED_SESSION_FLAGS: Record<string, ReadonlySet<string>> = {
|
||||
*
|
||||
* 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).
|
||||
|
||||
@@ -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<string, ModelsDevModel>();
|
||||
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<string>();
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ModelsDevProvider['models']>)
|
||||
: undefined;
|
||||
if (!models || Object.keys(models).length === 0) continue;
|
||||
const modelsPayload = isPlainObject(value.models) ? value.models : undefined;
|
||||
if (!modelsPayload) continue;
|
||||
|
||||
const models: NonNullable<ModelsDevProvider['models']> = {};
|
||||
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<ModelsDevProvider['models']>[string]),
|
||||
id: modelId,
|
||||
};
|
||||
}
|
||||
if (Object.keys(models).length === 0) continue;
|
||||
|
||||
providers[id] = {
|
||||
...(value as ModelsDevProvider),
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
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<SqliteJsonRow[]> {
|
||||
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<SqliteJsonRow[]> {
|
||||
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<Sqli
|
||||
throw new Error(`sqlite3 query failed for ${dbPath}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Export internals for unit testing — not part of the public API.
|
||||
export { resolveTrustedSqlitePath, validateEnvOverridePath, getPlatformTrustedPrefixes };
|
||||
|
||||
@@ -1,57 +1,17 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import {
|
||||
hasClaudeBackgroundWorkerUsingBaseUrlInProcessList,
|
||||
shouldKeepSessionProxiesAlive,
|
||||
} from '../../../src/cliproxy/executor/session-bridge';
|
||||
/**
|
||||
* The bg-keepalive feature (shouldKeepSessionProxiesAlive, hasClaudeBackgroundWorker*,
|
||||
* backgroundProbe) was removed as dead code: the sole caller (claude-launcher.ts) never
|
||||
* provided a trusted hasBackgroundWorkerUsingBaseUrl detector, so the keepalive path was
|
||||
* unreachable. The ps-based detector was also spoofable (fixed in #1340). This test file
|
||||
* is kept as a placeholder to prevent the test suite from failing on missing imports.
|
||||
*
|
||||
* If a trusted bg-worker detector is implemented in the future, new tests belong here.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'bun:test';
|
||||
|
||||
describe('session bridge background proxy keepalive', () => {
|
||||
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.
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof setCachedModelsDevRegistry>[0]
|
||||
);
|
||||
|
||||
expect(() => getModelPricing('openai/gpt-5.5')).not.toThrow();
|
||||
expect(getModelPricing('openai/gpt-5.5').inputPerMillion).toBe(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,10 +4,6 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>UI Verification Report - Health Page Redesign</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { font-family: 'Inter', sans-serif; background-color: #f8f9fa; }
|
||||
.glass { background: rgba(255, 255, 255, 0.7); backdrop-filter: blur(10px); border: 1px solid rgba(255, 255, 255, 0.3); }
|
||||
|
||||
+2
-2
@@ -384,8 +384,8 @@ function getCodexWindowKindFromLabel(label: string): CodexWindowKind {
|
||||
* "OmniReview" -> "OmniReview"
|
||||
* "" -> ""
|
||||
*/
|
||||
export function prettifyCodexFeatureLabel(featureLabel: string): string {
|
||||
const trimmed = (featureLabel || '').trim();
|
||||
export function prettifyCodexFeatureLabel(featureLabel: unknown): string {
|
||||
const trimmed = typeof featureLabel === 'string' ? featureLabel.trim() : '';
|
||||
if (!trimmed) return '';
|
||||
const stripped = trimmed.replace(/^GPT-[\d.]+-Codex-/i, '');
|
||||
if (stripped !== trimmed && stripped.length > 0) {
|
||||
|
||||
@@ -37,6 +37,11 @@ describe('prettifyCodexFeatureLabel', () => {
|
||||
expect(prettifyCodexFeatureLabel(' ')).toBe('');
|
||||
expect(prettifyCodexFeatureLabel(' GPT-5.3-Codex-Spark ')).toBe('Codex Spark');
|
||||
});
|
||||
|
||||
it('returns empty string for non-string input', () => {
|
||||
expect(prettifyCodexFeatureLabel({ label: 'Spark' })).toBe('');
|
||||
expect(prettifyCodexFeatureLabel(123)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCodexWindowKind', () => {
|
||||
|
||||
Reference in New Issue
Block a user