mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 20:17:03 +00:00
fix(image-analysis): harden runtime and provisioning
This commit is contained in:
1 parent
6d1c895721
commit
77b488f8b1
21 files changed
+811
-169
No files matched your search
@@ -9,6 +9,7 @@ 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 MAX_PROMPT_TEMPLATE_BYTES = 32 * 1024;
|
||||
const SCREENSHOT_NAME_REGEX =
|
||||
/(screen[-_ ]?shot|screen[-_ ]?capture|screencap|snapshot|snip|clip|capture)/i;
|
||||
const TEMPLATE_FILE_NAMES = {
|
||||
@@ -103,6 +104,10 @@ function selectPromptTemplate(filePath, requestedTemplate) {
|
||||
|
||||
function readPromptFile(filePath) {
|
||||
try {
|
||||
const stats = fs.statSync(filePath);
|
||||
if (stats.size > MAX_PROMPT_TEMPLATE_BYTES) {
|
||||
return null;
|
||||
}
|
||||
const content = fs.readFileSync(filePath, 'utf8').trim();
|
||||
return content.length > 0 ? content : null;
|
||||
} catch {
|
||||
@@ -189,10 +194,6 @@ function getRuntimeBaseUrl() {
|
||||
return normalizedBaseUrl;
|
||||
}
|
||||
|
||||
if (normalizedPath.length > 0 && normalizedPath !== '/') {
|
||||
return normalizedBaseUrl;
|
||||
}
|
||||
|
||||
parsed.pathname = runtimePath;
|
||||
return parsed.toString().replace(/\/+$/, '');
|
||||
} catch {
|
||||
@@ -217,6 +218,11 @@ function getApiKey() {
|
||||
return process.env.CCS_CLIPROXY_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || 'ccs-internal-managed';
|
||||
}
|
||||
|
||||
function shouldAllowSelfSigned() {
|
||||
const value = `${process.env.CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED || ''}`.trim().toLowerCase();
|
||||
return value === '1' || value === 'true' || value === 'yes';
|
||||
}
|
||||
|
||||
function getTimeoutMs(timeoutMs) {
|
||||
if (typeof timeoutMs === 'number' && timeoutMs > 0) {
|
||||
return timeoutMs;
|
||||
@@ -301,6 +307,7 @@ function analyzeViaCliProxy(base64Data, mediaType, model, prompt, timeoutMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const endpoint = new URL(getRuntimeEndpoint());
|
||||
const transport = endpoint.protocol === 'https:' ? https : http;
|
||||
const apiKey = getApiKey();
|
||||
const requestBody = JSON.stringify({
|
||||
model,
|
||||
max_tokens: 4096,
|
||||
@@ -325,9 +332,13 @@ function analyzeViaCliProxy(base64Data, mediaType, model, prompt, timeoutMs) {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(requestBody),
|
||||
'x-api-key': getApiKey(),
|
||||
'x-api-key': apiKey,
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
timeout: timeoutMs,
|
||||
...(endpoint.protocol === 'https:' && shouldAllowSelfSigned()
|
||||
? { rejectUnauthorized: false }
|
||||
: {}),
|
||||
},
|
||||
(res) => {
|
||||
let data = '';
|
||||
|
||||
@@ -55,11 +55,11 @@ function getTools() {
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
filePath: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Absolute or workspace-relative path to a local image or PDF file to analyze.',
|
||||
},
|
||||
filePath: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Workspace-relative path, or an absolute path inside the current workspace, to a local image or PDF file to analyze.',
|
||||
},
|
||||
focus: {
|
||||
type: 'string',
|
||||
description:
|
||||
@@ -128,6 +128,20 @@ function normalizeTemplate(value) {
|
||||
return TEMPLATE_NAMES.includes(normalized) ? normalized : undefined;
|
||||
}
|
||||
|
||||
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 resolveFilePath(toolArgs) {
|
||||
if (!toolArgs || typeof toolArgs !== 'object') {
|
||||
return '';
|
||||
@@ -143,6 +157,60 @@ function resolveFilePath(toolArgs) {
|
||||
return '';
|
||||
}
|
||||
|
||||
function resolveWorkspaceFilePath(toolArgs) {
|
||||
const requestedPath = resolveFilePath(toolArgs);
|
||||
if (!requestedPath) {
|
||||
return { filePath: '', error: null };
|
||||
}
|
||||
|
||||
const workspaceRoot = (() => {
|
||||
try {
|
||||
return fs.realpathSync(process.cwd());
|
||||
} catch {
|
||||
return path.resolve(process.cwd());
|
||||
}
|
||||
})();
|
||||
const absolutePath = path.resolve(process.cwd(), requestedPath);
|
||||
const comparisonPath = (() => {
|
||||
if (fs.existsSync(absolutePath)) {
|
||||
return fs.realpathSync(absolutePath);
|
||||
}
|
||||
|
||||
const suffixSegments = [];
|
||||
let currentPath = absolutePath;
|
||||
while (!fs.existsSync(currentPath)) {
|
||||
const parentPath = path.dirname(currentPath);
|
||||
if (parentPath === currentPath) {
|
||||
break;
|
||||
}
|
||||
suffixSegments.unshift(path.basename(currentPath));
|
||||
currentPath = parentPath;
|
||||
}
|
||||
|
||||
const resolvedExistingPath = fs.existsSync(currentPath)
|
||||
? fs.realpathSync(currentPath)
|
||||
: path.resolve(currentPath);
|
||||
return path.join(resolvedExistingPath, ...suffixSegments);
|
||||
})();
|
||||
|
||||
if (
|
||||
!isPathWithinWorkspace(
|
||||
normalizePathForComparison(workspaceRoot),
|
||||
normalizePathForComparison(comparisonPath)
|
||||
)
|
||||
) {
|
||||
return {
|
||||
filePath: '',
|
||||
error: 'ImageAnalysis only allows files inside the current workspace.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
filePath: absolutePath,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveFocus(toolArgs) {
|
||||
return typeof toolArgs.focus === 'string' && toolArgs.focus.trim().length > 0
|
||||
? toolArgs.focus.trim()
|
||||
@@ -213,9 +281,13 @@ async function handleToolCall(message) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = resolveFilePath(toolArgs);
|
||||
const { filePath, error: filePathError } = resolveWorkspaceFilePath(toolArgs);
|
||||
if (!filePath) {
|
||||
writeError(id, -32602, `Tool "${TOOL_NAME}" requires a non-empty filePath.`);
|
||||
writeError(
|
||||
id,
|
||||
-32602,
|
||||
filePathError || `Tool "${TOOL_NAME}" requires a non-empty filePath.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -296,22 +368,53 @@ let inputBuffer = Buffer.alloc(0);
|
||||
|
||||
function processIncomingBuffer() {
|
||||
while (true) {
|
||||
const newlineIndex = inputBuffer.indexOf(0x0a);
|
||||
if (newlineIndex === -1) {
|
||||
return;
|
||||
}
|
||||
let body;
|
||||
const startsWithLegacyHeaders = inputBuffer
|
||||
.slice(0, Math.min(inputBuffer.length, 32))
|
||||
.toString('utf8')
|
||||
.toLowerCase()
|
||||
.startsWith('content-length:');
|
||||
|
||||
const lineBuffer = inputBuffer.subarray(0, newlineIndex);
|
||||
inputBuffer = inputBuffer.subarray(newlineIndex + 1);
|
||||
const line = lineBuffer.toString('utf8').replace(/\r$/, '').trim();
|
||||
if (!line) {
|
||||
continue;
|
||||
if (startsWithLegacyHeaders) {
|
||||
const headerEnd = inputBuffer.indexOf('\r\n\r\n');
|
||||
if (headerEnd === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const headerText = inputBuffer.slice(0, headerEnd).toString('utf8');
|
||||
const contentLengthMatch = headerText.match(/content-length:\s*(\d+)/i);
|
||||
if (!contentLengthMatch) {
|
||||
inputBuffer = Buffer.alloc(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const contentLength = Number.parseInt(contentLengthMatch[1], 10);
|
||||
const messageEnd = headerEnd + 4 + contentLength;
|
||||
if (inputBuffer.length < messageEnd) {
|
||||
return;
|
||||
}
|
||||
|
||||
body = inputBuffer.slice(headerEnd + 4, messageEnd).toString('utf8');
|
||||
inputBuffer = inputBuffer.slice(messageEnd);
|
||||
} else {
|
||||
const newlineIndex = inputBuffer.indexOf('\n');
|
||||
if (newlineIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
body = inputBuffer.slice(0, newlineIndex).toString('utf8').replace(/\r$/, '').trim();
|
||||
inputBuffer = inputBuffer.slice(newlineIndex + 1);
|
||||
if (!body) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const message = JSON.parse(line);
|
||||
const message = JSON.parse(body);
|
||||
Promise.resolve(handleMessage(message)).catch((error) => {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
if (message && message.id !== undefined) {
|
||||
writeError(message.id, -32603, (error && error.message) || 'Internal error');
|
||||
} else if (process.env.CCS_DEBUG) {
|
||||
console.error(`[ccs-image-analysis] ${error instanceof Error ? error.stack : error}`);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user