diff --git a/.cursor/hooks/adapter.js b/.cursor/hooks/adapter.js index e7e44073b..e1400d820 100644 --- a/.cursor/hooks/adapter.js +++ b/.cursor/hooks/adapter.js @@ -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, }; diff --git a/.cursor/hooks/before-read-file.js b/.cursor/hooks/before-read-file.js index eaf3d3b0b..4b9bd8a80 100644 --- a/.cursor/hooks/before-read-file.js +++ b/.cursor/hooks/before-read-file.js @@ -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'); diff --git a/.cursor/hooks/before-shell-execution.js b/.cursor/hooks/before-shell-execution.js index be1fcb664..982ceccbd 100644 --- a/.cursor/hooks/before-shell-execution.js +++ b/.cursor/hooks/before-shell-execution.js @@ -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; diff --git a/.cursor/hooks/before-tab-file-read.js b/.cursor/hooks/before-tab-file-read.js index 532dc2752..57256dda6 100644 --- a/.cursor/hooks/before-tab-file-read.js +++ b/.cursor/hooks/before-tab-file-read.js @@ -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); diff --git a/.cursor/hooks/session-start.js b/.cursor/hooks/session-start.js index ec04f039a..3bb3b2adc 100644 --- a/.cursor/hooks/session-start.js +++ b/.cursor/hooks/session-start.js @@ -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))); diff --git a/.cursor/hooks/stop.js b/.cursor/hooks/stop.js index a5d75fc70..cca7a15f1 100644 --- a/.cursor/hooks/stop.js +++ b/.cursor/hooks/stop.js @@ -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, { diff --git a/scripts/hooks/stop-format-typecheck.js b/scripts/hooks/stop-format-typecheck.js index 7f2274c5a..d6e002cf4 100644 --- a/scripts/hooks/stop-format-typecheck.js +++ b/scripts/hooks/stop-format-typecheck.js @@ -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, }; diff --git a/tests/hooks/cursor-block-no-verify.test.js b/tests/hooks/cursor-block-no-verify.test.js index 2fc5e740e..921255a1c 100644 --- a/tests/hooks/cursor-block-no-verify.test.js +++ b/tests/hooks/cursor-block-no-verify.test.js @@ -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, diff --git a/tests/hooks/cursor-hook-dispatchers.test.js b/tests/hooks/cursor-hook-dispatchers.test.js index f42892d94..7d5919bc3 100644 --- a/tests/hooks/cursor-hook-dispatchers.test.js +++ b/tests/hooks/cursor-hook-dispatchers.test.js @@ -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)); diff --git a/tests/hooks/stop-format-typecheck.test.js b/tests/hooks/stop-format-typecheck.test.js index c3c01f88f..2b2504ecf 100644 --- a/tests/hooks/stop-format-typecheck.test.js +++ b/tests/hooks/stop-format-typecheck.test.js @@ -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');