refactor(hooks): deprecate block-image-read, add CLIProxy fallback

- Remove redundant block-image-read.cjs hook (image-analyzer handles all)
- Add fallback blocking when CLIProxy unavailable (prevents context overflow)
- Simplify fallback message to prevent context pollution/hallucination
- Remove image-read-block-hook-env.ts and related exports
- Update comments per PR #442 review suggestions
- Add defensive check for empty models array

Closes #426
This commit is contained in:
kaitranntt
2026-02-04 00:49:52 -05:00
parent 26f4021770
commit 51b719ef34
11 changed files with 47 additions and 362 deletions
-170
View File
@@ -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);
}
+40 -4
View File
@@ -196,6 +196,10 @@ function getModelsToTry() {
*/
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++) {
@@ -636,6 +640,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
*/
@@ -763,12 +799,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();
-5
View File
@@ -13,7 +13,6 @@ 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';
@@ -166,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
};
@@ -622,7 +619,6 @@ 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 : {};
@@ -644,7 +640,6 @@ 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,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
};
+1
View File
@@ -545,6 +545,7 @@ 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',
},
@@ -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
*/
@@ -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<string, string> {
const config = getImageReadBlockConfig();
const env: Record<string, string> = {};
if (config.enabled) {
env.CCS_BLOCK_IMAGE_READ = '1';
} else {
// Explicit disable signal
env.CCS_BLOCK_IMAGE_READ = '0';
}
return env;
}
-1
View File
@@ -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,
+1 -104
View File
@@ -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;
}
}
+2 -10
View File
@@ -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';
+2 -4
View File
@@ -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) {