Merge pull request #442 from kaitranntt/kai/feat/426-block-image-read

feat(hooks): achieve full WebSearch parity for image analyzer hooks
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-02-04 01:08:54 -05:00
commit 205f5c3315
13 files changed
+173 -377

No files matched your search

-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);
}
+131 -9
View File
@@ -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);
+11 -5
View File
@@ -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<void> {
// 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<void> {
// 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<void> {
// 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<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 : {};
@@ -637,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);
@@ -660,18 +662,22 @@ async function main(): Promise<void> {
// 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);
}
-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
};
@@ -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;
}
+3
View File
@@ -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',
},
};
@@ -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;
@@ -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) {
+9 -5
View File
@@ -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)', () => {