diff --git a/lib/hooks/block-image-read.cjs b/lib/hooks/block-image-read.cjs new file mode 100644 index 00000000..c4dceecd --- /dev/null +++ b/lib/hooks/block-image-read.cjs @@ -0,0 +1,150 @@ +#!/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. + * + * 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: + * CCS_BLOCK_IMAGE_READ=1 - Enable blocking (required) + * CCS_BLOCK_IMAGE_READ=0 - Disable blocking (allow all reads) + * 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 blocking is enabled via environment variable + */ +function isBlockingEnabled() { + // Must be explicitly enabled + return process.env.CCS_BLOCK_IMAGE_READ === '1'; +} + +/** + * Main hook processing logic + */ +function processHook() { + try { + // Skip if blocking not enabled + if (!isBlockingEnabled()) { + if (process.env.CCS_DEBUG) { + console.error('[CCS Hook] Image read blocking disabled (CCS_BLOCK_IMAGE_READ != 1)'); + } + 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/src/ccs.ts b/src/ccs.ts index 84a65178..6cf8140c 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -13,6 +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 { fail, info } from './utils/ui'; // Import centralized error handling @@ -164,10 +165,12 @@ 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 }; @@ -612,6 +615,7 @@ 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 : {}; @@ -633,6 +637,7 @@ 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); diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 0d824da4..ed98d8d8 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -37,6 +37,7 @@ 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 { supportsModelConfig, isModelBroken, getModelIssueUrl, findModel } from './model-catalog'; import { CodexReasoningProxy } from './codex-reasoning-proxy'; import { ToolSanitizationProxy } from './tool-sanitization-proxy'; @@ -944,10 +945,12 @@ export async function execClaudeWithCLIProxy( ANTHROPIC_BASE_URL: finalBaseUrl, }; const webSearchEnv = getWebSearchHookEnv(); + const imageReadBlockEnv = getImageReadBlockHookEnv(); const env = { ...process.env, ...effectiveEnvVars, ...webSearchEnv, + ...imageReadBlockEnv, CCS_PROFILE_TYPE: 'cliproxy', // Signal to WebSearch hook this is a third-party provider }; diff --git a/src/utils/hooks/image-read-block-hook-env.ts b/src/utils/hooks/image-read-block-hook-env.ts new file mode 100644 index 00000000..ad2f3333 --- /dev/null +++ b/src/utils/hooks/image-read-block-hook-env.ts @@ -0,0 +1,50 @@ +/** + * 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. + * + * @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 */ + enabled: boolean; +} + +/** + * Get image read block configuration from unified config. + * Defaults to disabled (opt-in feature). + */ +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 false - must be explicitly enabled + enabled: hooksConfig?.block_image_read?.enabled ?? false, + }; +} + +/** + * Get environment variables for image read block hook configuration. + * + * @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'; + } + + return env; +} diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts new file mode 100644 index 00000000..16449791 --- /dev/null +++ b/src/utils/hooks/index.ts @@ -0,0 +1,9 @@ +/** + * Hooks Utilities Index + * + * Centralized exports for all hook-related utilities. + * + * @module utils/hooks + */ + +export { getImageReadBlockHookEnv, getImageReadBlockConfig } from './image-read-block-hook-env'; diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index 56d1223b..ffea9d3c 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -7,6 +7,7 @@ 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) @@ -28,11 +29,12 @@ 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 } - : { ...process.env, ...webSearchEnv }; + ? { ...process.env, ...envVars, ...webSearchEnv, ...imageReadBlockEnv } + : { ...process.env, ...webSearchEnv, ...imageReadBlockEnv }; let child: ChildProcess; if (needsShell) {