#!/usr/bin/env node /** * Executes a hook script only when enabled by ECC hook profile flags. * * Usage: * node run-with-flags.js [profilesCsv] */ 'use strict'; const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); const { isHookEnabled } = require('../lib/hook-flags'); const MAX_STDIN = 1024 * 1024; function readStdinRaw() { return new Promise(resolve => { let raw = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => { if (raw.length < MAX_STDIN) { const remaining = MAX_STDIN - raw.length; raw += chunk.substring(0, remaining); } }); process.stdin.on('end', () => resolve(raw)); process.stdin.on('error', () => resolve(raw)); }); } function getPluginRoot() { if (process.env.CLAUDE_PLUGIN_ROOT && process.env.CLAUDE_PLUGIN_ROOT.trim()) { return process.env.CLAUDE_PLUGIN_ROOT; } return path.resolve(__dirname, '..', '..'); } async function main() { const [, , hookId, relScriptPath, profilesCsv] = process.argv; const raw = await readStdinRaw(); if (!hookId || !relScriptPath) { process.stdout.write(raw); process.exit(0); } if (!isHookEnabled(hookId, { profiles: profilesCsv })) { process.stdout.write(raw); process.exit(0); } 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`); process.stdout.write(raw); process.exit(0); } if (!fs.existsSync(scriptPath)) { process.stderr.write(`[Hook] Script not found for ${hookId}: ${scriptPath}\n`); process.stdout.write(raw); process.exit(0); } // 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'); const hasRunExport = /\bmodule\.exports\b/.test(src) && /\brun\b/.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 { const output = hookModule.run(raw); if (output != null) process.stdout.write(output); } catch (runErr) { process.stderr.write(`[Hook] run() error for ${hookId}: ${runErr.message}\n`); process.stdout.write(raw); } process.exit(0); } // Legacy path: spawn a child Node process for hooks without run() export const result = spawnSync('node', [scriptPath], { input: raw, encoding: 'utf8', env: process.env, cwd: process.cwd(), timeout: 30000 }); if (result.stdout) process.stdout.write(result.stdout); if (result.stderr) process.stderr.write(result.stderr); const code = Number.isInteger(result.status) ? result.status : 0; process.exit(code); } main().catch(err => { process.stderr.write(`[Hook] run-with-flags error: ${err.message}\n`); process.exit(0); });