From 38eb74043c7f9e613e308392b2c159ebfc2a05c1 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 2 Feb 2026 17:28:21 -0500 Subject: [PATCH] feat(hooks): add block-image-read hook to prevent context overflow Add PreToolUse hook that intercepts Read tool calls on image files (.png, .jpg, .webp, etc.) and blocks them with helpful message. This prevents context exhaustion when image generation skills produce multiple files and the agent tries to read them (each image can consume 100K+ tokens). Configuration: - Enable via config.yaml: hooks.block_image_read.enabled: true - Or env var: CCS_BLOCK_IMAGE_READ=1 Hook integration: - lib/hooks/block-image-read.cjs - the hook script - src/utils/hooks/image-read-block-hook-env.ts - config loader - Integrated into all spawn locations (ccs.ts, shell-executor, cliproxy-executor) Closes #426 --- lib/hooks/block-image-read.cjs | 150 +++++++++++++++++++ src/ccs.ts | 5 + src/cliproxy/cliproxy-executor.ts | 3 + src/utils/hooks/image-read-block-hook-env.ts | 50 +++++++ src/utils/hooks/index.ts | 9 ++ src/utils/shell-executor.ts | 6 +- 6 files changed, 221 insertions(+), 2 deletions(-) create mode 100644 lib/hooks/block-image-read.cjs create mode 100644 src/utils/hooks/image-read-block-hook-env.ts create mode 100644 src/utils/hooks/index.ts 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) {