Files
ECC/scripts/hooks/run-with-flags.js
T
Affaan MustafaandGitHub fc9273e5e0 Merge pull request #3126 from VarunGore36/fix/reviewer-followups
fix(security): security follow-ups for worker, installer, claw, hooks
2026-09-19 19:58:43 -04:00

307 lines
10 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 { isHookEnabled, isDryRun } = require('../lib/hook-flags');
const { readStdinRaw: readBoundedStdin, resolveMaxStdin } = require('./hook-input');
const { buildPreToolUseAdditionalContext } = require('./pretooluse-visible-output');
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)
});
function readStdinRaw() {
return readBoundedStdin(process.stdin, {
maxStdin: MAX_STDIN,
truncated: /^(1|true|yes)$/i.test(
String(process.env.ECC_HOOK_INPUT_TRUNCATED_UPSTREAM || '')
)
});
}
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 } = 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
});
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);
});