fix(security): constrain image fallback hook to workspace (#1232)

This commit is contained in:
Kai (Tam Nhu) Tran
2026-05-12 18:13:32 -04:00
committed by GitHub
parent 349db830df
commit 40300c286e
3 changed files with 106 additions and 5 deletions
+49 -3
View File
@@ -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);
@@ -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,
+54 -2
View File
@@ -47,7 +47,10 @@ function enqueueResponses(...responses: MockResponse[]): void {
queuedResponses = responses;
}
function invokeHook(env: Record<string, string> = {}): Promise<HookResult> {
function invokeHook(
env: Record<string, string> = {},
options: { filePath?: string; cwd?: string } = {}
): Promise<HookResult> {
return new Promise((resolve, reject) => {
const child = spawn('node', [HOOK_PATH], {
env: {
@@ -68,6 +71,7 @@ function invokeHook(env: Record<string, string> = {}): Promise<HookResult> {
...env,
},
stdio: ['pipe', 'pipe', 'pipe'],
cwd: options.cwd ?? TEST_DIR,
});
let stdout = '';
@@ -98,7 +102,7 @@ function invokeHook(env: Record<string, string> = {}): Promise<HookResult> {
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',