diff --git a/lib/hooks/block-image-read.cjs b/lib/hooks/block-image-read.cjs deleted file mode 100644 index 78d87e27..00000000 --- a/lib/hooks/block-image-read.cjs +++ /dev/null @@ -1,170 +0,0 @@ -#!/usr/bin/env node -/** - * CCS Image Read Blocking Hook - * - * Blocks Claude's Read tool from reading image files to prevent context overflow. - * Each image can consume 100K+ tokens, causing immediate context exhaustion. - * - * This is a PreToolUse hook that runs BEFORE the tool is executed. - * - * Behavior (matches WebSearch pattern): - * - ENABLED by default for third-party profiles (settings, cliproxy) - * - DISABLED for native Claude accounts (account, default profiles) - * - User can override via config: hooks.block_image_read.enabled: false - * - * Usage: - * Configured in ~/.claude/settings.json: - * { - * "hooks": { - * "PreToolUse": [{ - * "matcher": "Read", - * "hooks": [{ - * "type": "command", - * "command": "node ~/.ccs/hooks/block-image-read.cjs", - * "timeout": 5 - * }] - * }] - * } - * } - * - * Environment Variables (set by CCS): - * CCS_BLOCK_IMAGE_READ=1 - Enable blocking (default for third-party) - * CCS_BLOCK_IMAGE_READ=0 - Disable blocking - * CCS_PROFILE_TYPE - Profile type (account, default, settings, cliproxy) - * CCS_DEBUG=1 - Enable debug output - * - * Exit codes: - * 0 - Allow tool (pass-through) - * 2 - Block tool (deny with message) - * - * @module hooks/block-image-read - */ - -// Image file extensions to block -const IMAGE_EXTENSIONS = /\.(png|jpg|jpeg|webp|gif|bmp|tiff|tif|ico|svg|heic|heif|avif)$/i; - -// Read input from stdin -let input = ''; -process.stdin.setEncoding('utf8'); -process.stdin.on('data', (chunk) => { - input += chunk; -}); -process.stdin.on('end', () => { - processHook(); -}); - -// Handle stdin not being available -process.stdin.on('error', () => { - process.exit(0); -}); - -/** - * Check if hook should skip (for native Claude accounts). - * Matches WebSearch hook pattern. - */ -function shouldSkipHook() { - // Account/default profiles use native Claude - don't block - const profileType = process.env.CCS_PROFILE_TYPE; - if (profileType === 'account' || profileType === 'default') { - if (process.env.CCS_DEBUG) { - console.error(`[CCS Hook] Skipping image block for profile type: ${profileType}`); - } - return true; - } - - // Explicit disable via config - if (process.env.CCS_BLOCK_IMAGE_READ === '0') { - if (process.env.CCS_DEBUG) { - console.error('[CCS Hook] Image read blocking disabled by config'); - } - return true; - } - - return false; -} - -/** - * Main hook processing logic - */ -function processHook() { - try { - // Skip for native accounts or explicit disable - if (shouldSkipHook()) { - process.exit(0); - } - - const data = JSON.parse(input); - - // Only handle Read tool - if (data.tool_name !== 'Read') { - process.exit(0); - } - - const filePath = data.tool_input?.file_path || ''; - - if (process.env.CCS_DEBUG) { - console.error(`[CCS Hook] Read intercepted: ${filePath}`); - } - - // Check if file is an image - if (IMAGE_EXTENSIONS.test(filePath)) { - if (process.env.CCS_DEBUG) { - console.error(`[CCS Hook] Blocking image read: ${filePath}`); - } - outputBlock(filePath); - return; - } - - // Allow non-image files - process.exit(0); - } catch (err) { - if (process.env.CCS_DEBUG) { - console.error('[CCS Hook] Parse error:', err.message); - } - // Don't block on parse errors - process.exit(0); - } -} - -/** - * Output block response and exit - */ -function outputBlock(filePath) { - // Extract just the filename for cleaner display - const fileName = filePath.split(/[/\\]/).pop() || filePath; - - const message = [ - '[Image Read Blocked - Context Protection]', - '', - `File: ${fileName}`, - `Path: ${filePath}`, - '', - 'Image files consume 100K+ tokens each and will exhaust context.', - '', - 'The image was generated successfully. To view it:', - ' - Open the file path above in your image viewer', - ' - Use your file manager to navigate to the location', - ' - On macOS: open "' + filePath + '"', - ' - On Linux: xdg-open "' + filePath + '"', - ' - On Windows: start "" "' + filePath + '"', - '', - 'If you need to analyze the image, use the ai-multimodal skill', - 'which processes images via Gemini API without loading into context.', - ].join('\n'); - - const output = { - decision: 'block', - reason: 'Image file blocked to prevent context overflow', - // User-facing message (shows in CLI output) - systemMessage: `[Image Read Blocked] ${fileName} - Open file directly to view.`, - hookSpecificOutput: { - hookEventName: 'PreToolUse', - permissionDecision: 'deny', - // Claude reads this - explains what happened and alternatives - permissionDecisionReason: message, - }, - }; - - console.log(JSON.stringify(output)); - process.exit(2); -} diff --git a/lib/hooks/image-analyzer-transformer.cjs b/lib/hooks/image-analyzer-transformer.cjs index d8bb71e1..09362bb6 100755 --- a/lib/hooks/image-analyzer-transformer.cjs +++ b/lib/hooks/image-analyzer-transformer.cjs @@ -12,6 +12,7 @@ * CCS_CURRENT_PROVIDER - Current CLIProxy provider (e.g., agy, gemini, codex) * CCS_IMAGE_ANALYSIS_TIMEOUT=60 - Timeout in seconds (default: 60) * CCS_PROFILE_TYPE - Profile type (account/default skip) + * ANTHROPIC_MODEL - Fallback model if provider not in mapping * CCS_DEBUG=1 - Enable debug output * * Exit codes: @@ -107,14 +108,14 @@ function getDebugContext(filePath, stats) { const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS); const model = providerModels[currentProvider] || DEFAULT_MODEL; const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10); - const isDefaultModel = !providerModels[currentProvider]; + const modelsToTry = getModelsToTry(); return { file: path.basename(filePath), size: stats ? `${(stats.size / 1024).toFixed(1)} KB` : 'unknown', provider: currentProvider, model: model, - config: isDefaultModel ? 'default' : 'user-configured', + modelsToTry: modelsToTry.length > 1 ? modelsToTry.join(' -> ') : model, timeout: `${timeout}s`, endpoint: `http://${CLIPROXY_HOST}:${CLIPROXY_PORT}${CLIPROXY_PATH}`, }; @@ -148,6 +149,7 @@ function parseProviderModels(envValue) { /** * Get model for current provider from provider_models mapping + * Returns primary model only (for display/logging) */ function getModelForProvider() { const currentProvider = process.env.CCS_CURRENT_PROVIDER || ''; @@ -155,6 +157,93 @@ function getModelForProvider() { return providerModels[currentProvider] || DEFAULT_MODEL; } +/** + * Get list of models to try in order: + * 1. provider_models[current_provider] (if exists) + * 2. DEFAULT_MODEL + * 3. ANTHROPIC_MODEL from profile (if different and exists) + */ +function getModelsToTry() { + const currentProvider = process.env.CCS_CURRENT_PROVIDER || ''; + const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS); + const anthropicModel = process.env.ANTHROPIC_MODEL; + + const models = []; + const seen = new Set(); + + // 1. Provider-specific model + if (providerModels[currentProvider]) { + models.push(providerModels[currentProvider]); + seen.add(providerModels[currentProvider]); + } + + // 2. Default model + if (!seen.has(DEFAULT_MODEL)) { + models.push(DEFAULT_MODEL); + seen.add(DEFAULT_MODEL); + } + + // 3. ANTHROPIC_MODEL fallback + if (anthropicModel && !seen.has(anthropicModel)) { + models.push(anthropicModel); + } + + return models; +} + +/** + * Analyze with retry logic - tries models in order until one succeeds + */ +async function analyzeWithRetry(base64Data, mediaType, timeoutMs) { + const models = getModelsToTry(); + // Defensive check - should never happen but provides clear error + if (models.length === 0) { + throw new Error('No models configured for image analysis'); + } + let lastError = null; + + for (let i = 0; i < models.length; i++) { + const model = models[i]; + try { + debugLog(`Trying model ${i + 1}/${models.length}`, { model }); + const result = await analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs); + if (i > 0) { + debugLog('Retry succeeded', { model, attempt: i + 1 }); + } + return { description: result, model }; + } catch (err) { + lastError = err; + const isLastModel = i === models.length - 1; + + // Don't retry on certain errors (auth, rate limit, timeout, file access, network) + const errMsg = err.message || ''; + const noRetryPatterns = [ + 'AUTH_ERROR', 'RATE_LIMIT', 'TIMEOUT', + 'EACCES', 'EPERM', 'ECONNREFUSED', + 'ENOTFOUND', 'ENETUNREACH', 'EAI_AGAIN' // Network errors - no point retrying + ]; + const shouldNotRetry = noRetryPatterns.some(p => errMsg.includes(p)); + + if (shouldNotRetry || isLastModel) { + debugLog('Analysis failed, no more retries', { + model, + error: errMsg, + reason: shouldNotRetry ? 'non-retryable error' : 'last model' + }); + throw err; + } + + debugLog('Model failed, trying next', { + model, + error: errMsg.substring(0, 100), + nextModel: models[i + 1] + }); + } + } + + throw lastError || new Error('No models available'); +} + /** * Check if file is an analyzable image or PDF */ @@ -555,6 +644,38 @@ function outputUnknownError(filePath, error) { process.exit(2); } +/** + * CLIProxy unavailable fallback - blocks Read to prevent context overflow + * When CLIProxy is not running, we cannot analyze the image. + * Blocking prevents the image from loading into Claude's context (100K+ tokens). + */ +function outputCliProxyUnavailableFallback(filePath) { + const fileName = filePath.split(/[/\\]/).pop() || filePath; + + // Keep message minimal to avoid context pollution and hallucination + const message = [ + '[Image Read Blocked]', + '', + `File: ${fileName}`, + '', + 'CLIProxy unavailable. Image blocked to prevent context overflow.', + ].join('\n'); + + const output = { + decision: 'block', + reason: 'CLIProxy unavailable - image blocked to prevent context overflow', + systemMessage: `[Image Blocked] ${fileName} - CLIProxy unavailable. Start: ccs config`, + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: message, + }, + }; + + console.log(JSON.stringify(output)); + process.exit(2); +} + /** * Output success response and exit */ @@ -682,12 +803,12 @@ async function processHook() { // Check CLIProxy availability const cliProxyAvailable = await isCliProxyAvailable(); if (!cliProxyAvailable) { - debugLog('Skipping: CLIProxy not available', { + debugLog('Blocking: CLIProxy not available', { endpoint: `http://${CLIPROXY_HOST}:${CLIPROXY_PORT}`, - action: 'passing through to native Read', + action: 'blocking to prevent context overflow', }); - // Pass through to native Read - process.exit(0); + outputCliProxyUnavailableFallback(filePath); + return; } const model = getModelForProvider(); @@ -707,15 +828,16 @@ async function processHook() { base64Length: `${(base64Data.length / 1024).toFixed(1)}KB`, }); - // Analyze via CLIProxy - const description = await analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs); + // Analyze via CLIProxy with retry logic + const { description, model: usedModel } = await analyzeWithRetry(base64Data, mediaType, timeoutMs); debugLog('Analysis complete', { responseLength: `${description.length} chars`, + model: usedModel, }); // Output success - outputSuccess(filePath, description, model, stats.size); + outputSuccess(filePath, description, usedModel, stats.size); } catch (err) { if (process.env.CCS_DEBUG) { console.error('[CCS Hook] Error:', err.message); diff --git a/src/ccs.ts b/src/ccs.ts index 6cf8140c..1d447e48 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -13,7 +13,7 @@ import { ensureProfileHooks, } from './utils/websearch-manager'; import { getGlobalEnvConfig } from './config/unified-config-loader'; -import { getImageReadBlockHookEnv } from './utils/hooks/image-read-block-hook-env'; +import { ensureProfileHooks as ensureImageAnalyzerHooks } from './utils/hooks/image-analyzer-profile-hook-injector'; import { fail, info } from './utils/ui'; // Import centralized error handling @@ -165,12 +165,10 @@ async function execClaudeWithProxy( const isWindows = process.platform === 'win32'; const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli); const webSearchEnv = getWebSearchHookEnv(); - const imageReadBlockEnv = getImageReadBlockHookEnv(); const env = { ...process.env, ...envVars, ...webSearchEnv, - ...imageReadBlockEnv, CCS_PROFILE_TYPE: 'settings', // Signal to WebSearch hook this is a third-party provider }; @@ -523,6 +521,8 @@ async function main(): Promise { // CLIPROXY FLOW: OAuth-based profiles (gemini, codex, agy, qwen) or user-defined variants // Inject WebSearch hook into profile settings before launch ensureProfileHooks(profileInfo.name); + // Inject Image Analyzer hook into profile settings before launch + ensureImageAnalyzerHooks(profileInfo.name); const provider = profileInfo.provider || (profileInfo.name as CLIProxyProvider); const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles @@ -535,6 +535,8 @@ async function main(): Promise { // COPILOT FLOW: GitHub Copilot subscription via copilot-api proxy // Inject WebSearch hook into profile settings before launch ensureProfileHooks(profileInfo.name); + // Inject Image Analyzer hook into profile settings before launch + ensureImageAnalyzerHooks(profileInfo.name); const { executeCopilotProfile } = await import('./copilot'); const copilotConfig = profileInfo.copilotConfig; @@ -549,6 +551,8 @@ async function main(): Promise { // WebSearch is server-side tool - third-party providers have no access // Inject WebSearch hook into profile settings before launch ensureProfileHooks(profileInfo.name); + // Inject Image Analyzer hook into profile settings before launch + ensureImageAnalyzerHooks(profileInfo.name); ensureMcpWebSearch(); @@ -615,7 +619,6 @@ async function main(): Promise { // Use --settings flag (backward compatible) const expandedSettingsPath = getSettingsPath(profileInfo.name); const webSearchEnv = getWebSearchHookEnv(); - const imageReadBlockEnv = getImageReadBlockHookEnv(); // Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles const globalEnvConfig = getGlobalEnvConfig(); const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {}; @@ -637,7 +640,6 @@ async function main(): Promise { ...globalEnv, ...settingsEnv, // Explicitly inject all settings env vars ...webSearchEnv, - ...imageReadBlockEnv, CCS_PROFILE_TYPE: 'settings', // Signal to WebSearch hook this is a third-party provider }; execClaude(claudeCli, ['--settings', expandedSettingsPath, ...remainingArgs], envVars); @@ -660,18 +662,22 @@ async function main(): Promise { // Execute Claude with instance isolation // Skip WebSearch hook - account profiles use native server-side WebSearch + // Skip Image Analyzer hook - account profiles have native vision support const envVars: NodeJS.ProcessEnv = { CLAUDE_CONFIG_DIR: instancePath, CCS_PROFILE_TYPE: 'account', CCS_WEBSEARCH_SKIP: '1', + CCS_IMAGE_ANALYSIS_SKIP: '1', }; execClaude(claudeCli, remainingArgs, envVars); } else { // DEFAULT: No profile configured, use Claude's own defaults // Skip WebSearch hook - native Claude has server-side WebSearch + // Skip Image Analyzer hook - native Claude has native vision support const envVars: NodeJS.ProcessEnv = { CCS_PROFILE_TYPE: 'default', CCS_WEBSEARCH_SKIP: '1', + CCS_IMAGE_ANALYSIS_SKIP: '1', }; execClaude(claudeCli, remainingArgs, envVars); } diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 808a1814..0d640a8e 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -37,7 +37,6 @@ import { DEFAULT_BACKEND } from './platform-detector'; import { configureProviderModel, getCurrentModel } from './model-config'; import { resolveProxyConfig, PROXY_CLI_FLAGS } from './proxy-config-resolver'; import { getWebSearchHookEnv } from '../utils/websearch-manager'; -import { getImageReadBlockHookEnv } from '../utils/hooks/image-read-block-hook-env'; import { getImageAnalysisHookEnv } from '../utils/hooks/get-image-analysis-hook-env'; import { supportsModelConfig, isModelBroken, getModelIssueUrl, findModel } from './model-catalog'; import { CodexReasoningProxy } from './codex-reasoning-proxy'; @@ -969,13 +968,11 @@ export async function execClaudeWithCLIProxy( ANTHROPIC_BASE_URL: finalBaseUrl, }; const webSearchEnv = getWebSearchHookEnv(); - const imageReadBlockEnv = getImageReadBlockHookEnv(); const imageAnalysisEnv = getImageAnalysisHookEnv(provider); const env = { ...process.env, ...effectiveEnvVars, ...webSearchEnv, - ...imageReadBlockEnv, ...imageAnalysisEnv, CCS_PROFILE_TYPE: 'cliproxy', // Signal to WebSearch hook this is a third-party provider }; diff --git a/src/cliproxy/services/variant-settings.ts b/src/cliproxy/services/variant-settings.ts index 872ae910..d50c0cf2 100644 --- a/src/cliproxy/services/variant-settings.ts +++ b/src/cliproxy/services/variant-settings.ts @@ -14,6 +14,7 @@ import { expandPath } from '../../utils/helpers'; import { getClaudeEnvVars, CLIPROXY_DEFAULT_PORT } from '../config-generator'; import { CLIProxyProvider } from '../types'; import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector'; +import { ensureProfileHooks as ensureImageAnalyzerHooks } from '../../utils/hooks/image-analyzer-profile-hook-injector'; /** Environment settings structure */ interface SettingsEnv { @@ -109,6 +110,9 @@ export function createSettingsFile( // Inject WebSearch hooks into variant settings ensureProfileHooks(`${provider}-${name}`); + // Inject Image Analyzer hooks into variant settings + ensureImageAnalyzerHooks(`${provider}-${name}`); + return settingsPath; } @@ -134,6 +138,9 @@ export function createSettingsFileUnified( // Inject WebSearch hooks into variant settings ensureProfileHooks(`${provider}-${name}`); + // Inject Image Analyzer hooks into variant settings + ensureImageAnalyzerHooks(`${provider}-${name}`); + return settingsPath; } diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index c654d9ec..5a8dcffe 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -545,6 +545,9 @@ export const DEFAULT_IMAGE_ANALYSIS_CONFIG: ImageAnalysisConfig = { kiro: 'kiro-claude-haiku-4-5', ghcp: 'claude-haiku-4.5', claude: 'claude-haiku-4-5-20251001', + // 'vision-model' is a generic placeholder - users can override via config.yaml + qwen: 'vision-model', + iflow: 'qwen3-vl-plus', }, }; diff --git a/src/utils/hooks/image-analyzer-profile-hook-injector.ts b/src/utils/hooks/image-analyzer-profile-hook-injector.ts index 4d4e4f12..94d414da 100644 --- a/src/utils/hooks/image-analyzer-profile-hook-injector.ts +++ b/src/utils/hooks/image-analyzer-profile-hook-injector.ts @@ -4,7 +4,7 @@ * Injects image analyzer hooks into per-profile settings files. * This replaces the global ~/.claude/settings.json approach. * - * Only injects for CLIProxy profiles (agy, gemini) that support vision analysis. + * Injects for profiles configured in image_analysis.provider_models. * * @module utils/hooks/image-analyzer-profile-injector */ @@ -94,14 +94,15 @@ export function ensureProfileHooks(profileName: string): boolean { return false; } - // Only inject for CLIProxy profiles with vision support - const visionProfiles = ['agy', 'gemini']; - if (!visionProfiles.includes(profileName)) { + const imageConfig = getImageAnalysisConfig(); + + // Only inject for profiles that have a model mapping in provider_models + // This allows dynamic extension without hardcoding profile names + const configuredProviders = Object.keys(imageConfig.provider_models); + if (!configuredProviders.includes(profileName)) { return false; } - const imageConfig = getImageAnalysisConfig(); - // Skip if image analysis is disabled if (!imageConfig.enabled) { return false; diff --git a/src/utils/hooks/image-read-block-hook-env.ts b/src/utils/hooks/image-read-block-hook-env.ts deleted file mode 100644 index b2cc91fc..00000000 --- a/src/utils/hooks/image-read-block-hook-env.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Image Read Block Hook Environment Variables - * - * Provides environment variables for image read blocking hook configuration. - * Prevents context overflow when skills generate images and agent tries to read them. - * - * Enabled by default for third-party profiles (settings, cliproxy). - * Disabled for native Claude accounts where context is managed server-side. - * - * @module utils/hooks/image-read-block-hook-env - */ - -import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; - -/** - * Configuration for image read blocking. - */ -export interface ImageReadBlockConfig { - /** Whether blocking is enabled (default: true) */ - enabled: boolean; -} - -/** - * Get image read block configuration from unified config. - * Defaults to ENABLED (opt-out feature) - matches WebSearch pattern. - */ -export function getImageReadBlockConfig(): ImageReadBlockConfig { - const config = loadOrCreateUnifiedConfig(); - // Access hooks config via type assertion since it's a new field not yet in UnifiedConfig type - const hooksConfig = ( - config as unknown as { hooks?: { block_image_read?: { enabled?: boolean } } } - ).hooks; - return { - // Default to TRUE - enabled by default, user can opt-out - enabled: hooksConfig?.block_image_read?.enabled ?? true, - }; -} - -/** - * Get environment variables for image read block hook configuration. - * - * Like WebSearch, this respects CCS_PROFILE_TYPE: - * - 'account' or 'default' profiles: Skip blocking (native Claude) - * - 'settings' or 'cliproxy' profiles: Apply blocking - * - * @returns Record of environment variables to set before spawning Claude - */ -export function getImageReadBlockHookEnv(): Record { - const config = getImageReadBlockConfig(); - const env: Record = {}; - - if (config.enabled) { - env.CCS_BLOCK_IMAGE_READ = '1'; - } else { - // Explicit disable signal - env.CCS_BLOCK_IMAGE_READ = '0'; - } - - return env; -} diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts index d952a096..7e05c9d5 100644 --- a/src/utils/hooks/index.ts +++ b/src/utils/hooks/index.ts @@ -6,7 +6,6 @@ * @module utils/hooks */ -export { getImageReadBlockHookEnv, getImageReadBlockConfig } from './image-read-block-hook-env'; export { getImageAnalysisHookEnv } from './get-image-analysis-hook-env'; export { getImageAnalyzerHookPath, diff --git a/src/utils/image-analysis/hook-installer.ts b/src/utils/image-analysis/hook-installer.ts index 99c4f624..3832131b 100644 --- a/src/utils/image-analysis/hook-installer.ts +++ b/src/utils/image-analysis/hook-installer.ts @@ -1,9 +1,7 @@ /** * Image Analysis Hook Installer * - * Manages installation of: - * 1. block-image-read.cjs hook (blocks image reads to prevent context overflow) - * 2. Prompt templates for image analysis (user-customizable) + * Manages installation of prompt templates for image analysis (user-customizable). * * @module utils/image-analysis/hook-installer */ @@ -13,16 +11,6 @@ import * as path from 'path'; import { info, warn } from '../ui'; import { getCcsDir } from '../config-manager'; -// Hook file name -const IMAGE_BLOCK_HOOK = 'block-image-read.cjs'; - -/** - * Get path to installed hook script - */ -export function getHookPath(): string { - return path.join(getCcsDir(), 'hooks', IMAGE_BLOCK_HOOK); -} - /** * Get CCS hooks directory */ @@ -37,72 +25,6 @@ export function getPromptsDir(): string { return path.join(getCcsDir(), 'prompts', 'image-analysis'); } -/** - * Check if image block hook is installed - */ -export function hasImageBlockHook(): boolean { - return fs.existsSync(getHookPath()); -} - -/** - * Install image block hook to ~/.ccs/hooks/ - * - * This hook intercepts Read tool calls for image files and blocks them - * to prevent context overflow (images consume 100K+ tokens each). - * - * @returns true if hook installed successfully - */ -export function installImageBlockHook(): boolean { - try { - // Ensure hooks directory exists - const hooksDir = getCcsHooksDir(); - if (!fs.existsSync(hooksDir)) { - fs.mkdirSync(hooksDir, { recursive: true, mode: 0o700 }); - } - - const hookPath = getHookPath(); - - // Find the bundled hook script - // In npm package: node_modules/ccs/lib/hooks/ - // In development: lib/hooks/ - const possiblePaths = [ - path.join(__dirname, '..', '..', '..', 'lib', 'hooks', IMAGE_BLOCK_HOOK), - path.join(__dirname, '..', '..', 'lib', 'hooks', IMAGE_BLOCK_HOOK), - path.join(__dirname, '..', 'lib', 'hooks', IMAGE_BLOCK_HOOK), - ]; - - let sourcePath: string | null = null; - for (const p of possiblePaths) { - if (fs.existsSync(p)) { - sourcePath = p; - break; - } - } - - if (!sourcePath) { - if (process.env.CCS_DEBUG) { - console.error(warn(`Image block hook source not found: ${IMAGE_BLOCK_HOOK}`)); - } - return false; - } - - // Copy hook to ~/.ccs/hooks/ - fs.copyFileSync(sourcePath, hookPath); - fs.chmodSync(hookPath, 0o755); - - if (process.env.CCS_DEBUG) { - console.error(info(`Installed image block hook: ${hookPath}`)); - } - - return true; - } catch (error) { - if (process.env.CCS_DEBUG) { - console.error(warn(`Failed to install image block hook: ${(error as Error).message}`)); - } - return false; - } -} - /** * Install prompt templates to ~/.ccs/prompts/image-analysis/ * Only installs if directory doesn't exist (doesn't overwrite user edits) @@ -179,28 +101,3 @@ export function installImageAnalysisPrompts(): boolean { return false; } } - -/** - * Uninstall image block hook from ~/.ccs/hooks/ - * - * @returns true if hook uninstalled successfully - */ -export function uninstallImageBlockHook(): boolean { - try { - const hookPath = getHookPath(); - - if (fs.existsSync(hookPath)) { - fs.unlinkSync(hookPath); - if (process.env.CCS_DEBUG) { - console.error(info(`Uninstalled image block hook: ${hookPath}`)); - } - } - - return true; - } catch (error) { - if (process.env.CCS_DEBUG) { - console.error(warn(`Failed to uninstall image block hook: ${(error as Error).message}`)); - } - return false; - } -} diff --git a/src/utils/image-analysis/index.ts b/src/utils/image-analysis/index.ts index 1fea2b08..3a322ac9 100644 --- a/src/utils/image-analysis/index.ts +++ b/src/utils/image-analysis/index.ts @@ -1,15 +1,7 @@ /** * Image Analysis Utilities * - * Exports hook installer functions for image blocking and prompt management + * Exports hook installer functions for prompt management */ -export { - getHookPath, - getCcsHooksDir, - getPromptsDir, - hasImageBlockHook, - installImageBlockHook, - installImageAnalysisPrompts, - uninstallImageBlockHook, -} from './hook-installer'; +export { getCcsHooksDir, getPromptsDir, installImageAnalysisPrompts } from './hook-installer'; diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index d60e3574..9ee00511 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -7,7 +7,6 @@ import { spawn, ChildProcess } from 'child_process'; import { ErrorManager } from './error-manager'; import { getWebSearchHookEnv } from './websearch-manager'; -import { getImageReadBlockHookEnv } from './hooks/image-read-block-hook-env'; /** * Escape arguments for shell execution (Windows compatibility) @@ -53,12 +52,11 @@ export function execClaude( // Get WebSearch hook config env vars const webSearchEnv = getWebSearchHookEnv(); - const imageReadBlockEnv = getImageReadBlockHookEnv(); // Prepare environment (merge with process.env if envVars provided) const env = envVars - ? { ...process.env, ...envVars, ...webSearchEnv, ...imageReadBlockEnv } - : { ...process.env, ...webSearchEnv, ...imageReadBlockEnv }; + ? { ...process.env, ...envVars, ...webSearchEnv } + : { ...process.env, ...webSearchEnv }; let child: ChildProcess; if (needsShell) { diff --git a/tests/e2e/image-analyzer-hook.e2e.test.ts b/tests/e2e/image-analyzer-hook.e2e.test.ts index b3da61dd..de00aa62 100644 --- a/tests/e2e/image-analyzer-hook.e2e.test.ts +++ b/tests/e2e/image-analyzer-hook.e2e.test.ts @@ -468,7 +468,7 @@ describe('Image Analyzer Hook', () => { resetMockState(); }); - it('should pass through when CLIProxy is unavailable', async () => { + it('should block when CLIProxy is unavailable to prevent context overflow', async () => { // Force hook to use a port that's definitely not running const result = invokeHook( { @@ -483,9 +483,13 @@ describe('Image Analyzer Hook', () => { } ); - // Should pass through (exit 0) when CLIProxy not available - expect(result.code).toBe(0); - expect(result.stderr).toContain('CLIProxy not available'); + // Should block (exit 2) when CLIProxy not available to prevent context overflow + expect(result.code).toBe(2); + const output = JSON.parse(result.stdout); + expect(output.decision).toBe('block'); + expect(output.hookSpecificOutput.permissionDecisionReason).toContain( + 'CLIProxy unavailable' + ); }); it('should analyze PNG via mock CLIProxy and return analysis', () => { @@ -634,7 +638,7 @@ describe('Image Analyzer Hook', () => { // Should output debug info to stderr expect(result.stderr).toContain('[CCS Hook]'); - expect(result.stderr).toContain('Analyzing'); + expect(result.stderr).toContain('Starting image analysis'); }); it('should handle API error response gracefully (pass through)', () => {