mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-14 12:15:29 +02:00
Carry forward PR #3055 at92f53c4contoef648e01, preserving contributor attribution and current main. Focused maintainer fixes reuse shared input ceilings, preserve late root context through outer bootstrap, bound frontmatter before immutable parsing, and assert real oversized input in the limits workflow.
334 lines
11 KiB
JavaScript
Executable File
334 lines
11 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/**
|
|
* Executes a hook script only when enabled by ECC hook profile flags.
|
|
*
|
|
* Usage:
|
|
* node run-with-flags.js <hookId> <scriptRelativePath> [profilesCsv]
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { spawnSync } = require('child_process');
|
|
const { StringDecoder } = require('string_decoder');
|
|
const { isHookEnabled, isDryRun } = require('../lib/hook-flags');
|
|
const { readStdinRaw: readBoundedStdin, resolveMaxStdin } = require('./hook-input');
|
|
const { buildPreToolUseAdditionalContext } = require('./pretooluse-visible-output');
|
|
const { createHookContextScanner } = require('./hook-input-limits');
|
|
|
|
const FAIL_CLOSED_ON_TRUNCATION_HOOKS = new Set([
|
|
'pre:powershell:gateguard-fact-force',
|
|
'pre:edit-write:gateguard-fact-force',
|
|
'pre:mcp-health-check'
|
|
]);
|
|
|
|
const MAX_STDIN = resolveMaxStdin(process.env.ECC_HOOK_INPUT_MAX_BYTES, {
|
|
writeDiagnostic: message => process.stderr.write(message)
|
|
});
|
|
const UPSTREAM_TRUNCATED = /^(1|true|yes)$/i.test(
|
|
String(process.env.ECC_HOOK_INPUT_TRUNCATED_UPSTREAM || '')
|
|
);
|
|
|
|
function readUpstreamHookContext() {
|
|
if (!UPSTREAM_TRUNCATED) return {};
|
|
try {
|
|
const parsed = JSON.parse(String(process.env.ECC_HOOK_CONTEXT_JSON || '{}'));
|
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function readStdinRaw() {
|
|
const contextDecoder = new StringDecoder('utf8');
|
|
const contextScanner = createHookContextScanner();
|
|
const upstreamHookContext = readUpstreamHookContext();
|
|
return readBoundedStdin(process.stdin, {
|
|
maxStdin: MAX_STDIN,
|
|
truncated: UPSTREAM_TRUNCATED,
|
|
onChunk: buffer => contextScanner.push(contextDecoder.write(buffer))
|
|
}).then(result => {
|
|
contextScanner.push(contextDecoder.end());
|
|
const scannedHookContext = contextScanner.context;
|
|
return {
|
|
...result,
|
|
hookContext: UPSTREAM_TRUNCATED
|
|
? { ...scannedHookContext, ...upstreamHookContext }
|
|
: scannedHookContext
|
|
};
|
|
});
|
|
}
|
|
|
|
function writeStderr(stderr) {
|
|
if (typeof stderr !== 'string' || stderr.length === 0) {
|
|
return;
|
|
}
|
|
|
|
process.stderr.write(stderr.endsWith('\n') ? stderr : `${stderr}\n`);
|
|
}
|
|
|
|
/**
|
|
* Exit only after stdout and any previously queued stderr have drained.
|
|
* `process.exit()` immediately after a stream write drops anything beyond
|
|
* the OS pipe buffer, which cut large hook output mid-payload and made the
|
|
* harness treat the hook as failed (#2222).
|
|
*/
|
|
function exitWithStdout(text, exitCode) {
|
|
process.exitCode = exitCode;
|
|
let pendingWrites = 1;
|
|
const exitWhenFlushed = () => {
|
|
pendingWrites -= 1;
|
|
if (pendingWrites === 0) {
|
|
process.exit(exitCode);
|
|
}
|
|
};
|
|
|
|
if (typeof text === 'string' && text.length > 0) {
|
|
pendingWrites += 1;
|
|
process.stdout.write(text, exitWhenFlushed);
|
|
}
|
|
process.stderr.write('', exitWhenFlushed);
|
|
}
|
|
|
|
function resolveHookResult(output) {
|
|
if (typeof output === 'string' || Buffer.isBuffer(output)) {
|
|
return { stdout: String(output), exitCode: 0 };
|
|
}
|
|
|
|
if (output && typeof output === 'object') {
|
|
writeStderr(output.stderr);
|
|
const exitCode = Number.isInteger(output.exitCode) ? output.exitCode : 0;
|
|
|
|
if (Object.prototype.hasOwnProperty.call(output, 'additionalContext')) {
|
|
return { stdout: buildPreToolUseAdditionalContext(output.additionalContext), exitCode };
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(output, 'stdout')) {
|
|
return { stdout: String(output.stdout ?? ''), exitCode };
|
|
}
|
|
return { stdout: '', exitCode };
|
|
}
|
|
|
|
return { stdout: '', exitCode: 0 };
|
|
}
|
|
|
|
function resolveLegacySpawnStdout(result) {
|
|
const stdout = typeof result.stdout === 'string' ? result.stdout : '';
|
|
return stdout || '';
|
|
}
|
|
|
|
function truncatedInputResult(hookId, maxStdin) {
|
|
if (!FAIL_CLOSED_ON_TRUNCATION_HOOKS.has(hookId)) return null;
|
|
if (hookId === 'pre:powershell:gateguard-fact-force'
|
|
|| hookId === 'pre:edit-write:gateguard-fact-force') {
|
|
const gateGuardValue = String(process.env.ECC_GATEGUARD || '').trim().toLowerCase();
|
|
const legacyDisabled = String(process.env.GATEGUARD_DISABLED || '').trim() === '1';
|
|
if (legacyDisabled || ['0', 'false', 'off', 'disabled', 'disable'].includes(gateGuardValue)) {
|
|
return null;
|
|
}
|
|
}
|
|
if (hookId === 'pre:mcp-health-check') {
|
|
const failOpen = /^(1|true|yes)$/i.test(
|
|
String(process.env.ECC_MCP_HEALTH_FAIL_OPEN || '')
|
|
);
|
|
if (failOpen) return null;
|
|
}
|
|
|
|
return {
|
|
stdout: '',
|
|
stderr: `BLOCKED: Hook input exceeded ${maxStdin} bytes, so ${hookId} could not safely inspect the complete request. Retry with a smaller tool input or explicitly disable this hook.`,
|
|
exitCode: 2
|
|
};
|
|
}
|
|
|
|
function getPluginRoot() {
|
|
if (process.env.CLAUDE_PLUGIN_ROOT && process.env.CLAUDE_PLUGIN_ROOT.trim()) {
|
|
return process.env.CLAUDE_PLUGIN_ROOT;
|
|
}
|
|
return path.resolve(__dirname, '..', '..');
|
|
}
|
|
|
|
//Safely extract target context from hook stdin JSON for dry-run preview.
|
|
|
|
function extractTargetContext(raw) {
|
|
const result = { tool: '', filePath: '', command: '' };
|
|
if (!raw || typeof raw !== 'string') return result;
|
|
|
|
try {
|
|
const payload = JSON.parse(raw);
|
|
if (payload && typeof payload === 'object') {
|
|
result.tool = String(payload.tool || '');
|
|
const input = payload.tool_input;
|
|
if (input && typeof input === 'object') {
|
|
result.filePath = String(input.file_path || input.path || '');
|
|
result.command = String(input.command || '');
|
|
}
|
|
}
|
|
} catch {
|
|
// best-effort field extraction; ignore malformed input
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Build the [DryRun] preview line for stderr.
|
|
|
|
function buildDryRunPreview(hookId, relScriptPath, profilesCsv, raw) {
|
|
const ctx = extractTargetContext(raw);
|
|
const parts = [`[DryRun] Hook "${hookId}" would execute: ${relScriptPath}`, `(enabled=true, profiles=${profilesCsv || 'default'})`];
|
|
|
|
if (ctx.tool) {
|
|
parts.push(`tool=${ctx.tool}`);
|
|
}
|
|
if (ctx.filePath) {
|
|
parts.push(`target=${ctx.filePath}`);
|
|
}
|
|
if (ctx.command) {
|
|
parts.push(`command=${ctx.command}`);
|
|
}
|
|
|
|
return parts.join(' ') + '\n';
|
|
}
|
|
|
|
async function main() {
|
|
const [, , hookId, relScriptPath, profilesCsv] = process.argv;
|
|
const { raw, truncated, hookContext } = await readStdinRaw();
|
|
|
|
// Oversized payloads: never echo the truncated string — a JSON document
|
|
// cut mid-stream is treated by the harness as a hook failure, blocking the
|
|
// tool call (#2222). Empty stdout + exit 0 means "no opinion", so
|
|
// silent/no-op paths fail open. The hook itself still runs and receives
|
|
// the truncated flag (run() context / ECC_HOOK_INPUT_TRUNCATED), so
|
|
// security hooks like config-protection can still choose to block.
|
|
const sanitizeEcho = text => (truncated && text === raw ? '' : text);
|
|
if (truncated) {
|
|
process.stderr.write(`[Hook] stdin exceeded ${MAX_STDIN} bytes for ${hookId || 'unknown'}; suppressing raw passthrough\n`);
|
|
}
|
|
|
|
if (!hookId || !relScriptPath) {
|
|
exitWithStdout('', 0);
|
|
return;
|
|
}
|
|
|
|
if (!isHookEnabled(hookId, { profiles: profilesCsv })) {
|
|
exitWithStdout('', 0);
|
|
return;
|
|
}
|
|
|
|
if (isDryRun()) {
|
|
const preview = buildDryRunPreview(hookId, relScriptPath, profilesCsv, raw);
|
|
process.stderr.write(preview);
|
|
exitWithStdout('', 0);
|
|
return;
|
|
}
|
|
|
|
const pluginRoot = getPluginRoot();
|
|
const resolvedRoot = path.resolve(pluginRoot);
|
|
const scriptPath = path.resolve(pluginRoot, relScriptPath);
|
|
|
|
// Prevent path traversal outside the plugin root
|
|
if (!scriptPath.startsWith(resolvedRoot + path.sep)) {
|
|
process.stderr.write(`[Hook] Path traversal rejected for ${hookId}: ${scriptPath}\n`);
|
|
exitWithStdout('', 0);
|
|
return;
|
|
}
|
|
|
|
if (!fs.existsSync(scriptPath)) {
|
|
process.stderr.write(`[Hook] Script not found for ${hookId}: ${scriptPath}\n`);
|
|
exitWithStdout('', 0);
|
|
return;
|
|
}
|
|
|
|
const truncationBlock = truncated ? truncatedInputResult(hookId, MAX_STDIN) : null;
|
|
if (truncationBlock) {
|
|
writeStderr(truncationBlock.stderr);
|
|
exitWithStdout(truncationBlock.stdout, truncationBlock.exitCode);
|
|
return;
|
|
}
|
|
|
|
// Prefer direct require() when the hook exports a run(rawInput) function.
|
|
// This eliminates one Node.js process spawn (~50-100ms savings per hook).
|
|
//
|
|
// SAFETY: Only require() hooks that export run(). Legacy hooks execute
|
|
// side effects at module scope (stdin listeners, process.exit, main() calls)
|
|
// which would interfere with the parent process or cause double execution.
|
|
let hookModule;
|
|
const src = fs.readFileSync(scriptPath, 'utf8');
|
|
// Gate require() on concrete export syntax, not a bare word match: the old
|
|
// /\bmodule\.exports\b/ && /\brun\b/ test fired on comments, strings, and
|
|
// unrelated properties, causing require() — and its module-scope side
|
|
// effects — to run for hooks that export no run(). Still lexical (no parser
|
|
// dependency), but requires an actual export assignment form.
|
|
const RUN_EXPORT_PATTERNS = [
|
|
/module\.exports\s*\.\s*run\s*=/,
|
|
/exports\s*\.\s*run\s*=/,
|
|
/module\.exports\s*=\s*\{[^}]*\brun\b/,
|
|
/module\.exports\s*=\s*(async\s+)?function\s+run\b/,
|
|
];
|
|
const hasRunExport = RUN_EXPORT_PATTERNS.some(re => re.test(src));
|
|
|
|
if (hasRunExport) {
|
|
try {
|
|
hookModule = require(scriptPath);
|
|
} catch (requireErr) {
|
|
process.stderr.write(`[Hook] require() failed for ${hookId}: ${requireErr.message}\n`);
|
|
// Fall through to legacy spawnSync path
|
|
}
|
|
}
|
|
|
|
if (hookModule && typeof hookModule.run === 'function') {
|
|
try {
|
|
// Awaited so a hook may export `async run()`. Without this an async hook
|
|
// hands back a pending Promise, which resolveHookResult reads as "no
|
|
// opinion" and silently degrades to pass-through. Synchronous hooks are
|
|
// unaffected: awaiting a plain value just costs a microtask.
|
|
const output = await hookModule.run(raw, {
|
|
hookId,
|
|
pluginRoot,
|
|
scriptPath,
|
|
truncated,
|
|
maxStdin: MAX_STDIN,
|
|
...(truncated ? hookContext : {})
|
|
});
|
|
const result = resolveHookResult(output);
|
|
exitWithStdout(sanitizeEcho(result.stdout), result.exitCode);
|
|
} catch (runErr) {
|
|
process.stderr.write(`[Hook] run() error for ${hookId}: ${runErr.message}\n`);
|
|
exitWithStdout('', 0);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Legacy path: spawn a child Node process for hooks without run() export
|
|
const result = spawnSync(process.execPath, [scriptPath], {
|
|
input: raw,
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
CLAUDE_PLUGIN_ROOT: pluginRoot,
|
|
ECC_PLUGIN_ROOT: pluginRoot,
|
|
ECC_HOOK_ID: hookId,
|
|
ECC_HOOK_INPUT_TRUNCATED: truncated ? '1' : '0',
|
|
ECC_HOOK_INPUT_MAX_BYTES: String(MAX_STDIN)
|
|
},
|
|
cwd: process.cwd(),
|
|
timeout: 30000
|
|
});
|
|
|
|
const legacyStdout = sanitizeEcho(resolveLegacySpawnStdout(result));
|
|
if (result.stderr) process.stderr.write(result.stderr);
|
|
|
|
if (result.error || result.signal || result.status === null) {
|
|
const failureDetail = result.error ? result.error.message : result.signal ? `terminated by signal ${result.signal}` : 'missing exit status';
|
|
writeStderr(`[Hook] legacy hook execution failed for ${hookId}: ${failureDetail}`);
|
|
exitWithStdout(legacyStdout, 1);
|
|
return;
|
|
}
|
|
|
|
exitWithStdout(legacyStdout, Number.isInteger(result.status) ? result.status : 0);
|
|
}
|
|
|
|
main().catch(err => {
|
|
process.stderr.write(`[Hook] run-with-flags error: ${err.message}\n`);
|
|
process.exit(0);
|
|
});
|