fix(hooks): keep silent hook paths silent (#2987)

* fix(hooks): keep silent hook paths silent

* fix(hooks): harden stream failure handling

* fix(hooks): settle interrupted input streams

* test(hooks): name stream input limits

* test(hooks): isolate PostToolUse dispatcher fixtures
This commit is contained in:
He Dong
2026-09-18 18:42:20 -04:00
committed by GitHub
parent 34de45f210
commit 8bf16ccfec
25 changed files with 1489 additions and 289 deletions
+21 -7
View File
@@ -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"
}
+10 -10
View File
@@ -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<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const s=p.join(r,'scripts/hooks/posttooluse-dispatcher.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.env.ECC_POSTTOOLUSE_PASSTHROUGH='1';process.argv.splice(1,0,s);require(s).cli()\" sync",
"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<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const s=p.join(r,'scripts/hooks/posttooluse-dispatcher.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s).cli()\" sync",
"timeout": 30
}
]
@@ -136,7 +136,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<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const s=p.join(r,'scripts/hooks/posttooluse-dispatcher.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.env.ECC_POSTTOOLUSE_PASSTHROUGH='1';process.argv.splice(1,0,s);require(s).cli()\" async",
"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<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const s=p.join(r,'scripts/hooks/posttooluse-dispatcher.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s).cli()\" async",
"async": true,
"timeout": 45
}
@@ -169,7 +169,7 @@
"hooks": [
{
"type": "command",
"command": "node -e \"const fs=require('fs');const path=require('path');const {spawnSync}=require('child_process');const raw=fs.readFileSync(0,'utf8');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 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<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const script=path.join(root,rel);if(fs.existsSync(script)){const result=spawnSync(process.execPath,[script,'stop:plan-canvas-pending','scripts/hooks/plan-canvas-pending.js','minimal,standard,strict'],{input:raw,encoding:'utf8',env:process.env,cwd:process.cwd(),timeout:30000,maxBuffer:16*1024*1024});const failed=result.error||result.status===null||result.signal;const stdout=!failed&&typeof result.stdout==='string'?result.stdout:'';let stderr=typeof result.stderr==='string'?result.stderr:'';let code=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+='[Stop] ERROR: hook runner failed: '+reason+String.fromCharCode(10);code=1;}finish(stdout,stderr,code);}else{finish(raw,'[Stop] WARNING: could not resolve ECC plugin root; skipping hook'+String.fromCharCode(10),0);}\""
"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<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const n=process.platform==='win32'&&r.charAt(0)==='/'&&/[a-zA-Z]/.test(r.charAt(1))&&(r.length===2||r.charAt(2)==='/')?r.charAt(1).toUpperCase()+':/'+r.slice(3):r;const s=p.join(n,'scripts/hooks/lifecycle-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=n;if(require('fs').existsSync(s)){process.argv.splice(1,0,s);require(s).cli()}else{process.stderr.write('[Hook] lifecycle bootstrap unavailable; skipping hook'+String.fromCharCode(10))}\" stop:plan-canvas-pending scripts/hooks/plan-canvas-pending.js minimal,standard,strict 30000"
}
]
},
@@ -178,7 +178,7 @@
"hooks": [
{
"type": "command",
"command": "node -e \"const fs=require('fs');const path=require('path');const {spawnSync}=require('child_process');const raw=fs.readFileSync(0,'utf8');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 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<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const script=path.join(root,rel);if(fs.existsSync(script)){const result=spawnSync(process.execPath,[script,'stop:format-typecheck','scripts/hooks/stop-format-typecheck.js','standard,strict'],{input:raw,encoding:'utf8',env:process.env,cwd:process.cwd(),timeout:300000,maxBuffer:16*1024*1024});const failed=result.error||result.status===null||result.signal;const stdout=!failed&&typeof result.stdout==='string'?result.stdout:'';let stderr=typeof result.stderr==='string'?result.stderr:'';let code=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+='[Stop] ERROR: hook runner failed: '+reason+String.fromCharCode(10);code=1;}finish(stdout,stderr,code);}else{finish(raw,'[Stop] WARNING: could not resolve ECC plugin root; skipping hook'+String.fromCharCode(10),0);}\"",
"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<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const n=process.platform==='win32'&&r.charAt(0)==='/'&&/[a-zA-Z]/.test(r.charAt(1))&&(r.length===2||r.charAt(2)==='/')?r.charAt(1).toUpperCase()+':/'+r.slice(3):r;const s=p.join(n,'scripts/hooks/lifecycle-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=n;if(require('fs').existsSync(s)){process.argv.splice(1,0,s);require(s).cli()}else{process.stderr.write('[Hook] lifecycle bootstrap unavailable; skipping hook'+String.fromCharCode(10))}\" stop:format-typecheck scripts/hooks/stop-format-typecheck.js standard,strict 300000",
"timeout": 300
}
]
@@ -188,7 +188,7 @@
"hooks": [
{
"type": "command",
"command": "node -e \"const fs=require('fs');const path=require('path');const {spawnSync}=require('child_process');const raw=fs.readFileSync(0,'utf8');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 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<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const script=path.join(root,rel);if(fs.existsSync(script)){const result=spawnSync(process.execPath,[script,'stop:check-console-log','scripts/hooks/check-console-log.js','standard,strict'],{input:raw,encoding:'utf8',env:process.env,cwd:process.cwd(),timeout:30000,maxBuffer:16*1024*1024});const failed=result.error||result.status===null||result.signal;const stdout=!failed&&typeof result.stdout==='string'?result.stdout:'';let stderr=typeof result.stderr==='string'?result.stderr:'';let code=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+='[Stop] ERROR: hook runner failed: '+reason+String.fromCharCode(10);code=1;}finish(stdout,stderr,code);}else{finish(raw,'[Stop] WARNING: could not resolve ECC plugin root; skipping hook'+String.fromCharCode(10),0);}\""
"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<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const n=process.platform==='win32'&&r.charAt(0)==='/'&&/[a-zA-Z]/.test(r.charAt(1))&&(r.length===2||r.charAt(2)==='/')?r.charAt(1).toUpperCase()+':/'+r.slice(3):r;const s=p.join(n,'scripts/hooks/lifecycle-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=n;if(require('fs').existsSync(s)){process.argv.splice(1,0,s);require(s).cli()}else{process.stderr.write('[Hook] lifecycle bootstrap unavailable; skipping hook'+String.fromCharCode(10))}\" stop:check-console-log scripts/hooks/check-console-log.js standard,strict 30000"
}
]
},
@@ -197,7 +197,7 @@
"hooks": [
{
"type": "command",
"command": "node -e \"const fs=require('fs');const path=require('path');const {spawnSync}=require('child_process');const raw=fs.readFileSync(0,'utf8');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 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<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const script=path.join(root,rel);if(fs.existsSync(script)){const result=spawnSync(process.execPath,[script,'stop:session-end','scripts/hooks/session-end.js','minimal,standard,strict'],{input:raw,encoding:'utf8',env:process.env,cwd:process.cwd(),timeout:30000,maxBuffer:16*1024*1024});const failed=result.error||result.status===null||result.signal;const stdout=!failed&&typeof result.stdout==='string'?result.stdout:'';let stderr=typeof result.stderr==='string'?result.stderr:'';let code=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+='[Stop] ERROR: hook runner failed: '+reason+String.fromCharCode(10);code=1;}finish(stdout,stderr,code);}else{finish(raw,'[Stop] WARNING: could not resolve ECC plugin root; skipping hook'+String.fromCharCode(10),0);}\"",
"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<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const n=process.platform==='win32'&&r.charAt(0)==='/'&&/[a-zA-Z]/.test(r.charAt(1))&&(r.length===2||r.charAt(2)==='/')?r.charAt(1).toUpperCase()+':/'+r.slice(3):r;const s=p.join(n,'scripts/hooks/lifecycle-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=n;if(require('fs').existsSync(s)){process.argv.splice(1,0,s);require(s).cli()}else{process.stderr.write('[Hook] lifecycle bootstrap unavailable; skipping hook'+String.fromCharCode(10))}\" stop:session-end scripts/hooks/session-end.js minimal,standard,strict 30000",
"async": true,
"timeout": 10
}
@@ -208,7 +208,7 @@
"hooks": [
{
"type": "command",
"command": "node -e \"const fs=require('fs');const path=require('path');const {spawnSync}=require('child_process');const raw=fs.readFileSync(0,'utf8');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 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<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const script=path.join(root,rel);if(fs.existsSync(script)){const result=spawnSync(process.execPath,[script,'stop:evaluate-session','scripts/hooks/evaluate-session.js','minimal,standard,strict'],{input:raw,encoding:'utf8',env:process.env,cwd:process.cwd(),timeout:30000,maxBuffer:16*1024*1024});const failed=result.error||result.status===null||result.signal;const stdout=!failed&&typeof result.stdout==='string'?result.stdout:'';let stderr=typeof result.stderr==='string'?result.stderr:'';let code=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+='[Stop] ERROR: hook runner failed: '+reason+String.fromCharCode(10);code=1;}finish(stdout,stderr,code);}else{finish(raw,'[Stop] WARNING: could not resolve ECC plugin root; skipping hook'+String.fromCharCode(10),0);}\"",
"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<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const n=process.platform==='win32'&&r.charAt(0)==='/'&&/[a-zA-Z]/.test(r.charAt(1))&&(r.length===2||r.charAt(2)==='/')?r.charAt(1).toUpperCase()+':/'+r.slice(3):r;const s=p.join(n,'scripts/hooks/lifecycle-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=n;if(require('fs').existsSync(s)){process.argv.splice(1,0,s);require(s).cli()}else{process.stderr.write('[Hook] lifecycle bootstrap unavailable; skipping hook'+String.fromCharCode(10))}\" stop:evaluate-session scripts/hooks/evaluate-session.js minimal,standard,strict 30000",
"async": true,
"timeout": 10
}
@@ -219,7 +219,7 @@
"hooks": [
{
"type": "command",
"command": "node -e \"const fs=require('fs');const path=require('path');const {spawnSync}=require('child_process');const raw=fs.readFileSync(0,'utf8');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 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<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const script=path.join(root,rel);if(fs.existsSync(script)){const result=spawnSync(process.execPath,[script,'stop:cost-tracker','scripts/hooks/cost-tracker.js','minimal,standard,strict'],{input:raw,encoding:'utf8',env:process.env,cwd:process.cwd(),timeout:30000,maxBuffer:16*1024*1024});const failed=result.error||result.status===null||result.signal;const stdout=!failed&&typeof result.stdout==='string'?result.stdout:'';let stderr=typeof result.stderr==='string'?result.stderr:'';let code=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+='[Stop] ERROR: hook runner failed: '+reason+String.fromCharCode(10);code=1;}finish(stdout,stderr,code);}else{finish(raw,'[Stop] WARNING: could not resolve ECC plugin root; skipping hook'+String.fromCharCode(10),0);}\"",
"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<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const n=process.platform==='win32'&&r.charAt(0)==='/'&&/[a-zA-Z]/.test(r.charAt(1))&&(r.length===2||r.charAt(2)==='/')?r.charAt(1).toUpperCase()+':/'+r.slice(3):r;const s=p.join(n,'scripts/hooks/lifecycle-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=n;if(require('fs').existsSync(s)){process.argv.splice(1,0,s);require(s).cli()}else{process.stderr.write('[Hook] lifecycle bootstrap unavailable; skipping hook'+String.fromCharCode(10))}\" stop:cost-tracker scripts/hooks/cost-tracker.js minimal,standard,strict 30000",
"async": true,
"timeout": 10
}
@@ -230,7 +230,7 @@
"hooks": [
{
"type": "command",
"command": "node -e \"const fs=require('fs');const path=require('path');const {spawnSync}=require('child_process');const raw=fs.readFileSync(0,'utf8');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 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<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const script=path.join(root,rel);if(fs.existsSync(script)){const result=spawnSync(process.execPath,[script,'stop:desktop-notify','scripts/hooks/desktop-notify.js','standard,strict'],{input:raw,encoding:'utf8',env:process.env,cwd:process.cwd(),timeout:30000,maxBuffer:16*1024*1024});const failed=result.error||result.status===null||result.signal;const stdout=!failed&&typeof result.stdout==='string'?result.stdout:'';let stderr=typeof result.stderr==='string'?result.stderr:'';let code=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+='[Stop] ERROR: hook runner failed: '+reason+String.fromCharCode(10);code=1;}finish(stdout,stderr,code);}else{finish(raw,'[Stop] WARNING: could not resolve ECC plugin root; skipping hook'+String.fromCharCode(10),0);}\"",
"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<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const n=process.platform==='win32'&&r.charAt(0)==='/'&&/[a-zA-Z]/.test(r.charAt(1))&&(r.length===2||r.charAt(2)==='/')?r.charAt(1).toUpperCase()+':/'+r.slice(3):r;const s=p.join(n,'scripts/hooks/lifecycle-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=n;if(require('fs').existsSync(s)){process.argv.splice(1,0,s);require(s).cli()}else{process.stderr.write('[Hook] lifecycle bootstrap unavailable; skipping hook'+String.fromCharCode(10))}\" stop:desktop-notify scripts/hooks/desktop-notify.js standard,strict 30000",
"async": true,
"timeout": 10
}
@@ -243,7 +243,7 @@
"hooks": [
{
"type": "command",
"command": "node -e \"const fs=require('fs');const path=require('path');const {spawnSync}=require('child_process');const raw=fs.readFileSync(0,'utf8');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<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const script=path.join(root,rel);if(fs.existsSync(script)){const result=spawnSync(process.execPath,[script,'session:end:marker','scripts/hooks/session-end-marker.js','minimal,standard,strict'],{input:raw,encoding:'utf8',env:process.env,cwd:process.cwd(),timeout:30000});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('[SessionEnd] ERROR: hook runner failed: '+reason+String.fromCharCode(10));process.exit(1);}process.exit(Number.isInteger(result.status)?result.status:0);}process.stderr.write('[SessionEnd] WARNING: could not resolve ECC plugin root; skipping hook'+String.fromCharCode(10));process.stdout.write(raw);\"",
"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<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const n=process.platform==='win32'&&r.charAt(0)==='/'&&/[a-zA-Z]/.test(r.charAt(1))&&(r.length===2||r.charAt(2)==='/')?r.charAt(1).toUpperCase()+':/'+r.slice(3):r;const s=p.join(n,'scripts/hooks/lifecycle-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=n;if(require('fs').existsSync(s)){process.argv.splice(1,0,s);require(s).cli()}else{process.stderr.write('[Hook] lifecycle bootstrap unavailable; skipping hook'+String.fromCharCode(10))}\" session:end:marker scripts/hooks/session-end-marker.js minimal,standard,strict 30000",
"async": true,
"timeout": 10
}
+10 -10
View File
@@ -71,12 +71,12 @@
{
"id": "post:dispatcher:sync",
"description": "Run synchronous PostToolUse hooks in one process while preserving per-hook controls",
"fingerprint": "cc868baab727"
"fingerprint": "69422cb651aa"
},
{
"id": "post:dispatcher:async",
"description": "Run background PostToolUse hooks in one process while preserving per-hook controls",
"fingerprint": "5e256d15db44"
"fingerprint": "01af98da6841"
}
],
"PostToolUseFailure": [
@@ -95,44 +95,44 @@
{
"id": "stop:plan-canvas-pending",
"description": "Deliver undelivered Plan Canvas browser feedback before the agent stops",
"fingerprint": "e1a0fd79c26f"
"fingerprint": "5953e0fed81c"
},
{
"id": "stop:format-typecheck",
"description": "Batch format (Biome/Prettier) and typecheck (tsc) all JS/TS files edited this response — runs once at Stop instead of after every Edit",
"fingerprint": "9836d01e962e"
"fingerprint": "a9d9bb04e060"
},
{
"id": "stop:check-console-log",
"description": "Check for console.log in modified files after each response",
"fingerprint": "235c7f182b76"
"fingerprint": "a675a517c549"
},
{
"id": "stop:session-end",
"description": "Persist session state after each response (Stop carries transcript_path)",
"fingerprint": "981212c32849"
"fingerprint": "d094692bee01"
},
{
"id": "stop:evaluate-session",
"description": "Evaluate session for extractable patterns",
"fingerprint": "d874ecf69ef7"
"fingerprint": "664eec4bb68f"
},
{
"id": "stop:cost-tracker",
"description": "Track token and cost metrics per session",
"fingerprint": "57d255146fc0"
"fingerprint": "5c5fe7253e20"
},
{
"id": "stop:desktop-notify",
"description": "Send desktop notification (macOS/WSL) with task summary when Claude responds",
"fingerprint": "668cdbae027d"
"fingerprint": "7a492e898bf6"
}
],
"SessionEnd": [
{
"id": "session:end:marker",
"description": "Session end lifecycle marker (non-blocking)",
"fingerprint": "23a3832480e1"
"fingerprint": "9270863f2935"
}
]
}
+69
View File
@@ -0,0 +1,69 @@
'use strict';
const { StringDecoder } = require('string_decoder');
const DEFAULT_MAX_STDIN = 1024 * 1024;
function resolveMaxStdin(value, options = {}) {
const writeDiagnostic = options.writeDiagnostic || (() => {});
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
};
+120
View File
@@ -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 };
+23 -30
View File
@@ -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 = {
+18 -38
View File
@@ -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;
});
+28 -10
View File
@@ -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;
});
+59 -39
View File
@@ -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) {
+61 -45
View File
@@ -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;
});
+11
View File
@@ -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,
};
+43
View File
@@ -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' } };
+3 -6
View File
@@ -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 });
+2 -15
View File
@@ -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++;
+1 -1
View File
@@ -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 });
+129
View File
@@ -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;
});
+4 -2
View File
@@ -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)}...`);
}
}
}
+12
View File
@@ -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 {
+144 -7
View File
@@ -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');
@@ -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);
+10 -14
View File
@@ -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);
+132 -50
View File
@@ -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);
+5 -3
View File
@@ -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);
}
+16 -1
View File
@@ -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('[['));
+1 -1
View File
@@ -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 })