fix(cursor): fail closed on oversized guard input

This commit is contained in:
wellkilo committed 2026-09-07 09:27:54 +08:00
1 parent cb24b0acd7
commit 0718ec240f
10 files changed
+197 -48

No files matched your search

+36 -13
View File
@@ -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,
};
+1 -1
View File
@@ -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');
+20 -2
View File
@@ -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;
+6 -2
View File
@@ -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);
+8 -5
View File
@@ -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
View File
@@ -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, {
+8 -1
View File
@@ -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,
+98 -15
View File
@@ -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');