mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-20 16:47:59 +02:00
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:
+21
-7
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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' } };
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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)}...`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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('[['));
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user