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
This commit is contained in:
kaitranntt
2026-02-02 17:28:21 -05:00
parent 24b03121fd
commit 38eb74043c
6 changed files with 221 additions and 2 deletions
+150
View File
@@ -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);
}
+5
View File
@@ -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<void> {
// 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<void> {
...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);
+3
View File
@@ -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
};
@@ -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<string, string> {
const config = getImageReadBlockConfig();
const env: Record<string, string> = {};
if (config.enabled) {
env.CCS_BLOCK_IMAGE_READ = '1';
}
return env;
}
+9
View File
@@ -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';
+4 -2
View File
@@ -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) {