diff --git a/CLAUDE.md b/CLAUDE.md index e4705720..f8a9d07b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,19 @@ AI-facing guidance for Claude Code when working with this repository. +## Critical Constraints (NEVER VIOLATE) + +### Test Isolation (MANDATORY) + +**NEVER touch the user's real `~/.ccs/` or `~/.claude/` directories during tests.** + +- All code accessing CCS paths MUST use `getCcsDir()` from `src/utils/config-manager.ts` +- This function respects `CCS_HOME` env var for test isolation +- **WRONG:** `path.join(os.homedir(), '.ccs', ...)` +- **CORRECT:** `path.join(getCcsDir(), ...)` + +Tests set `process.env.CCS_HOME` to a temp directory. Code using `os.homedir()` directly will modify the user's real files. + ## Core Function CLI wrapper for instant switching between multiple Claude accounts and alternative models (GLM, GLMT, Kimi). See README.md for user documentation. diff --git a/src/utils/websearch/hook-config.ts b/src/utils/websearch/hook-config.ts index 954e843b..00cc4391 100644 --- a/src/utils/websearch/hook-config.ts +++ b/src/utils/websearch/hook-config.ts @@ -11,16 +11,21 @@ import * as path from 'path'; import * as os from 'os'; import { info, warn } from '../ui'; import { getWebSearchConfig } from '../../config/unified-config-loader'; +import { getCcsDir } from '../config-manager'; -// Path to Claude settings.json +// Path to Claude settings.json (intentionally uses real homedir for global settings) const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json'); -// CCS hooks directory -const CCS_HOOKS_DIR = path.join(os.homedir(), '.ccs', 'hooks'); - // Hook file name const WEBSEARCH_HOOK = 'websearch-transformer.cjs'; +/** + * Get CCS hooks directory (respects CCS_HOME for test isolation) + */ +function getCcsHooksDir(): string { + return path.join(getCcsDir(), 'hooks'); +} + // Buffer time added to max provider timeout for hook timeout (seconds) const HOOK_TIMEOUT_BUFFER = 30; @@ -31,7 +36,7 @@ const MIN_HOOK_TIMEOUT = 60; * Get path to WebSearch hook */ export function getHookPath(): string { - return path.join(CCS_HOOKS_DIR, WEBSEARCH_HOOK); + return path.join(getCcsHooksDir(), WEBSEARCH_HOOK); } /** diff --git a/src/utils/websearch/hook-installer.ts b/src/utils/websearch/hook-installer.ts index 8a0c3234..c54a3d61 100644 --- a/src/utils/websearch/hook-installer.ts +++ b/src/utils/websearch/hook-installer.ts @@ -8,21 +8,25 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import { info, warn } from '../ui'; import { getWebSearchConfig } from '../../config/unified-config-loader'; import { getHookPath, ensureHookConfig } from './hook-config'; import { removeMigrationMarker } from './profile-hook-injector'; +import { getCcsDir } from '../config-manager'; // Re-export from hook-config for backward compatibility export { getHookPath, getWebSearchHookConfig } from './hook-config'; -// CCS hooks directory -const CCS_HOOKS_DIR = path.join(os.homedir(), '.ccs', 'hooks'); - // Hook file name const WEBSEARCH_HOOK = 'websearch-transformer.cjs'; +/** + * Get CCS hooks directory (respects CCS_HOME for test isolation) + */ +function getCcsHooksDir(): string { + return path.join(getCcsDir(), 'hooks'); +} + /** * Check if WebSearch hook is installed */ @@ -50,8 +54,9 @@ export function installWebSearchHook(): boolean { } // Ensure hooks directory exists - if (!fs.existsSync(CCS_HOOKS_DIR)) { - fs.mkdirSync(CCS_HOOKS_DIR, { recursive: true, mode: 0o700 }); + const hooksDir = getCcsHooksDir(); + if (!fs.existsSync(hooksDir)) { + fs.mkdirSync(hooksDir, { recursive: true, mode: 0o700 }); } const hookPath = getHookPath(); diff --git a/src/utils/websearch/profile-hook-injector.ts b/src/utils/websearch/profile-hook-injector.ts index 7742b9f3..2f13e6aa 100644 --- a/src/utils/websearch/profile-hook-injector.ts +++ b/src/utils/websearch/profile-hook-injector.ts @@ -9,17 +9,21 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import { info, warn } from '../ui'; import { getWebSearchHookConfig, getHookPath } from './hook-config'; import { getWebSearchConfig } from '../../config/unified-config-loader'; import { removeHookConfig } from './hook-config'; +import { getCcsDir } from '../config-manager'; -// CCS directory -const CCS_DIR = path.join(os.homedir(), '.ccs'); +// Valid profile name pattern (alphanumeric, dash, underscore only) +const VALID_PROFILE_NAME = /^[a-zA-Z0-9_-]+$/; -// Migration marker file -const MIGRATION_MARKER = path.join(CCS_DIR, '.hook-migrated'); +/** + * Get migration marker path (respects CCS_HOME for test isolation) + */ +function getMigrationMarkerPath(): string { + return path.join(getCcsDir(), '.hook-migrated'); +} /** * Check if CCS WebSearch hook exists in settings @@ -44,7 +48,8 @@ function hasCcsHook(settings: Record): boolean { * Migrate CCS hook from global settings to profile settings (one-time) */ function migrateGlobalHook(): void { - if (fs.existsSync(MIGRATION_MARKER)) { + const markerPath = getMigrationMarkerPath(); + if (fs.existsSync(markerPath)) { return; // Already migrated } @@ -53,8 +58,13 @@ function migrateGlobalHook(): void { if (removed && process.env.CCS_DEBUG) { console.error(info('Migrated WebSearch hook from global settings')); } + // Ensure CCS dir exists before creating marker + const ccsDir = getCcsDir(); + if (!fs.existsSync(ccsDir)) { + fs.mkdirSync(ccsDir, { recursive: true, mode: 0o700 }); + } // Create marker file - fs.writeFileSync(MIGRATION_MARKER, new Date().toISOString(), 'utf8'); + fs.writeFileSync(markerPath, new Date().toISOString(), 'utf8'); } catch (error) { if (process.env.CCS_DEBUG) { console.error(warn(`Migration failed: ${(error as Error).message}`)); @@ -70,6 +80,14 @@ function migrateGlobalHook(): void { */ export function ensureProfileHooks(profileName: string): boolean { try { + // Validate profile name to prevent path traversal + if (!VALID_PROFILE_NAME.test(profileName)) { + if (process.env.CCS_DEBUG) { + console.error(warn(`Invalid profile name: ${profileName}`)); + } + return false; + } + const wsConfig = getWebSearchConfig(); // Skip if WebSearch is disabled @@ -80,7 +98,15 @@ export function ensureProfileHooks(profileName: string): boolean { // One-time migration from global settings migrateGlobalHook(); - const settingsPath = path.join(CCS_DIR, `${profileName}.settings.json`); + // Get CCS directory (respects CCS_HOME for test isolation) + const ccsDir = getCcsDir(); + + // Ensure CCS dir exists + if (!fs.existsSync(ccsDir)) { + fs.mkdirSync(ccsDir, { recursive: true, mode: 0o700 }); + } + + const settingsPath = path.join(ccsDir, `${profileName}.settings.json`); // Read existing settings or create empty let settings: Record = {}; @@ -193,8 +219,9 @@ function updateHookTimeoutIfNeeded( */ export function removeMigrationMarker(): void { try { - if (fs.existsSync(MIGRATION_MARKER)) { - fs.unlinkSync(MIGRATION_MARKER); + const markerPath = getMigrationMarkerPath(); + if (fs.existsSync(markerPath)) { + fs.unlinkSync(markerPath); } } catch { // Ignore errors