mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-18 22:13:45 +02:00
merge: preserve tested commit-review source 2974
This commit is contained in:
+5
-9
@@ -5,7 +5,7 @@
|
||||
{
|
||||
"command": "node .cursor/hooks/session-start.js",
|
||||
"event": "sessionStart",
|
||||
"description": "Load previous context and detect environment"
|
||||
"description": "Set the Cursor data root, load previous context, and detect the environment"
|
||||
}
|
||||
],
|
||||
"sessionEnd": [
|
||||
@@ -16,15 +16,10 @@
|
||||
}
|
||||
],
|
||||
"beforeShellExecution": [
|
||||
{
|
||||
"command": "node .cursor/hooks/before-shell-execution-block-no-verify.js",
|
||||
"event": "beforeShellExecution",
|
||||
"description": "Block git hook-bypass flag to protect pre-commit, commit-msg, and pre-push hooks from being skipped"
|
||||
},
|
||||
{
|
||||
"command": "node .cursor/hooks/before-shell-execution.js",
|
||||
"event": "beforeShellExecution",
|
||||
"description": "Tmux dev server blocker, tmux reminder, git push review"
|
||||
"description": "Dispatch git hook-bypass protection, dev-server blocking, tmux reminders, and git push review"
|
||||
}
|
||||
],
|
||||
"afterShellExecution": [
|
||||
@@ -38,7 +33,7 @@
|
||||
{
|
||||
"command": "node .cursor/hooks/after-file-edit.js",
|
||||
"event": "afterFileEdit",
|
||||
"description": "Auto-format, TypeScript check, console.log warning, and frontend design-quality reminder"
|
||||
"description": "Accumulate JS/TS edits, warn on console.log, and run the frontend design-quality reminder"
|
||||
}
|
||||
],
|
||||
"beforeMCPExecution": [
|
||||
@@ -108,7 +103,8 @@
|
||||
{
|
||||
"command": "node .cursor/hooks/stop.js",
|
||||
"event": "stop",
|
||||
"description": "Console.log audit on all modified files"
|
||||
"description": "Batch format and typecheck edits, audit console.log usage, and persist session state",
|
||||
"timeout": 300
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+200
-30
@@ -5,31 +5,113 @@
|
||||
* then delegates to existing scripts/hooks/*.js
|
||||
*/
|
||||
|
||||
const { execFileSync } = require('child_process');
|
||||
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) => {
|
||||
const stream = options.stream || process.stdin;
|
||||
let data = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (data.length < MAX_STDIN) data += chunk.substring(0, MAX_STDIN - data.length);
|
||||
let dataBytes = 0;
|
||||
let truncated = false;
|
||||
stream.setEncoding('utf8');
|
||||
stream.on('data', chunk => {
|
||||
const chunkBytes = Buffer.byteLength(chunk, 'utf8');
|
||||
if (truncated || dataBytes + chunkBytes > MAX_STDIN_BYTES) {
|
||||
truncated = true;
|
||||
return;
|
||||
}
|
||||
data += chunk;
|
||||
dataBytes += chunkBytes;
|
||||
});
|
||||
stream.on('end', () => {
|
||||
if (truncated) {
|
||||
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, readError: false }
|
||||
: '');
|
||||
return;
|
||||
}
|
||||
resolve(options.includeMetadata === true
|
||||
? { raw: data, truncated: false, readError: false }
|
||||
: data);
|
||||
});
|
||||
stream.on('error', error => {
|
||||
process.stderr.write(`[Cursor Hook] stdin read failed: ${error.message}\n`);
|
||||
resolve(options.includeMetadata === true
|
||||
? { raw: '', truncated: false, readError: true }
|
||||
: '');
|
||||
});
|
||||
process.stdin.on('end', () => resolve(data));
|
||||
});
|
||||
}
|
||||
|
||||
function getPluginRoot() {
|
||||
return path.resolve(__dirname, '..', '..');
|
||||
const candidates = [
|
||||
// Installed layout: <project>/.cursor/hooks -> <project>/.cursor/scripts.
|
||||
path.resolve(__dirname, '..'),
|
||||
// Repository layout: <repo>/.cursor/hooks -> <repo>/scripts.
|
||||
path.resolve(__dirname, '..', '..'),
|
||||
];
|
||||
|
||||
return candidates.find(candidate => (
|
||||
fs.existsSync(path.join(candidate, 'scripts', 'hooks'))
|
||||
&& fs.existsSync(path.join(candidate, 'scripts', 'lib'))
|
||||
)) || candidates[0];
|
||||
}
|
||||
|
||||
function resolveRuntimePath(...segments) {
|
||||
const runtimeRoot = getPluginRoot();
|
||||
const resolvedRoot = path.resolve(runtimeRoot);
|
||||
const resolvedPath = path.resolve(runtimeRoot, ...segments);
|
||||
if (!resolvedPath.startsWith(`${resolvedRoot}${path.sep}`)) {
|
||||
throw new Error(`Cursor hook runtime path escapes its root: ${resolvedPath}`);
|
||||
}
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
throw new Error(`Cursor hook runtime file not found: ${resolvedPath}`);
|
||||
}
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
function loadRuntimeModule(...segments) {
|
||||
return require(resolveRuntimePath(...segments));
|
||||
}
|
||||
|
||||
function fallbackHookEnabled(hookId, allowedProfiles) {
|
||||
const enabled = String(process.env.ECC_HOOKS_ENABLED ?? 'true').trim().toLowerCase();
|
||||
if (['0', 'false', 'no', 'off'].includes(enabled)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rawProfile = String(process.env.ECC_HOOK_PROFILE || 'standard').trim().toLowerCase();
|
||||
const profile = ['minimal', 'standard', 'strict'].includes(rawProfile)
|
||||
? rawProfile
|
||||
: 'standard';
|
||||
const disabled = new Set(
|
||||
String(process.env.ECC_DISABLED_HOOKS || '')
|
||||
.split(',')
|
||||
.map(value => value.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
return !disabled.has(String(hookId || '').trim().toLowerCase())
|
||||
&& allowedProfiles.includes(profile);
|
||||
}
|
||||
|
||||
function transformToClaude(cursorInput, overrides = {}) {
|
||||
const cursorSessionId = cursorInput.conversation_id || cursorInput.session_id || '';
|
||||
return {
|
||||
tool_input: {
|
||||
command: cursorInput.command || cursorInput.args?.command || '',
|
||||
file_path: cursorInput.path || cursorInput.file || cursorInput.args?.filePath || '',
|
||||
file_path: cursorInput.file_path || cursorInput.path || cursorInput.file || cursorInput.args?.filePath || '',
|
||||
...overrides.tool_input,
|
||||
},
|
||||
tool_output: {
|
||||
@@ -37,6 +119,7 @@ function transformToClaude(cursorInput, overrides = {}) {
|
||||
...overrides.tool_output,
|
||||
},
|
||||
transcript_path: cursorInput.transcript_path || cursorInput.transcriptPath || cursorInput.session?.transcript_path || '',
|
||||
session_id: cursorSessionId,
|
||||
_cursor: {
|
||||
conversation_id: cursorInput.conversation_id,
|
||||
hook_event_name: cursorInput.hook_event_name,
|
||||
@@ -46,36 +129,123 @@ function transformToClaude(cursorInput, overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function runExistingHook(scriptName, stdinData) {
|
||||
const scriptPath = path.join(getPluginRoot(), 'scripts', 'hooks', scriptName);
|
||||
function normalizeSessionStartOutput(rawOutput, options = {}) {
|
||||
if (!rawOutput) return {};
|
||||
const parsed = JSON.parse(rawOutput);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
||||
return {
|
||||
...(parsed.env && typeof parsed.env === 'object' && !Array.isArray(parsed.env)
|
||||
? { env: parsed.env }
|
||||
: {}),
|
||||
...(typeof parsed.additional_context === 'string' && parsed.additional_context
|
||||
? { additional_context: parsed.additional_context }
|
||||
: {}),
|
||||
...(options.shared === true
|
||||
&& typeof parsed.hookSpecificOutput?.additionalContext === 'string'
|
||||
&& parsed.hookSpecificOutput.additionalContext
|
||||
? { additional_context: parsed.hookSpecificOutput.additionalContext }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function mergeSessionStartOutputs(outputs) {
|
||||
const validOutputs = outputs.filter(output => output && typeof output === 'object');
|
||||
const env = Object.assign({}, ...validOutputs.map(output => output.env || {}));
|
||||
const additionalContext = validOutputs
|
||||
.map(output => output.additional_context)
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
|
||||
return {
|
||||
...(Object.keys(env).length > 0 ? { env } : {}),
|
||||
...(additionalContext ? { additional_context: additionalContext } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
execFileSync('node', [scriptPath], {
|
||||
const scriptPath = resolveRuntimePath('scripts', 'hooks', scriptName);
|
||||
let cursorSessionId = '';
|
||||
try {
|
||||
const input = typeof stdinData === 'string' ? JSON.parse(stdinData) : stdinData;
|
||||
cursorSessionId = input?._cursor?.conversation_id
|
||||
|| input?.conversation_id
|
||||
|| input?.session_id
|
||||
|| '';
|
||||
} catch {
|
||||
// Raw non-JSON hook input has no session identifier to propagate.
|
||||
}
|
||||
const result = spawnSync(process.execPath, [scriptPath], {
|
||||
input: typeof stdinData === 'string' ? stdinData : JSON.stringify(stdinData),
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 15000,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
...(options.env || {}),
|
||||
...(cursorSessionId
|
||||
? { CLAUDE_SESSION_ID: String(cursorSessionId) }
|
||||
: {}),
|
||||
},
|
||||
timeout: options.timeout || 15000,
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.status === 2) process.exit(2); // Forward blocking exit code
|
||||
|
||||
if (result.stderr && (result.status === 2 || options.forwardStderr === true)) {
|
||||
process.stderr.write(result.stderr);
|
||||
}
|
||||
if (result.status === 2) {
|
||||
process.exit(2);
|
||||
}
|
||||
if (result.error || result.signal || result.status === null) {
|
||||
const reason = result.error
|
||||
? result.error.message
|
||||
: result.signal
|
||||
? `signal ${result.signal}`
|
||||
: 'missing exit status';
|
||||
process.stderr.write(`[Cursor Hook] ${scriptName} failed: ${reason}\n`);
|
||||
} else if (Number.isInteger(result.status) && result.status !== 0) {
|
||||
process.stderr.write(
|
||||
`[Cursor Hook] ${scriptName} exited with code ${result.status}; continuing\n`
|
||||
);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
process.stderr.write(`[Cursor Hook] ${scriptName} skipped: ${error.message}\n`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hookEnabled(hookId, allowedProfiles = ['standard', 'strict']) {
|
||||
const rawProfile = String(process.env.ECC_HOOK_PROFILE || 'standard').toLowerCase();
|
||||
const profile = ['minimal', 'standard', 'strict'].includes(rawProfile) ? rawProfile : 'standard';
|
||||
|
||||
const disabled = new Set(
|
||||
String(process.env.ECC_DISABLED_HOOKS || '')
|
||||
.split(',')
|
||||
.map(v => v.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
if (disabled.has(String(hookId || '').toLowerCase())) {
|
||||
return false;
|
||||
try {
|
||||
const { isHookEnabled } = loadRuntimeModule('scripts', 'lib', 'hook-flags.js');
|
||||
return isHookEnabled(hookId, { profiles: allowedProfiles });
|
||||
} catch {
|
||||
// Keep native Cursor checks controllable in partial or manually copied installs.
|
||||
return fallbackHookEnabled(hookId, allowedProfiles);
|
||||
}
|
||||
|
||||
return allowedProfiles.includes(profile);
|
||||
}
|
||||
|
||||
module.exports = { readStdin, getPluginRoot, transformToClaude, runExistingHook, hookEnabled };
|
||||
module.exports = {
|
||||
readStdin,
|
||||
getPluginRoot,
|
||||
resolveRuntimePath,
|
||||
loadRuntimeModule,
|
||||
fallbackHookEnabled,
|
||||
transformToClaude,
|
||||
normalizeSessionStartOutput,
|
||||
mergeSessionStartOutputs,
|
||||
createStopFormatTypecheckOptions,
|
||||
runExistingHook,
|
||||
hookEnabled,
|
||||
};
|
||||
|
||||
@@ -4,16 +4,22 @@ readStdin().then(raw => {
|
||||
try {
|
||||
const input = JSON.parse(raw);
|
||||
const claudeInput = transformToClaude(input, {
|
||||
tool_input: { file_path: input.path || input.file || '' }
|
||||
tool_input: { file_path: input.file_path || input.path || input.file || '' }
|
||||
});
|
||||
const claudeStr = JSON.stringify(claudeInput);
|
||||
|
||||
// Accumulate edited paths for batch format+typecheck at stop time
|
||||
runExistingHook('post-edit-accumulator.js', claudeStr);
|
||||
runExistingHook('post-edit-console-warn.js', claudeStr);
|
||||
if (hookEnabled('post:edit:design-quality-check', ['standard', 'strict'])) {
|
||||
runExistingHook('design-quality-check.js', claudeStr);
|
||||
// Accumulate edited paths for batch format+typecheck at stop time.
|
||||
if (hookEnabled('post:edit:accumulator', ['standard', 'strict'])) {
|
||||
runExistingHook('post-edit-accumulator.js', claudeStr);
|
||||
}
|
||||
} catch {}
|
||||
if (hookEnabled('post:edit:console-warn', ['standard', 'strict'])) {
|
||||
runExistingHook('post-edit-console-warn.js', claudeStr, { forwardStderr: true });
|
||||
}
|
||||
if (hookEnabled('post:edit:design-quality-check', ['standard', 'strict'])) {
|
||||
runExistingHook('design-quality-check.js', claudeStr, { forwardStderr: true });
|
||||
}
|
||||
} catch {
|
||||
// Cursor post-event hooks fail open on malformed or partial payloads.
|
||||
}
|
||||
process.stdout.write(raw);
|
||||
}).catch(() => process.exit(0));
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
#!/usr/bin/env node
|
||||
const { readStdin } = require('./adapter');
|
||||
const { readStdin, hookEnabled } = require('./adapter');
|
||||
readStdin().then(raw => {
|
||||
if (!hookEnabled('post:mcp:audit', ['standard', 'strict'])) {
|
||||
process.stdout.write(raw);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const input = JSON.parse(raw);
|
||||
const server = input.server || input.mcp_server || 'unknown';
|
||||
const tool = input.tool || input.mcp_tool || 'unknown';
|
||||
const success = input.error ? 'FAILED' : 'OK';
|
||||
console.error(`[ECC] MCP result: ${server}/${tool} - ${success}`);
|
||||
} catch {}
|
||||
} catch {
|
||||
// Audit-only hook: malformed payloads must not interrupt the agent loop.
|
||||
}
|
||||
process.stdout.write(raw);
|
||||
}).catch(() => process.exit(0));
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
#!/usr/bin/env node
|
||||
const { readStdin, runExistingHook, transformToClaude } = require('./adapter');
|
||||
const { readStdin, runExistingHook, transformToClaude, hookEnabled } = require('./adapter');
|
||||
readStdin().then(raw => {
|
||||
if (!hookEnabled('post:tab-edit:format', ['standard', 'strict'])) {
|
||||
process.stdout.write(raw);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const input = JSON.parse(raw);
|
||||
const claudeInput = transformToClaude(input, {
|
||||
tool_input: { file_path: input.path || input.file || '' }
|
||||
tool_input: { file_path: input.file_path || input.path || input.file || '' }
|
||||
});
|
||||
runExistingHook('post-edit-format.js', JSON.stringify(claudeInput));
|
||||
} catch {}
|
||||
} catch {
|
||||
// Formatting is advisory, so malformed payloads fail open.
|
||||
}
|
||||
process.stdout.write(raw);
|
||||
}).catch(() => process.exit(0));
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
#!/usr/bin/env node
|
||||
const { readStdin } = require('./adapter');
|
||||
const { readStdin, hookEnabled } = require('./adapter');
|
||||
readStdin().then(raw => {
|
||||
if (!hookEnabled('pre:mcp:audit', ['standard', 'strict'])) {
|
||||
process.stdout.write(raw);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const input = JSON.parse(raw);
|
||||
const server = input.server || input.mcp_server || 'unknown';
|
||||
const tool = input.tool || input.mcp_tool || 'unknown';
|
||||
console.error(`[ECC] MCP invocation: ${server}/${tool}`);
|
||||
} catch {}
|
||||
} catch {
|
||||
// Audit-only hook: malformed payloads must not interrupt the agent loop.
|
||||
}
|
||||
process.stdout.write(raw);
|
||||
}).catch(() => process.exit(0));
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
#!/usr/bin/env node
|
||||
const { readStdin } = require('./adapter');
|
||||
const { readStdin, hookEnabled } = require('./adapter');
|
||||
readStdin().then(raw => {
|
||||
if (!hookEnabled('pre:read:sensitive-file-warning', ['minimal', 'standard', 'strict'])) {
|
||||
process.stdout.write(raw);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const input = JSON.parse(raw);
|
||||
const filePath = input.path || input.file || '';
|
||||
const filePath = [input.file_path, input.path, input.file]
|
||||
.find(value => typeof value === 'string' && value.trim()) || '';
|
||||
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');
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
// Preserve the existing fail-open behavior for malformed Cursor payloads.
|
||||
}
|
||||
process.stdout.write(raw);
|
||||
}).catch(() => process.exit(0));
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Cursor wrapper for block-no-verify.
|
||||
*
|
||||
* Cursor hooks previously called `npx block-no-verify@1.1.2`, an external
|
||||
* package whose matcher over-matches: it blocks legitimate `git commit`
|
||||
* whenever the literal string `--no-verify` (or `no-verify`) appears
|
||||
* anywhere in the command string, including inside the commit message
|
||||
* body. See issue #2107.
|
||||
*
|
||||
* The Claude Code surface already routes through the local, in-repo hook
|
||||
* `scripts/hooks/block-no-verify.js`, which performs flag-position-aware
|
||||
* tokenisation (skipping the value of `-m`, `-F`, `-am "..."`, etc.) and
|
||||
* passes 25 regression tests covering every false-positive case.
|
||||
*
|
||||
* This wrapper gives Cursor the same matcher: read Cursor stdin, transform
|
||||
* to the Claude Code `tool_input.command` shape the local hook understands,
|
||||
* delegate to its exported `run()`, then forward the exit code and stderr.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { readStdin, hookEnabled } = require('./adapter');
|
||||
const { run } = require('../../scripts/hooks/block-no-verify');
|
||||
|
||||
readStdin()
|
||||
.then(raw => {
|
||||
if (!hookEnabled('pre:bash:block-no-verify', ['minimal', 'standard', 'strict'])) {
|
||||
process.stdout.write(raw);
|
||||
return;
|
||||
}
|
||||
|
||||
let command = '';
|
||||
try {
|
||||
const parsed = JSON.parse(raw || '{}');
|
||||
command = String(parsed.command || parsed.args?.command || '');
|
||||
} catch {
|
||||
command = String(raw || '');
|
||||
}
|
||||
|
||||
// Local hook accepts either the raw command string or a Claude-Code
|
||||
// shaped `{ tool_input: { command } }` JSON. Pass the Claude shape so
|
||||
// the JSON branch in extractCommand() is exercised the same way the
|
||||
// Claude Code surface exercises it — keeps the two surfaces on the
|
||||
// same code path.
|
||||
const claudeInput = JSON.stringify({ tool_input: { command } });
|
||||
const result = run(claudeInput);
|
||||
|
||||
if (result && result.exitCode === 2) {
|
||||
if (result.stderr) {
|
||||
process.stderr.write(String(result.stderr) + '\n');
|
||||
}
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
process.stdout.write(raw);
|
||||
})
|
||||
.catch(() => {
|
||||
// Per repo rule: hooks must exit 0 on non-critical errors and never
|
||||
// unexpectedly block tool execution. A parse / transport error here
|
||||
// is non-critical — fall through.
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -1,23 +1,56 @@
|
||||
#!/usr/bin/env node
|
||||
const { readStdin, hookEnabled } = require('./adapter');
|
||||
const { splitShellSegments } = require('../../scripts/lib/shell-split');
|
||||
const {
|
||||
readStdin,
|
||||
hookEnabled,
|
||||
runExistingHook,
|
||||
transformToClaude,
|
||||
} = require('./adapter');
|
||||
|
||||
readStdin({ includeMetadata: true })
|
||||
.then(({ raw, truncated, readError }) => {
|
||||
if (truncated || readError) {
|
||||
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(truncated
|
||||
? '[Cursor Hook] stdin exceeded the safety limit; blocking beforeShellExecution'
|
||||
: '[Cursor Hook] stdin read failed; blocking beforeShellExecution');
|
||||
process.exit(2);
|
||||
}
|
||||
console.error(truncated
|
||||
? '[Cursor Hook] stdin exceeded the safety limit; suppressing truncated input'
|
||||
: '[Cursor Hook] stdin read failed; suppressing untrusted input');
|
||||
return;
|
||||
}
|
||||
|
||||
readStdin()
|
||||
.then(raw => {
|
||||
try {
|
||||
const input = JSON.parse(raw || '{}');
|
||||
const cmd = String(input.command || input.args?.command || '');
|
||||
let input;
|
||||
let cmd;
|
||||
try {
|
||||
input = JSON.parse(raw || '{}');
|
||||
cmd = String(input.command || input.args?.command || '');
|
||||
} catch {
|
||||
input = {};
|
||||
cmd = String(raw || '');
|
||||
}
|
||||
const claudeInput = transformToClaude(input, {
|
||||
tool_input: { command: cmd },
|
||||
});
|
||||
const claudeRaw = JSON.stringify(claudeInput);
|
||||
|
||||
// Cursor may execute only the first hook registered for an event. Keep
|
||||
// every blocking shell guard behind this single event-level dispatcher.
|
||||
if (hookEnabled('pre:bash:block-no-verify', ['minimal', 'standard', 'strict'])) {
|
||||
runExistingHook('block-no-verify.js', claudeRaw);
|
||||
}
|
||||
|
||||
if (hookEnabled('pre:bash:dev-server-block', ['standard', 'strict']) && process.platform !== 'win32') {
|
||||
const segments = splitShellSegments(cmd);
|
||||
const tmuxLauncher = /^\s*tmux\s+(new|new-session|new-window|split-window)\b/;
|
||||
const devPattern = /\b(npm\s+run\s+dev|pnpm(?:\s+run)?\s+dev|yarn\s+dev|bun\s+run\s+dev)\b/;
|
||||
const hasBlockedDev = segments.some(segment => devPattern.test(segment) && !tmuxLauncher.test(segment));
|
||||
if (hasBlockedDev) {
|
||||
console.error('[ECC] BLOCKED: Dev server must run in tmux for log access');
|
||||
console.error('[ECC] Use: tmux new-session -d -s dev "npm run dev"');
|
||||
process.exit(2);
|
||||
}
|
||||
runExistingHook('pre-bash-dev-server-block.js', claudeRaw);
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
#!/usr/bin/env node
|
||||
const { readStdin } = require('./adapter');
|
||||
const { readStdin, hookEnabled } = require('./adapter');
|
||||
readStdin().then(raw => {
|
||||
if (!hookEnabled('pre:prompt:secret-warning', ['minimal', 'standard', 'strict'])) {
|
||||
process.stdout.write(raw);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const input = JSON.parse(raw);
|
||||
const prompt = input.prompt || input.content || input.message || '';
|
||||
@@ -18,6 +22,8 @@ readStdin().then(raw => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
// This warning hook remains advisory when Cursor sends malformed input.
|
||||
}
|
||||
process.stdout.write(raw);
|
||||
}).catch(() => process.exit(0));
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
#!/usr/bin/env node
|
||||
const { readStdin } = require('./adapter');
|
||||
readStdin().then(raw => {
|
||||
const { readStdin, hookEnabled } = require('./adapter');
|
||||
readStdin({ includeMetadata: true }).then(({ raw, truncated, readError }) => {
|
||||
if (!hookEnabled('pre:tab-read:sensitive-file-block', ['minimal', 'standard', 'strict'])) {
|
||||
process.stdout.write(raw);
|
||||
return;
|
||||
}
|
||||
if (truncated || readError) {
|
||||
console.error(truncated
|
||||
? '[Cursor Hook] stdin exceeded the safety limit; blocking beforeTabFileRead'
|
||||
: '[Cursor Hook] stdin read failed; 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]
|
||||
.find(value => typeof value === 'string' && value.trim()) || '';
|
||||
if (/\.(env|key|pem)$|\.env\.|credentials|secret/i.test(filePath)) {
|
||||
console.error('[ECC] BLOCKED: Tab cannot read sensitive file: ' + filePath);
|
||||
process.exit(2);
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
// Preserve the existing fail-open behavior for malformed Cursor payloads.
|
||||
}
|
||||
process.stdout.write(raw);
|
||||
}).catch(() => process.exit(0));
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
#!/usr/bin/env node
|
||||
const { readStdin, runExistingHook, transformToClaude } = require('./adapter');
|
||||
const { readStdin, runExistingHook, transformToClaude, hookEnabled } = require('./adapter');
|
||||
readStdin().then(raw => {
|
||||
const claudeInput = JSON.parse(raw || '{}');
|
||||
runExistingHook('pre-compact.js', transformToClaude(claudeInput));
|
||||
try {
|
||||
const claudeInput = JSON.parse(raw || '{}');
|
||||
if (hookEnabled('pre:compact', ['standard', 'strict'])) {
|
||||
runExistingHook('pre-compact.js', transformToClaude(claudeInput));
|
||||
}
|
||||
} catch {
|
||||
// PreCompact is advisory; malformed payloads fail open.
|
||||
}
|
||||
process.stdout.write(raw);
|
||||
}).catch(() => process.exit(0));
|
||||
|
||||
@@ -1,10 +1,64 @@
|
||||
#!/usr/bin/env node
|
||||
const { readStdin, runExistingHook, transformToClaude, hookEnabled } = require('./adapter');
|
||||
const {
|
||||
hookEnabled,
|
||||
mergeSessionStartOutputs,
|
||||
normalizeSessionStartOutput,
|
||||
readStdin,
|
||||
runExistingHook,
|
||||
transformToClaude,
|
||||
} = require('./adapter');
|
||||
readStdin().then(raw => {
|
||||
const input = JSON.parse(raw || '{}');
|
||||
const claudeInput = transformToClaude(input);
|
||||
if (hookEnabled('session:start', ['minimal', 'standard', 'strict'])) {
|
||||
runExistingHook('session-start.js', claudeInput);
|
||||
let input = {};
|
||||
try {
|
||||
const parsed = JSON.parse(raw || '{}');
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
input = parsed;
|
||||
}
|
||||
} catch {
|
||||
// The Cursor env helper handles malformed input and supplies a safe default.
|
||||
}
|
||||
process.stdout.write(raw);
|
||||
const transformedInput = transformToClaude(input);
|
||||
const claudeInput = {
|
||||
...transformedInput,
|
||||
hook_event_name: 'SessionStart',
|
||||
source: 'startup',
|
||||
};
|
||||
let outputs = [];
|
||||
if (!hookEnabled('session:start', ['minimal', 'standard', 'strict'])) {
|
||||
process.stdout.write(JSON.stringify({}));
|
||||
return;
|
||||
}
|
||||
|
||||
const envResult = runExistingHook('cursor-session-env.js', raw, {
|
||||
forwardStderr: true,
|
||||
});
|
||||
if (envResult && envResult.status === 0 && envResult.stdout) {
|
||||
try {
|
||||
outputs = [...outputs, normalizeSessionStartOutput(envResult.stdout)];
|
||||
} catch {
|
||||
console.error('[Cursor Hook] Ignoring invalid Cursor session environment output');
|
||||
}
|
||||
}
|
||||
|
||||
const sessionEnv = outputs[0]?.env || {};
|
||||
const sessionResult = runExistingHook('session-start.js', claudeInput, {
|
||||
env: {
|
||||
...sessionEnv,
|
||||
...(transformedInput.session_id
|
||||
? { CLAUDE_SESSION_ID: transformedInput.session_id }
|
||||
: {}),
|
||||
},
|
||||
forwardStderr: true,
|
||||
});
|
||||
if (sessionResult && sessionResult.status === 0 && sessionResult.stdout) {
|
||||
try {
|
||||
outputs = [
|
||||
...outputs,
|
||||
normalizeSessionStartOutput(sessionResult.stdout, { shared: true }),
|
||||
];
|
||||
} catch {
|
||||
console.error('[Cursor Hook] Ignoring invalid shared SessionStart output');
|
||||
}
|
||||
}
|
||||
process.stdout.write(JSON.stringify(mergeSessionStartOutputs(outputs)));
|
||||
}).catch(() => process.exit(0));
|
||||
|
||||
+28
-7
@@ -1,20 +1,41 @@
|
||||
#!/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);
|
||||
const sharedOptions = {
|
||||
env: claudeInput.session_id
|
||||
? { CLAUDE_SESSION_ID: claudeInput.session_id }
|
||||
: {},
|
||||
};
|
||||
|
||||
if (hookEnabled('stop:check-console-log', ['standard', 'strict'])) {
|
||||
runExistingHook('check-console-log.js', claudeInput);
|
||||
}
|
||||
if (hookEnabled('stop:session-end', ['minimal', 'standard', 'strict'])) {
|
||||
runExistingHook('session-end.js', claudeInput);
|
||||
runExistingHook('session-end.js', claudeInput, sharedOptions);
|
||||
}
|
||||
if (hookEnabled('stop:evaluate-session', ['minimal', 'standard', 'strict'])) {
|
||||
runExistingHook('evaluate-session.js', claudeInput);
|
||||
runExistingHook('evaluate-session.js', claudeInput, sharedOptions);
|
||||
}
|
||||
if (hookEnabled('stop:cost-tracker', ['minimal', 'standard', 'strict'])) {
|
||||
runExistingHook('cost-tracker.js', claudeInput);
|
||||
runExistingHook('cost-tracker.js', claudeInput, sharedOptions);
|
||||
}
|
||||
if (hookEnabled('stop:format-typecheck', ['standard', 'strict'])) {
|
||||
runExistingHook(
|
||||
'stop-format-typecheck.js',
|
||||
claudeInput,
|
||||
createStopFormatTypecheckOptions(sharedOptions.env)
|
||||
);
|
||||
}
|
||||
if (hookEnabled('stop:check-console-log', ['standard', 'strict'])) {
|
||||
runExistingHook('check-console-log.js', claudeInput, {
|
||||
...sharedOptions,
|
||||
forwardStderr: true,
|
||||
});
|
||||
}
|
||||
|
||||
process.stdout.write(raw);
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
#!/usr/bin/env node
|
||||
const { readStdin } = require('./adapter');
|
||||
const { readStdin, hookEnabled } = require('./adapter');
|
||||
readStdin().then(raw => {
|
||||
if (!hookEnabled('subagent:start:audit', ['standard', 'strict'])) {
|
||||
process.stdout.write(raw);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const input = JSON.parse(raw);
|
||||
const agent = input.agent_name || input.agent || 'unknown';
|
||||
console.error(`[ECC] Agent spawned: ${agent}`);
|
||||
} catch {}
|
||||
} catch {
|
||||
// Audit-only hook: malformed payloads must not interrupt the agent loop.
|
||||
}
|
||||
process.stdout.write(raw);
|
||||
}).catch(() => process.exit(0));
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
#!/usr/bin/env node
|
||||
const { readStdin } = require('./adapter');
|
||||
const { readStdin, hookEnabled } = require('./adapter');
|
||||
readStdin().then(raw => {
|
||||
if (!hookEnabled('subagent:stop:audit', ['standard', 'strict'])) {
|
||||
process.stdout.write(raw);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const input = JSON.parse(raw);
|
||||
const agent = input.agent_name || input.agent || 'unknown';
|
||||
console.error(`[ECC] Agent completed: ${agent}`);
|
||||
} catch {}
|
||||
} catch {
|
||||
// Audit-only hook: malformed payloads must not interrupt the agent loop.
|
||||
}
|
||||
process.stdout.write(raw);
|
||||
}).catch(() => process.exit(0));
|
||||
|
||||
@@ -1290,7 +1290,7 @@ Treat `stable`, `beta`, `experimental`, and `instruction-only` below as capabili
|
||||
|---|---|---|---|
|
||||
| Claude Code | Stable primary | Plugin or selective installer | The plugin advertises the installed catalog to the model; use a selective/manual profile when context footprint matters. Optional shell-backed skills are not portable to every OS. |
|
||||
| Codex | Supported native plugin | Codex marketplace plugin or repo config | Native hooks require an explicit trust decision and do not use Claude's hook profiles. The legacy sync is compatibility-only. |
|
||||
| Cursor | Beta project adapter | Selective installer into `.cursor/` | Agent discovery varies by Cursor build, and ECC's installer paths do not yet expose identical hook sets ([#2419](https://github.com/affaan-m/ECC/issues/2419)). |
|
||||
| Cursor | Beta project adapter | Selective installer into `.cursor/` | Agent discovery varies by Cursor build; hook events use profile-gated dispatchers, but full Claude feature parity is not claimed. |
|
||||
| OpenCode | Beta built plugin | Build plugin, then selective installer | ECC ships a subset of the catalog; connect a provider and select a model in OpenCode ([#2617](https://github.com/affaan-m/ECC/issues/2617)). |
|
||||
| GitHub Copilot Chat | Instruction-only | Checked-in instructions and prompt files | ECC hooks are not adapted. |
|
||||
| GitHub Copilot CLI | Beta project adapter | `--target copilot` places skills and adapted agents in `.github/` | ECC hooks, rules, and command shims are not installed by this target. |
|
||||
@@ -1436,7 +1436,7 @@ ECC provides Cursor IDE support with hooks, rules, agents, skills, commands, and
|
||||
| Component | Count | Details |
|
||||
|-----------|-------|---------|
|
||||
| Hook Events | 15 | sessionStart, beforeShellExecution, afterFileEdit, beforeMCPExecution, beforeSubmitPrompt, and 10 more |
|
||||
| Hook Scripts | 16 | Thin Node.js scripts delegating to `scripts/hooks/` via shared adapter |
|
||||
| Hook Entrypoints | 15 | Thin Node.js event dispatchers delegating to `scripts/hooks/` via shared adapter |
|
||||
| Rules | 34 | 9 common (alwaysApply) + 25 language-specific (TypeScript, Python, Go, Swift, PHP) |
|
||||
| Agents | 48 | `.cursor/agents/ecc-*.md` when installed; prefixed to avoid collisions with user or marketplace agents |
|
||||
| Skills | Shared + Bundled | `.cursor/skills/` for translated additions |
|
||||
@@ -1470,7 +1470,7 @@ Continuous learning v2 instincts remain separate under `CLV2_HOMUNCULUS_DIR` (de
|
||||
|
||||
#### Hook architecture (DRY adapter pattern)
|
||||
|
||||
Cursor has **more hook events than Claude Code** (20 vs 8). The `.cursor/hooks/adapter.js` module transforms Cursor's stdin JSON to Claude Code's format, allowing existing `scripts/hooks/*.js` to be reused without duplication.
|
||||
Cursor has **more hook events than Claude Code** (20 vs 8). The `.cursor/hooks/adapter.js` module transforms Cursor's stdin JSON to Claude Code's format, allowing existing `scripts/hooks/*.js` to be reused without duplication. Each Cursor event is registered with one event-level dispatcher so enabled checks run in a deterministic order and remain compatible with Cursor versions that execute only the first matching entry. The adapter resolves the shared runtime from both the ECC checkout and an installed project's `.cursor/scripts/` tree.
|
||||
|
||||
```
|
||||
Cursor stdin JSON -> adapter.js -> transforms -> scripts/hooks/*.js
|
||||
@@ -1478,8 +1478,8 @@ Cursor stdin JSON -> adapter.js -> transforms -> scripts/hooks/*.js
|
||||
```
|
||||
|
||||
Key hooks:
|
||||
- **beforeShellExecution**: Blocks dev servers outside tmux (exit 2), git push review
|
||||
- **afterFileEdit**: Auto-format + TypeScript check + console.log warning
|
||||
- **beforeShellExecution**: Blocks git hook bypasses and dev servers outside tmux (exit 2), then adds profile-gated shell reminders
|
||||
- **afterFileEdit / stop**: Accumulates edited JS/TS files and runs one profile-gated format/typecheck batch at Stop, alongside the console.log audit
|
||||
- **beforeSubmitPrompt**: Detects secrets (sk-, ghp_, AKIA patterns) in prompts
|
||||
- **beforeTabFileRead**: Blocks Tab from reading .env, .key, .pem files (exit 2)
|
||||
- **beforeMCPExecution / afterMCPExecution**: MCP audit logging
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"hooks": {
|
||||
"sessionStart": [
|
||||
{
|
||||
"command": "node .cursor/scripts/hooks/cursor-session-env.js"
|
||||
"command": "node .cursor/hooks/session-start.js"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -26,7 +26,23 @@ const { findProjectRoot, detectFormatter, resolveFormatterBin } = require('../li
|
||||
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
// Total ms budget reserved for all batches (leaves headroom below the 300s Stop timeout)
|
||||
const TOTAL_BUDGET_MS = 270_000;
|
||||
const DEFAULT_TOTAL_BUDGET_MS = 270_000;
|
||||
const MAX_TOTAL_BUDGET_MS = 270_000;
|
||||
|
||||
function getTotalBudgetMs(env = process.env) {
|
||||
const raw = String(env.ECC_STOP_FORMAT_TYPECHECK_BUDGET_MS || '').trim();
|
||||
if (!/^\d+$/.test(raw)) return DEFAULT_TOTAL_BUDGET_MS;
|
||||
const parsed = Number(raw);
|
||||
return Number.isSafeInteger(parsed) && parsed > 0
|
||||
? Math.min(parsed, MAX_TOTAL_BUDGET_MS)
|
||||
: 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\...".
|
||||
@@ -197,7 +213,8 @@ function main() {
|
||||
// Distribute the budget evenly across all batches so the cumulative total
|
||||
// stays within the Stop hook wall-clock limit even in large monorepos.
|
||||
const totalBatches = byProjectRoot.size + byTsConfigDir.size;
|
||||
const perBatchMs = totalBatches > 0 ? Math.floor(TOTAL_BUDGET_MS / totalBatches) : 60_000;
|
||||
const totalBudgetMs = getTotalBudgetMs();
|
||||
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);
|
||||
@@ -248,4 +265,10 @@ if (require.main === module) {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { run, parseAccumulator, isPluginClonePath };
|
||||
module.exports = {
|
||||
run,
|
||||
parseAccumulator,
|
||||
isPluginClonePath,
|
||||
getTotalBudgetMs,
|
||||
getPerBatchBudgetMs,
|
||||
};
|
||||
|
||||
@@ -88,15 +88,19 @@ module.exports = createInstallTargetAdapter({
|
||||
}));
|
||||
}).sort((left, right) => {
|
||||
const getPriority = value => {
|
||||
if (value === '.cursor') {
|
||||
if (value === 'hooks') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (value === 'rules') {
|
||||
if (value === '.cursor') {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 2;
|
||||
if (value === 'rules') {
|
||||
return 2;
|
||||
}
|
||||
|
||||
return 3;
|
||||
};
|
||||
|
||||
const leftPriority = getPriority(left.sourceRelativePath);
|
||||
@@ -161,6 +165,23 @@ module.exports = createInstallTargetAdapter({
|
||||
}));
|
||||
}
|
||||
|
||||
if (sourceRelativePath === 'hooks') {
|
||||
return takeUniqueOperations([
|
||||
createManagedOperation({
|
||||
moduleId: module.id,
|
||||
sourceRelativePath: path.join('.cursor', 'hooks'),
|
||||
destinationPath: path.join(targetRoot, 'hooks'),
|
||||
strategy: 'preserve-relative-path',
|
||||
}),
|
||||
createManagedOperation({
|
||||
moduleId: module.id,
|
||||
sourceRelativePath: path.join('.cursor', 'hooks.json'),
|
||||
destinationPath: path.join(targetRoot, 'hooks.json'),
|
||||
strategy: 'preserve-relative-path',
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
if (sourceRelativePath === '.cursor') {
|
||||
const cursorRoot = path.join(repoRoot, '.cursor');
|
||||
if (!fs.existsSync(cursorRoot) || !fs.statSync(cursorRoot).isDirectory()) {
|
||||
@@ -169,7 +190,7 @@ module.exports = createInstallTargetAdapter({
|
||||
|
||||
const childOperations = fs.readdirSync(cursorRoot, { withFileTypes: true })
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
.filter(entry => entry.name !== 'rules')
|
||||
.filter(entry => !['rules', 'hooks', 'hooks.json'].includes(entry.name))
|
||||
.map(entry => createManagedOperation({
|
||||
moduleId: module.id,
|
||||
sourceRelativePath: path.join('.cursor', entry.name),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Tests for .cursor/hooks/before-shell-execution-block-no-verify.js
|
||||
* Tests for block-no-verify behavior in the Cursor beforeShell dispatcher.
|
||||
*
|
||||
* Issue #2107: previously .cursor/hooks.json wired `npx block-no-verify@1.1.2`,
|
||||
* which over-matches and blocks legitimate commits whose message body
|
||||
* mentions `--no-verify` or `-n`. The wrapper added in this PR delegates
|
||||
* mentions `--no-verify` or `-n`. The event-level dispatcher delegates
|
||||
* to the local scripts/hooks/block-no-verify.js so Cursor users get the
|
||||
* same flag-position-aware matcher Claude Code already uses.
|
||||
*/
|
||||
@@ -14,17 +14,23 @@ const assert = require('assert');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const wrapper = path.join(
|
||||
const dispatcher = path.join(
|
||||
__dirname, '..', '..',
|
||||
'.cursor', 'hooks', 'before-shell-execution-block-no-verify.js'
|
||||
'.cursor', 'hooks', 'before-shell-execution.js'
|
||||
);
|
||||
|
||||
function runWrapper(input, env = {}) {
|
||||
const rawInput = typeof input === 'string' ? input : JSON.stringify(input);
|
||||
const result = spawnSync('node', [wrapper], {
|
||||
const result = spawnSync('node', [dispatcher], {
|
||||
input: rawInput,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ECC_HOOK_PROFILE: 'standard', ...env },
|
||||
env: {
|
||||
...process.env,
|
||||
ECC_HOOKS_ENABLED: 'true',
|
||||
ECC_HOOK_PROFILE: 'standard',
|
||||
ECC_DISABLED_HOOKS: 'pre:bash:dev-server-block',
|
||||
...env,
|
||||
},
|
||||
timeout: 15000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
@@ -51,7 +57,7 @@ function test(name, fn) {
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
console.log('\ncursor block-no-verify wrapper tests');
|
||||
console.log('\ncursor block-no-verify dispatcher tests');
|
||||
console.log('─'.repeat(50));
|
||||
|
||||
// --- Cursor input shapes ---
|
||||
@@ -129,6 +135,12 @@ if (test('handles malformed JSON gracefully (treats raw as command string)', ()
|
||||
assert.strictEqual(r.code, 0, `expected 0, got ${r.code}: ${r.stderr}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('blocks a bypass attempt supplied as a raw command string', () => {
|
||||
const r = runWrapper('git commit --no-verify -m "msg"');
|
||||
assert.strictEqual(r.code, 2, `expected 2, got ${r.code}: ${r.stderr}`);
|
||||
assert.ok(r.stderr.includes('BLOCKED'), `stderr should contain BLOCKED: ${r.stderr}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Disable via ECC_DISABLED_HOOKS ---
|
||||
|
||||
if (test('respects ECC_DISABLED_HOOKS=pre:bash:block-no-verify', () => {
|
||||
@@ -136,7 +148,7 @@ if (test('respects ECC_DISABLED_HOOKS=pre:bash:block-no-verify', () => {
|
||||
{ command: 'git commit --no-verify -m "msg"' },
|
||||
{ ECC_DISABLED_HOOKS: 'pre:bash:block-no-verify' }
|
||||
);
|
||||
// When the hook is disabled, the wrapper should pass through (exit 0)
|
||||
// When the hook is disabled, the dispatcher should pass through (exit 0)
|
||||
// even on a real bypass attempt.
|
||||
assert.strictEqual(r.code, 0, `expected 0 when disabled, got ${r.code}: ${r.stderr}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
@@ -0,0 +1,699 @@
|
||||
/**
|
||||
* Contract tests for Cursor's event-level hook dispatchers.
|
||||
*
|
||||
* Issue #2419: Cursor installs must expose one command per event, preserve
|
||||
* blocking exit codes while dispatching multiple checks, and resolve shared
|
||||
* runtime modules from the installed .cursor/ tree.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..', '..');
|
||||
const HOOKS_CONFIG = path.join(REPO_ROOT, '.cursor', 'hooks.json');
|
||||
const BEFORE_SHELL = path.join(REPO_ROOT, '.cursor', 'hooks', 'before-shell-execution.js');
|
||||
const STOP = path.join(REPO_ROOT, '.cursor', 'hooks', 'stop.js');
|
||||
const BEFORE_MCP = path.join(REPO_ROOT, '.cursor', 'hooks', 'before-mcp-execution.js');
|
||||
const AFTER_MCP = path.join(REPO_ROOT, '.cursor', 'hooks', 'after-mcp-execution.js');
|
||||
const AFTER_SHELL = path.join(REPO_ROOT, '.cursor', 'hooks', 'after-shell-execution.js');
|
||||
const AFTER_TAB_EDIT = path.join(REPO_ROOT, '.cursor', 'hooks', 'after-tab-file-edit.js');
|
||||
const BEFORE_READ = path.join(REPO_ROOT, '.cursor', 'hooks', 'before-read-file.js');
|
||||
const BEFORE_PROMPT = path.join(REPO_ROOT, '.cursor', 'hooks', 'before-submit-prompt.js');
|
||||
const BEFORE_TAB_READ = path.join(REPO_ROOT, '.cursor', 'hooks', 'before-tab-file-read.js');
|
||||
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 {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runHook(scriptPath, input, env = {}, cwd = REPO_ROOT) {
|
||||
const raw = typeof input === 'string' ? input : JSON.stringify(input);
|
||||
const childEnv = {
|
||||
...process.env,
|
||||
ECC_HOOK_PROFILE: 'standard',
|
||||
...env,
|
||||
};
|
||||
if (!Object.hasOwn(env, 'ECC_HOOKS_ENABLED')) {
|
||||
delete childEnv.ECC_HOOKS_ENABLED;
|
||||
}
|
||||
if (!Object.hasOwn(env, 'ECC_DISABLED_HOOKS')) {
|
||||
delete childEnv.ECC_DISABLED_HOOKS;
|
||||
}
|
||||
if (!Object.hasOwn(env, 'CLAUDE_SESSION_ID')) {
|
||||
delete childEnv.CLAUDE_SESSION_ID;
|
||||
}
|
||||
if (!Object.hasOwn(env, 'ECC_SESSION_ID')) {
|
||||
delete childEnv.ECC_SESSION_ID;
|
||||
}
|
||||
for (const key of [
|
||||
'CLAUDE_PLUGIN_OPTION_HOOKS_ENABLED',
|
||||
'CLAUDE_PLUGIN_OPTION_HOOK_PROFILE',
|
||||
'ECC_HOOK_CONFIG',
|
||||
'CLAUDE_PLUGIN_ROOT',
|
||||
'ECC_PLUGIN_ROOT',
|
||||
]) {
|
||||
if (!Object.hasOwn(env, key)) {
|
||||
delete childEnv[key];
|
||||
}
|
||||
}
|
||||
return spawnSync(process.execPath, [scriptPath], {
|
||||
cwd,
|
||||
env: childEnv,
|
||||
input: raw,
|
||||
encoding: 'utf8',
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
function accumulatorPath(sessionId, cwd) {
|
||||
const raw = sessionId
|
||||
|| crypto.createHash('sha1').update(cwd).digest('hex').slice(0, 12);
|
||||
const safeId = raw.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
|
||||
return path.join(os.tmpdir(), `ecc-edited-${safeId}.txt`);
|
||||
}
|
||||
|
||||
function writeFile(filePath, content) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, content, 'utf8');
|
||||
}
|
||||
|
||||
function createInstalledCursorFixture(options = {}) {
|
||||
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-cursor-dispatcher-'));
|
||||
const cursorRoot = path.join(projectRoot, '.cursor');
|
||||
const hooksRoot = path.join(cursorRoot, 'hooks');
|
||||
const scriptsRoot = path.join(cursorRoot, 'scripts');
|
||||
|
||||
fs.mkdirSync(hooksRoot, { recursive: true });
|
||||
for (const fileName of [
|
||||
'adapter.js',
|
||||
'after-file-edit.js',
|
||||
'before-shell-execution.js',
|
||||
'session-start.js',
|
||||
'stop.js',
|
||||
]) {
|
||||
fs.copyFileSync(
|
||||
path.join(REPO_ROOT, '.cursor', 'hooks', fileName),
|
||||
path.join(hooksRoot, fileName)
|
||||
);
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.join(scriptsRoot, 'lib'), { recursive: true });
|
||||
if (options.includeHookFlags !== false) {
|
||||
fs.copyFileSync(
|
||||
path.join(REPO_ROOT, 'scripts', 'lib', 'hook-flags.js'),
|
||||
path.join(scriptsRoot, 'lib', 'hook-flags.js')
|
||||
);
|
||||
}
|
||||
writeFile(
|
||||
path.join(scriptsRoot, 'lib', 'shell-split.js'),
|
||||
"module.exports = { splitShellSegments(value) { return [value]; } };\n"
|
||||
);
|
||||
writeFile(
|
||||
path.join(scriptsRoot, 'hooks', 'block-no-verify.js'),
|
||||
[
|
||||
"'use strict';",
|
||||
"const fs = require('fs');",
|
||||
"const command = JSON.parse(fs.readFileSync(0, 'utf8')).tool_input.command;",
|
||||
"if (command.includes('--no-verify')) {",
|
||||
" process.stderr.write('BLOCKED: installed runtime\\n');",
|
||||
' process.exit(2);',
|
||||
'}',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
writeFile(
|
||||
path.join(scriptsRoot, 'hooks', 'pre-bash-dev-server-block.js'),
|
||||
"process.stdout.write(require('fs').readFileSync(0, 'utf8'));\n"
|
||||
);
|
||||
writeFile(
|
||||
path.join(scriptsRoot, 'hooks', 'stop-format-typecheck.js'),
|
||||
[
|
||||
"'use strict';",
|
||||
"const fs = require('fs');",
|
||||
"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')
|
||||
);
|
||||
writeFile(
|
||||
path.join(scriptsRoot, 'hooks', 'post-edit-accumulator.js'),
|
||||
[
|
||||
"'use strict';",
|
||||
"const fs = require('fs');",
|
||||
"fs.appendFileSync(process.env.CURSOR_HOOK_LOG, 'post-edit-accumulator\\n');",
|
||||
"process.stdout.write(require('fs').readFileSync(0, 'utf8'));",
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
writeFile(
|
||||
path.join(scriptsRoot, 'hooks', 'post-edit-console-warn.js'),
|
||||
"process.stdout.write(require('fs').readFileSync(0, 'utf8'));\n"
|
||||
);
|
||||
writeFile(
|
||||
path.join(scriptsRoot, 'hooks', 'design-quality-check.js'),
|
||||
"process.stdout.write(require('fs').readFileSync(0, 'utf8'));\n"
|
||||
);
|
||||
writeFile(
|
||||
path.join(scriptsRoot, 'hooks', 'cursor-session-env.js'),
|
||||
"process.stdout.write(JSON.stringify({ env: { ECC_AGENT_DATA_HOME: '/tmp/cursor-ecc' }, additional_context: 'cursor env' }));\n"
|
||||
);
|
||||
writeFile(
|
||||
path.join(scriptsRoot, 'hooks', 'session-start.js'),
|
||||
[
|
||||
"const fs = require('fs');",
|
||||
"const input = JSON.parse(fs.readFileSync(0, 'utf8'));",
|
||||
"if (input.hook_event_name !== 'SessionStart' || input.source !== 'startup') process.exit(3);",
|
||||
"if (process.env.ECC_AGENT_DATA_HOME !== '/tmp/cursor-ecc') process.exit(4);",
|
||||
"process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: 'shared context' } }));",
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
return {
|
||||
projectRoot,
|
||||
beforeShell: path.join(hooksRoot, 'before-shell-execution.js'),
|
||||
afterFileEdit: path.join(hooksRoot, 'after-file-edit.js'),
|
||||
sessionStart: path.join(hooksRoot, 'session-start.js'),
|
||||
stop: path.join(hooksRoot, 'stop.js'),
|
||||
logPath: path.join(projectRoot, 'hook.log'),
|
||||
};
|
||||
}
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
console.log('\nCursor hook dispatcher contract');
|
||||
console.log('-'.repeat(55));
|
||||
|
||||
if (test('registers at most one command for every Cursor event', () => {
|
||||
const config = JSON.parse(fs.readFileSync(HOOKS_CONFIG, 'utf8'));
|
||||
for (const [eventName, entries] of Object.entries(config.hooks)) {
|
||||
assert.strictEqual(
|
||||
entries.length,
|
||||
1,
|
||||
`${eventName} must use one event-level dispatcher`
|
||||
);
|
||||
}
|
||||
assert.strictEqual(
|
||||
config.hooks.beforeShellExecution[0].command,
|
||||
'node .cursor/hooks/before-shell-execution.js'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('Cursor sessionStart scaffold points at the event dispatcher', () => {
|
||||
const scaffold = JSON.parse(fs.readFileSync(
|
||||
path.join(REPO_ROOT, 'scaffolds', 'cursor', 'hooks.json'),
|
||||
'utf8'
|
||||
));
|
||||
assert.deepStrictEqual(scaffold.hooks.sessionStart, [{
|
||||
command: 'node .cursor/hooks/session-start.js',
|
||||
}]);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('beforeShellExecution dispatcher blocks git hook bypass attempts', () => {
|
||||
const result = runHook(BEFORE_SHELL, { command: 'git commit --no-verify -m test' });
|
||||
assert.strictEqual(result.status, 2, result.stderr);
|
||||
assert.match(result.stderr, /BLOCKED/);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('beforeShellExecution dispatcher honors the master hook switch', () => {
|
||||
const result = runHook(
|
||||
BEFORE_SHELL,
|
||||
{ command: 'git commit --no-verify -m test' },
|
||||
{ ECC_HOOKS_ENABLED: 'false' }
|
||||
);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('beforeShellExecution dispatcher preserves the dev-server guard', () => {
|
||||
if (process.platform === 'win32') {
|
||||
console.log(' (tmux guard is not supported on Windows; skipping)');
|
||||
return;
|
||||
}
|
||||
const result = runHook(BEFORE_SHELL, { command: 'npm run dev' });
|
||||
assert.strictEqual(result.status, 2, result.stderr);
|
||||
assert.match(result.stderr, /Dev server must run in tmux/);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('Cursor-native security hooks honor the master hook switch', () => {
|
||||
const disabledEnv = { ECC_HOOKS_ENABLED: 'false' };
|
||||
const cases = [
|
||||
[BEFORE_READ, { path: '.env' }],
|
||||
[BEFORE_PROMPT, { prompt: 'token sk-123456789012345678901234' }],
|
||||
[BEFORE_TAB_READ, { path: 'credentials.pem' }],
|
||||
];
|
||||
for (const [scriptPath, input] of cases) {
|
||||
const result = runHook(scriptPath, input, disabledEnv);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.strictEqual(result.stderr, '', `${path.basename(scriptPath)} should be disabled`);
|
||||
}
|
||||
})) 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('invalid path field types cannot shadow a valid string fallback', () => {
|
||||
const input = { file_path: {}, path: '.env' };
|
||||
const warning = runHook(BEFORE_READ, input);
|
||||
assert.strictEqual(warning.status, 0, warning.stderr);
|
||||
assert.match(warning.stderr, /Reading sensitive file/);
|
||||
|
||||
const blocked = runHook(BEFORE_TAB_READ, input);
|
||||
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' }],
|
||||
[AFTER_MCP, { server: 'example', tool: 'read' }],
|
||||
[AFTER_SHELL, { command: 'gh pr create', output: 'https://github.com/a/b/pull/1' }],
|
||||
[AFTER_TAB_EDIT, { path: 'src/app.js' }],
|
||||
[PRE_COMPACT, {}],
|
||||
[SUBAGENT_START, { agent_name: 'explore' }],
|
||||
[SUBAGENT_STOP, { agent_name: 'explore' }],
|
||||
];
|
||||
for (const [scriptPath, input] of cases) {
|
||||
const result = runHook(scriptPath, input, { ECC_HOOK_PROFILE: 'minimal' });
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.strictEqual(result.stderr, '', `${path.basename(scriptPath)} should be disabled`);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('disabled preCompact preserves malformed input unchanged', () => {
|
||||
const raw = '{not-json';
|
||||
const result = runHook(PRE_COMPACT, raw, { ECC_HOOK_PROFILE: 'minimal' });
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.strictEqual(result.stdout, raw);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('Stop dispatcher runs batch format/typecheck in standard profile', () => {
|
||||
const sessionId = `cursor-stop-standard-${process.pid}`;
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-cursor-stop-'));
|
||||
const editedFile = path.join(tempRoot, 'edited.js');
|
||||
const accumFile = accumulatorPath(sessionId, tempRoot);
|
||||
writeFile(editedFile, 'const answer = 42;\n');
|
||||
writeFile(accumFile, `${editedFile}\n`);
|
||||
|
||||
try {
|
||||
const result = runHook(STOP, { conversation_id: sessionId }, {
|
||||
CLAUDE_SESSION_ID: sessionId,
|
||||
ECC_DISABLED_HOOKS: [
|
||||
'stop:check-console-log',
|
||||
'stop:session-end',
|
||||
'stop:evaluate-session',
|
||||
'stop:cost-tracker',
|
||||
].join(','),
|
||||
}, tempRoot);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.ok(!fs.existsSync(accumFile), 'Stop should consume the edit accumulator');
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
fs.rmSync(accumFile, { force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('afterFileEdit maps Cursor file_path and conversation_id', () => {
|
||||
const conversationId = `cursor-edit-${process.pid}`;
|
||||
const accumFile = accumulatorPath(conversationId, REPO_ROOT);
|
||||
fs.rmSync(accumFile, { force: true });
|
||||
|
||||
try {
|
||||
const editedFile = path.join(REPO_ROOT, 'src', 'cursor-edit.js');
|
||||
const result = runHook(AFTER_FILE_EDIT, {
|
||||
conversation_id: conversationId,
|
||||
file_path: editedFile,
|
||||
}, {
|
||||
ECC_DISABLED_HOOKS: [
|
||||
'post:edit:console-warn',
|
||||
'post:edit:design-quality-check',
|
||||
].join(','),
|
||||
});
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.strictEqual(fs.readFileSync(accumFile, 'utf8'), `${editedFile}\n`);
|
||||
} finally {
|
||||
fs.rmSync(accumFile, { force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('parallel Cursor conversations keep separate edit accumulators', () => {
|
||||
const firstId = `cursor-first-${process.pid}`;
|
||||
const secondId = `cursor-second-${process.pid}`;
|
||||
const firstAccum = accumulatorPath(firstId, REPO_ROOT);
|
||||
const secondAccum = accumulatorPath(secondId, REPO_ROOT);
|
||||
fs.rmSync(firstAccum, { force: true });
|
||||
fs.rmSync(secondAccum, { force: true });
|
||||
|
||||
try {
|
||||
const env = {
|
||||
ECC_DISABLED_HOOKS: [
|
||||
'post:edit:console-warn',
|
||||
'post:edit:design-quality-check',
|
||||
].join(','),
|
||||
};
|
||||
assert.strictEqual(runHook(AFTER_FILE_EDIT, {
|
||||
conversation_id: firstId,
|
||||
file_path: path.join(REPO_ROOT, 'src', 'first.js'),
|
||||
}, env).status, 0);
|
||||
assert.strictEqual(runHook(AFTER_FILE_EDIT, {
|
||||
conversation_id: secondId,
|
||||
file_path: path.join(REPO_ROOT, 'src', 'second.js'),
|
||||
}, env).status, 0);
|
||||
|
||||
assert.ok(fs.existsSync(firstAccum), 'First conversation should own an accumulator');
|
||||
assert.ok(fs.existsSync(secondAccum), 'Second conversation should own an accumulator');
|
||||
|
||||
const stopped = runHook(STOP, { conversation_id: firstId }, {
|
||||
ECC_DISABLED_HOOKS: [
|
||||
'stop:check-console-log',
|
||||
'stop:session-end',
|
||||
'stop:evaluate-session',
|
||||
'stop:cost-tracker',
|
||||
].join(','),
|
||||
});
|
||||
assert.strictEqual(stopped.status, 0, stopped.stderr);
|
||||
assert.ok(!fs.existsSync(firstAccum), 'First Stop should consume only its accumulator');
|
||||
assert.ok(fs.existsSync(secondAccum), 'Second conversation accumulator must remain');
|
||||
} finally {
|
||||
fs.rmSync(firstAccum, { force: true });
|
||||
fs.rmSync(secondAccum, { force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('afterFileEdit forwards user-visible console warnings', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-cursor-warning-'));
|
||||
const editedFile = path.join(tempRoot, 'warning.js');
|
||||
writeFile(editedFile, 'console.log("debug");\n');
|
||||
|
||||
try {
|
||||
const result = runHook(AFTER_FILE_EDIT, { file_path: editedFile }, {
|
||||
ECC_DISABLED_HOOKS: [
|
||||
'post:edit:accumulator',
|
||||
'post:edit:design-quality-check',
|
||||
].join(','),
|
||||
}, tempRoot);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.match(result.stderr, /console\.log found/);
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('Stop reserves time for lifecycle hooks before the format batch', () => {
|
||||
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', () => {
|
||||
const sessionId = `cursor-stop-minimal-${process.pid}`;
|
||||
const accumFile = accumulatorPath(sessionId, REPO_ROOT);
|
||||
writeFile(accumFile, `${path.join(REPO_ROOT, 'README.md')}\n`);
|
||||
|
||||
try {
|
||||
const result = runHook(STOP, { conversation_id: sessionId }, {
|
||||
CLAUDE_SESSION_ID: sessionId,
|
||||
ECC_HOOK_PROFILE: 'minimal',
|
||||
ECC_DISABLED_HOOKS: [
|
||||
'stop:session-end',
|
||||
'stop:evaluate-session',
|
||||
'stop:cost-tracker',
|
||||
].join(','),
|
||||
});
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.ok(fs.existsSync(accumFile), 'Minimal profile must not run format/typecheck');
|
||||
} finally {
|
||||
fs.rmSync(accumFile, { force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('afterFileEdit does not accumulate files in the minimal profile', () => {
|
||||
const sessionId = `cursor-edit-minimal-${process.pid}`;
|
||||
const accumFile = accumulatorPath(sessionId, REPO_ROOT);
|
||||
fs.rmSync(accumFile, { force: true });
|
||||
|
||||
try {
|
||||
const result = runHook(
|
||||
AFTER_FILE_EDIT,
|
||||
{ path: path.join(REPO_ROOT, 'example.js') },
|
||||
{ CLAUDE_SESSION_ID: sessionId, ECC_HOOK_PROFILE: 'minimal' }
|
||||
);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.ok(!fs.existsSync(accumFile), 'Minimal profile must not accumulate edit paths');
|
||||
} finally {
|
||||
fs.rmSync(accumFile, { force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('installed Cursor dispatchers resolve shared hooks inside .cursor/scripts', () => {
|
||||
const fixture = createInstalledCursorFixture();
|
||||
try {
|
||||
const blocked = runHook(
|
||||
fixture.beforeShell,
|
||||
{ command: 'git commit --no-verify -m test' },
|
||||
{},
|
||||
fixture.projectRoot
|
||||
);
|
||||
assert.strictEqual(blocked.status, 2, blocked.stderr);
|
||||
assert.match(blocked.stderr, /installed runtime/);
|
||||
|
||||
const edited = runHook(
|
||||
fixture.afterFileEdit,
|
||||
{ path: path.join(fixture.projectRoot, 'src', 'app.js') },
|
||||
{ CURSOR_HOOK_LOG: fixture.logPath },
|
||||
fixture.projectRoot
|
||||
);
|
||||
assert.strictEqual(edited.status, 0, edited.stderr);
|
||||
assert.strictEqual(fs.readFileSync(fixture.logPath, 'utf8'), 'post-edit-accumulator\n');
|
||||
} finally {
|
||||
fs.rmSync(fixture.projectRoot, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('installed Cursor hooks keep profile controls without the shared helper', () => {
|
||||
const fixture = createInstalledCursorFixture({ includeHookFlags: false });
|
||||
try {
|
||||
const disabled = runHook(
|
||||
fixture.beforeShell,
|
||||
{ command: 'git commit --no-verify -m test' },
|
||||
{ ECC_HOOKS_ENABLED: 'false' },
|
||||
fixture.projectRoot
|
||||
);
|
||||
assert.strictEqual(disabled.status, 0, disabled.stderr);
|
||||
|
||||
const enabled = runHook(
|
||||
fixture.beforeShell,
|
||||
{ command: 'git commit --no-verify -m test' },
|
||||
{ ECC_HOOKS_ENABLED: 'true' },
|
||||
fixture.projectRoot
|
||||
);
|
||||
assert.strictEqual(enabled.status, 2, enabled.stderr);
|
||||
} finally {
|
||||
fs.rmSync(fixture.projectRoot, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('installed Stop dispatcher resolves hooks inside .cursor/scripts', () => {
|
||||
const fixture = createInstalledCursorFixture();
|
||||
try {
|
||||
const result = runHook(
|
||||
fixture.stop,
|
||||
{ conversation_id: 'installed-stop' },
|
||||
{
|
||||
CURSOR_HOOK_LOG: fixture.logPath,
|
||||
ECC_DISABLED_HOOKS: [
|
||||
'stop:check-console-log',
|
||||
'stop:session-end',
|
||||
'stop:evaluate-session',
|
||||
'stop:cost-tracker',
|
||||
].join(','),
|
||||
},
|
||||
fixture.projectRoot
|
||||
);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(fixture.logPath, 'utf8'),
|
||||
'stop-format-typecheck:210000\n'
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(fixture.projectRoot, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('installed sessionStart dispatcher combines env and shared context', () => {
|
||||
const fixture = createInstalledCursorFixture();
|
||||
try {
|
||||
const result = runHook(
|
||||
fixture.sessionStart,
|
||||
{ conversation_id: 'installed-session' },
|
||||
{},
|
||||
fixture.projectRoot
|
||||
);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.deepStrictEqual(JSON.parse(result.stdout), {
|
||||
env: { ECC_AGENT_DATA_HOME: '/tmp/cursor-ecc' },
|
||||
additional_context: 'cursor env\n\nshared context',
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(fixture.projectRoot, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('installed sessionStart keeps environment fallback for malformed input', () => {
|
||||
const fixture = createInstalledCursorFixture();
|
||||
try {
|
||||
for (const raw of ['{not-json', 'null']) {
|
||||
const result = runHook(fixture.sessionStart, raw, {}, fixture.projectRoot);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.strictEqual(JSON.parse(result.stdout).env.ECC_AGENT_DATA_HOME, '/tmp/cursor-ecc');
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(fixture.projectRoot, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('sessionStart dispatcher honors the master hook switch', () => {
|
||||
const result = runHook(
|
||||
path.join(REPO_ROOT, '.cursor', 'hooks', 'session-start.js'),
|
||||
{ conversation_id: 'disabled-session' },
|
||||
{ ECC_HOOKS_ENABLED: 'false' }
|
||||
);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.deepStrictEqual(JSON.parse(result.stdout), {});
|
||||
})) passed++; else failed++;
|
||||
|
||||
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: 'git commit --no-verify',
|
||||
padding: 'x'.repeat(1024 * 1024),
|
||||
});
|
||||
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.*blocking/i);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('readStdin exposes stream failures to blocking dispatchers', () => {
|
||||
const probe = [
|
||||
"const { EventEmitter } = require('events');",
|
||||
`const { readStdin } = require(${JSON.stringify(path.join(REPO_ROOT, '.cursor', 'hooks', 'adapter.js'))});`,
|
||||
"const stream = new EventEmitter();",
|
||||
"stream.setEncoding = () => {};",
|
||||
"readStdin({ includeMetadata: true, stream }).then(result => process.stdout.write(JSON.stringify(result)));",
|
||||
"setImmediate(() => stream.emit('error', new Error('synthetic read failure')));",
|
||||
].join('\n');
|
||||
const result = spawnSync(process.execPath, ['-e', probe], {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
});
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.deepStrictEqual(JSON.parse(result.stdout), {
|
||||
raw: '',
|
||||
truncated: false,
|
||||
readError: true,
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log('-'.repeat(55));
|
||||
console.log(`Passed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -13,7 +13,12 @@ const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const accumulator = require('../../scripts/hooks/post-edit-accumulator');
|
||||
const { parseAccumulator, isPluginClonePath } = require('../../scripts/hooks/stop-format-typecheck');
|
||||
const {
|
||||
getPerBatchBudgetMs,
|
||||
getTotalBudgetMs,
|
||||
isPluginClonePath,
|
||||
parseAccumulator,
|
||||
} = require('../../scripts/hooks/stop-format-typecheck');
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
@@ -203,6 +208,27 @@ if (test('parseAccumulator ignores blank lines and trims whitespace', () => {
|
||||
assert.deepStrictEqual(result, ['/tmp/a.ts', '/tmp/b.ts']);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('getTotalBudgetMs honors a bounded dispatcher budget', () => {
|
||||
assert.strictEqual(
|
||||
getTotalBudgetMs({ ECC_STOP_FORMAT_TYPECHECK_BUDGET_MS: '210000' }),
|
||||
210000
|
||||
);
|
||||
assert.strictEqual(
|
||||
getTotalBudgetMs({ ECC_STOP_FORMAT_TYPECHECK_BUDGET_MS: '999999' }),
|
||||
270000
|
||||
);
|
||||
assert.strictEqual(
|
||||
getTotalBudgetMs({ ECC_STOP_FORMAT_TYPECHECK_BUDGET_MS: 'invalid' }),
|
||||
270000
|
||||
);
|
||||
})) 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');
|
||||
|
||||
@@ -260,7 +260,7 @@ function runTests() {
|
||||
&& operation.destinationPath === path.join(projectRoot, '.cursor', 'hooks.json')
|
||||
&& operation.strategy === 'preserve-relative-path'
|
||||
)),
|
||||
'Should preserve non-rule Cursor platform files'
|
||||
'Developer Cursor installs should register hooks through hooks-runtime'
|
||||
);
|
||||
assert.ok(
|
||||
plan.operations.some(operation => (
|
||||
|
||||
@@ -231,9 +231,11 @@ function runTests() {
|
||||
normalizedRelativePath(operation.sourceRelativePath) === '.cursor/rules/common-coding-style.md'
|
||||
));
|
||||
|
||||
assert.ok(hooksJson, 'Should preserve non-rule Cursor platform config files');
|
||||
assert.strictEqual(hooksJson.strategy, 'preserve-relative-path');
|
||||
assert.strictEqual(hooksJson.destinationPath, path.join(projectRoot, '.cursor', 'hooks.json'));
|
||||
assert.strictEqual(
|
||||
hooksJson,
|
||||
undefined,
|
||||
'Platform configs should not register hooks without hooks-runtime'
|
||||
);
|
||||
assert.ok(mcpJson, 'Should materialize a Cursor MCP config from the shared root MCP config');
|
||||
assert.strictEqual(mcpJson.kind, 'merge-json');
|
||||
assert.strictEqual(mcpJson.strategy, 'merge-json');
|
||||
@@ -395,11 +397,10 @@ function runTests() {
|
||||
'Should not preserve .md Cursor platform rule files'
|
||||
);
|
||||
assert.ok(
|
||||
plan.operations.some(operation => (
|
||||
normalizedRelativePath(operation.sourceRelativePath) === '.cursor/hooks.json'
|
||||
&& operation.destinationPath === path.join(projectRoot, '.cursor', 'hooks.json')
|
||||
!plan.operations.some(operation => (
|
||||
operation.destinationPath === path.join(projectRoot, '.cursor', 'hooks.json')
|
||||
)),
|
||||
'Should preserve non-rule Cursor platform config files'
|
||||
'Should not register Cursor hooks without hooks-runtime'
|
||||
);
|
||||
assert.ok(
|
||||
plan.operations.some(operation => (
|
||||
@@ -479,6 +480,14 @@ function runTests() {
|
||||
'.cursor/hooks',
|
||||
'Should prefer native Cursor hooks over generic hooks-runtime hooks'
|
||||
);
|
||||
const hooksConfig = plan.operations.find(operation => (
|
||||
operation.destinationPath === path.join(projectRoot, '.cursor', 'hooks.json')
|
||||
));
|
||||
assert.strictEqual(
|
||||
normalizedRelativePath(hooksConfig.sourceRelativePath),
|
||||
'.cursor/hooks.json',
|
||||
'Should register the native Cursor hook config through hooks-runtime'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('plans native Antigravity 2.0 rules, workflows, skills, and agents', () => {
|
||||
|
||||
@@ -255,6 +255,38 @@ function runTests() {
|
||||
const mcpConfig = readJson(path.join(projectDir, '.cursor', 'mcp.json'));
|
||||
assert.strictEqual(hooksConfig.version, 1);
|
||||
assert.ok(hooksConfig.hooks.sessionStart, 'Should keep Cursor sessionStart hooks');
|
||||
assert.strictEqual(
|
||||
hooksConfig.hooks.sessionStart[0].command,
|
||||
'node .cursor/hooks/session-start.js',
|
||||
'Cursor sessionStart should use the event-level dispatcher'
|
||||
);
|
||||
for (const [eventName, entries] of Object.entries(hooksConfig.hooks)) {
|
||||
assert.strictEqual(
|
||||
entries.length,
|
||||
1,
|
||||
`Installed Cursor event ${eventName} should use one dispatcher`
|
||||
);
|
||||
}
|
||||
|
||||
const installedShellDispatcher = path.join(
|
||||
projectDir,
|
||||
'.cursor',
|
||||
'hooks',
|
||||
'before-shell-execution.js'
|
||||
);
|
||||
const blocked = spawnSync(process.execPath, [installedShellDispatcher], {
|
||||
cwd: projectDir,
|
||||
env: { ...process.env, HOME: homeDir, USERPROFILE: homeDir },
|
||||
input: JSON.stringify({ command: 'git commit --no-verify -m test' }),
|
||||
encoding: 'utf8',
|
||||
timeout: DEFAULT_INSTALL_APPLY_TIMEOUT_MS,
|
||||
});
|
||||
assert.strictEqual(
|
||||
blocked.status,
|
||||
2,
|
||||
`Installed Cursor dispatcher should preserve blocking exit codes: ${blocked.stderr}`
|
||||
);
|
||||
assert.match(blocked.stderr, /BLOCKED/);
|
||||
assert.ok(mcpConfig.mcpServers['chrome-devtools'], 'Should install shared MCP servers into Cursor');
|
||||
|
||||
const statePath = path.join(projectDir, '.cursor', 'ecc-install-state.json');
|
||||
@@ -931,12 +963,12 @@ function runTests() {
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
try {
|
||||
const result = run(['--target', 'cursor', '--modules', 'platform-configs', '--enable-hooks'], {
|
||||
const result = run(['--target', 'cursor', '--modules', 'platform-configs'], {
|
||||
cwd: projectDir,
|
||||
homeDir,
|
||||
});
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'hooks.json')));
|
||||
assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'hooks.json')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-agents.mdc')));
|
||||
assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-agents.md')));
|
||||
|
||||
@@ -947,6 +979,7 @@ function runTests() {
|
||||
assert.deepStrictEqual(state.request.excludeComponents, []);
|
||||
assert.strictEqual(state.request.legacyMode, false);
|
||||
assert.ok(state.resolution.selectedModules.includes('platform-configs'));
|
||||
assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'hooks')));
|
||||
assert.ok(
|
||||
!state.operations.some(operation => operation.destinationPath.endsWith('ecc-install-state.json')),
|
||||
'Manifest copy operations should not include generated install-state files'
|
||||
|
||||
Reference in New Issue
Block a user