From 40300c286ec9f40e89cb2b040a416cba04680930 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Tue, 12 May 2026 18:13:32 -0400 Subject: [PATCH] fix(security): constrain image fallback hook to workspace (#1232) --- lib/hooks/image-analyzer-transformer.cjs | 52 ++++++++++++++++- tests/e2e/image-analyzer-hook.e2e.test.ts | 3 + tests/integration/image-analyzer-hook.test.ts | 56 ++++++++++++++++++- 3 files changed, 106 insertions(+), 5 deletions(-) diff --git a/lib/hooks/image-analyzer-transformer.cjs b/lib/hooks/image-analyzer-transformer.cjs index 6449915e..9b739ff8 100755 --- a/lib/hooks/image-analyzer-transformer.cjs +++ b/lib/hooks/image-analyzer-transformer.cjs @@ -379,6 +379,47 @@ function outputSuccess(filePath, description, model, fileSize) { /** * Determine if hook should skip, with debug logging */ +function normalizePathForComparison(value) { + return process.platform === 'win32' ? value.toLowerCase() : value; +} + +function isPathWithinWorkspace(workspaceRoot, candidatePath) { + const relativePath = path.relative(workspaceRoot, candidatePath); + return ( + relativePath === '' || + (!relativePath.startsWith('..') && + !relativePath.startsWith(`..${path.sep}`) && + !path.isAbsolute(relativePath)) + ); +} + +function resolveWorkspaceFilePath(filePath) { + const workspaceRoot = (() => { + try { + return fs.realpathSync(process.cwd()); + } catch { + return path.resolve(process.cwd()); + } + })(); + const absolutePath = path.resolve(process.cwd(), filePath); + const realFilePath = fs.realpathSync(absolutePath); + + if ( + !isPathWithinWorkspace( + normalizePathForComparison(workspaceRoot), + normalizePathForComparison(realFilePath) + ) + ) { + debugLog('Skipping: file is outside the current workspace', { + workspaceRoot, + filePath: realFilePath, + }); + return null; + } + + return realFilePath; +} + function shouldSkipHook() { if (process.env.CCS_IMAGE_ANALYSIS_SKIP_HOOK === '1') { debugLog('Skipping: CCS_IMAGE_ANALYSIS_SKIP_HOOK=1'); @@ -484,11 +525,16 @@ async function processHook() { process.exit(0); } - const debugContext = getDebugContext(filePath, null); + const workspaceFilePath = resolveWorkspaceFilePath(filePath); + if (!workspaceFilePath) { + process.exit(0); + } + + const debugContext = getDebugContext(workspaceFilePath, null); debugLog('Image analysis runtime prepared', debugContext); - const result = await analyzeFile(filePath); - outputSuccess(filePath, result.description, result.model, result.fileSize); + const result = await analyzeFile(workspaceFilePath); + outputSuccess(workspaceFilePath, result.description, result.model, result.fileSize); } catch (err) { if (process.env.CCS_DEBUG) { console.error('[CCS Hook] Error:', err.message); diff --git a/tests/e2e/image-analyzer-hook.e2e.test.ts b/tests/e2e/image-analyzer-hook.e2e.test.ts index d13a3a83..422216d7 100644 --- a/tests/e2e/image-analyzer-hook.e2e.test.ts +++ b/tests/e2e/image-analyzer-hook.e2e.test.ts @@ -169,6 +169,7 @@ function invokeHook( input: JSON.stringify(input), encoding: 'utf8', env: buildHookEnv(env), + cwd: TEST_DIR, timeout: 10000, // 10 second timeout per test }); @@ -187,6 +188,7 @@ function invokeHookAsync( const child = spawn('node', [HOOK_PATH], { env: buildHookEnv(env), stdio: ['pipe', 'pipe', 'pipe'], + cwd: TEST_DIR, }); let stdout = ''; @@ -531,6 +533,7 @@ describe('Image Analyzer Hook', () => { input: 'not valid json', encoding: 'utf8', timeout: 5000, + cwd: TEST_DIR, env: { ...process.env, CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY, diff --git a/tests/integration/image-analyzer-hook.test.ts b/tests/integration/image-analyzer-hook.test.ts index 57b8cc27..5c8d43fa 100644 --- a/tests/integration/image-analyzer-hook.test.ts +++ b/tests/integration/image-analyzer-hook.test.ts @@ -47,7 +47,10 @@ function enqueueResponses(...responses: MockResponse[]): void { queuedResponses = responses; } -function invokeHook(env: Record = {}): Promise { +function invokeHook( + env: Record = {}, + options: { filePath?: string; cwd?: string } = {} +): Promise { return new Promise((resolve, reject) => { const child = spawn('node', [HOOK_PATH], { env: { @@ -68,6 +71,7 @@ function invokeHook(env: Record = {}): Promise { ...env, }, stdio: ['pipe', 'pipe', 'pipe'], + cwd: options.cwd ?? TEST_DIR, }); let stdout = ''; @@ -98,7 +102,7 @@ function invokeHook(env: Record = {}): Promise { child.stdin.end( JSON.stringify({ tool_name: 'Read', - tool_input: { file_path: TEST_PNG_PATH }, + tool_input: { file_path: options.filePath ?? TEST_PNG_PATH }, }) ); }); @@ -198,6 +202,54 @@ describe('image analyzer hook regression coverage', () => { expect((requests[0].body as { model: string }).model).toBe('gpt-5.1-codex-mini'); }); + it('does not analyze files outside the current workspace', async () => { + const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-image-hook-outside-')); + const outsidePath = path.join(outsideDir, 'sensitive-outside-workspace.png'); + createTestPng(outsidePath); + + try { + const result = await invokeHook({}, { filePath: outsidePath }); + + expect(result.code).toBe(0); + expect(requests).toHaveLength(0); + } finally { + fs.rmSync(outsideDir, { recursive: true, force: true }); + } + }); + + it.if(process.platform === 'win32')( + 'analyzes Windows-style relative paths inside the current workspace', + async () => { + const nestedDir = path.join(TEST_DIR, 'windows-style-fixture-dir'); + const nestedPath = path.join(nestedDir, 'inside-workspace.png'); + fs.mkdirSync(nestedDir, { recursive: true }); + createTestPng(nestedPath); + + const result = await invokeHook({}, { filePath: path.win32.relative(TEST_DIR, nestedPath) }); + + expect(result.code).toBe(2); + expect(requests).toHaveLength(1); + } + ); + + it('does not analyze workspace symlinks that resolve outside the current workspace', async () => { + const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-image-hook-symlink-')); + const outsidePath = path.join(outsideDir, 'sensitive-symlink-target.png'); + const linkPath = path.join(TEST_DIR, 'linked-sensitive.png'); + createTestPng(outsidePath); + + try { + fs.symlinkSync(outsidePath, linkPath); + const result = await invokeHook({}, { filePath: linkPath }); + + expect(result.code).toBe(0); + expect(requests).toHaveLength(0); + } finally { + fs.rmSync(linkPath, { force: true }); + fs.rmSync(outsideDir, { recursive: true, force: true }); + } + }); + it('skips analysis before contacting CLIProxy when the current provider has no mapped vision model', async () => { const result = await invokeHook({ CCS_CURRENT_PROVIDER: 'unknown-provider',