mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 12:14:19 +00:00
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:
13 files changed
+173
-377
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
Reference in new issue
Block a user