feat(hooks): add image/PDF analysis via CLIProxy transformer

Intercept Read tool calls for image/PDF files and route through CLIProxy
with gemini-2.5-flash for vision analysis. Returns text descriptions
instead of blocking, enabling Claude to "see" images via proxy.

Key changes:
- Add image-analyzer-transformer.cjs hook script
- Add ImageAnalysisConfig type and loader
- Add hook installer and profile injector
- Add prompt templates for analysis customization
- Add e2e test suite (excluded from normal CI runs)
- Configure test:e2e script for manual testing

Environment variables:
- CCS_IMAGE_ANALYSIS_ENABLED: Enable/disable (default: 1)
- CCS_IMAGE_ANALYSIS_MODEL: Vision model (default: gemini-2.5-flash)
- CCS_IMAGE_ANALYSIS_TIMEOUT: Timeout in seconds (default: 60)
- CCS_CLIPROXY_API_KEY: API key for CLIProxy auth
- CCS_CLIPROXY_PORT: CLIProxy port (default: 8317)

Closes #426
This commit is contained in:
kaitranntt
2026-02-03 20:34:05 -05:00
parent 9f3edc5daf
commit d5f2acaa6e
16 changed files with 1953 additions and 1 deletions
+2
View File
@@ -1,5 +1,7 @@
[test]
# Exclude UI tests - they use vitest and require jsdom environment
# Run UI tests separately with: cd ui && bun run test
# Exclude e2e tests - they require manual setup and are slow
# Run e2e tests with: bun run test:e2e
root = "./tests"
timeout = 10000
+399
View File
@@ -0,0 +1,399 @@
#!/usr/bin/env node
/**
* CCS Image Analyzer Hook - Read Tool Interceptor
*
* Intercepts Claude's Read tool for image/PDF files and analyzes them via CLIProxy.
* Returns detailed text descriptions instead of allowing direct visual access.
*
* Environment Variables (set by CCS):
* CCS_IMAGE_ANALYSIS_SKIP=1 - Skip this hook entirely
* CCS_IMAGE_ANALYSIS_ENABLED=1 - Enable image analysis (default: 1)
* CCS_IMAGE_ANALYSIS_MODEL - Model to use (default: gemini-2.5-flash)
* CCS_IMAGE_ANALYSIS_TIMEOUT=60 - Timeout in seconds (default: 60)
* CCS_PROFILE_TYPE - Profile type (account/default skip)
* CCS_DEBUG=1 - Enable debug output
*
* Exit codes:
* 0 - Allow tool (pass-through to native Read)
* 2 - Block tool (deny with analysis/message)
*
* @module hooks/image-analyzer-transformer
*/
const fs = require('fs');
const path = require('path');
const http = require('http');
// ============================================================================
// PLATFORM DETECTION
// ============================================================================
const isWindows = process.platform === 'win32';
// ============================================================================
// CONFIGURATION
// ============================================================================
const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.heic', '.bmp', '.tiff'];
const PDF_EXTENSIONS = ['.pdf'];
const DEFAULT_MODEL = 'gemini-2.5-flash';
const DEFAULT_TIMEOUT_SEC = 60;
const MAX_FILE_SIZE_MB = 10;
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
const CLIPROXY_HOST = '127.0.0.1';
const CLIPROXY_PORT = parseInt(process.env.CCS_CLIPROXY_PORT || '8317', 10);
const CLIPROXY_PATH = '/v1/messages';
// API key passed via env from cliproxy-executor, defaults to CCS internal key
const CLIPROXY_API_KEY = process.env.CCS_CLIPROXY_API_KEY || 'ccs-internal-managed';
// Default analysis prompt
const DEFAULT_PROMPT = `Analyze this image/document thoroughly and provide a detailed description.
Include:
1. Overall content and purpose
2. Text content (if any) - transcribe important text
3. Visual elements (diagrams, charts, UI components)
4. Layout and structure
5. Colors, styling, notable design elements
6. Any actionable information (buttons, links, code)
Be comprehensive - this description replaces direct visual access.`;
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
/**
* Check if file is an analyzable image or PDF
*/
function isAnalyzableFile(filePath) {
const ext = path.extname(filePath).toLowerCase();
return IMAGE_EXTENSIONS.includes(ext) || PDF_EXTENSIONS.includes(ext);
}
/**
* Get MIME type from file extension
*/
function getMediaType(filePath) {
const ext = path.extname(filePath).toLowerCase();
const mimeTypes = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.heic': 'image/heic',
'.bmp': 'image/bmp',
'.tiff': 'image/tiff',
'.pdf': 'application/pdf',
};
return mimeTypes[ext] || 'application/octet-stream';
}
/**
* Encode file to base64
*/
function encodeFileToBase64(filePath) {
const content = fs.readFileSync(filePath);
return content.toString('base64');
}
/**
* Check if CLIProxy is available
*/
function isCliProxyAvailable() {
return new Promise((resolve) => {
const req = http.request(
{
hostname: CLIPROXY_HOST,
port: CLIPROXY_PORT,
path: '/',
method: 'GET',
timeout: 2000,
},
(res) => {
resolve(res.statusCode >= 200 && res.statusCode < 500);
}
);
req.on('error', () => resolve(false));
req.on('timeout', () => {
req.destroy();
resolve(false);
});
req.end();
});
}
/**
* Analyze file via CLIProxy vision API
*/
function analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs) {
return new Promise((resolve, reject) => {
const requestBody = JSON.stringify({
model: model,
max_tokens: 4096,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: DEFAULT_PROMPT },
{
type: 'image',
source: {
type: 'base64',
media_type: mediaType,
data: base64Data,
},
},
],
},
],
});
const req = http.request(
{
hostname: CLIPROXY_HOST,
port: CLIPROXY_PORT,
path: CLIPROXY_PATH,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(requestBody),
'x-api-key': CLIPROXY_API_KEY,
},
timeout: timeoutMs,
},
(res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
if (res.statusCode !== 200) {
reject(new Error(`CLIProxy returned status ${res.statusCode}: ${data}`));
return;
}
try {
const response = JSON.parse(data);
const text = response.content?.[0]?.text;
if (!text) {
reject(new Error('No text content in response'));
return;
}
resolve(text);
} catch (err) {
reject(new Error(`Failed to parse response: ${err.message}`));
}
});
}
);
req.on('error', (err) => reject(err));
req.on('timeout', () => {
req.destroy();
reject(new Error('Request timed out'));
});
req.write(requestBody);
req.end();
});
}
/**
* Format analysis description for Claude
*/
function formatDescription(filePath, description, model) {
return [
`## Image Analysis: ${path.basename(filePath)}`,
'',
description,
'',
'---',
`*Analyzed via CLIProxy (${model})*`,
].join('\n');
}
/**
* Output success response and exit
*/
function outputSuccess(filePath, description, model) {
const formattedDescription = formatDescription(filePath, description, model);
const output = {
decision: 'block',
reason: `Image analyzed: ${path.basename(filePath)}`,
systemMessage: `[Image Analysis] ${path.basename(filePath)} analyzed via CLIProxy`,
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: formattedDescription,
},
};
console.log(JSON.stringify(output));
process.exit(2);
}
/**
* Output error message
*/
function outputError(filePath, error) {
const message = [
`[Image Analysis - Error]`,
'',
`Failed to analyze: ${path.basename(filePath)}`,
'',
`Error: ${error}`,
'',
'Troubleshooting:',
' - Check CLIProxy is running: http://127.0.0.1:8317',
' - Verify you are authenticated with agy or gemini',
' - Check file size is under 10MB',
].join('\n');
const output = {
decision: 'block',
reason: `Image analysis failed: ${error}`,
systemMessage: `[Image Analysis] Failed to analyze ${path.basename(filePath)}`,
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: message,
},
};
console.log(JSON.stringify(output));
process.exit(2);
}
/**
* Determine if hook should skip
*/
function shouldSkipHook() {
// Explicit skip signal
if (process.env.CCS_IMAGE_ANALYSIS_SKIP === '1') return true;
// Explicit disable
if (process.env.CCS_IMAGE_ANALYSIS_ENABLED === '0') return true;
// Account/default profiles - use native Read
const profileType = process.env.CCS_PROFILE_TYPE;
if (profileType === 'account' || profileType === 'default') return true;
return false;
}
// ============================================================================
// MAIN HOOK LOGIC
// ============================================================================
// 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);
});
/**
* Main hook processing logic
*/
async 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 (!filePath) {
process.exit(0);
}
// Check if file exists
if (!fs.existsSync(filePath)) {
// Let native Read handle the error
process.exit(0);
}
// Check if file is analyzable
if (!isAnalyzableFile(filePath)) {
process.exit(0);
}
// Check file size
const stats = fs.statSync(filePath);
if (stats.size > MAX_FILE_SIZE_BYTES) {
outputError(filePath, `File too large (${(stats.size / 1024 / 1024).toFixed(2)}MB > ${MAX_FILE_SIZE_MB}MB)`);
return;
}
// Check CLIProxy availability
const cliProxyAvailable = await isCliProxyAvailable();
if (!cliProxyAvailable) {
if (process.env.CCS_DEBUG) {
console.error('[CCS Hook] CLIProxy not available, passing through');
}
// Pass through to native Read
process.exit(0);
}
const model = process.env.CCS_IMAGE_ANALYSIS_MODEL || DEFAULT_MODEL;
const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10);
const timeoutMs = timeout * 1000;
if (process.env.CCS_DEBUG) {
console.error(`[CCS Hook] Analyzing ${path.basename(filePath)} via CLIProxy (${model})`);
}
// Encode file to base64
const base64Data = encodeFileToBase64(filePath);
const mediaType = getMediaType(filePath);
// Analyze via CLIProxy
const description = await analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs);
// Output success
outputSuccess(filePath, description, model);
} catch (err) {
if (process.env.CCS_DEBUG) {
console.error('[CCS Hook] Error:', err.message);
}
// Try to extract file path from parsed input
let filePath = 'unknown file';
try {
const data = JSON.parse(input);
filePath = data.tool_input?.file_path || 'unknown file';
} catch {
// Ignore parse errors
}
// Output error
outputError(filePath, err.message || 'Unknown error');
}
}
+12
View File
@@ -0,0 +1,12 @@
Analyze this image/document thoroughly and provide a detailed description.
Include:
1. Overall content and purpose
2. Text content (if any) - transcribe important text verbatim
3. Visual elements (diagrams, charts, UI components, icons)
4. Layout and structure (sections, hierarchy, flow)
5. Colors, styling, notable design elements
6. Any actionable information (buttons, links, code snippets)
Be comprehensive - this description replaces direct visual access.
The AI assistant reading this cannot see the original image.
+13
View File
@@ -0,0 +1,13 @@
Analyze this document/PDF thoroughly for a developer.
Extract and provide:
1. Document title, type, and structure
2. All text content - transcribe in reading order
3. Tables - format as markdown tables
4. Lists and bullet points - preserve structure
5. Code blocks or technical content
6. Diagrams or flowcharts - describe in detail
7. Headers and section organization
8. Any important metadata visible
Accuracy in text extraction is critical.
+13
View File
@@ -0,0 +1,13 @@
Analyze this screenshot in detail for a developer who cannot see it.
Focus on:
1. Application/website type and state
2. UI elements visible (buttons, inputs, menus, modals)
3. All text content - transcribe exactly
4. Error messages or notifications (quote exactly)
5. Layout and component hierarchy
6. Interactive elements and their states
7. Console output or logs if visible
8. Any code snippets shown
Be precise - this enables the assistant to help debug or understand the UI.
+2 -1
View File
@@ -68,10 +68,11 @@
"verify:bundle": "node scripts/verify-bundle.js",
"test": "bun run build && bun run test:all",
"test:ci": "bun run test:all",
"test:all": "bun test",
"test:all": "bun test tests/unit tests/integration tests/npm",
"test:unit": "bun test tests/unit/",
"test:npm": "bun test tests/npm/",
"test:native": "bash tests/native/unix/edge-cases.sh",
"test:e2e": "bun test tests/e2e/ --bail --timeout 60000",
"dev": "bun run build:server && bun dist/ccs.js config --dev",
"dev:symlink": "bash scripts/dev-symlink.sh",
"dev:unlink": "bash scripts/dev-symlink.sh --restore",
+24
View File
@@ -20,9 +20,11 @@ import {
DEFAULT_QUOTA_MANAGEMENT_CONFIG,
DEFAULT_THINKING_CONFIG,
DEFAULT_DASHBOARD_AUTH_CONFIG,
DEFAULT_IMAGE_ANALYSIS_CONFIG,
GlobalEnvConfig,
ThinkingConfig,
DashboardAuthConfig,
ImageAnalysisConfig,
} from './unified-config-types';
import { isUnifiedConfigEnabled } from './feature-flags';
@@ -293,6 +295,13 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
partial.dashboard_auth?.session_timeout_hours ??
DEFAULT_DASHBOARD_AUTH_CONFIG.session_timeout_hours,
},
// Image analysis config - enabled by default for CLIProxy providers
image_analysis: {
enabled: partial.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled,
model: partial.image_analysis?.model ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.model,
timeout: partial.image_analysis?.timeout ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.timeout,
providers: partial.image_analysis?.providers ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.providers,
},
};
}
@@ -721,3 +730,18 @@ export function getDashboardAuthConfig(): DashboardAuthConfig {
session_timeout_hours: config.dashboard_auth?.session_timeout_hours ?? 24,
};
}
/**
* Get image_analysis configuration.
* Returns defaults if not configured.
*/
export function getImageAnalysisConfig(): ImageAnalysisConfig {
const config = loadOrCreateUnifiedConfig();
return {
enabled: config.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled,
model: config.image_analysis?.model ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.model,
timeout: config.image_analysis?.timeout ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.timeout,
providers: config.image_analysis?.providers ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.providers,
};
}
+29
View File
@@ -518,6 +518,32 @@ export const DEFAULT_DASHBOARD_AUTH_CONFIG: DashboardAuthConfig = {
session_timeout_hours: 24,
};
/**
* Image analysis configuration.
* Routes image/PDF files through CLIProxy for vision analysis.
*/
export interface ImageAnalysisConfig {
/** Enable image analysis via CLIProxy (default: true for agy/gemini) */
enabled: boolean;
/** Model to use for analysis (default: gemini-2.5-flash) */
model: string;
/** Timeout in seconds (default: 60) */
timeout: number;
/** Providers to enable for (default: ['agy', 'gemini']) */
providers: string[];
}
/**
* Default image analysis configuration.
* Enabled by default for CLIProxy providers with vision support.
*/
export const DEFAULT_IMAGE_ANALYSIS_CONFIG: ImageAnalysisConfig = {
enabled: true,
model: 'gemini-2.5-flash',
timeout: 60,
providers: ['agy', 'gemini'],
};
/**
* Main unified configuration structure.
* Stored in ~/.ccs/config.yaml
@@ -551,6 +577,8 @@ export interface UnifiedConfig {
thinking?: ThinkingConfig;
/** Dashboard authentication configuration (optional) */
dashboard_auth?: DashboardAuthConfig;
/** Image analysis configuration (vision via CLIProxy) */
image_analysis?: ImageAnalysisConfig;
}
/**
@@ -644,6 +672,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
quota_management: { ...DEFAULT_QUOTA_MANAGEMENT_CONFIG },
thinking: { ...DEFAULT_THINKING_CONFIG },
dashboard_auth: { ...DEFAULT_DASHBOARD_AUTH_CONFIG },
image_analysis: { ...DEFAULT_IMAGE_ANALYSIS_CONFIG },
};
}
@@ -0,0 +1,33 @@
/**
* Image Analysis Hook Environment Variables
*
* Provides environment variables for image analysis hook configuration.
* Hook routes image/PDF files through CLIProxy for vision analysis.
*
* @module utils/hooks/image-analysis-hook-env
*/
import { getImageAnalysisConfig } from '../../config/unified-config-loader';
/**
* Get image analysis hook environment variables.
* These env vars control the hook's behavior via Claude Code hook system.
*
* @param profileName - Current profile name (to determine if native Claude)
* @returns Environment variables for image analysis hook
*/
export function getImageAnalysisHookEnv(profileName?: string): Record<string, string> {
const config = getImageAnalysisConfig();
// Native Claude profiles (no CLIProxy) should skip image analysis
const isNativeProfile = !profileName || ['claude', 'anthropic'].includes(profileName);
const skipImageAnalysis = isNativeProfile || !config.enabled;
return {
CCS_IMAGE_ANALYSIS_ENABLED: config.enabled ? '1' : '0',
CCS_IMAGE_ANALYSIS_MODEL: config.model,
CCS_IMAGE_ANALYSIS_TIMEOUT: config.timeout.toString(),
CCS_IMAGE_ANALYSIS_PROVIDERS: config.providers.join(','),
CCS_IMAGE_ANALYSIS_SKIP: skipImageAnalysis ? '1' : '0',
};
}
@@ -0,0 +1,55 @@
/**
* Image Analyzer Hook Configuration
*
* Manages hook configuration for image analysis in Claude settings.
*
* @module utils/hooks/image-analyzer-hook-config
*/
import * as path from 'path';
import { getImageAnalysisConfig } from '../../config/unified-config-loader';
import { getCcsDir } from '../config-manager';
// Hook file name
const IMAGE_ANALYZER_HOOK = 'image-analyzer-transformer.cjs';
/**
* Get CCS hooks directory (respects CCS_HOME for test isolation)
*/
export function getCcsHooksDir(): string {
return path.join(getCcsDir(), 'hooks');
}
/**
* Get path to image analyzer hook
*/
export function getImageAnalyzerHookPath(): string {
return path.join(getCcsHooksDir(), IMAGE_ANALYZER_HOOK);
}
/**
* Get hook config for settings.json injection
* Timeout includes buffer for CLI overhead
*/
export function getImageAnalyzerHookConfig(): Record<string, unknown> {
const hookPath = getImageAnalyzerHookPath();
const imageConfig = getImageAnalysisConfig();
// Add 5 second buffer to analysis timeout for hook execution overhead
const hookTimeout = imageConfig.timeout * 1000 + 5000;
return {
PreToolUse: [
{
matcher: 'Read',
hooks: [
{
type: 'command',
command: `node "${hookPath}"`,
timeout: hookTimeout,
},
],
},
],
};
}
@@ -0,0 +1,137 @@
/**
* Image Analyzer Hook Installer
*
* Manages installation and uninstallation of the image analyzer hook.
* This hook intercepts Read tool calls and analyzes image files via CLIProxy.
*
* @module utils/hooks/image-analyzer-hook-installer
*/
import * as fs from 'fs';
import * as path from 'path';
import { info, warn } from '../ui';
import { getImageAnalyzerHookPath, getCcsHooksDir } from './image-analyzer-hook-configuration';
import { getImageAnalysisConfig } from '../../config/unified-config-loader';
import { removeMigrationMarker } from './image-analyzer-profile-hook-injector';
// Re-export from hook-configuration for backward compatibility
export {
getImageAnalyzerHookPath,
getImageAnalyzerHookConfig,
} from './image-analyzer-hook-configuration';
// Hook file name
const IMAGE_ANALYZER_HOOK = 'image-analyzer-transformer.cjs';
/**
* Check if image analyzer hook is installed
*/
export function hasImageAnalyzerHook(): boolean {
return fs.existsSync(getImageAnalyzerHookPath());
}
/**
* Install image analyzer hook to ~/.ccs/hooks/
*
* This hook intercepts Read calls and analyzes images via CLIProxy.
*
* @returns true if hook installed successfully
*/
export function installImageAnalyzerHook(): boolean {
try {
const imageConfig = getImageAnalysisConfig();
// Skip if disabled
if (!imageConfig.enabled) {
if (process.env.CCS_DEBUG) {
console.error(info('Image analysis disabled - skipping hook install'));
}
return false;
}
// Ensure hooks directory exists
const hooksDir = getCcsHooksDir();
if (!fs.existsSync(hooksDir)) {
fs.mkdirSync(hooksDir, { recursive: true, mode: 0o700 });
}
const hookPath = getImageAnalyzerHookPath();
// 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_ANALYZER_HOOK),
path.join(__dirname, '..', '..', 'lib', 'hooks', IMAGE_ANALYZER_HOOK),
path.join(__dirname, '..', 'lib', 'hooks', IMAGE_ANALYZER_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 analyzer hook source not found: ${IMAGE_ANALYZER_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 analyzer hook: ${hookPath}`));
}
// Note: Hook registration is handled by ensureProfileHooks() in image-analyzer-profile-injector.ts
// which writes to per-profile settings (~/.ccs/<profile>.settings.json)
// Global settings (~/.claude/settings.json) are NOT modified here
return true;
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(warn(`Failed to install image analyzer hook: ${(error as Error).message}`));
}
return false;
}
}
/**
* Uninstall image analyzer hook from ~/.ccs/hooks/
*
* Note: Does NOT touch global ~/.claude/settings.json.
* Profile-specific hooks are removed when ~/.ccs/ is deleted.
*
* @returns true if hook uninstalled successfully
*/
export function uninstallImageAnalyzerHook(): boolean {
try {
const hookPath = getImageAnalyzerHookPath();
if (fs.existsSync(hookPath)) {
fs.unlinkSync(hookPath);
if (process.env.CCS_DEBUG) {
console.error(info(`Uninstalled image analyzer hook: ${hookPath}`));
}
}
// Remove migration marker (so fresh install re-runs migration)
removeMigrationMarker();
// Note: Do NOT call removeHookConfig() - global settings should not be touched.
// Per-profile hooks in ~/.ccs/*.settings.json are cleaned up when ~/.ccs/ is deleted.
return true;
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(warn(`Failed to uninstall image analyzer hook: ${(error as Error).message}`));
}
return false;
}
}
@@ -0,0 +1,253 @@
/**
* Image Analyzer Profile Hook Injector
*
* 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.
*
* @module utils/hooks/image-analyzer-profile-injector
*/
import * as fs from 'fs';
import * as path from 'path';
import { info, warn } from '../ui';
import {
getImageAnalyzerHookConfig,
getImageAnalyzerHookPath,
} from './image-analyzer-hook-configuration';
import { getImageAnalysisConfig } from '../../config/unified-config-loader';
import { getCcsDir } from '../config-manager';
// Valid profile name pattern (alphanumeric, dash, underscore only)
const VALID_PROFILE_NAME = /^[a-zA-Z0-9_-]+$/;
/**
* Get migration marker path (respects CCS_HOME for test isolation)
*/
function getMigrationMarkerPath(): string {
return path.join(getCcsDir(), '.image-analyzer-hook-migrated');
}
/**
* Check if CCS image analyzer hook exists in settings
*/
function hasCcsHook(settings: Record<string, unknown>): boolean {
const hooks = settings.hooks as Record<string, unknown[]> | undefined;
if (!hooks?.PreToolUse) return false;
return hooks.PreToolUse.some((h: unknown) => {
const hook = h as Record<string, unknown>;
if (hook.matcher !== 'Read') return false;
const hookArray = hook.hooks as Array<Record<string, unknown>> | undefined;
const command = hookArray?.[0]?.command;
if (typeof command !== 'string') return false;
const normalized = command
.replace(/\\/g, '/') // Windows backslashes
.replace(/\/+/g, '/'); // Collapse multiple slashes
return normalized.includes('.ccs/hooks/image-analyzer-transformer');
});
}
/**
* One-time migration marker management
*/
function migrateGlobalHook(): void {
const markerPath = getMigrationMarkerPath();
if (fs.existsSync(markerPath)) {
return; // Already migrated
}
try {
// No global hook to migrate (image analyzer is profile-only from the start)
// Just create marker to prevent future migration attempts
const ccsDir = getCcsDir();
if (!fs.existsSync(ccsDir)) {
fs.mkdirSync(ccsDir, { recursive: true, mode: 0o700 });
}
// Create marker file atomically (wx = fail if exists, prevents race condition)
fs.writeFileSync(markerPath, new Date().toISOString(), { encoding: 'utf8', flag: 'wx' });
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(warn(`Migration failed: ${(error as Error).message}`));
}
}
}
/**
* Ensure image analyzer hook is configured in profile's settings file
*
* Only injects for CLIProxy profiles with vision support (agy, gemini).
*
* @param profileName - Name of the profile (e.g., 'agy', 'gemini')
* @returns true if hook is configured (existing or newly added)
*/
export function ensureProfileHooks(profileName: string): boolean {
try {
// Validate profile name to prevent path traversal
if (!VALID_PROFILE_NAME.test(profileName)) {
if (process.env.CCS_DEBUG) {
console.error(warn(`Invalid profile name: ${profileName}`));
}
return false;
}
// Only inject for CLIProxy profiles with vision support
const visionProfiles = ['agy', 'gemini'];
if (!visionProfiles.includes(profileName)) {
return false;
}
const imageConfig = getImageAnalysisConfig();
// Skip if image analysis is disabled
if (!imageConfig.enabled) {
return false;
}
// One-time migration marker
migrateGlobalHook();
// Get CCS directory (respects CCS_HOME for test isolation)
const ccsDir = getCcsDir();
// Ensure CCS dir exists
if (!fs.existsSync(ccsDir)) {
fs.mkdirSync(ccsDir, { recursive: true, mode: 0o700 });
}
const settingsPath = path.join(ccsDir, `${profileName}.settings.json`);
// Read existing settings or create empty
let settings: Record<string, unknown> = {};
if (fs.existsSync(settingsPath)) {
try {
const content = fs.readFileSync(settingsPath, 'utf8');
settings = JSON.parse(content);
} catch (parseError) {
if (process.env.CCS_DEBUG) {
console.error(
warn(`Malformed ${profileName}.settings.json: ${(parseError as Error).message}`)
);
}
// Continue with empty settings, will add hooks
}
}
// Check if CCS hook already present
if (hasCcsHook(settings)) {
// Update timeout if needed
return updateHookTimeoutIfNeeded(settings, settingsPath);
}
// Get hook config
const hookConfig = getImageAnalyzerHookConfig();
// Ensure hooks structure exists
if (!settings.hooks) {
settings.hooks = {};
}
const settingsHooks = settings.hooks as Record<string, unknown[]>;
if (!settingsHooks.PreToolUse) {
settingsHooks.PreToolUse = [];
}
// Add CCS hook
const preToolUseHooks = hookConfig.PreToolUse as unknown[];
settingsHooks.PreToolUse.push(...preToolUseHooks);
// Write updated settings
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
if (process.env.CCS_DEBUG) {
console.error(info(`Added image analyzer hook to ${profileName}.settings.json`));
}
return true;
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(warn(`Failed to inject hook: ${(error as Error).message}`));
}
return false;
}
}
/**
* Update hook timeout if it differs from current config
*/
function updateHookTimeoutIfNeeded(
settings: Record<string, unknown>,
settingsPath: string
): boolean {
try {
const hooks = settings.hooks as Record<string, unknown[]>;
const hookConfig = getImageAnalyzerHookConfig();
const expectedHookPath = getImageAnalyzerHookPath();
const expectedCommand = `node "${expectedHookPath}"`;
const expectedHooks = (hookConfig.PreToolUse as Array<Record<string, unknown>>)[0]
.hooks as Array<Record<string, unknown>>;
const expectedTimeout = expectedHooks[0].timeout as number;
let needsUpdate = false;
for (const h of hooks.PreToolUse) {
const hook = h as Record<string, unknown>;
if (hook.matcher !== 'Read') continue;
const hookArray = hook.hooks as Array<Record<string, unknown>>;
if (!hookArray?.[0]?.command) continue;
const command = hookArray[0].command;
if (typeof command !== 'string') continue;
// Normalize path separators for cross-platform matching (Windows uses backslashes)
const normalizedCommand = command
.replace(/\\/g, '/') // Windows backslashes
.replace(/\/+/g, '/'); // Collapse multiple slashes
if (!normalizedCommand.includes('.ccs/hooks/image-analyzer-transformer')) continue;
// Found CCS hook - check if needs update
if (hookArray[0].command !== expectedCommand) {
hookArray[0].command = expectedCommand;
needsUpdate = true;
}
if (hookArray[0].timeout !== expectedTimeout) {
hookArray[0].timeout = expectedTimeout;
needsUpdate = true;
}
}
if (needsUpdate) {
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
if (process.env.CCS_DEBUG) {
console.error(info('Updated image analyzer hook timeout in profile settings'));
}
}
return true;
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(warn(`updateHookTimeoutIfNeeded failed: ${(error as Error).message}`));
}
return false;
}
}
/**
* Remove migration marker (called during uninstall)
*/
export function removeMigrationMarker(): void {
try {
const markerPath = getMigrationMarkerPath();
if (fs.existsSync(markerPath)) {
fs.unlinkSync(markerPath);
}
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(warn(`removeMigrationMarker failed: ${(error as Error).message}`));
}
}
}
+9
View File
@@ -7,3 +7,12 @@
*/
export { getImageReadBlockHookEnv, getImageReadBlockConfig } from './image-read-block-hook-env';
export { getImageAnalysisHookEnv } from './get-image-analysis-hook-env';
export {
getImageAnalyzerHookPath,
getImageAnalyzerHookConfig,
hasImageAnalyzerHook,
installImageAnalyzerHook,
uninstallImageAnalyzerHook,
} from './image-analyzer-hook-installer';
export { ensureProfileHooks as ensureImageAnalyzerProfileHooks } from './image-analyzer-profile-hook-injector';
+206
View File
@@ -0,0 +1,206 @@
/**
* 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)
*
* @module utils/image-analysis/hook-installer
*/
import * as fs from 'fs';
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
*/
export function getCcsHooksDir(): string {
return path.join(getCcsDir(), 'hooks');
}
/**
* Get prompts directory for image analysis
*/
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)
*
* @returns true if prompts installed or already exist
*/
export function installImageAnalysisPrompts(): boolean {
try {
const promptsDir = getPromptsDir();
// Skip if already exists (preserve user customizations)
if (fs.existsSync(promptsDir)) {
if (process.env.CCS_DEBUG) {
console.error(
info('Image analysis prompts already installed - preserving user customizations')
);
}
return true;
}
// Create directory
fs.mkdirSync(promptsDir, { recursive: true, mode: 0o755 });
// Find bundled prompts
const possibleBasePaths = [
path.join(__dirname, '..', '..', '..', 'lib', 'prompts'),
path.join(__dirname, '..', '..', 'lib', 'prompts'),
path.join(__dirname, '..', 'lib', 'prompts'),
];
let promptsBasePath: string | null = null;
for (const p of possibleBasePaths) {
if (fs.existsSync(p)) {
promptsBasePath = p;
break;
}
}
if (!promptsBasePath) {
if (process.env.CCS_DEBUG) {
console.error(warn('Image analysis prompts source not found'));
}
return false;
}
// Copy prompt files
const promptFiles = [
'image-analysis-default.txt',
'image-analysis-screenshot.txt',
'image-analysis-document.txt',
];
for (const file of promptFiles) {
const sourcePath = path.join(promptsBasePath, file);
const destPath = path.join(promptsDir, file.replace('image-analysis-', ''));
if (fs.existsSync(sourcePath)) {
fs.copyFileSync(sourcePath, destPath);
fs.chmodSync(destPath, 0o644);
} else if (process.env.CCS_DEBUG) {
console.error(warn(`Prompt template not found: ${file}`));
}
}
if (process.env.CCS_DEBUG) {
console.error(info(`Installed image analysis prompts: ${promptsDir}`));
}
return true;
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(warn(`Failed to install image analysis prompts: ${(error as Error).message}`));
}
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;
}
}
+15
View File
@@ -0,0 +1,15 @@
/**
* Image Analysis Utilities
*
* Exports hook installer functions for image blocking and prompt management
*/
export {
getHookPath,
getCcsHooksDir,
getPromptsDir,
hasImageBlockHook,
installImageBlockHook,
installImageAnalysisPrompts,
uninstallImageBlockHook,
} from './hook-installer';
+751
View File
@@ -0,0 +1,751 @@
/**
* E2E Tests for Image Analyzer Hook
*
* ⚠️ NOT RUN IN NORMAL CI/CD - This is an E2E test file (.e2e.ts)
*
* Run manually with: bun test tests/integration/hooks/image-analyzer-hook.e2e.ts --bail
*
* Tests the image-analyzer-transformer.cjs hook with:
* - Generated test fixtures with predictable content
* - Mock CLIProxy server for reliable, fast tests
* - Direct hook invocation via stdin
*
* Uses a mock HTTP server that returns predictable responses to verify
* the hook correctly formats requests and parses responses.
*
* Use --bail flag to exit on first failure (recommended for long tests).
*/
import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as http from 'http';
// ============================================================================
// TEST CONFIGURATION
// ============================================================================
const HOOK_PATH = path.join(__dirname, '../../lib/hooks/image-analyzer-transformer.cjs');
const TEST_DIR = '/tmp/ccs-hook-tests';
const MOCK_PORT = 59876; // Use a unique port for mock server
const CLIPROXY_API_KEY = 'test-api-key-12345';
// ============================================================================
// MOCK SERVER
// ============================================================================
interface MockServerRequest {
method: string;
path: string;
headers: Record<string, string | string[] | undefined>;
body: unknown;
}
let mockServer: http.Server | null = null;
let lastRequest: MockServerRequest | null = null;
let mockResponse: { content: string; statusCode: number } = {
content: 'This is a test image showing a red pixel.',
statusCode: 200,
};
/**
* Start mock CLIProxy server
*/
function startMockServer(): Promise<void> {
return new Promise((resolve, reject) => {
mockServer = http.createServer((req, res) => {
// Health check endpoint - always return 200
if (req.method === 'GET' && req.url === '/') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
return;
}
let body = '';
req.on('data', (chunk) => {
body += chunk.toString();
});
req.on('end', () => {
// Capture request for verification
lastRequest = {
method: req.method || 'GET',
path: req.url || '/',
headers: req.headers,
body: body ? JSON.parse(body) : null,
};
// Return mock response in Anthropic format
if (mockResponse.statusCode !== 200) {
res.writeHead(mockResponse.statusCode, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: { message: 'Mock error' } }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
content: [{ type: 'text', text: mockResponse.content }],
})
);
});
});
mockServer.on('error', reject);
mockServer.listen(MOCK_PORT, '127.0.0.1', () => {
resolve();
});
});
}
/**
* Stop mock CLIProxy server
*/
function stopMockServer(): Promise<void> {
return new Promise((resolve) => {
if (mockServer) {
mockServer.close(() => {
mockServer = null;
resolve();
});
} else {
resolve();
}
});
}
/**
* Reset mock server state between tests
*/
function resetMockState(): void {
lastRequest = null;
mockResponse = {
content: 'This is a test image showing a red pixel.',
statusCode: 200,
};
}
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
/**
* Invoke the hook with JSON input
*/
function invokeHook(
input: object,
env: Record<string, string> = {}
): { code: number; stdout: string; stderr: string } {
const result = spawnSync('node', [HOOK_PATH], {
input: JSON.stringify(input),
encoding: 'utf8',
env: {
...process.env,
CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY,
CCS_CLIPROXY_PORT: String(MOCK_PORT),
...env,
},
timeout: 10000, // 10 second timeout per test
});
return {
code: result.status ?? -1,
stdout: result.stdout || '',
stderr: result.stderr || '',
};
}
/**
* Create a minimal valid PNG file (1x1 red pixel)
*/
function createTestPng(filepath: string): void {
// 1x1 PNG with a red pixel (RGB: 255, 0, 0)
const png = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR chunk
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, // IDAT chunk (red pixel)
0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x01, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d,
0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, // IEND chunk
0x44, 0xae, 0x42, 0x60, 0x82,
]);
fs.writeFileSync(filepath, png);
}
/**
* Create a minimal valid JPEG file
*/
function createTestJpeg(filepath: string): void {
const jpeg = Buffer.from([
0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x01, 0x00, 0x48,
0x00, 0x48, 0x00, 0x00, 0xff, 0xdb, 0x00, 0x43, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, 0x01,
0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xff, 0xc4, 0x00, 0x14, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x09, 0xff, 0xc4, 0x00, 0x14,
0x10, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00, 0x7f, 0xff, 0xd9,
]);
fs.writeFileSync(filepath, jpeg);
}
/**
* Create a test text file
*/
function createTestTextFile(filepath: string, content: string): void {
fs.writeFileSync(filepath, content, 'utf8');
}
/**
* Create a large file exceeding 10MB
*/
function createLargeFile(filepath: string, sizeMB: number): void {
const bufferSize = 1024 * 1024; // 1MB
const totalBuffers = sizeMB;
const buffer = Buffer.alloc(bufferSize, 'A');
const stream = fs.createWriteStream(filepath);
for (let i = 0; i < totalBuffers; i++) {
stream.write(buffer);
}
stream.end();
}
// ============================================================================
// TEST SUITE
// ============================================================================
describe('Image Analyzer Hook', () => {
let testPngPath: string;
let testJpegPath: string;
let testTextPath: string;
beforeAll(async () => {
// Create test directory
if (!fs.existsSync(TEST_DIR)) {
fs.mkdirSync(TEST_DIR, { recursive: true });
}
// Start mock server
await startMockServer();
console.log(`[Test Setup] Mock CLIProxy started on port ${MOCK_PORT}`);
// Create test files
testPngPath = path.join(TEST_DIR, 'test-image.png');
testJpegPath = path.join(TEST_DIR, 'test-image.jpg');
testTextPath = path.join(TEST_DIR, 'test-file.txt');
createTestPng(testPngPath);
createTestJpeg(testJpegPath);
createTestTextFile(testTextPath, 'This is a test file.');
});
afterAll(async () => {
// Stop mock server
await stopMockServer();
// Clean up test files
const filesToClean = [testPngPath, testJpegPath, testTextPath];
for (const f of filesToClean) {
if (f && fs.existsSync(f)) {
try {
fs.unlinkSync(f);
} catch {
// Ignore cleanup errors
}
}
}
if (fs.existsSync(TEST_DIR)) {
try {
fs.rmdirSync(TEST_DIR);
} catch {
// Ignore if not empty
}
}
});
// ==========================================================================
// GROUP A: FILE DETECTION AND FILTERING
// ==========================================================================
describe('File Detection and Filtering', () => {
it('should pass through non-Read tools', () => {
const result = invokeHook({
tool_name: 'Write',
tool_input: { file_path: testPngPath, content: 'test' },
});
expect(result.code).toBe(0);
});
it('should pass through Read tool for non-image files (.txt)', () => {
const result = invokeHook({
tool_name: 'Read',
tool_input: { file_path: testTextPath },
});
expect(result.code).toBe(0);
});
it('should pass through Read tool for .ts files', () => {
const result = invokeHook({
tool_name: 'Read',
tool_input: { file_path: '/tmp/test.ts' },
});
expect(result.code).toBe(0);
});
it('should pass through Read tool for .md files', () => {
const result = invokeHook({
tool_name: 'Read',
tool_input: { file_path: '/tmp/test.md' },
});
expect(result.code).toBe(0);
});
it('should pass through Read tool for .json files', () => {
const result = invokeHook({
tool_name: 'Read',
tool_input: { file_path: '/tmp/test.json' },
});
expect(result.code).toBe(0);
});
it('should pass through files that do not exist', () => {
const result = invokeHook({
tool_name: 'Read',
tool_input: { file_path: '/tmp/nonexistent-file-12345.png' },
});
// Should pass through to let native Read handle the error
expect(result.code).toBe(0);
});
});
// ==========================================================================
// GROUP B: ENVIRONMENT VARIABLE CONTROLS
// ==========================================================================
describe('Environment Variable Controls', () => {
it('should skip when CCS_IMAGE_ANALYSIS_SKIP=1', () => {
const result = invokeHook(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
},
{ CCS_IMAGE_ANALYSIS_SKIP: '1' }
);
expect(result.code).toBe(0);
});
it('should skip when CCS_IMAGE_ANALYSIS_ENABLED=0', () => {
const result = invokeHook(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
},
{ CCS_IMAGE_ANALYSIS_ENABLED: '0' }
);
expect(result.code).toBe(0);
});
it('should skip for account profile type', () => {
const result = invokeHook(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
},
{ CCS_PROFILE_TYPE: 'account' }
);
expect(result.code).toBe(0);
});
it('should skip for default profile type', () => {
const result = invokeHook(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
},
{ CCS_PROFILE_TYPE: 'default' }
);
expect(result.code).toBe(0);
});
});
// ==========================================================================
// GROUP C: INPUT VALIDATION
// ==========================================================================
describe('Input Validation', () => {
it('should handle missing file_path gracefully', () => {
const result = invokeHook({
tool_name: 'Read',
tool_input: {},
});
expect(result.code).toBe(0);
});
it('should handle empty file_path', () => {
const result = invokeHook({
tool_name: 'Read',
tool_input: { file_path: '' },
});
expect(result.code).toBe(0);
});
it('should handle malformed JSON input', () => {
const hookProcess = spawnSync('node', [HOOK_PATH], {
input: 'not valid json',
encoding: 'utf8',
timeout: 5000,
env: { ...process.env, CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY },
});
// Should exit with error (code 2)
expect(hookProcess.status).toBe(2);
});
});
// ==========================================================================
// GROUP D: FILE SIZE LIMITS
// ==========================================================================
describe('File Size Limits', () => {
it('should reject files larger than 10MB', () => {
// Create 11MB file
const largePath = path.join(TEST_DIR, 'large-test.png');
createLargeFile(largePath, 11);
const result = invokeHook(
{
tool_name: 'Read',
tool_input: { file_path: largePath },
},
{ CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' }
);
// Should block with error
expect(result.code).toBe(2);
const output = JSON.parse(result.stdout);
expect(output.decision).toBe('block');
expect(output.hookSpecificOutput.permissionDecisionReason).toContain('File too large');
// Cleanup
if (fs.existsSync(largePath)) fs.unlinkSync(largePath);
});
});
// ==========================================================================
// GROUP E: MOCK CLIPROXY INTEGRATION (FAST, RELIABLE)
// ==========================================================================
describe('CLIProxy Integration (Mock Server)', () => {
beforeAll(() => {
resetMockState();
});
it('should pass through when CLIProxy is unavailable', async () => {
// Force hook to use a port that's definitely not running
const result = invokeHook(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
},
{
CCS_IMAGE_ANALYSIS_ENABLED: '1',
CCS_PROFILE_TYPE: 'cliproxy',
CCS_CLIPROXY_PORT: '59999', // Non-existent port
CCS_DEBUG: '1',
}
);
// Should pass through (exit 0) when CLIProxy not available
expect(result.code).toBe(0);
expect(result.stderr).toContain('CLIProxy not available');
});
it('should analyze PNG via mock CLIProxy and return analysis', () => {
resetMockState();
mockResponse = {
content: 'This image shows a small red square, likely a single pixel or very minimal graphic.',
statusCode: 200,
};
const result = invokeHook(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
},
{ CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' }
);
// Should block with analysis (exit 2)
expect(result.code).toBe(2);
const output = JSON.parse(result.stdout);
expect(output.decision).toBe('block');
expect(output.hookSpecificOutput.permissionDecision).toBe('deny');
expect(output.hookSpecificOutput.permissionDecisionReason).toContain('red square');
});
it('should analyze JPEG via mock CLIProxy', () => {
resetMockState();
mockResponse = {
content: 'A minimalist white image, possibly a blank canvas or placeholder.',
statusCode: 200,
};
const result = invokeHook(
{
tool_name: 'Read',
tool_input: { file_path: testJpegPath },
},
{ CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' }
);
expect(result.code).toBe(2);
const output = JSON.parse(result.stdout);
expect(output.decision).toBe('block');
expect(output.hookSpecificOutput.permissionDecisionReason).toContain('white image');
});
it('should include API key in request header', () => {
resetMockState();
invokeHook(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
},
{ CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' }
);
// Verify API key was sent
expect(lastRequest).not.toBeNull();
expect(lastRequest?.headers['x-api-key']).toBe(CLIPROXY_API_KEY);
});
it('should send correct request format to CLIProxy', () => {
resetMockState();
invokeHook(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
},
{
CCS_IMAGE_ANALYSIS_ENABLED: '1',
CCS_PROFILE_TYPE: 'cliproxy',
CCS_IMAGE_ANALYSIS_MODEL: 'gemini-2.5-flash',
}
);
// Verify request format
expect(lastRequest).not.toBeNull();
expect(lastRequest?.method).toBe('POST');
expect(lastRequest?.path).toBe('/v1/messages');
const body = lastRequest?.body as {
model: string;
max_tokens: number;
messages: Array<{
role: string;
content: Array<{
type: string;
text?: string;
source?: { type: string; media_type: string; data: string };
}>;
}>;
};
expect(body.model).toBe('gemini-2.5-flash');
expect(body.max_tokens).toBe(4096);
expect(body.messages).toHaveLength(1);
expect(body.messages[0].role).toBe('user');
// Should have text prompt and image content
const content = body.messages[0].content;
expect(content.some((c) => c.type === 'text')).toBe(true);
expect(content.some((c) => c.type === 'image')).toBe(true);
// Verify image is base64 encoded
const imageContent = content.find((c) => c.type === 'image');
expect(imageContent?.source?.type).toBe('base64');
expect(imageContent?.source?.media_type).toBe('image/png');
expect(imageContent?.source?.data).toBeDefined();
});
it('should use correct media type for JPEG', () => {
resetMockState();
invokeHook(
{
tool_name: 'Read',
tool_input: { file_path: testJpegPath },
},
{ CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' }
);
const body = lastRequest?.body as {
messages: Array<{
content: Array<{
type: string;
source?: { media_type: string };
}>;
}>;
};
const imageContent = body.messages[0].content.find((c) => c.type === 'image');
expect(imageContent?.source?.media_type).toBe('image/jpeg');
});
it('should respect debug mode and output debug messages', () => {
resetMockState();
const result = invokeHook(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
},
{ CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy', CCS_DEBUG: '1' }
);
// Should output debug info to stderr
expect(result.stderr).toContain('[CCS Hook]');
expect(result.stderr).toContain('Analyzing');
});
it('should handle API error response gracefully (pass through)', () => {
resetMockState();
mockResponse = {
content: '',
statusCode: 500,
};
const result = invokeHook(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
},
{ CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' }
);
// On API error, hook blocks with error message (exit 2)
// This ensures Claude knows the analysis failed rather than silently passing through
expect(result.code).toBe(2);
const output = JSON.parse(result.stdout);
expect(output.decision).toBe('block');
expect(output.hookSpecificOutput.permissionDecisionReason).toContain('Error');
});
it('should use default model when CCS_IMAGE_ANALYSIS_MODEL is not set', () => {
resetMockState();
invokeHook(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
},
{ CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' }
);
const body = lastRequest?.body as { model: string };
expect(body.model).toBe('gemini-2.5-flash'); // Default model
});
});
// ==========================================================================
// GROUP F: OUTPUT FORMAT VALIDATION
// ==========================================================================
describe('Output Format Validation', () => {
it('should output valid JSON structure on success', () => {
resetMockState();
const result = invokeHook(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
},
{ CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' }
);
expect(result.code).toBe(2);
const output = JSON.parse(result.stdout);
// Validate structure
expect(output.decision).toBe('block');
expect(output.reason).toBeDefined();
expect(output.systemMessage).toBeDefined();
expect(output.hookSpecificOutput).toBeDefined();
expect(output.hookSpecificOutput.hookEventName).toBe('PreToolUse');
expect(output.hookSpecificOutput.permissionDecision).toBe('deny');
expect(output.hookSpecificOutput.permissionDecisionReason).toBeDefined();
});
it('should include filename in output', () => {
resetMockState();
const result = invokeHook(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
},
{ CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' }
);
const output = JSON.parse(result.stdout);
expect(output.hookSpecificOutput.permissionDecisionReason).toContain('test-image.png');
});
it('should include model name in output', () => {
resetMockState();
const result = invokeHook(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
},
{ CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' }
);
const output = JSON.parse(result.stdout);
expect(output.hookSpecificOutput.permissionDecisionReason).toContain('gemini-2.5-flash');
});
it('should output valid JSON structure on file read error', () => {
// Create and immediately delete file to trigger error
const errorPath = path.join(TEST_DIR, 'error-test.png');
createTestPng(errorPath);
// Make file unreadable (simulate permission error)
fs.chmodSync(errorPath, 0o000);
const result = invokeHook(
{
tool_name: 'Read',
tool_input: { file_path: errorPath },
},
{ CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' }
);
// Restore permissions and cleanup
fs.chmodSync(errorPath, 0o644);
fs.unlinkSync(errorPath);
// Should output error in JSON format
expect(result.code).toBe(2);
const output = JSON.parse(result.stdout);
expect(output.decision).toBe('block');
expect(output.hookSpecificOutput.permissionDecisionReason).toContain('Error');
});
});
});