mirror of
https://github.com/affaan-m/ECC.git
synced 2026-11-08 00:55:55 +00:00
fix(cursor): fail closed on oversized guard input
This commit is contained in:
1 parent
cb24b0acd7
commit
0718ec240f
10 files changed
+197
-48
No files matched your search
+36
-13
@@ -9,35 +9,46 @@ const { spawnSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
const MAX_STDIN_BYTES = 1024 * 1024;
|
||||
const STOP_FORMAT_TYPECHECK_BUDGET_MS = 210000;
|
||||
const STOP_FORMAT_TYPECHECK_TIMEOUT_HEADROOM_MS = 15000;
|
||||
|
||||
function readStdin() {
|
||||
function readStdin(options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
let data = '';
|
||||
let dataBytes = 0;
|
||||
let truncated = false;
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (data.length < MAX_STDIN) {
|
||||
const remaining = MAX_STDIN - data.length;
|
||||
data += chunk.substring(0, remaining);
|
||||
if (chunk.length > remaining) truncated = true;
|
||||
} else {
|
||||
const chunkBytes = Buffer.byteLength(chunk, 'utf8');
|
||||
if (truncated || dataBytes + chunkBytes > MAX_STDIN_BYTES) {
|
||||
truncated = true;
|
||||
return;
|
||||
}
|
||||
data += chunk;
|
||||
dataBytes += chunkBytes;
|
||||
});
|
||||
process.stdin.on('end', () => {
|
||||
if (truncated) {
|
||||
process.stderr.write(
|
||||
`[Cursor Hook] stdin exceeded ${MAX_STDIN} characters; suppressing truncated input\n`
|
||||
);
|
||||
resolve('');
|
||||
if (options.includeMetadata !== true) {
|
||||
process.stderr.write(
|
||||
`[Cursor Hook] stdin exceeded ${MAX_STDIN_BYTES} bytes; suppressing truncated input\n`
|
||||
);
|
||||
}
|
||||
resolve(options.includeMetadata === true
|
||||
? { raw: '', truncated: true }
|
||||
: '');
|
||||
return;
|
||||
}
|
||||
resolve(data);
|
||||
resolve(options.includeMetadata === true
|
||||
? { raw: data, truncated: false }
|
||||
: data);
|
||||
});
|
||||
process.stdin.on('error', error => {
|
||||
process.stderr.write(`[Cursor Hook] stdin read failed: ${error.message}\n`);
|
||||
resolve('');
|
||||
resolve(options.includeMetadata === true
|
||||
? { raw: '', truncated: false }
|
||||
: '');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -150,6 +161,17 @@ function mergeSessionStartOutputs(outputs) {
|
||||
};
|
||||
}
|
||||
|
||||
function createStopFormatTypecheckOptions(sessionEnv = {}) {
|
||||
return {
|
||||
timeout: STOP_FORMAT_TYPECHECK_BUDGET_MS + STOP_FORMAT_TYPECHECK_TIMEOUT_HEADROOM_MS,
|
||||
forwardStderr: true,
|
||||
env: {
|
||||
...sessionEnv,
|
||||
ECC_STOP_FORMAT_TYPECHECK_BUDGET_MS: String(STOP_FORMAT_TYPECHECK_BUDGET_MS),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function runExistingHook(scriptName, stdinData, options = {}) {
|
||||
try {
|
||||
const scriptPath = resolveRuntimePath('scripts', 'hooks', scriptName);
|
||||
@@ -222,6 +244,7 @@ module.exports = {
|
||||
transformToClaude,
|
||||
normalizeSessionStartOutput,
|
||||
mergeSessionStartOutputs,
|
||||
createStopFormatTypecheckOptions,
|
||||
runExistingHook,
|
||||
hookEnabled,
|
||||
};
|
||||
@@ -7,7 +7,7 @@ readStdin().then(raw => {
|
||||
}
|
||||
try {
|
||||
const input = JSON.parse(raw);
|
||||
const filePath = input.path || input.file || '';
|
||||
const filePath = input.file_path || input.path || input.file || '';
|
||||
if (/\.(env|key|pem)$|\.env\.|credentials|secret/i.test(filePath)) {
|
||||
console.error('[ECC] WARNING: Reading sensitive file: ' + filePath);
|
||||
console.error('[ECC] Ensure this data is not exposed in outputs');
|
||||
|
||||
@@ -6,8 +6,26 @@ const {
|
||||
transformToClaude,
|
||||
} = require('./adapter');
|
||||
|
||||
readStdin()
|
||||
.then(raw => {
|
||||
readStdin({ includeMetadata: true })
|
||||
.then(({ raw, truncated }) => {
|
||||
if (truncated) {
|
||||
const hasBlockingGuard = (
|
||||
hookEnabled('pre:bash:block-no-verify', ['minimal', 'standard', 'strict'])
|
||||
|| (
|
||||
process.platform !== 'win32'
|
||||
&& hookEnabled('pre:bash:dev-server-block', ['standard', 'strict'])
|
||||
)
|
||||
);
|
||||
if (hasBlockingGuard) {
|
||||
console.error(
|
||||
'[Cursor Hook] stdin exceeded the safety limit; blocking beforeShellExecution'
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
console.error('[Cursor Hook] stdin exceeded the safety limit; suppressing truncated input');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let input;
|
||||
let cmd;
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
#!/usr/bin/env node
|
||||
const { readStdin, hookEnabled } = require('./adapter');
|
||||
readStdin().then(raw => {
|
||||
readStdin({ includeMetadata: true }).then(({ raw, truncated }) => {
|
||||
if (!hookEnabled('pre:tab-read:sensitive-file-block', ['minimal', 'standard', 'strict'])) {
|
||||
process.stdout.write(raw);
|
||||
return;
|
||||
}
|
||||
if (truncated) {
|
||||
console.error('[Cursor Hook] stdin exceeded the safety limit; blocking beforeTabFileRead');
|
||||
process.exit(2);
|
||||
}
|
||||
try {
|
||||
const input = JSON.parse(raw);
|
||||
const filePath = input.path || input.file || '';
|
||||
const filePath = input.file_path || input.path || input.file || '';
|
||||
if (/\.(env|key|pem)$|\.env\.|credentials|secret/i.test(filePath)) {
|
||||
console.error('[ECC] BLOCKED: Tab cannot read sensitive file: ' + filePath);
|
||||
process.exit(2);
|
||||
|
||||
@@ -23,7 +23,7 @@ readStdin().then(raw => {
|
||||
hook_event_name: 'SessionStart',
|
||||
source: 'startup',
|
||||
};
|
||||
const outputs = [];
|
||||
let outputs = [];
|
||||
if (!hookEnabled('session:start', ['minimal', 'standard', 'strict'])) {
|
||||
process.stdout.write(JSON.stringify({}));
|
||||
return;
|
||||
@@ -34,9 +34,9 @@ readStdin().then(raw => {
|
||||
});
|
||||
if (envResult && envResult.status === 0 && envResult.stdout) {
|
||||
try {
|
||||
outputs.push(normalizeSessionStartOutput(envResult.stdout));
|
||||
outputs = [...outputs, normalizeSessionStartOutput(envResult.stdout)];
|
||||
} catch {
|
||||
// The shared session-start hook can still run without optional env output.
|
||||
console.error('[Cursor Hook] Ignoring invalid Cursor session environment output');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,9 +52,12 @@ readStdin().then(raw => {
|
||||
});
|
||||
if (sessionResult && sessionResult.status === 0 && sessionResult.stdout) {
|
||||
try {
|
||||
outputs.push(normalizeSessionStartOutput(sessionResult.stdout, { shared: true }));
|
||||
outputs = [
|
||||
...outputs,
|
||||
normalizeSessionStartOutput(sessionResult.stdout, { shared: true }),
|
||||
];
|
||||
} catch {
|
||||
// Invalid optional context output must not block Cursor startup.
|
||||
console.error('[Cursor Hook] Ignoring invalid shared SessionStart output');
|
||||
}
|
||||
}
|
||||
process.stdout.write(JSON.stringify(mergeSessionStartOutputs(outputs)));
|
||||
|
||||
+12
-9
@@ -1,5 +1,11 @@
|
||||
#!/usr/bin/env node
|
||||
const { readStdin, runExistingHook, transformToClaude, hookEnabled } = require('./adapter');
|
||||
const {
|
||||
createStopFormatTypecheckOptions,
|
||||
hookEnabled,
|
||||
readStdin,
|
||||
runExistingHook,
|
||||
transformToClaude,
|
||||
} = require('./adapter');
|
||||
readStdin().then(raw => {
|
||||
const input = JSON.parse(raw || '{}');
|
||||
const claudeInput = transformToClaude(input);
|
||||
@@ -19,14 +25,11 @@ readStdin().then(raw => {
|
||||
runExistingHook('cost-tracker.js', claudeInput, sharedOptions);
|
||||
}
|
||||
if (hookEnabled('stop:format-typecheck', ['standard', 'strict'])) {
|
||||
runExistingHook('stop-format-typecheck.js', claudeInput, {
|
||||
timeout: 225000,
|
||||
forwardStderr: true,
|
||||
env: {
|
||||
...sharedOptions.env,
|
||||
ECC_STOP_FORMAT_TYPECHECK_BUDGET_MS: '210000',
|
||||
},
|
||||
});
|
||||
runExistingHook(
|
||||
'stop-format-typecheck.js',
|
||||
claudeInput,
|
||||
createStopFormatTypecheckOptions(sharedOptions.env)
|
||||
);
|
||||
}
|
||||
if (hookEnabled('stop:check-console-log', ['standard', 'strict'])) {
|
||||
runExistingHook('check-console-log.js', claudeInput, {
|
||||
|
||||
@@ -38,6 +38,12 @@ function getTotalBudgetMs(env = process.env) {
|
||||
: DEFAULT_TOTAL_BUDGET_MS;
|
||||
}
|
||||
|
||||
function getPerBatchBudgetMs(totalBudgetMs, totalBatches) {
|
||||
return totalBatches > 0
|
||||
? Math.max(1, Math.floor(totalBudgetMs / totalBatches))
|
||||
: 60_000;
|
||||
}
|
||||
|
||||
// Characters cmd.exe treats as separators/operators when shell: true is used.
|
||||
// Includes spaces and parentheses to guard paths like "C:\Users\John Doe\...".
|
||||
const UNSAFE_PATH_CHARS = /[&|<>^%!\s()]/;
|
||||
@@ -208,7 +214,7 @@ function main() {
|
||||
// stays within the Stop hook wall-clock limit even in large monorepos.
|
||||
const totalBatches = byProjectRoot.size + byTsConfigDir.size;
|
||||
const totalBudgetMs = getTotalBudgetMs();
|
||||
const perBatchMs = totalBatches > 0 ? Math.floor(totalBudgetMs / totalBatches) : 60_000;
|
||||
const perBatchMs = getPerBatchBudgetMs(totalBudgetMs, totalBatches);
|
||||
|
||||
for (const [root, batch] of byProjectRoot) formatBatch(root, batch, perBatchMs);
|
||||
for (const [tsDir, batch] of byTsConfigDir) typecheckBatch(tsDir, batch, perBatchMs);
|
||||
@@ -264,4 +270,5 @@ module.exports = {
|
||||
parseAccumulator,
|
||||
isPluginClonePath,
|
||||
getTotalBudgetMs,
|
||||
getPerBatchBudgetMs,
|
||||
};
|
||||
@@ -26,6 +26,7 @@ function runWrapper(input, env = {}) {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
ECC_HOOKS_ENABLED: 'true',
|
||||
ECC_HOOK_PROFILE: 'standard',
|
||||
ECC_DISABLED_HOOKS: 'pre:bash:dev-server-block',
|
||||
...env,
|
||||
|
||||
@@ -30,6 +30,7 @@ const PRE_COMPACT = path.join(REPO_ROOT, '.cursor', 'hooks', 'pre-compact.js');
|
||||
const SUBAGENT_START = path.join(REPO_ROOT, '.cursor', 'hooks', 'subagent-start.js');
|
||||
const SUBAGENT_STOP = path.join(REPO_ROOT, '.cursor', 'hooks', 'subagent-stop.js');
|
||||
const AFTER_FILE_EDIT = path.join(REPO_ROOT, '.cursor', 'hooks', 'after-file-edit.js');
|
||||
const { createStopFormatTypecheckOptions } = require('../../.cursor/hooks/adapter');
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
@@ -147,7 +148,17 @@ function createInstalledCursorFixture(options = {}) {
|
||||
[
|
||||
"'use strict';",
|
||||
"const fs = require('fs');",
|
||||
"fs.appendFileSync(process.env.CURSOR_HOOK_LOG, 'stop-format-typecheck\\n');",
|
||||
"fs.appendFileSync(process.env.CURSOR_HOOK_LOG, `stop-format-typecheck:${process.env.ECC_STOP_FORMAT_TYPECHECK_BUDGET_MS}\\n`);",
|
||||
"process.stdout.write(require('fs').readFileSync(0, 'utf8'));",
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
writeFile(
|
||||
path.join(scriptsRoot, 'hooks', 'session-end.js'),
|
||||
[
|
||||
"'use strict';",
|
||||
"const fs = require('fs');",
|
||||
"fs.appendFileSync(process.env.CURSOR_HOOK_LOG, 'session-end\\n');",
|
||||
"process.stdout.write(require('fs').readFileSync(0, 'utf8'));",
|
||||
'',
|
||||
].join('\n')
|
||||
@@ -266,6 +277,16 @@ if (test('Cursor-native security hooks honor the master hook switch', () => {
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('Cursor-sensitive file hooks recognize the official file_path field', () => {
|
||||
const warning = runHook(BEFORE_READ, { file_path: '.env' });
|
||||
assert.strictEqual(warning.status, 0, warning.stderr);
|
||||
assert.match(warning.stderr, /Reading sensitive file/);
|
||||
|
||||
const blocked = runHook(BEFORE_TAB_READ, { file_path: 'credentials.pem' });
|
||||
assert.strictEqual(blocked.status, 2, blocked.stderr);
|
||||
assert.match(blocked.stderr, /BLOCKED/);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('Cursor-native audit hooks stay disabled in the minimal profile', () => {
|
||||
const cases = [
|
||||
[BEFORE_MCP, { server: 'example', tool: 'read' }],
|
||||
@@ -403,14 +424,32 @@ if (test('afterFileEdit forwards user-visible console warnings', () => {
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('Stop reserves time for lifecycle hooks before the format batch', () => {
|
||||
const source = fs.readFileSync(STOP, 'utf8');
|
||||
assert.ok(
|
||||
source.indexOf("runExistingHook('session-end.js'")
|
||||
< source.indexOf("runExistingHook('stop-format-typecheck.js'"),
|
||||
'Session persistence should run before the long format/typecheck batch'
|
||||
);
|
||||
assert.match(source, /timeout:\s*225000/);
|
||||
assert.match(source, /ECC_STOP_FORMAT_TYPECHECK_BUDGET_MS:\s*'210000'/);
|
||||
const fixture = createInstalledCursorFixture();
|
||||
try {
|
||||
const result = runHook(fixture.stop, { conversation_id: 'ordered-stop' }, {
|
||||
CURSOR_HOOK_LOG: fixture.logPath,
|
||||
ECC_DISABLED_HOOKS: [
|
||||
'stop:check-console-log',
|
||||
'stop:evaluate-session',
|
||||
'stop:cost-tracker',
|
||||
].join(','),
|
||||
}, fixture.projectRoot);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.deepStrictEqual(
|
||||
fs.readFileSync(fixture.logPath, 'utf8').trim().split('\n'),
|
||||
['session-end', 'stop-format-typecheck:210000']
|
||||
);
|
||||
assert.deepStrictEqual(createStopFormatTypecheckOptions({ CLAUDE_SESSION_ID: 'abc' }), {
|
||||
timeout: 225000,
|
||||
forwardStderr: true,
|
||||
env: {
|
||||
CLAUDE_SESSION_ID: 'abc',
|
||||
ECC_STOP_FORMAT_TYPECHECK_BUDGET_MS: '210000',
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(fixture.projectRoot, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('Stop dispatcher leaves batch format/typecheck disabled in minimal profile', () => {
|
||||
@@ -519,7 +558,10 @@ if (test('installed Stop dispatcher resolves hooks inside .cursor/scripts', () =
|
||||
fixture.projectRoot
|
||||
);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.strictEqual(fs.readFileSync(fixture.logPath, 'utf8'), 'stop-format-typecheck\n');
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(fixture.logPath, 'utf8'),
|
||||
'stop-format-typecheck:210000\n'
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(fixture.projectRoot, { recursive: true, force: true });
|
||||
}
|
||||
@@ -567,15 +609,56 @@ if (test('sessionStart dispatcher honors the master hook switch', () => {
|
||||
assert.deepStrictEqual(JSON.parse(result.stdout), {});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('oversized Cursor input is never echoed as truncated JSON', () => {
|
||||
if (test('oversized beforeShellExecution input fails closed', () => {
|
||||
const oversizedPayloads = [
|
||||
JSON.stringify({
|
||||
command: 'git commit --no-verify',
|
||||
padding: 'x'.repeat(1024 * 1024),
|
||||
}),
|
||||
JSON.stringify({
|
||||
command: 'git commit --no-verify',
|
||||
padding: '界'.repeat(400000),
|
||||
}),
|
||||
];
|
||||
for (const oversized of oversizedPayloads) {
|
||||
assert.ok(Buffer.byteLength(oversized, 'utf8') > 1024 * 1024);
|
||||
const result = runHook(BEFORE_SHELL, oversized);
|
||||
assert.strictEqual(result.status, 2, result.stderr);
|
||||
assert.strictEqual(result.stdout, '');
|
||||
assert.match(result.stderr, /stdin exceeded.*blocking/i);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('oversized beforeShellExecution input honors explicit guard disablement', () => {
|
||||
const oversized = JSON.stringify({
|
||||
command: 'echo safe',
|
||||
command: 'git commit --no-verify',
|
||||
padding: 'x'.repeat(1024 * 1024),
|
||||
});
|
||||
const result = runHook(BEFORE_SHELL, oversized);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
for (const env of [
|
||||
{ ECC_HOOKS_ENABLED: 'false' },
|
||||
{
|
||||
ECC_DISABLED_HOOKS: [
|
||||
'pre:bash:block-no-verify',
|
||||
'pre:bash:dev-server-block',
|
||||
].join(','),
|
||||
},
|
||||
]) {
|
||||
const result = runHook(BEFORE_SHELL, oversized, env);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.strictEqual(result.stdout, '');
|
||||
assert.match(result.stderr, /stdin exceeded.*suppressing/i);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('oversized beforeTabFileRead input fails closed', () => {
|
||||
const oversized = JSON.stringify({
|
||||
file_path: '.env',
|
||||
padding: '界'.repeat(400000),
|
||||
});
|
||||
const result = runHook(BEFORE_TAB_READ, oversized);
|
||||
assert.strictEqual(result.status, 2, result.stderr);
|
||||
assert.strictEqual(result.stdout, '');
|
||||
assert.match(result.stderr, /stdin exceeded/);
|
||||
assert.match(result.stderr, /stdin exceeded.*blocking/i);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log('-'.repeat(55));
|
||||
|
||||
@@ -14,6 +14,7 @@ const path = require('path');
|
||||
|
||||
const accumulator = require('../../scripts/hooks/post-edit-accumulator');
|
||||
const {
|
||||
getPerBatchBudgetMs,
|
||||
getTotalBudgetMs,
|
||||
isPluginClonePath,
|
||||
parseAccumulator,
|
||||
@@ -222,6 +223,12 @@ if (test('getTotalBudgetMs honors a bounded dispatcher budget', () => {
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('getPerBatchBudgetMs always returns a positive child timeout', () => {
|
||||
assert.strictEqual(getPerBatchBudgetMs(210000, 2), 105000);
|
||||
assert.strictEqual(getPerBatchBudgetMs(210000, 210001), 1);
|
||||
assert.strictEqual(getPerBatchBudgetMs(210000, 0), 60000);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('stop hook clears accumulator after processing duplicates', () => {
|
||||
cleanAccumFile();
|
||||
fs.writeFileSync(getAccumFile(), '/nonexistent/x.ts\n/nonexistent/x.ts\n/nonexistent/y.ts\n', 'utf8');
|
||||
|
||||
Reference in new issue
Block a user