diff --git a/hooks/README.md b/hooks/README.md index 06df4413b..548658774 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -114,6 +114,18 @@ export ECC_HOOK_PROFILE=standard # Disable specific hook IDs (comma-separated) export ECC_DISABLED_HOOKS="pre:bash:tmux-reminder,post:edit:typecheck" +# Lower the hook input cap in bytes (default and maximum: 1048576). +# run-with-flags.js adds runner-level fail-closed handling for +# pre:edit-write:gateguard-fact-force and pre:mcp-health-check because they +# cannot inspect the complete request. Other safety hooks, including the Bash +# dispatcher and config protection, retain their own fail-closed behavior. +# If a trusted tool call legitimately exceeds the cap, retry with a smaller +# input or temporarily set ECC_GATEGUARD=off (or GATEGUARD_DISABLED=1) for +# GateGuard, or ECC_MCP_HEALTH_FAIL_OPEN=yes for MCP health, then restore it. +# These switches reduce only the named protection while enabled; they do not +# bypass the Bash dispatcher or config-protection checks. +export ECC_HOOK_INPUT_MAX_BYTES=524288 + # Disable only GateGuard during setup or recovery export ECC_GATEGUARD=off @@ -147,7 +159,10 @@ update the plugin and change those preferences. ### Writing Your Own Hook -Hooks are shell commands that receive tool input as JSON on stdin and must output JSON on stdout. +Hooks are shell commands that receive tool input as JSON on stdin. A hook with +no decision or context to return should leave stdout empty. Only explicit hook +output, such as a deny decision or `additionalContext`, should be written to +stdout; the input payload must not be echoed as a no-op response. **Basic structure:** @@ -169,8 +184,7 @@ process.stdin.on('end', () => { // Block (PreToolUse only): exit with code 2 // process.exit(2); - // Always output the original data to stdout - console.log(data); + // No opinion: leave stdout empty. }); ``` @@ -221,7 +235,7 @@ Async hooks run in the background. They cannot block tool execution. "matcher": "Edit", "hooks": [{ "type": "command", - "command": "node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{const i=JSON.parse(d);const ns=i.tool_input?.new_string||'';if(/TODO|FIXME|HACK/.test(ns)){console.error('[Hook] New TODO/FIXME added - consider creating an issue')}console.log(d)})\"" + "command": "node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{const i=JSON.parse(d);const ns=i.tool_input?.new_string||'';if(/TODO|FIXME|HACK/.test(ns)){console.error('[Hook] New TODO/FIXME added - consider creating an issue')}})\"" }], "description": "Warn when adding TODO/FIXME comments" } @@ -234,7 +248,7 @@ Async hooks run in the background. They cannot block tool execution. "matcher": "Write", "hooks": [{ "type": "command", - "command": "node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{const i=JSON.parse(d);const c=i.tool_input?.content||'';const lines=c.split('\\n').length;if(lines>800){console.error('[Hook] BLOCKED: File exceeds 800 lines ('+lines+' lines)');console.error('[Hook] Split into smaller, focused modules');process.exit(2)}console.log(d)})\"" + "command": "node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{const i=JSON.parse(d);const c=i.tool_input?.content||'';const lines=c.split('\\n').length;if(lines>800){console.error('[Hook] BLOCKED: File exceeds 800 lines ('+lines+' lines)');console.error('[Hook] Split into smaller, focused modules');process.exit(2)}})\"" }], "description": "Block creation of files larger than 800 lines" } @@ -247,7 +261,7 @@ Async hooks run in the background. They cannot block tool execution. "matcher": "Edit", "hooks": [{ "type": "command", - "command": "node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{const i=JSON.parse(d);const p=i.tool_input?.file_path||'';if(/\\.py$/.test(p)){const{execFileSync}=require('child_process');try{execFileSync('ruff',['format',p],{stdio:'pipe'})}catch(e){}}console.log(d)})\"" + "command": "node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{const i=JSON.parse(d);const p=i.tool_input?.file_path||'';if(/\\.py$/.test(p)){const{execFileSync}=require('child_process');try{execFileSync('ruff',['format',p],{stdio:'pipe'})}catch(e){}}})\"" }], "description": "Auto-format Python files with ruff after edits" } @@ -260,7 +274,7 @@ Async hooks run in the background. They cannot block tool execution. "matcher": "Write", "hooks": [{ "type": "command", - "command": "node -e \"const fs=require('fs');let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{const i=JSON.parse(d);const p=i.tool_input?.file_path||'';if(/src\\/.*\\.(ts|js)$/.test(p)&&!/\\.test\\.|\\.spec\\./.test(p)){const testPath=p.replace(/\\.(ts|js)$/,'.test.$1');if(!fs.existsSync(testPath)){console.error('[Hook] No test file found for: '+p);console.error('[Hook] Expected: '+testPath);console.error('[Hook] Consider writing tests first (/tdd)')}}console.log(d)})\"" + "command": "node -e \"const fs=require('fs');let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{const i=JSON.parse(d);const p=i.tool_input?.file_path||'';if(/src\\/.*\\.(ts|js)$/.test(p)&&!/\\.test\\.|\\.spec\\./.test(p)){const testPath=p.replace(/\\.(ts|js)$/,'.test.$1');if(!fs.existsSync(testPath)){console.error('[Hook] No test file found for: '+p);console.error('[Hook] Expected: '+testPath);console.error('[Hook] Consider writing tests first (/tdd)')}}})\"" }], "description": "Remind to create tests when adding new source files" } diff --git a/hooks/hooks.json b/hooks/hooks.json index 641873396..8ec470673 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -126,7 +126,7 @@ "hooks": [ { "type": "command", - "command": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i{let pending=1;const done=()=>{pending-=1;if(pending===0)process.exit(code);};if(out){pending+=1;process.stdout.write(out,done);}if(err){pending+=1;process.stderr.write(err,done);}process.nextTick(done);};const rel=path.join('scripts','hooks','run-with-flags.js');const root=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i{let pending=1;const done=()=>{pending-=1;if(pending===0)process.exit(code);};if(out){pending+=1;process.stdout.write(out,done);}if(err){pending+=1;process.stderr.write(err,done);}process.nextTick(done);};const rel=path.join('scripts','hooks','run-with-flags.js');const root=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i{let pending=1;const done=()=>{pending-=1;if(pending===0)process.exit(code);};if(out){pending+=1;process.stdout.write(out,done);}if(err){pending+=1;process.stderr.write(err,done);}process.nextTick(done);};const rel=path.join('scripts','hooks','run-with-flags.js');const root=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i{let pending=1;const done=()=>{pending-=1;if(pending===0)process.exit(code);};if(out){pending+=1;process.stdout.write(out,done);}if(err){pending+=1;process.stderr.write(err,done);}process.nextTick(done);};const rel=path.join('scripts','hooks','run-with-flags.js');const root=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i{let pending=1;const done=()=>{pending-=1;if(pending===0)process.exit(code);};if(out){pending+=1;process.stdout.write(out,done);}if(err){pending+=1;process.stderr.write(err,done);}process.nextTick(done);};const rel=path.join('scripts','hooks','run-with-flags.js');const root=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i{let pending=1;const done=()=>{pending-=1;if(pending===0)process.exit(code);};if(out){pending+=1;process.stdout.write(out,done);}if(err){pending+=1;process.stderr.write(err,done);}process.nextTick(done);};const rel=path.join('scripts','hooks','run-with-flags.js');const root=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i{let pending=1;const done=()=>{pending-=1;if(pending===0)process.exit(code);};if(out){pending+=1;process.stdout.write(out,done);}if(err){pending+=1;process.stderr.write(err,done);}process.nextTick(done);};const rel=path.join('scripts','hooks','run-with-flags.js');const root=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i {}); + if (value === undefined || value === '') return DEFAULT_MAX_STDIN; + + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + writeDiagnostic( + '[Hook] ECC_HOOK_INPUT_MAX_BYTES must be a positive safe integer; using the 1 MiB default\n' + ); + return DEFAULT_MAX_STDIN; + } + if (parsed > DEFAULT_MAX_STDIN) { + writeDiagnostic( + '[Hook] ECC_HOOK_INPUT_MAX_BYTES exceeds the 1 MiB safety maximum; clamping to 1 MiB\n' + ); + return DEFAULT_MAX_STDIN; + } + return parsed; +} + +function readStdinRaw(stream = process.stdin, options = {}) { + const maxStdin = options.maxStdin || DEFAULT_MAX_STDIN; + const decoder = new StringDecoder('utf8'); + let raw = ''; + let acceptedBytes = 0; + let truncated = options.truncated === true; + + return new Promise(resolve => { + let settled = false; + stream.on('data', chunk => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + const remaining = Math.max(0, maxStdin - acceptedBytes); + const accepted = buffer.subarray(0, remaining); + if (accepted.length > 0) { + raw += decoder.write(accepted); + acceptedBytes += accepted.length; + } + if (accepted.length < buffer.length) truncated = true; + }); + const finish = () => { + if (settled) return; + settled = true; + if (!truncated) raw += decoder.end(); + resolve({ raw, truncated }); + }; + const finishIncomplete = () => { + if (settled) return; + truncated = true; + finish(); + }; + stream.once('end', finish); + // A transport error or premature close can leave a syntactically plausible + // prefix behind. Mark it incomplete so safety hooks remain fail closed. + stream.once('error', finishIncomplete); + stream.once('close', finishIncomplete); + }); +} + +module.exports = { + DEFAULT_MAX_STDIN, + readStdinRaw, + resolveMaxStdin +}; diff --git a/scripts/hooks/lifecycle-hook-bootstrap.js b/scripts/hooks/lifecycle-hook-bootstrap.js new file mode 100644 index 000000000..66280147f --- /dev/null +++ b/scripts/hooks/lifecycle-hook-bootstrap.js @@ -0,0 +1,120 @@ +#!/usr/bin/env node +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const { spawnSync } = require('child_process'); +const { normalizePluginRootForPlatform } = require('../lib/resolve-ecc-root'); +const { readStdinRaw, resolveMaxStdin } = require('./hook-input'); + +const DEFAULT_TIMEOUT_MS = 30000; +const MAX_TIMEOUT_MS = 300000; + +function writeStderr(text) { + if (typeof text !== 'string' || text.length === 0) return; + process.stderr.write(text.endsWith('\n') ? text : `${text}\n`); +} + +function resolveTimeout(value) { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0) return DEFAULT_TIMEOUT_MS; + return Math.min(parsed, MAX_TIMEOUT_MS); +} + +function exitAfterFlush(stdout, stderr, exitCode) { + process.exitCode = exitCode; + let pendingWrites = 2; + const finish = () => { + pendingWrites -= 1; + if (pendingWrites === 0) process.exit(exitCode); + }; + + // Empty writes still queue callbacks behind any earlier diagnostics on the + // same stream, so both streams are drained before the explicit exit. + process.stdout.write(stdout || '', finish); + process.stderr.write(stderr || '', finish); +} + +async function main() { + const [, , hookId, relScriptPath, profilesCsv, timeoutValue] = process.argv; + const maxStdin = resolveMaxStdin(process.env.ECC_HOOK_INPUT_MAX_BYTES, { + writeDiagnostic: message => process.stderr.write(message) + }); + const { raw, truncated } = await readStdinRaw(process.stdin, { maxStdin }); + + if (!hookId || !relScriptPath) { + writeStderr('[Hook] lifecycle bootstrap missing hook ID or script path; skipping hook'); + process.exitCode = 0; + return; + } + + const pluginRoot = normalizePluginRootForPlatform( + process.env.CLAUDE_PLUGIN_ROOT || process.env.ECC_PLUGIN_ROOT + ); + if (!pluginRoot) { + writeStderr('[Hook] lifecycle bootstrap could not resolve ECC plugin root; skipping hook'); + process.exitCode = 0; + return; + } + const resolvedRoot = path.resolve(pluginRoot); + const runner = path.resolve(resolvedRoot, 'scripts', 'hooks', 'run-with-flags.js'); + if (!runner.startsWith(resolvedRoot + path.sep) || !fs.existsSync(runner)) { + writeStderr('[Hook] lifecycle bootstrap could not resolve ECC plugin root; skipping hook'); + process.exitCode = 0; + return; + } + + if (truncated) { + writeStderr(`[Hook] lifecycle stdin exceeded ${maxStdin} bytes; forwarded a bounded prefix`); + } + + const result = spawnSync( + process.execPath, + [runner, hookId, relScriptPath, profilesCsv || 'minimal,standard,strict'], + { + input: raw, + encoding: 'utf8', + env: { + ...process.env, + CLAUDE_PLUGIN_ROOT: resolvedRoot, + ECC_PLUGIN_ROOT: resolvedRoot, + ECC_HOOK_INPUT_MAX_BYTES: String(maxStdin), + ECC_HOOK_INPUT_TRUNCATED_UPSTREAM: truncated ? '1' : '0' + }, + cwd: process.cwd(), + timeout: resolveTimeout(timeoutValue), + maxBuffer: 16 * 1024 * 1024, + windowsHide: true + } + ); + + const failed = result.error || result.status === null || result.signal; + const stdout = !failed && typeof result.stdout === 'string' && result.stdout !== raw + ? result.stdout + : ''; + let stderr = typeof result.stderr === 'string' ? result.stderr : ''; + let exitCode = Number.isInteger(result.status) ? result.status : 0; + + if (failed) { + const reason = result.error + ? result.error.message + : result.signal + ? `signal ${result.signal}` + : 'missing exit status'; + stderr += `[Hook] lifecycle runner failed for ${hookId}: ${reason}\n`; + exitCode = 1; + } + + exitAfterFlush(stdout, stderr, exitCode); +} + +function cli() { + main().catch(error => { + writeStderr(`[Hook] lifecycle bootstrap failed: ${error.message}`); + process.exitCode = 0; + }); +} + +if (require.main === module) cli(); + +module.exports = { cli, exitAfterFlush, main, resolveTimeout }; diff --git a/scripts/hooks/plugin-hook-bootstrap.js b/scripts/hooks/plugin-hook-bootstrap.js index 8d573ffed..233e32980 100644 --- a/scripts/hooks/plugin-hook-bootstrap.js +++ b/scripts/hooks/plugin-hook-bootstrap.js @@ -1,21 +1,14 @@ #!/usr/bin/env node 'use strict'; -const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); const { ensureAgentDataHomeEnv } = require('../lib/agent-data-home'); +const { normalizePluginRootForPlatform } = require('../lib/resolve-ecc-root'); +const { readStdinRaw: readBoundedStdin, resolveMaxStdin } = require('./hook-input'); const SHELL_PROBE_TIMEOUT_MS = 2000; -function readStdinRaw() { - try { - return fs.readFileSync(0, 'utf8'); - } catch (_error) { - return ''; - } -} - function writeStderr(stderr) { if ((typeof stderr === 'string' || Buffer.isBuffer(stderr)) && stderr.length > 0) { process.stderr.write(stderr); @@ -78,20 +71,6 @@ function passthrough(result) { } } -function normalizePluginRootForPlatform(rootDir, platform = process.platform) { - if (platform !== 'win32' || typeof rootDir !== 'string') { - return rootDir; - } - - const match = rootDir.match(/^\/([a-zA-Z])(?:\/(.*))?$/); - if (!match) { - return rootDir; - } - - const [, driveLetter, rest = ''] = match; - return `${driveLetter.toUpperCase()}:/${rest}`; -} - function resolveTarget(rootDir, relPath) { const resolvedRoot = path.resolve(rootDir); const resolvedTarget = path.resolve(rootDir, relPath); @@ -183,12 +162,14 @@ function findBashBinary() { return null; } -function spawnNode(rootDir, relPath, raw, args) { +function spawnNode(rootDir, relPath, raw, args, options = {}) { ensureAgentDataHomeEnv(); const hookEnv = { ...process.env, CLAUDE_PLUGIN_ROOT: rootDir, ECC_PLUGIN_ROOT: rootDir, + ECC_HOOK_INPUT_MAX_BYTES: String(options.maxStdin), + ECC_HOOK_INPUT_TRUNCATED_UPSTREAM: options.truncated ? '1' : '0', }; const result = spawnSync(process.execPath, [resolveTarget(rootDir, relPath), ...args], { input: raw, @@ -204,7 +185,7 @@ function spawnNode(rootDir, relPath, raw, args) { // (all hooks use 'node' mode). It is provided for third-party plugins that // register shell-backed hooks. Plugins should supply .ps1 scripts on Windows // and .sh scripts on Unix; mixing them will produce a skip with a stderr warning. -function spawnShell(rootDir, relPath, raw, args) { +function spawnShell(rootDir, relPath, raw, args, options = {}) { const shell = findShellBinary(); if (!shell) { return { @@ -219,6 +200,8 @@ function spawnShell(rootDir, relPath, raw, args) { ...process.env, CLAUDE_PLUGIN_ROOT: rootDir, ECC_PLUGIN_ROOT: rootDir, + ECC_HOOK_INPUT_MAX_BYTES: String(options.maxStdin), + ECC_HOOK_INPUT_TRUNCATED_UPSTREAM: options.truncated ? '1' : '0', }; const scriptPath = resolveTarget(rootDir, relPath); const isPs = isPowerShellBin(shell); @@ -260,9 +243,12 @@ function spawnShell(rootDir, relPath, raw, args) { return withComparisonInput(result, Buffer.from(raw, 'utf8')); } -function main() { +async function main() { const [, , mode, relPath, ...args] = process.argv; - const raw = readStdinRaw(); + const maxStdin = resolveMaxStdin(process.env.ECC_HOOK_INPUT_MAX_BYTES, { + writeDiagnostic: message => process.stderr.write(message) + }); + const { raw, truncated } = await readBoundedStdin(process.stdin, { maxStdin }); const rootDir = normalizePluginRootForPlatform( process.env.CLAUDE_PLUGIN_ROOT || process.env.ECC_PLUGIN_ROOT ); @@ -275,12 +261,16 @@ function main() { return; } + if (truncated) { + process.stderr.write(`[Hook] bootstrap: stdin exceeded ${maxStdin} bytes; forwarded a bounded prefix\n`); + } + let result; try { if (mode === 'node') { - result = spawnNode(rootDir, relPath, raw, args); + result = spawnNode(rootDir, relPath, raw, args, { maxStdin, truncated }); } else if (mode === 'shell') { - result = spawnShell(rootDir, relPath, raw, args); + result = spawnShell(rootDir, relPath, raw, args, { maxStdin, truncated }); } else { writeStderr(`[Hook] unknown bootstrap mode: ${mode}; emitting empty stdout\n`); process.exitCode = 0; @@ -317,7 +307,10 @@ function main() { // exports (tests), require.main is a real, different module, so main() stays // dormant. if (require.main === module || require.main === undefined) { - main(); + main().catch(error => { + writeStderr(`[Hook] bootstrap failed: ${error.message}\n`); + process.exitCode = 0; + }); } module.exports = { diff --git a/scripts/hooks/posttooluse-dispatcher.js b/scripts/hooks/posttooluse-dispatcher.js index fcffeb400..58fbec53e 100644 --- a/scripts/hooks/posttooluse-dispatcher.js +++ b/scripts/hooks/posttooluse-dispatcher.js @@ -7,8 +7,8 @@ 'use strict'; const path = require('path'); -const { StringDecoder } = require('string_decoder'); const { isHookEnabled } = require('../lib/hook-flags'); +const { readStdinRaw: readBoundedStdin, resolveMaxStdin } = require('./hook-input'); const { runPostBash } = require('./bash-hook-dispatcher'); const { run: runQualityGate } = require('./quality-gate'); const { run: runDesignQualityCheck } = require('./design-quality-check'); @@ -21,7 +21,12 @@ const { run: runMetricsBridge } = require('./ecc-metrics-bridge'); const { run: runContextMonitor } = require('./ecc-context-monitor'); const { run: runSkillRunTracker } = require('./skill-run-tracker'); -const MAX_STDIN = 1024 * 1024; +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 || '') +); const SYNC_HOOKS = [ { id: 'post:edit:design-quality-check', matcher: 'Edit|Write|MultiEdit', profiles: 'standard,strict', script: 'scripts/hooks/design-quality-check.js', run: runDesignQualityCheck }, @@ -210,40 +215,17 @@ function runHooks(raw, hooks, options = {}) { } function readStdinRaw() { - return new Promise(resolve => { - const decoder = new StringDecoder('utf8'); - let raw = ''; - let bytesRead = 0; - let truncated = false; - let settled = false; - process.stdin.on('data', chunk => { - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - const remaining = Math.max(0, MAX_STDIN - bytesRead); - const accepted = buffer.subarray(0, remaining); - if (accepted.length > 0) { - raw += decoder.write(accepted); - bytesRead += accepted.length; - } - if (buffer.length > accepted.length) truncated = true; - }); - const finish = () => { - if (settled) return; - settled = true; - if (!truncated) raw += decoder.end(); - resolve({ raw, truncated }); - }; - process.stdin.once('end', finish); - process.stdin.once('error', finish); + return readBoundedStdin(process.stdin, { + maxStdin: MAX_STDIN, + truncated: UPSTREAM_TRUNCATED }); } -function resolveMainStdout(raw, result, options = {}) { - if (result.stdout) return result.stdout; - if (options.truncated || result.exitCode !== 0 || !options.passthrough) return ''; - return raw; +function resolveMainStdout(_raw, result, _options = {}) { + return result.stdout || ''; } -async function main() { +async function main(options = {}) { const mode = process.argv[2] === 'async' ? 'async' : 'sync'; const { raw, truncated } = await readStdinRaw(); const dispatcherId = `post:dispatcher:${mode}`; @@ -254,22 +236,20 @@ async function main() { }, process.env ); - const hooks = dispatcherEnabled ? (mode === 'async' ? ASYNC_HOOKS : SYNC_HOOKS) : []; + const configuredHooks = options.hookListOverride || (mode === 'async' ? ASYNC_HOOKS : SYNC_HOOKS); + const hooks = dispatcherEnabled ? configuredHooks : []; const result = runHooks(raw, hooks, { truncated }); if (truncated) { process.stderr.write(`[Hook] stdin exceeded ${MAX_STDIN} bytes for PostToolUse ${mode}; suppressing pass-through\n`); } if (result.stderr) process.stderr.write(result.stderr); - const stdout = resolveMainStdout(raw, result, { - passthrough: process.env.ECC_POSTTOOLUSE_PASSTHROUGH === '1', - truncated - }); + const stdout = resolveMainStdout(raw, result, { truncated }); if (stdout) process.stdout.write(stdout); process.exitCode = result.exitCode; } -function cli() { - main().catch(error => { +function cli(options = {}) { + main(options).catch(error => { process.stderr.write(`[Hook] PostToolUse dispatcher failed: ${error.message}\n`); process.exitCode = 0; }); diff --git a/scripts/hooks/pre-bash-dispatcher.js b/scripts/hooks/pre-bash-dispatcher.js index b9ccad7d6..34bb19db8 100644 --- a/scripts/hooks/pre-bash-dispatcher.js +++ b/scripts/hooks/pre-bash-dispatcher.js @@ -2,23 +2,41 @@ 'use strict'; const { runPreBash } = require('./bash-hook-dispatcher'); +const { readStdinRaw, resolveMaxStdin } = require('./hook-input'); +const { isHookEnabled } = require('../lib/hook-flags'); -let raw = ''; -const MAX_STDIN = 1024 * 1024; - -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); - } +const maxStdin = resolveMaxStdin(process.env.ECC_HOOK_INPUT_MAX_BYTES, { + writeDiagnostic: message => process.stderr.write(message) }); -process.stdin.on('end', () => { +readStdinRaw(process.stdin, { + maxStdin, + truncated: /^(1|true|yes)$/i.test( + String(process.env.ECC_HOOK_INPUT_TRUNCATED_UPSTREAM || '') + ) +}).then(({ raw, truncated }) => { + if (!isHookEnabled('pre:bash:dispatcher', { + profiles: 'minimal,standard,strict' + })) { + process.exitCode = 0; + return; + } + + if (truncated) { + process.stderr.write( + `[Hook] stdin exceeded ${maxStdin} bytes for pre:bash:dispatcher; blocking because safety checks require the complete request\n` + ); + process.exitCode = 2; + return; + } + const result = runPreBash(raw); if (result.stderr) { process.stderr.write(result.stderr); } process.stdout.write(result.output); process.exitCode = result.exitCode; +}).catch(error => { + process.stderr.write(`[Hook] pre-bash dispatcher failed: ${error.message}\n`); + process.exitCode = 2; }); diff --git a/scripts/hooks/run-with-flags.js b/scripts/hooks/run-with-flags.js index 9f6de3722..c24505adc 100755 --- a/scripts/hooks/run-with-flags.js +++ b/scripts/hooks/run-with-flags.js @@ -12,28 +12,25 @@ 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 MAX_STDIN = 1024 * 1024; +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 new Promise(resolve => { - let raw = ''; - let truncated = false; - 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); - if (chunk.length > remaining) { - truncated = true; - } - } else { - truncated = true; - } - }); - process.stdin.on('end', () => resolve({ raw, truncated })); - process.stdin.on('error', () => resolve({ raw, truncated })); + return readBoundedStdin(process.stdin, { + maxStdin: MAX_STDIN, + truncated: /^(1|true|yes)$/i.test( + String(process.env.ECC_HOOK_INPUT_TRUNCATED_UPSTREAM || '') + ) }); } @@ -68,7 +65,7 @@ function exitWithStdout(text, exitCode) { process.stderr.write('', exitWhenFlushed); } -function resolveHookResult(raw, output) { +function resolveHookResult(output) { if (typeof output === 'string' || Buffer.isBuffer(output)) { return { stdout: String(output), exitCode: 0 }; } @@ -83,23 +80,39 @@ function resolveHookResult(raw, output) { if (Object.prototype.hasOwnProperty.call(output, 'stdout')) { return { stdout: String(output.stdout ?? ''), exitCode }; } - return { stdout: exitCode === 0 ? raw : '', exitCode }; + return { stdout: '', exitCode }; } - return { stdout: raw, exitCode: 0 }; + return { stdout: '', exitCode: 0 }; } -function resolveLegacySpawnStdout(raw, result) { +function resolveLegacySpawnStdout(result) { const stdout = typeof result.stdout === 'string' ? result.stdout : ''; - if (stdout) { - return 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; } - if (Number.isInteger(result.status) && result.status === 0) { - return raw; - } - - return ''; + 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() { @@ -157,28 +170,28 @@ async function main() { // 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 - // pass-through paths fail open. The hook itself still runs and receives + // 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 pass-through (fail-open unless the hook blocks)\n`); + process.stderr.write(`[Hook] stdin exceeded ${MAX_STDIN} bytes for ${hookId || 'unknown'}; suppressing raw passthrough\n`); } if (!hookId || !relScriptPath) { - exitWithStdout(sanitizeEcho(raw), 0); + exitWithStdout('', 0); return; } if (!isHookEnabled(hookId, { profiles: profilesCsv })) { - exitWithStdout(sanitizeEcho(raw), 0); + exitWithStdout('', 0); return; } if (isDryRun()) { const preview = buildDryRunPreview(hookId, relScriptPath, profilesCsv, raw); process.stderr.write(preview); - exitWithStdout(sanitizeEcho(raw), 0); + exitWithStdout('', 0); return; } @@ -189,13 +202,20 @@ async function main() { // 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(sanitizeEcho(raw), 0); + exitWithStdout('', 0); return; } if (!fs.existsSync(scriptPath)) { process.stderr.write(`[Hook] Script not found for ${hookId}: ${scriptPath}\n`); - exitWithStdout(sanitizeEcho(raw), 0); + exitWithStdout('', 0); + return; + } + + const truncationBlock = truncated ? truncatedInputResult(hookId, MAX_STDIN) : null; + if (truncationBlock) { + writeStderr(truncationBlock.stderr); + exitWithStdout(truncationBlock.stdout, truncationBlock.exitCode); return; } @@ -231,11 +251,11 @@ async function main() { truncated, maxStdin: MAX_STDIN }); - const result = resolveHookResult(raw, output); + const result = resolveHookResult(output); exitWithStdout(sanitizeEcho(result.stdout), result.exitCode); } catch (runErr) { process.stderr.write(`[Hook] run() error for ${hookId}: ${runErr.message}\n`); - exitWithStdout(sanitizeEcho(raw), 0); + exitWithStdout('', 0); } return; } @@ -256,7 +276,7 @@ async function main() { timeout: 30000 }); - const legacyStdout = sanitizeEcho(resolveLegacySpawnStdout(raw, result)); + const legacyStdout = sanitizeEcho(resolveLegacySpawnStdout(result)); if (result.stderr) process.stderr.write(result.stderr); if (result.error || result.signal || result.status === null) { diff --git a/scripts/hooks/session-start-bootstrap.js b/scripts/hooks/session-start-bootstrap.js index 4da168bad..4897fc8cc 100644 --- a/scripts/hooks/session-start-bootstrap.js +++ b/scripts/hooks/session-start-bootstrap.js @@ -22,64 +22,80 @@ * 3. Delegates to `scripts/hooks/run-with-flags.js` with the `session:start` * event, which applies hook-profile gating and then runs session-start.js. * 4. Passes stdout/stderr through and forwards the child exit code. - * 5. If the plugin root cannot be found, emits a warning and passes stdin - * through unchanged so Claude Code can continue normally. + * 5. If the plugin root cannot be found, emits a warning and no stdout so + * Claude Code can continue normally without duplicating the event. */ const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); const { resolveEccRoot } = require('../lib/resolve-ecc-root'); +const { readStdinRaw, resolveMaxStdin } = require('./hook-input'); +const { exitAfterFlush } = require('./lifecycle-hook-bootstrap'); -// Read the raw JSON event from stdin -const raw = fs.readFileSync(0, 'utf8'); +async function main() { + const maxStdin = resolveMaxStdin(process.env.ECC_HOOK_INPUT_MAX_BYTES, { + writeDiagnostic: message => process.stderr.write(message) + }); + const { raw, truncated } = await readStdinRaw(process.stdin, { + maxStdin, + truncated: /^(1|true|yes)$/i.test( + String(process.env.ECC_HOOK_INPUT_TRUNCATED_UPSTREAM || '') + ) + }); + if (truncated) { + process.stderr.write(`[SessionStart] stdin exceeded ${maxStdin} bytes; forwarded a bounded prefix\n`); + } -// Path (relative to plugin root) to the hook runner -const rel = path.join('scripts', 'hooks', 'run-with-flags.js'); + // Path (relative to plugin root) to the hook runner + const rel = path.join('scripts', 'hooks', 'run-with-flags.js'); // Resolve the ECC plugin root via the shared resolver, probing for the runner // so a valid root is one that actually contains run-with-flags.js. -const root = resolveEccRoot({ probe: rel }); -const script = path.join(root, rel); + const root = resolveEccRoot({ probe: rel }); + const script = path.join(root, rel); -if (fs.existsSync(script)) { - const result = spawnSync( - process.execPath, - [script, 'session:start', 'scripts/hooks/session-start.js', 'minimal,standard,strict'], - { - input: raw, - encoding: 'utf8', - env: process.env, - cwd: process.cwd(), - timeout: 30000, + if (fs.existsSync(script)) { + const result = spawnSync( + process.execPath, + [script, 'session:start', 'scripts/hooks/session-start.js', 'minimal,standard,strict'], + { + input: raw, + encoding: 'utf8', + env: { + ...process.env, + ECC_HOOK_INPUT_MAX_BYTES: String(maxStdin), + ECC_HOOK_INPUT_TRUNCATED_UPSTREAM: truncated ? '1' : '0' + }, + cwd: process.cwd(), + timeout: 30000, + } + ); + + const stdout = typeof result.stdout === 'string' ? result.stdout : ''; + let stderr = typeof result.stderr === 'string' ? result.stderr : ''; + let exitCode = Number.isInteger(result.status) ? result.status : 0; + + if (result.error || result.status === null || result.signal) { + const reason = result.error + ? result.error.message + : result.signal + ? 'signal ' + result.signal + : 'missing exit status'; + stderr += '[SessionStart] ERROR: session-start hook failed: ' + reason + '\n'; + exitCode = 1; } + + exitAfterFlush(stdout, stderr, exitCode); + return; + } + + process.stderr.write( + '[SessionStart] WARNING: could not resolve ECC plugin root; skipping session-start hook\n' ); - - const stdout = typeof result.stdout === 'string' ? result.stdout : ''; - if (stdout) { - process.stdout.write(stdout); - } else { - process.stdout.write(raw); - } - - if (result.stderr) { - process.stderr.write(result.stderr); - } - - if (result.error || result.status === null || result.signal) { - const reason = result.error - ? result.error.message - : result.signal - ? 'signal ' + result.signal - : 'missing exit status'; - process.stderr.write('[SessionStart] ERROR: session-start hook failed: ' + reason + '\n'); - process.exit(1); - } - - process.exit(Number.isInteger(result.status) ? result.status : 0); } -process.stderr.write( - '[SessionStart] WARNING: could not resolve ECC plugin root; skipping session-start hook\n' -); -process.stdout.write(raw); +main().catch(error => { + process.stderr.write(`[SessionStart] bootstrap failed: ${error.message}\n`); + process.exitCode = 0; +}); diff --git a/scripts/lib/resolve-ecc-root.js b/scripts/lib/resolve-ecc-root.js index 396aac377..c9b8ee423 100644 --- a/scripts/lib/resolve-ecc-root.js +++ b/scripts/lib/resolve-ecc-root.js @@ -126,6 +126,16 @@ function resolveEccRoot(options = {}) { return claudeDir; } +function normalizePluginRootForPlatform(rootDir, platform = process.platform) { + if (platform !== 'win32' || typeof rootDir !== 'string') return rootDir; + + const match = rootDir.match(/^\/([a-zA-Z])(?:\/(.*))?$/); + if (!match) return rootDir; + + const [, driveLetter, rest = ''] = match; + return `${driveLetter.toUpperCase()}:/${rest}`; +} + /** * Compact inline locator for embedding in hooks.json and command .md code blocks. * @@ -151,5 +161,6 @@ const INLINE_RESOLVE = `(function(){var p=require('path'),f=require('fs'),o=requ module.exports = { resolveEccRoot, + normalizePluginRootForPlatform, INLINE_RESOLVE, }; diff --git a/tests/hooks/bash-hook-dispatcher.test.js b/tests/hooks/bash-hook-dispatcher.test.js index 9832f50ce..893b45d5b 100644 --- a/tests/hooks/bash-hook-dispatcher.test.js +++ b/tests/hooks/bash-hook-dispatcher.test.js @@ -63,6 +63,49 @@ function runTests() { assert.strictEqual(result.stdout, '', `Pass-through must emit empty stdout, got: ${result.stdout}`); })) passed++; else failed++; + if (test('pre dispatcher fails closed when its configured byte cap truncates input', () => { + const input = { + tool_name: 'Bash', + tool_input: { command: `echo ${'x'.repeat(256)}` } + }; + const result = runScript(preDispatcher, input, { + ECC_HOOK_PROFILE: 'standard', + ECC_HOOK_INPUT_MAX_BYTES: '64' + }); + assert.strictEqual(result.status, 2, result.stderr); + assert.strictEqual(result.stdout, ''); + assert.match(result.stderr, /safety checks require the complete request/); + })) passed++; else failed++; + + if (test('pre dispatcher applies its byte cap at UTF-8 boundaries', () => { + const input = { + tool_name: 'Bash', + tool_input: { command: String.fromCodePoint(0xe9).repeat(64) } + }; + const result = runScript(preDispatcher, input, { + ECC_HOOK_PROFILE: 'standard', + ECC_HOOK_INPUT_MAX_BYTES: '65' + }); + assert.strictEqual(result.status, 2, result.stderr); + assert.strictEqual(result.stdout, ''); + assert.match(result.stderr, /stdin exceeded 65 bytes/); + })) passed++; else failed++; + + if (test('disabled pre dispatcher does not block truncated input', () => { + const input = { + tool_name: 'Bash', + tool_input: { command: `echo ${'x'.repeat(256)}` } + }; + for (const env of [ + { ECC_HOOK_INPUT_MAX_BYTES: '64', ECC_DISABLED_HOOKS: 'pre:bash:dispatcher' }, + { ECC_HOOK_INPUT_MAX_BYTES: '64', ECC_HOOKS_ENABLED: 'false' } + ]) { + const result = runScript(preDispatcher, input, env); + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(result.stdout, ''); + } + })) passed++; else failed++; + if (test('pre dispatcher still honors per-hook disable flags', () => { const input = { tool_input: { command: 'git push origin main' } }; diff --git a/tests/hooks/config-protection.test.js b/tests/hooks/config-protection.test.js index e383753ec..87bce5365 100644 --- a/tests/hooks/config-protection.test.js +++ b/tests/hooks/config-protection.test.js @@ -112,10 +112,9 @@ function runTests() { } }; - const rawInput = JSON.stringify(input); const result = runHook(input); assert.strictEqual(result.code, 0, 'Expected safe file edit to pass'); - assert.strictEqual(result.stdout, rawInput, 'Expected exact raw JSON passthrough'); + assert.strictEqual(result.stdout, '', 'Allowed edits should not echo raw hook input'); assert.strictEqual(result.stderr, '', 'Expected no stderr for safe edits'); }) ) @@ -155,10 +154,9 @@ function runTests() { } }; - const rawInput = JSON.stringify(input); const result = runHook(input); assert.strictEqual(result.code, 0, `Expected exit 0 for first-time creation, got ${result.code}; stderr: ${result.stderr}`); - assert.strictEqual(result.stdout, rawInput, 'Expected raw passthrough when creation is allowed'); + assert.strictEqual(result.stdout, '', 'Allowed creation should not echo raw hook input'); assert.strictEqual(result.stderr, '', `Expected no stderr for first-time creation, got: ${result.stderr}`); } finally { try { @@ -189,10 +187,9 @@ function runTests() { } }; - const rawInput = JSON.stringify(input); const result = runHook(input); assert.strictEqual(result.code, 0, `Expected exit 0 for ENOENT path, got ${result.code}; stderr: ${result.stderr}`); - assert.strictEqual(result.stdout, rawInput, 'Expected raw passthrough when path does not exist'); + assert.strictEqual(result.stdout, '', 'Allowed missing paths should not echo raw hook input'); } finally { try { fs.rmSync(tmpDir, { recursive: true, force: true }); diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js index 495928691..324ce7f73 100644 --- a/tests/hooks/gateguard-fact-force.test.js +++ b/tests/hooks/gateguard-fact-force.test.js @@ -241,13 +241,7 @@ function runTests() { }; const result = runHook(input, { GATEGUARD_STATE_DIR: invalidStateDir }); assert.strictEqual(result.code, 0, 'exit code should be 0'); - const output = parseOutput(result.stdout); - assert.ok(output, 'should produce valid JSON output'); - if (output.hookSpecificOutput) { - assert.notStrictEqual(output.hookSpecificOutput.permissionDecision, 'deny', 'unpersistable state must not deny a retry that can never be recorded'); - } else { - assert.strictEqual(output.tool_name, 'Write', 'pass-through should preserve input'); - } + assert.strictEqual(result.stdout, '', 'fail-open result without an explicit decision must stay silent'); assert.ok(result.stderr.includes('GateGuard state could not be persisted'), 'should warn that state persistence failed'); }) ) @@ -487,14 +481,7 @@ function runTests() { }); assert.strictEqual(result.code, 0, 'exit code should be 0'); - const output = parseOutput(result.stdout); - assert.ok(output, 'should produce valid JSON output'); - if (output.hookSpecificOutput) { - assert.notStrictEqual(output.hookSpecificOutput.permissionDecision, 'deny', 'should not deny when hook is disabled'); - } else { - // When disabled, hook passes through raw input - assert.strictEqual(output.tool_name, 'Edit', 'pass-through should preserve input'); - } + assert.strictEqual(result.stdout, '', 'disabled wrapper hook must stay silent'); }) ) passed++; diff --git a/tests/hooks/hook-flags.test.js b/tests/hooks/hook-flags.test.js index f642bae3b..9e566edbf 100644 --- a/tests/hooks/hook-flags.test.js +++ b/tests/hooks/hook-flags.test.js @@ -247,7 +247,7 @@ function runTests() { encoding: 'utf8', }); assert.strictEqual(result.status, 0, result.stderr); - assert.strictEqual(result.stdout, raw); + assert.strictEqual(result.stdout, '', 'disabled wrapper hooks must not echo stdin'); assert.ok(!fs.existsSync(markerPath), 'disabled wrapper hook must not execute'); } finally { fs.rmSync(root, { recursive: true, force: true }); diff --git a/tests/hooks/hook-input.test.js b/tests/hooks/hook-input.test.js new file mode 100644 index 000000000..b9e84e518 --- /dev/null +++ b/tests/hooks/hook-input.test.js @@ -0,0 +1,129 @@ +/** + * Regression tests for bounded hook stdin reads. + */ + +'use strict'; + +const assert = require('assert'); +const { PassThrough } = require('stream'); +const { readStdinRaw } = require('../../scripts/hooks/hook-input'); +const { run: runConfigProtection } = require('../../scripts/hooks/config-protection'); + +const TEST_STDIN_LIMIT = 1024; +const STREAM_SETTLEMENT_TIMEOUT_MS = 500; + +async function test(name, fn) { + try { + await fn(); + console.log(` ✓ ${name}`); + return true; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +async function readFromErroredStream(partialInput) { + const stream = new PassThrough(); + const resultPromise = readStdinRaw(stream, { maxStdin: TEST_STDIN_LIMIT }); + stream.write(partialInput); + stream.destroy(new Error('simulated stdin read failure')); + return resultPromise; +} + +async function readFromClosedStream(partialInput) { + const stream = new PassThrough(); + const resultPromise = readStdinRaw(stream, { maxStdin: TEST_STDIN_LIMIT }); + stream.write(partialInput); + stream.destroy(); + + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error('readStdinRaw did not settle after stream close')), + STREAM_SETTLEMENT_TIMEOUT_MS + ); + resultPromise.then( + result => { + clearTimeout(timer); + resolve(result); + }, + error => { + clearTimeout(timer); + reject(error); + } + ); + }); +} + +async function runTests() { + console.log('\nHook input reader tests:'); + + let passed = 0; + let failed = 0; + + if ( + await test('clean end preserves complete input', async () => { + const stream = new PassThrough(); + const resultPromise = readStdinRaw(stream, { maxStdin: TEST_STDIN_LIMIT }); + stream.end('{"complete":true}'); + + assert.deepStrictEqual(await resultPromise, { + raw: '{"complete":true}', + truncated: false + }); + }) + ) + passed++; + else failed++; + + if ( + await test('stream error marks partial input as truncated', async () => { + const partialInput = '{"tool_name":"Write","tool_input":{'; + const result = await readFromErroredStream(partialInput); + + assert.strictEqual(result.raw, partialInput); + assert.strictEqual(result.truncated, true); + }) + ) + passed++; + else failed++; + + if ( + await test('close without end marks partial input as truncated', async () => { + const partialInput = '{"tool_name":"Write","tool_input":{'; + const result = await readFromClosedStream(partialInput); + + assert.strictEqual(result.raw, partialInput); + assert.strictEqual(result.truncated, true); + }) + ) + passed++; + else failed++; + + if ( + await test('errored partial PreToolUse input remains fail closed', async () => { + const partialInput = '{"tool_name":"Write","tool_input":{"file_path":".eslintrc.js"'; + const inputResult = await readFromErroredStream(partialInput); + const hookResult = runConfigProtection(inputResult.raw, { + truncated: inputResult.truncated, + maxStdin: TEST_STDIN_LIMIT + }); + + assert.strictEqual(inputResult.truncated, true); + assert.strictEqual(hookResult.exitCode, 2); + assert.match(hookResult.stderr, /Refusing to bypass config-protection/); + }) + ) + passed++; + else failed++; + + console.log(`\nPassed: ${passed}`); + console.log(`Failed: ${failed}\n`); + process.exitCode = failed > 0 ? 1 : 0; +} + +runTests().catch(error => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index 635566b27..f2635973a 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -2838,8 +2838,9 @@ async function runTests() { (Array.isArray(hook.command) && hook.command[0] === 'node' && hook.command[1] === '-e') || (typeof hook.command === 'string' && hook.command.startsWith('node -e "')), 'Lifecycle hook should use inline node resolver' ); - assert.ok(commandText.includes('run-with-flags.js'), 'Lifecycle hook should resolve the runner script'); + assert.ok(commandText.includes('lifecycle-hook-bootstrap.js'), 'Lifecycle hook should resolve the shared lifecycle bootstrap'); assert.ok(commandText.includes('CLAUDE_PLUGIN_ROOT'), 'Lifecycle hook should consult CLAUDE_PLUGIN_ROOT'); + assert.ok(commandText.includes("process.platform==='win32'"), 'Lifecycle hook should normalize Git Bash drive roots before loading the bootstrap'); assert.ok(!commandText.includes('${CLAUDE_PLUGIN_ROOT}'), 'Lifecycle hook should not depend on raw shell placeholder expansion'); assert.ok(commandText.includes('resolve-ecc-root'), 'Lifecycle hook should delegate to the committed resolver module'); assert.ok(!commandText.includes('find '), 'Lifecycle hook should not scan arbitrary plugin paths with find'); @@ -2863,8 +2864,9 @@ async function runTests() { const usesInlineResolver = commandStart.startsWith('node -e') && commandText.includes('run-with-flags.js'); const usesPluginBootstrap = commandStart.startsWith('node -e') && commandText.includes('plugin-hook-bootstrap.js'); const usesDirectPostDispatcher = commandStart.startsWith('node -e') && commandText.includes('posttooluse-dispatcher.js') && commandText.includes('resolve-ecc-root'); + const usesLifecycleBootstrap = commandStart.startsWith('node -e') && commandText.includes('lifecycle-hook-bootstrap.js') && commandText.includes('resolve-ecc-root'); assert.ok(!commandText.includes('${CLAUDE_PLUGIN_ROOT}'), `Script paths should not depend on raw shell placeholder expansion: ${commandText.substring(0, 80)}...`); - assert.ok(usesInlineResolver || usesPluginBootstrap || usesDirectPostDispatcher, `Script paths should use the inline resolver or plugin bootstrap: ${commandText.substring(0, 80)}...`); + assert.ok(usesInlineResolver || usesPluginBootstrap || usesDirectPostDispatcher || usesLifecycleBootstrap, `Script paths should use a safe inline resolver or plugin bootstrap: ${commandText.substring(0, 80)}...`); } } } diff --git a/tests/hooks/plugin-hook-bootstrap.test.js b/tests/hooks/plugin-hook-bootstrap.test.js index f00c56149..e1ec6529d 100644 --- a/tests/hooks/plugin-hook-bootstrap.test.js +++ b/tests/hooks/plugin-hook-bootstrap.test.js @@ -11,7 +11,9 @@ const path = require('path'); const { spawnSync } = require('child_process'); const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'plugin-hook-bootstrap.js'); +const LIFECYCLE_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'lifecycle-hook-bootstrap.js'); const { normalizePluginRootForPlatform, withComparisonInput } = require(SCRIPT); +const { resolveTimeout } = require(LIFECYCLE_SCRIPT); function createTempDir() { return fs.mkdtempSync(path.join(os.tmpdir(), 'plugin-hook-bootstrap-')); @@ -126,6 +128,16 @@ function runTests() { ); })) passed++; else failed++; + if (test('lifecycle bootstrap shares Windows root normalization and bounds timeouts', () => { + const rootResolver = require(path.join(__dirname, '..', '..', 'scripts', 'lib', 'resolve-ecc-root.js')); + assert.strictEqual( + rootResolver.normalizePluginRootForPlatform('/c/Users/x/.claude/plugins/ecc', 'win32'), + 'C:/Users/x/.claude/plugins/ecc' + ); + assert.strictEqual(resolveTimeout('600000'), 300000); + assert.strictEqual(resolveTimeout('invalid'), 30000); + })) passed++; else failed++; + if (test('node mode runs target script with plugin root environment', () => { const root = createTempDir(); try { diff --git a/tests/hooks/posttooluse-dispatcher.test.js b/tests/hooks/posttooluse-dispatcher.test.js index ae6dbaaa1..f4ee81381 100644 --- a/tests/hooks/posttooluse-dispatcher.test.js +++ b/tests/hooks/posttooluse-dispatcher.test.js @@ -71,6 +71,30 @@ function runConfiguredCommand(entry, raw, env = {}) { }); } +function runInspectingDispatcher(input, env = {}) { + const script = [ + `const dispatcher = require(${JSON.stringify(dispatcherPath)});`, + "const hooks = [{ id: 'post:test:inspect', matcher: '*', profiles: 'standard,strict', run: (raw, context) => ({ stdout: JSON.stringify({ raw: raw.length <= 16 ? raw : null, bytes: Buffer.byteLength(raw, 'utf8'), truncated: context.truncated, maxStdin: context.maxStdin }) }) }];", + "process.argv[2] = 'sync';", + 'dispatcher.cli({ hookListOverride: hooks });' + ].join(''); + return spawnSync(process.execPath, ['-e', script], { + cwd: repoRoot, + input, + encoding: 'utf8', + env: { + ...process.env, + CLAUDE_PLUGIN_ROOT: repoRoot, + ECC_HOOK_PROFILE: 'standard', + ECC_DISABLED_HOOKS: '', + ...env, + ECC_DRY_RUN: '0' + }, + timeout: 10000, + maxBuffer: 4 * 1024 * 1024 + }); +} + function runTests() { console.log('\n=== PostToolUse dispatcher tests ===\n'); @@ -97,6 +121,10 @@ function runTests() { entries.every(entry => !entry.hooks[0].command.includes('plugin-hook-bootstrap.js')), 'PostToolUse dispatchers should not spawn a second Node bootstrap process' ); + assert.ok( + entries.every(entry => !entry.hooks[0].command.includes('ECC_POSTTOOLUSE_PASSTHROUGH')), + 'PostToolUse commands must not opt back into raw stdin passthrough' + ); assert.ok(entries[1].hooks[0].timeout >= 30); }) ) @@ -162,7 +190,7 @@ function runTests() { else failed++; if ( - test('actual hooks.json commands preserve Edit dry-run output and IDs', () => { + test('actual hooks.json commands keep Edit dry-run silent and preserve IDs', () => { const entries = readHooksConfig(hooksPath).hooks.PostToolUse; const raw = JSON.stringify({ hook_event_name: 'PostToolUse', @@ -174,7 +202,7 @@ function runTests() { for (const result of results) { assert.strictEqual(result.status, 0, result.stderr); - assert.strictEqual(result.stdout, raw, 'configured command should preserve pass-through output'); + assert.strictEqual(result.stdout, '', 'configured command should stay silent when no child hook emits output'); } const ids = results.flatMap(result => previewedIds(result.stderr)); assert.deepStrictEqual(ids, [ @@ -219,6 +247,94 @@ function runTests() { passed++; else failed++; + if ( + test('legacy passthrough env cannot restore silent sync or async output', () => { + for (const mode of ['sync', 'async']) { + const result = runDispatcher(mode, 'Read', { + ECC_DRY_RUN: '1', + ECC_POSTTOOLUSE_PASSTHROUGH: '1' + }); + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(result.stdout, '', `${mode} dispatcher must ignore legacy passthrough opt-in`); + } + }) + ) + passed++; + else failed++; + + if ( + test('configured stdin cap controls PostToolUse input and hook context', () => { + const result = runInspectingDispatcher('x'.repeat(256), { ECC_HOOK_INPUT_MAX_BYTES: '128' }); + assert.strictEqual(result.status, 0, result.stderr); + assert.deepStrictEqual(JSON.parse(result.stdout), { + raw: null, + bytes: 128, + truncated: true, + maxStdin: 128 + }); + assert.match(result.stderr, /stdin exceeded 128 bytes/); + }) + ) + passed++; + else failed++; + + if ( + test('PostToolUse stdin cap honors UTF-8 byte boundaries', () => { + const character = String.fromCodePoint(0xe9); + const exact = runInspectingDispatcher(character.repeat(2), { ECC_HOOK_INPUT_MAX_BYTES: '4' }); + assert.strictEqual(exact.status, 0, exact.stderr); + assert.deepStrictEqual(JSON.parse(exact.stdout), { + raw: character.repeat(2), + bytes: 4, + truncated: false, + maxStdin: 4 + }); + + const truncated = runInspectingDispatcher(character.repeat(2), { ECC_HOOK_INPUT_MAX_BYTES: '3' }); + assert.strictEqual(truncated.status, 0, truncated.stderr); + assert.deepStrictEqual(JSON.parse(truncated.stdout), { + raw: character, + bytes: 2, + truncated: true, + maxStdin: 3 + }); + }) + ) + passed++; + else failed++; + + if ( + test('invalid PostToolUse stdin caps fall back with a diagnostic', () => { + for (const value of ['0', '-1', '1.5', 'not-a-number']) { + const result = runInspectingDispatcher('payload', { ECC_HOOK_INPUT_MAX_BYTES: value }); + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(JSON.parse(result.stdout).maxStdin, 1024 * 1024); + assert.match(result.stderr, /must be a positive safe integer/); + } + }) + ) + passed++; + else failed++; + + if ( + test('PostToolUse stdin cap cannot exceed the 1 MiB safety maximum', () => { + const result = runInspectingDispatcher('x'.repeat(1024 * 1024 + 1), { + ECC_HOOK_INPUT_MAX_BYTES: String(2 * 1024 * 1024) + }); + assert.strictEqual(result.status, 0, result.stderr); + assert.deepStrictEqual(JSON.parse(result.stdout), { + raw: null, + bytes: 1024 * 1024, + truncated: true, + maxStdin: 1024 * 1024 + }); + assert.match(result.stderr, /exceeds the 1 MiB safety maximum/); + assert.match(result.stderr, /stdin exceeded 1048576 bytes/); + }) + ) + passed++; + else failed++; + if ( test('profiles and disabled IDs remain scoped to each original hook', () => { const minimalSync = runDispatcher('sync', 'Edit', { @@ -286,7 +402,7 @@ function runTests() { }); assert.strictEqual(result.status, 0, result.stderr); assert.deepStrictEqual(previewedIds(result.stderr), [], `${entry.id} should disable all child hooks`); - assert.strictEqual(result.stdout, raw); + assert.strictEqual(result.stdout, ''); } }) ) @@ -371,6 +487,23 @@ function runTests() { assert.ok(result.stderr.indexOf('post:test:broken') < result.stderr.indexOf('last warning')); assert.strictEqual(result.exitCode, 7, 'explicit child exit codes should be preserved'); assert.strictEqual(resolveMainStdout(raw, { stdout: '', exitCode: 7 }, { passthrough: true, truncated: false }), '', 'nonzero results should not restore raw input'); + assert.strictEqual(resolveMainStdout(raw, { stdout: '', exitCode: 0 }, { passthrough: true, truncated: false }), '', 'silent successful results must not restore raw input'); + + const explicitFailure = runHooks( + raw, + [ + { + id: 'post:test:explicit-failure', + matcher: '*', + profiles: 'standard,strict', + run: () => ({ stdout: explicitOutput, stderr: 'failure detail', exitCode: 9 }) + } + ], + { toolName: 'Read', env: { ECC_HOOK_PROFILE: 'standard' } } + ); + assert.strictEqual(explicitFailure.stdout, explicitOutput); + assert.strictEqual(explicitFailure.exitCode, 9); + assert.match(explicitFailure.stderr, /failure detail/); }) ) passed++; @@ -380,16 +513,20 @@ function runTests() { test('failing hook exit code propagates to the real dispatcher process status', () => { const script = [ `const dispatcher = require(${JSON.stringify(dispatcherPath)});`, - 'dispatcher.SYNC_HOOKS.length = 0;', - "dispatcher.SYNC_HOOKS.push({ id: 'post:test:fail', matcher: '*', profiles: 'standard,strict', run: () => ({ exitCode: 7 }) });", + "const hooks = [{ id: 'post:test:fail', matcher: '*', profiles: 'standard,strict', run: () => ({ exitCode: 7 }) }];", "process.argv[2] = 'sync';", - 'dispatcher.cli();' + 'dispatcher.cli({ hookListOverride: hooks });' ].join(''); const result = spawnSync(process.execPath, ['-e', script], { cwd: repoRoot, input: JSON.stringify({ hook_event_name: 'PostToolUse', tool_name: 'Read', tool_input: {}, tool_response: {} }), encoding: 'utf8', - env: { ...process.env, CLAUDE_PLUGIN_ROOT: repoRoot, ECC_POSTTOOLUSE_PASSTHROUGH: '1' }, + env: { + ...process.env, + CLAUDE_PLUGIN_ROOT: repoRoot, + ECC_POSTTOOLUSE_PASSTHROUGH: '1', + ECC_DRY_RUN: '0' + }, timeout: 10000 }); assert.strictEqual(result.status, 7, 'OS-level exit status should reflect the failing hook'); diff --git a/tests/hooks/run-with-flags-no-output.test.js b/tests/hooks/run-with-flags-no-output.test.js new file mode 100644 index 000000000..0ac1ea6b2 --- /dev/null +++ b/tests/hooks/run-with-flags-no-output.test.js @@ -0,0 +1,557 @@ +/** + * Regression tests for #2600: silent hook paths must not echo stdin. + */ + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const repoRoot = path.join(__dirname, '..', '..'); +const runner = path.join(repoRoot, 'scripts', 'hooks', 'run-with-flags.js'); +const sessionStartBootstrap = path.join(repoRoot, 'scripts', 'hooks', 'session-start-bootstrap.js'); +const { readHooksConfig } = require(path.join(repoRoot, 'scripts', 'lib', 'hooks-config.js')); +const hooksConfig = readHooksConfig(path.join(repoRoot, 'hooks', 'hooks.json')); +const pluginRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-hook-no-output-')); +const hooksDir = path.join(pluginRoot, 'hooks'); +fs.mkdirSync(hooksDir, { recursive: true }); + +const payload = JSON.stringify({ + hook_event_name: 'PostToolUse', + tool_name: 'Read', + tool_input: { file_path: 'README.md' }, + tool_response: { content: 'payload that must not be duplicated' } +}); + +function writeFixture(name, source) { + fs.writeFileSync(path.join(hooksDir, name), source); +} + +writeFixture('undefined.js', "module.exports.run = () => undefined;\n"); +writeFixture('object.js', "module.exports.run = () => ({ exitCode: 0 });\n"); +writeFixture('throws.js', "module.exports.run = () => { throw new Error('fixture failure'); };\n"); +writeFixture('explicit.js', "module.exports.run = () => 'explicit output';\n"); +writeFixture('buffer.js', "module.exports.run = () => Buffer.from('buffer output');\n"); +writeFixture('stdout.js', "module.exports.run = () => ({ stdout: 'object stdout' });\n"); +writeFixture('context.js', "module.exports.run = () => ({ additionalContext: 'context output' });\n"); +writeFixture('stderr.js', "module.exports.run = () => ({ stderr: 'diagnostic only', exitCode: 0 });\n"); +writeFixture('nonzero.js', "module.exports.run = () => ({ stderr: 'blocked', exitCode: 7 });\n"); +writeFixture('nonzero-output.js', "module.exports.run = () => ({ stdout: 'blocking output', stderr: 'blocked', exitCode: 7 });\n"); +writeFixture('direct-echo.js', 'module.exports.run = raw => raw;\n'); +writeFixture( + 'inspect-input.js', + "module.exports.run = (raw, context) => JSON.stringify({ raw, bytes: Buffer.byteLength(raw, 'utf8'), truncated: context.truncated, maxStdin: context.maxStdin });\n" +); +writeFixture('legacy-empty.js', "process.stdin.resume(); process.stdin.on('end', () => process.exit(0));\n"); +writeFixture('legacy-echo.js', 'process.stdin.pipe(process.stdout);\n'); +writeFixture( + 'legacy-inspect.js', + "let raw=''; process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => { raw += chunk; }); process.stdin.on('end', () => process.stdout.write(JSON.stringify({ bytes: Buffer.byteLength(raw, 'utf8'), truncated: process.env.ECC_HOOK_INPUT_TRUNCATED, maxStdin: process.env.ECC_HOOK_INPUT_MAX_BYTES })));\n" +); + +function run(args, env = {}, input = payload) { + return spawnSync(process.execPath, [runner, ...args], { + input, + encoding: 'utf8', + cwd: repoRoot, + env: { + ...process.env, + CLAUDE_PLUGIN_ROOT: pluginRoot, + ECC_HOOK_PROFILE: 'standard', + ...env + }, + timeout: 30000, + maxBuffer: 4 * 1024 * 1024 + }); +} + +function runConfiguredHook(entry, env = {}, input = payload) { + return spawnSync(entry.hooks[0].command, { + input, + encoding: 'utf8', + cwd: repoRoot, + env: { + ...process.env, + CLAUDE_PLUGIN_ROOT: repoRoot, + ECC_PLUGIN_ROOT: repoRoot, + ECC_AGENT_DATA_HOME: path.join(pluginRoot, 'agent-data'), + ECC_HOOK_PROFILE: 'standard', + ...env + }, + shell: true, + timeout: 30000, + maxBuffer: 4 * 1024 * 1024 + }); +} + +function runSessionStartBootstrapWithMissingRoot(input = payload) { + const missingRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-start-missing-root-')); + fs.rmSync(missingRoot, { recursive: true, force: true }); + return spawnSync(process.execPath, [sessionStartBootstrap], { + input, + encoding: 'utf8', + cwd: repoRoot, + env: { + ...process.env, + CLAUDE_PLUGIN_ROOT: missingRoot, + ECC_PLUGIN_ROOT: missingRoot + }, + timeout: 30000, + maxBuffer: 4 * 1024 * 1024 + }); +} + +function runSessionStartBootstrapWithLargeOutput(channel, exitCode) { + const outputBytes = 512 * 1024; + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-start-output-')); + const fixtureRunner = path.join(root, 'scripts', 'hooks', 'run-with-flags.js'); + fs.mkdirSync(path.dirname(fixtureRunner), { recursive: true }); + fs.writeFileSync( + fixtureRunner, + [ + "const size = Number(process.env.ECC_TEST_OUTPUT_BYTES);", + "const output = 'x'.repeat(size);", + "if (process.env.ECC_TEST_OUTPUT_CHANNEL !== 'stderr') process.stdout.write(output);", + "if (process.env.ECC_TEST_OUTPUT_CHANNEL !== 'stdout') process.stderr.write(output.replaceAll('x', 'y'));", + "process.exitCode = Number(process.env.ECC_TEST_EXIT_CODE);" + ].join('\n') + '\n' + ); + + try { + return spawnSync(process.execPath, [sessionStartBootstrap], { + input: payload, + encoding: 'utf8', + cwd: repoRoot, + env: { + ...process.env, + CLAUDE_PLUGIN_ROOT: root, + ECC_PLUGIN_ROOT: root, + ECC_TEST_OUTPUT_BYTES: String(outputBytes), + ECC_TEST_OUTPUT_CHANNEL: channel, + ECC_TEST_EXIT_CODE: String(exitCode) + }, + timeout: 30000, + maxBuffer: 4 * 1024 * 1024 + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +function runConfiguredHookWithMissingRoot(entry, input = payload) { + const missingRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-hook-missing-root-')); + fs.rmSync(missingRoot, { recursive: true, force: true }); + return runConfiguredHook( + entry, + { CLAUDE_PLUGIN_ROOT: missingRoot, ECC_PLUGIN_ROOT: missingRoot }, + input + ); +} + +function test(name, fn) { + try { + fn(); + console.log(` [PASS] ${name}`); + return true; + } catch (error) { + console.log(` [FAIL] ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +function assertSilent(result) { + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(result.stdout, ''); +} + +console.log('\nrun-with-flags no-output contract tests (#2600):'); + +let passed = 0; +let failed = 0; + +const silentCases = [ + ['missing arguments', [], {}], + ['disabled hook', ['post:test', 'hooks/undefined.js', 'standard'], { ECC_DISABLED_HOOKS: 'post:test' }], + ['dry run', ['post:test', 'hooks/undefined.js', 'standard'], { ECC_DRY_RUN: '1' }], + ['missing script', ['post:test', 'hooks/missing.js', 'standard'], {}], + ['path traversal rejection', ['post:test', '../outside.js', 'standard'], {}], + ['undefined run result', ['post:test', 'hooks/undefined.js', 'standard'], {}], + ['object result without output', ['post:test', 'hooks/object.js', 'standard'], {}], + ['run exception', ['post:test', 'hooks/throws.js', 'standard'], {}], + ['legacy process with empty stdout', ['post:test', 'hooks/legacy-empty.js', 'standard'], {}] +]; + +for (const [name, args, env] of silentCases) { + if (test(`${name} emits empty stdout`, () => assertSilent(run(args, env)))) passed++; + else failed++; +} + +const explicitCases = [ + ['string output', 'hooks/explicit.js', 'explicit output'], + ['Buffer output', 'hooks/buffer.js', 'buffer output'], + ['stdout property', 'hooks/stdout.js', 'object stdout'] +]; + +for (const [name, fixture, expected] of explicitCases) { + if ( + test(`preserves explicit ${name}`, () => { + const result = run(['post:test', fixture, 'standard']); + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(result.stdout, expected); + }) + ) + passed++; + else failed++; +} + +if ( + test('preserves additionalContext output', () => { + const result = run(['post:test', 'hooks/context.js', 'standard']); + assert.strictEqual(result.status, 0, result.stderr); + assert.deepStrictEqual(JSON.parse(result.stdout), { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + additionalContext: 'context output' + } + }); + }) +) + passed++; +else failed++; + +if ( + test('preserves stderr while keeping diagnostic-only success silent', () => { + const result = run(['post:test', 'hooks/stderr.js', 'standard']); + assertSilent(result); + assert.match(result.stderr, /diagnostic only/); + }) +) + passed++; +else failed++; + +if ( + test('preserves a nonzero exit code and stderr without synthesizing stdout', () => { + const result = run(['post:test', 'hooks/nonzero.js', 'standard']); + assert.strictEqual(result.status, 7); + assert.strictEqual(result.stdout, ''); + assert.match(result.stderr, /blocked/); + }) +) + passed++; +else failed++; + +if ( + test('preserves explicit stdout together with a nonzero exit code', () => { + const result = run(['post:test', 'hooks/nonzero-output.js', 'standard']); + assert.strictEqual(result.status, 7); + assert.strictEqual(result.stdout, 'blocking output'); + assert.match(result.stderr, /blocked/); + }) +) + passed++; +else failed++; + +if ( + test('preserves direct hook output that explicitly equals stdin', () => { + const result = run(['post:test', 'hooks/direct-echo.js', 'standard']); + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(result.stdout, payload); + }) +) + passed++; +else failed++; + +if ( + test('preserves legacy hook output that explicitly equals stdin', () => { + const result = run(['post:test', 'hooks/legacy-echo.js', 'standard']); + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(result.stdout, payload); + }) +) + passed++; +else failed++; + +if ( + test('ECC_HOOK_INPUT_MAX_BYTES controls the runner cap and in-process context', () => { + const result = run( + ['post:test', 'hooks/inspect-input.js', 'standard'], + { ECC_HOOK_INPUT_MAX_BYTES: '128' }, + 'x'.repeat(256) + ); + assert.strictEqual(result.status, 0, result.stderr); + assert.deepStrictEqual(JSON.parse(result.stdout), { + raw: 'x'.repeat(128), + bytes: 128, + truncated: true, + maxStdin: 128 + }); + assert.match(result.stderr, /stdin exceeded 128 bytes/); + }) +) + passed++; +else failed++; + +if ( + test('stdin cap counts UTF-8 bytes at an exact multibyte boundary', () => { + const input = String.fromCodePoint(0xe9).repeat(2); + const result = run( + ['post:test', 'hooks/inspect-input.js', 'standard'], + { ECC_HOOK_INPUT_MAX_BYTES: '4' }, + input + ); + assert.strictEqual(result.status, 0, result.stderr); + assert.deepStrictEqual(JSON.parse(result.stdout), { + raw: input, + bytes: 4, + truncated: false, + maxStdin: 4 + }); + }) +) + passed++; +else failed++; + +if ( + test('stdin cap discards an incomplete UTF-8 sequence at truncation', () => { + const character = String.fromCodePoint(0xe9); + const result = run( + ['post:test', 'hooks/inspect-input.js', 'standard'], + { ECC_HOOK_INPUT_MAX_BYTES: '3' }, + character.repeat(2) + ); + assert.strictEqual(result.status, 0, result.stderr); + assert.deepStrictEqual(JSON.parse(result.stdout), { + raw: character, + bytes: 2, + truncated: true, + maxStdin: 3 + }); + assert.match(result.stderr, /stdin exceeded 3 bytes/); + }) +) + passed++; +else failed++; + +if ( + test('invalid stdin caps warn and fall back without disabling hooks', () => { + for (const configuredLimit of ['0', '-1', '1.5', 'not-a-number']) { + const result = run( + ['post:test', 'hooks/inspect-input.js', 'standard'], + { ECC_HOOK_INPUT_MAX_BYTES: configuredLimit } + ); + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(JSON.parse(result.stdout).maxStdin, 1024 * 1024); + assert.match(result.stderr, /must be a positive safe integer/); + } + }) +) + passed++; +else failed++; + +if ( + test('stdin cap override cannot exceed the 1 MiB safety maximum', () => { + const result = run( + ['post:test', 'hooks/undefined.js', 'standard'], + { ECC_HOOK_INPUT_MAX_BYTES: String(2 * 1024 * 1024) }, + 'x'.repeat(1024 * 1024 + 1) + ); + assertSilent(result); + assert.match(result.stderr, /exceeds the 1 MiB safety maximum/); + assert.match(result.stderr, /stdin exceeded 1048576 bytes/); + }) +) + passed++; +else failed++; + +if ( + test('legacy hooks receive the resolved stdin cap and truncation flag', () => { + const result = run( + ['post:test', 'hooks/legacy-inspect.js', 'standard'], + { ECC_HOOK_INPUT_MAX_BYTES: '128' }, + 'x'.repeat(256) + ); + assert.strictEqual(result.status, 0, result.stderr); + assert.deepStrictEqual(JSON.parse(result.stdout), { + bytes: 128, + truncated: '1', + maxStdin: '128' + }); + }) +) + passed++; +else failed++; + +for (const [eventName, entries] of Object.entries(hooksConfig.hooks)) { + if (eventName === 'Stop') continue; + for (const entry of entries) { + if ( + test(`${eventName}/${entry.id} registered disabled path stays silent`, () => { + const result = runConfiguredHook(entry, { ECC_HOOKS_ENABLED: '0' }); + assertSilent(result); + }) + ) + passed++; + else failed++; + } +} + +const sessionEndEntry = hooksConfig.hooks.SessionEnd.find(entry => entry.id === 'session:end:marker'); +if ( + test('SessionEnd unresolved-root fallback stays silent', () => { + const result = runConfiguredHookWithMissingRoot(sessionEndEntry); + assertSilent(result); + assert.match(result.stderr, /lifecycle bootstrap unavailable/); + }) +) + passed++; +else failed++; + +for (const hookId of [ + 'pre:bash:dispatcher', + 'pre:powershell:gateguard-fact-force', + 'pre:config-protection', + 'pre:edit-write:gateguard-fact-force', + 'pre:mcp-health-check' +]) { + if ( + test(`${hookId} blocks registered PreToolUse input that was truncated`, () => { + const entry = hooksConfig.hooks.PreToolUse.find(candidate => candidate.id === hookId); + const toolInput = hookId === 'pre:powershell:gateguard-fact-force' + ? { command: `Remove-Item -Recurse -Force C:\\important\\data # ${'x'.repeat(256)}` } + : { + command: 'rm -rf /important/data', + file_path: '/src/important.js', + content: 'x'.repeat(256) + }; + const input = JSON.stringify({ + hook_event_name: 'PreToolUse', + tool_name: hookId === 'pre:powershell:gateguard-fact-force' + ? 'PowerShell' + : hookId === 'pre:bash:dispatcher' ? 'Bash' : 'Write', + tool_input: toolInput + }); + const result = runConfiguredHook(entry, { + ECC_DISABLED_HOOKS: '', + ECC_DRY_RUN: '', + ECC_HOOK_INPUT_MAX_BYTES: '64' + }, input); + assert.strictEqual(result.status, 2, result.stderr); + assert.strictEqual(result.stdout, ''); + assert.match(result.stderr, /complete request|truncated payload/); + assert.match(result.stderr, /bootstrap: stdin exceeded 64 bytes/); + }) + ) + passed++; + else failed++; +} + +for (const hookId of [ + 'pre:powershell:gateguard-fact-force', + 'pre:edit-write:gateguard-fact-force' +]) { + for (const env of [ + { ECC_GATEGUARD: 'off' }, + { GATEGUARD_DISABLED: '1' } + ]) { + if ( + test(`${hookId} recovery controls allow truncated input without stdout`, () => { + const entry = hooksConfig.hooks.PreToolUse.find( + candidate => candidate.id === hookId + ); + const input = JSON.stringify({ + hook_event_name: 'PreToolUse', + tool_name: hookId === 'pre:powershell:gateguard-fact-force' ? 'PowerShell' : 'Write', + tool_input: { file_path: '/src/recovery.js', content: 'x'.repeat(256) } + }); + const result = runConfiguredHook(entry, { + ECC_DISABLED_HOOKS: '', + ECC_DRY_RUN: '', + ECC_HOOK_INPUT_MAX_BYTES: '64', + ...env + }, input); + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(result.stdout, ''); + }) + ) + passed++; + else failed++; + } +} + +if ( + test('MCP health recovery control allows truncated input without stdout', () => { + const entry = hooksConfig.hooks.PreToolUse.find( + candidate => candidate.id === 'pre:mcp-health-check' + ); + const input = JSON.stringify({ + hook_event_name: 'PreToolUse', + tool_name: 'mcp__unhealthy__search', + tool_input: { query: 'x'.repeat(256) } + }); + const result = runConfiguredHook(entry, { + ECC_DISABLED_HOOKS: '', + ECC_DRY_RUN: '', + ECC_HOOK_INPUT_MAX_BYTES: '64', + ECC_MCP_HEALTH_FAIL_OPEN: 'yes' + }, input); + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(result.stdout, ''); + }) +) + passed++; +else failed++; + +if ( + test('SessionStart bootstrap unresolved-root fallback stays silent', () => { + const result = runSessionStartBootstrapWithMissingRoot(); + assertSilent(result); + assert.match(result.stderr, /could not resolve ECC plugin root/); + }) +) + passed++; +else failed++; + +if ( + test('SessionStart bootstrap flushes large additionalContext output before exit', () => { + const result = runSessionStartBootstrapWithLargeOutput('stdout', 0); + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(Buffer.byteLength(result.stdout, 'utf8'), 512 * 1024); + assert.match(result.stdout, /^x+$/); + }) +) + passed++; +else failed++; + +if ( + test('SessionStart bootstrap flushes large non-zero exit output before exit', () => { + const result = runSessionStartBootstrapWithLargeOutput('stderr', 7); + assert.strictEqual(result.status, 7, result.stderr.slice(-200)); + assert.strictEqual(Buffer.byteLength(result.stderr, 'utf8'), 512 * 1024); + assert.match(result.stderr, /^y+$/); + }) +) + passed++; +else failed++; + +if ( + test('SessionStart bootstrap flushes both large output streams before exit', () => { + const result = runSessionStartBootstrapWithLargeOutput('both', 9); + assert.strictEqual(result.status, 9, result.stderr.slice(-200)); + assert.strictEqual(Buffer.byteLength(result.stdout, 'utf8'), 512 * 1024); + assert.strictEqual(Buffer.byteLength(result.stderr, 'utf8'), 512 * 1024); + assert.match(result.stdout, /^x+$/); + assert.match(result.stderr, /^y+$/); + }) +) + passed++; +else failed++; + +fs.rmSync(pluginRoot, { recursive: true, force: true }); + +console.log(`\nPassed: ${passed}`); +console.log(`Failed: ${failed}\n`); +process.exit(failed > 0 ? 1 : 0); diff --git a/tests/hooks/run-with-flags-truncation.test.js b/tests/hooks/run-with-flags-truncation.test.js index 36cdf06ed..6157d4bf9 100644 --- a/tests/hooks/run-with-flags-truncation.test.js +++ b/tests/hooks/run-with-flags-truncation.test.js @@ -1,5 +1,5 @@ /** - * Regression tests for #2222: run-with-flags.js must fail open on >1MB stdin. + * Regression tests for #2222: run-with-flags.js must not echo truncated stdin. * * Before the fix, every fallthrough path echoed the truncated payload to * stdout. The harness parses hook stdout as JSON, got a document cut @@ -61,7 +61,7 @@ if ( assert.strictEqual(result.status, 0, `expected exit 0, got ${result.status}: ${result.stderr}`); assert.strictEqual(result.stdout, '', `stdout must be empty, got: ${result.stdout.slice(0, 120)}...`); assert.match(result.stderr, /stdin exceeded \d+ bytes for pre:write:doc-file-warning/); - assert.match(result.stderr, /fail-open/); + assert.match(result.stderr, /suppressing raw passthrough/); }) ) passed++; @@ -88,15 +88,14 @@ if ( else failed++; if ( - test('normal-sized payload still passes through unchanged', () => { + test('normal-sized no-output hook stays silent', () => { const payload = JSON.stringify({ tool_name: 'Write', tool_input: { file_path: '/tmp/small.js', content: 'const x = 1;\n' } }); const result = runRunner(['pre:write:doc-file-warning', 'scripts/hooks/doc-file-warning.js', 'standard,strict'], payload); assert.strictEqual(result.status, 0, `expected exit 0, got ${result.status}: ${result.stderr}`); - assert.ok(result.stdout.length > 0, 'normal payloads keep the pass-through behavior'); - JSON.parse(result.stdout); // stdout must remain valid JSON + assert.strictEqual(result.stdout, '', 'silent hooks must not echo normal payloads'); }) ) passed++; @@ -120,35 +119,32 @@ if ( else failed++; if ( - test('payload just under the cap echoes through completely (no 64KB pipe cut)', () => { - // process.exit() right after stdout.write() used to drop everything past - // the ~64KB pipe buffer, cutting the echoed JSON mid-stream. + test('missing-args path stays silent just under the cap', () => { const content = 'y'.repeat(MAX_STDIN - 1024); const payload = JSON.stringify({ tool_name: 'Write', tool_input: { file_path: '/tmp/edge.md', content } }); assert.ok(payload.length < MAX_STDIN, 'fixture must stay under the stdin cap'); const result = runRunner([], payload); assert.strictEqual(result.status, 0); - assert.strictEqual(result.stdout.length, payload.length, 'echo must not be cut at the pipe buffer'); - assert.strictEqual(result.stdout, payload, 'sub-cap payloads still echo through fallthrough paths'); + assert.strictEqual(result.stdout, '', 'missing-args path must not echo sub-cap payloads'); }) ) passed++; else failed++; if ( - test('disabled-hook passthrough of a >64KB payload stays valid JSON', () => { + test('disabled hook stays silent for a >64KB payload', () => { const payload = JSON.stringify({ tool_name: 'Write', tool_input: { file_path: '/tmp/medium.md', content: 'z'.repeat(256 * 1024) } }); const result = runRunner(['pre:write:doc-file-warning', 'scripts/hooks/doc-file-warning.js', 'standard,strict'], payload, { ECC_DISABLED_HOOKS: 'pre:write:doc-file-warning' }); assert.strictEqual(result.status, 0); - assert.strictEqual(result.stdout, payload); - JSON.parse(result.stdout); + assert.strictEqual(result.stdout, ''); }) ) passed++; else failed++; -console.log(`\n ${passed} passed, ${failed} failed\n`); +console.log(`\nPassed: ${passed}`); +console.log(`Failed: ${failed}\n`); process.exit(failed > 0 ? 1 : 0); diff --git a/tests/hooks/stop-hooks-stdout.test.js b/tests/hooks/stop-hooks-stdout.test.js index 5d3efdf86..32337754f 100644 --- a/tests/hooks/stop-hooks-stdout.test.js +++ b/tests/hooks/stop-hooks-stdout.test.js @@ -1,12 +1,9 @@ /** * Regression tests for #2090: "Stop hook error: JSON validation failed". * - * Stop hooks follow the ECC pass-through convention (echo stdin on stdout). - * The Stop payload carries `last_assistant_message`, which can be large; any - * hook that caps stdin and echoes the capped string emits a JSON document cut - * mid-stream, which the harness reports as a Stop hook JSON validation - * failure. Worst offender: cost-tracker capped stdin at 64KB, so any Stop - * payload with a >64KB final assistant message broke the whole Stop chain. + * Stop payloads carry `last_assistant_message`, which can be large. Silent + * wrapper paths must emit nothing; explicit hook output must remain complete + * and valid JSON so the harness never sees a truncated document. * * Contract under test: for every Stop hook, stdout is either empty or valid * JSON, and the exit code is 0 — for realistic large payloads and for @@ -115,6 +112,25 @@ function runRegisteredStopHook(entry, input, envOverrides = {}) { }); } +function runRegisteredStopHookWithMissingRoot(entry, input) { + const missingRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-missing-root-')); + fs.rmSync(missingRoot, { recursive: true, force: true }); + return spawnSync(entry.hooks[0].command, { + input, + encoding: 'utf8', + cwd: workDir, + env: { + ...hookEnv(), + CLAUDE_PLUGIN_ROOT: missingRoot, + ECC_PLUGIN_ROOT: missingRoot + }, + shell: true, + timeout: SUBPROCESS_TIMEOUT_MS, + maxBuffer: 16 * 1024 * 1024, + stdio: ['pipe', 'pipe', 'pipe'] + }); +} + function assertStdoutContract(result, label) { assert.strictEqual(result.status, 0, `${label}: expected exit 0, got ${result.status}: ${result.stderr}`); if (result.stdout.length > 0) { @@ -170,13 +186,11 @@ let failed = 0; // runner path, making the harness report "JSON validation failed". const realisticPayload = stopPayload(100 * 1024); -// Exercise the command users actually run from hooks.json. The runner already -// flushes large stdout before exiting, but the outer lifecycle wrapper used to -// call process.exit() immediately after forwarding it, cutting the JSON at the -// OS pipe buffer and reintroducing #2222 above the tested runner layer. +// Exercise the command users actually run from hooks.json. Disabled and +// no-opinion registered hooks must not copy their Stop payload to stdout. for (const entry of hooksConfig.hooks.Stop) { if ( - test(`${entry.id} registered wrapper flushes a 100KB Stop payload`, () => { + test(`${entry.id} disabled registered wrapper stays silent for a 100KB Stop payload`, () => { const startedAt = process.hrtime.bigint(); const result = runRegisteredStopHook(entry, realisticPayload); const elapsedMs = Math.round(Number(process.hrtime.bigint() - startedAt) / 1e6); @@ -185,11 +199,20 @@ for (const entry of hooksConfig.hooks.Stop) { 0, result.status === 0 ? undefined : `${entry.id}: expected exit 0; ${formatSpawnFailure(result, elapsedMs)}` ); - assert.ok( - result.stdout === realisticPayload, - `${entry.id}: registered wrapper must echo ${realisticPayload.length} characters uncut (got ${result.stdout.length})` - ); - JSON.parse(result.stdout); + assert.strictEqual(result.stdout, '', `${entry.id}: disabled wrapper must stay silent`); + }) + ) + passed++; + else failed++; +} + +for (const entry of hooksConfig.hooks.Stop) { + if ( + test(`${entry.id} unresolved-root fallback stays silent`, () => { + const result = runRegisteredStopHookWithMissingRoot(entry, realisticPayload); + assert.strictEqual(result.status, 0, `${entry.id}: expected exit 0, got ${result.status}: ${result.stderr}`); + assert.strictEqual(result.stdout, '', `${entry.id}: unresolved-root fallback must stay silent`); + assert.match(result.stderr, /lifecycle bootstrap unavailable/); }) ) passed++; @@ -199,15 +222,85 @@ for (const entry of hooksConfig.hooks.Stop) { const representativeStopEntry = hooksConfig.hooks.Stop.find( entry => entry.id === 'stop:cost-tracker' ); -const CALLBACK_FLUSH_WRAPPER = 'const finish=(out,err,code)=>{let pending=1;const done=()=>{pending-=1;if(pending===0)process.exit(code);};if(out){pending+=1;process.stdout.write(out,done);}if(err){pending+=1;process.stderr.write(err,done);}process.nextTick(done);};'; +const consoleLogStopEntry = hooksConfig.hooks.Stop.find( + entry => entry.id === 'stop:check-console-log' +); if ( - test('all registered Stop wrappers keep the large-output flush contract', () => { - for (const entry of hooksConfig.hooks.Stop) { - assert.match(entry.hooks[0].command, /maxBuffer:16\*1024\*1024/); + test('enabled registered Stop wrapper suppresses legacy raw-input passthrough', () => { + const result = runRegisteredStopHook(consoleLogStopEntry, realisticPayload, { + ECC_DISABLED_HOOKS: '' + }); + assert.strictEqual(result.status, 0, `expected exit 0, got ${result.status}: ${result.stderr}`); + assert.strictEqual(result.stdout, '', 'registered Stop boundary must suppress raw-input output'); + }) +) + passed++; +else failed++; + +if ( + test('registered Stop wrapper applies a configured byte cap', () => { + const result = runRegisteredStopHook(representativeStopEntry, realisticPayload, { + ECC_HOOK_INPUT_MAX_BYTES: '64' + }); + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(result.stdout, ''); + assert.match(result.stderr, /lifecycle stdin exceeded 64 bytes/); + }) +) + passed++; +else failed++; + +if ( + test('registered Plan Canvas Stop wrapper preserves an explicit block decision', () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-plan-canvas-stop-')); + const artifact = path.join(workDir, 'feature.plan.md'); + const timestamp = '2026-01-01T00:00:00.000Z'; + const state = { + sessions: { + aaaaaaaaaaaa: { + key: 'aaaaaaaaaaaa', + file: artifact, + status: 'feedback', + chat: [], + pendingFeedback: [ + { id: 'feedback-1', kind: 'chat', text: 'move phase 2 up', at: timestamp } + ], + createdAt: timestamp, + updatedAt: timestamp + } + }, + feedbackCounter: 1 + }; + try { + fs.writeFileSync(path.join(stateDir, 'sessions.json'), JSON.stringify(state)); + const entry = hooksConfig.hooks.Stop.find(candidate => candidate.id === 'stop:plan-canvas-pending'); + const input = JSON.stringify({ cwd: workDir, hook_event_name: 'Stop', stop_hook_active: false }); + const result = runRegisteredStopHook(entry, input, { + ECC_DISABLED_HOOKS: '', + ECC_PLAN_CANVAS_STATE_DIR: stateDir + }); + assert.strictEqual(result.status, 0, result.stderr); + const output = JSON.parse(result.stdout); + assert.strictEqual(output.decision, 'block'); + assert.match(output.reason, /move phase 2 up/); + } finally { + fs.rmSync(stateDir, { recursive: true, force: true }); + } + }) +) + passed++; +else failed++; +if ( + test('all registered lifecycle hooks use the bounded shared bootstrap', () => { + const lifecycleEntries = [ + ...hooksConfig.hooks.Stop, + ...hooksConfig.hooks.SessionEnd + ]; + for (const entry of lifecycleEntries) { assert.ok( - entry.hooks[0].command.includes(CALLBACK_FLUSH_WRAPPER), - `${entry.id}: wrapper must wait for stdout and stderr callbacks before exiting` + entry.hooks[0].command.includes('scripts/hooks/lifecycle-hook-bootstrap.js'), + `${entry.id}: expected the shared lifecycle bootstrap` ); } }) @@ -216,17 +309,13 @@ if ( else failed++; if ( - test('registered Stop wrapper flushes a 100KB dry-run payload', () => { + test('registered Stop wrapper stays silent for a 100KB dry-run payload', () => { const result = runRegisteredStopHook(representativeStopEntry, realisticPayload, { ECC_DISABLED_HOOKS: '', ECC_DRY_RUN: '1' }); assert.strictEqual(result.status, 0, `expected exit 0, got ${result.status}: ${result.stderr}`); - assert.ok( - result.stdout === realisticPayload, - `dry-run wrapper must echo ${realisticPayload.length} characters uncut (got ${result.stdout.length})` - ); - JSON.parse(result.stdout); + assert.strictEqual(result.stdout, '', 'dry-run wrapper must stay silent'); }) ) passed++; @@ -239,26 +328,17 @@ const multibytePayload = stopPayload(400 * 1024, '한'); assert.ok(multibytePayload.length < MAX_STDIN, 'fixture must stay below the runner character cap'); assert.ok(Buffer.byteLength(multibytePayload) > MAX_STDIN, 'fixture must exceed the default byte buffer'); -// Every registered command uses the same generated wrapper, verified above. -// Exercise the multi-megabyte byte-buffer edge once so the test does not -// amplify hosted-runner load by serializing the identical payload seven times. -if ( - test('registered Stop wrapper preserves a multibyte sub-cap payload', () => { - const result = runRegisteredStopHook(representativeStopEntry, multibytePayload); - assert.strictEqual( - result.status, - 0, - `expected exit 0, got ${result.status}: ${result.stderr}` - ); - assert.ok( - result.stdout === multibytePayload, - `registered wrapper must echo ${Buffer.byteLength(multibytePayload)} bytes uncut (got ${Buffer.byteLength(result.stdout)})` - ); - JSON.parse(result.stdout); - }) -) - passed++; -else failed++; +for (const entry of hooksConfig.hooks.Stop) { + if ( + test(`${entry.id} disabled registered wrapper stays silent for a multibyte payload`, () => { + const result = runRegisteredStopHook(entry, multibytePayload); + assert.strictEqual(result.status, 0, `${entry.id}: expected exit 0, got ${result.status}: ${result.stderr}`); + assert.strictEqual(result.stdout, '', `${entry.id}: disabled wrapper must stay silent`); + }) + ) + passed++; + else failed++; +} for (const [hookId, script] of STOP_HOOKS) { if ( @@ -266,7 +346,7 @@ for (const [hookId, script] of STOP_HOOKS) { const result = runViaRunner(hookId, script, realisticPayload); assertStdoutContract(result, hookId); if (result.stdout.length > 0) { - assert.strictEqual(result.stdout, realisticPayload, `${hookId}: pass-through must echo the payload uncut`); + assert.strictEqual(result.stdout, realisticPayload, `${hookId}: explicit raw output must remain complete`); } }) ) @@ -302,6 +382,7 @@ if ( 0, `wrapper must preserve oversized-input suppression (got ${result.stdout.length} characters)` ); + assert.match(result.stderr, /lifecycle stdin exceeded 1048576 bytes/); }) ) passed++; @@ -371,5 +452,6 @@ try { /* best-effort cleanup */ } -console.log(`\n ${passed} passed, ${failed} failed\n`); +console.log(`\nPassed: ${passed}`); +console.log(`Failed: ${failed}\n`); process.exit(failed > 0 ? 1 : 0); diff --git a/tests/lib/dry-run.test.js b/tests/lib/dry-run.test.js index 1d73eb7cf..d4d2a2d79 100644 --- a/tests/lib/dry-run.test.js +++ b/tests/lib/dry-run.test.js @@ -94,7 +94,7 @@ function runTests() { result.stderr.includes('target=/tmp/test.md'), `Expected stderr to contain target file path, got: ${result.stderr}` ); - assert.strictEqual(result.stdout, input, 'Expected stdin to be passed through unchanged'); + assert.strictEqual(result.stdout, '', 'Dry-run hooks must not echo stdin'); })) passed++; else failed++; if (test('flushes a large dry-run preview when oversized stdout is suppressed', () => { @@ -151,7 +151,7 @@ function runTests() { result.stderr.includes('command=git commit --no-verify'), `Expected stderr to contain command, got: ${result.stderr}` ); - assert.strictEqual(result.stdout, input, 'Expected stdin to be passed through unchanged'); + assert.strictEqual(result.stdout, '', 'Dry-run hooks must not echo stdin'); })) passed++; else failed++; if (test('dry-run preview handles non-JSON stdin gracefully', () => { @@ -180,7 +180,7 @@ function runTests() { !result.stderr.includes('tool='), 'Expected no tool= when stdin is not JSON' ); - assert.strictEqual(result.stdout, input, 'Expected stdin to be passed through unchanged'); + assert.strictEqual(result.stdout, '', 'Dry-run hooks must not echo stdin'); })) passed++; else failed++; if (test('dry-run preview handles empty stdin gracefully', () => { @@ -285,6 +285,8 @@ function runTests() { })) passed++; else failed++; console.log(`\nResults: ${passed} passed, ${failed} failed`); + console.log(`Passed: ${passed}`); + console.log(`Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); } diff --git a/tests/lib/resolve-ecc-root.test.js b/tests/lib/resolve-ecc-root.test.js index 25a53773c..f532eba98 100644 --- a/tests/lib/resolve-ecc-root.test.js +++ b/tests/lib/resolve-ecc-root.test.js @@ -17,7 +17,11 @@ const CURRENT_PACKAGE_VERSION = JSON.parse( fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8') ).version; -const { resolveEccRoot, INLINE_RESOLVE } = require('../../scripts/lib/resolve-ecc-root'); +const { + resolveEccRoot, + normalizePluginRootForPlatform, + INLINE_RESOLVE +} = require('../../scripts/lib/resolve-ecc-root'); // Sentinel ECC skill that resolveEccRoot() requires (alongside the script tree) // before accepting a root for skill consumers. Kept in sync with the module's @@ -401,6 +405,17 @@ function runTests() { assert.ok(INLINE_RESOLVE.length > 50, 'Should be a substantial inline expression'); })) passed++; else failed++; + if (test('normalizes Git Bash drive roots for Windows lifecycle loaders', () => { + assert.strictEqual( + normalizePluginRootForPlatform('/c/Users/x/.claude/plugins/ecc', 'win32'), + 'C:/Users/x/.claude/plugins/ecc' + ); + assert.strictEqual( + normalizePluginRootForPlatform('/workspace/ecc', 'win32'), + '/workspace/ecc' + ); + })) passed++; else failed++; + if (test('INLINE_RESOLVE does not contain spread, nested arrays, or escaped quotes', () => { assert.ok(!INLINE_RESOLVE.includes('...')); assert.ok(!INLINE_RESOLVE.includes('[[')); diff --git a/tests/pi/pi-extension-adapter.test.js b/tests/pi/pi-extension-adapter.test.js index 984db7ed0..6d214de7f 100644 --- a/tests/pi/pi-extension-adapter.test.js +++ b/tests/pi/pi-extension-adapter.test.js @@ -100,7 +100,7 @@ function buildEccSkeleton(repoRoot) { const hooksDir = path.join(root, "scripts", "hooks") fs.mkdirSync(hooksDir, { recursive: true }) - for (const name of ["run-with-flags.js", "session-end-marker.js", "pretooluse-visible-output.js"]) { + for (const name of ["hook-input.js", "run-with-flags.js", "session-end-marker.js", "pretooluse-visible-output.js"]) { fs.cpSync(path.join(repoRoot, "scripts", "hooks", name), path.join(hooksDir, name)) } fs.cpSync(path.join(repoRoot, "scripts", "lib"), path.join(root, "scripts", "lib"), { recursive: true })