mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 14:16:43 +00:00
fix(security): constrain image fallback hook to workspace (#1232)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user