| 1 | import test from 'node:test' |
| 2 | import assert from 'node:assert/strict' |
| 3 | import {realpathSync,mkdtempSync,writeFileSync,mkdirSync,symlinkSync,rmSync} from 'node:fs' |
| 4 | import {tmpdir} from 'node:os' |
| 5 | import {join} from 'node:path' |
| 6 | import {createHash} from 'node:crypto' |
| 7 | import {createHarnessModule} from '../dist/builtin/harness.mjs' |
| 8 | import {reviewedHookModule} from '../dist/shell-hooks.mjs' |
| 9 | const owner={plugin_id:'host:harness',generation:3,owner_token:'receipt'} |
| 10 | const all=['session_start','session_end','message_submit','tool_call_before','tool_call_after','mode_change','on_error','turn_end','subagent_spawn','subagent_complete','shell_env','session_idle','session_error','waiting_for_user','session_busy'] |
| 11 | const metadata=(event='tool_call_before',dialect='claude-code',point='PreToolUse',more={})=>({event,dialect,point,query:'write',...more}) |
| 12 | const params=(hook,more={})=>({owner,execution_id:'one',ticket:'opaque',deadline_ms:1000,hook,...more}) |
| 13 | const output=(event,stdout='',exit_code=0,stderr='')=>({kind:'hook',event,success:exit_code===0,exit_code,stdout,stderr}) |
| 14 | const signal=()=>new AbortController().signal |
| 15 | function fixture(raw){const root=realpathSync(mkdtempSync(join(tmpdir(),'cw-hook-bridge-')));const bytes=JSON.stringify(raw);writeFileSync(join(root,'hooks.json'),bytes);return {root,files:{'hooks.json':createHash('sha256').update(bytes).digest('hex')},cleanup:()=>rmSync(root,{recursive:true,force:true})}} |
| 16 | function context(){const rows=[];const warnings=[];return {rows,warnings,shellHooks:{register(row){rows.push(row);return ()=>{} }},logger:{warn(text){warnings.push(text)}}}} |
| 17 | |
| 18 | test('all 15 existing core firepoint projections complete using one opaque Execution RPC',async()=>{ |
| 19 | for(const event of all){const calls=[];const module=createHarnessModule({async request(method,args){calls.push([method,args]);return {kind:'hook',event,success:true,exit_code:0,...(event==='shell_env'?{keys:['PATH','TOKEN']}:{})}}},owner) |
| 20 | assert.deepEqual(await module.run(params(metadata(event,'codewhale',event)),signal()),{ok:true,hook_completed:true});assert.deepEqual(calls,[['exec/redeem',{owner,execution_id:'one',ticket:'opaque'}]]);await module.dispose()} |
| 21 | }) |
| 22 | test('ShellEnv read-only projection refuses private stdout, stderr and unrelated process data',async()=>{ |
| 23 | for(const extra of [{stdout:'TOKEN=private'}, {stderr:'private'}, {environment:{TOKEN:'private'}}]){const module=createHarnessModule({async request(){return {kind:'hook',event:'shell_env',success:true,exit_code:0,keys:['TOKEN'],...extra}}},owner);await assert.rejects(module.run(params(metadata('shell_env','codewhale','shell_env')),signal()),/private process data/);await module.dispose()} |
| 24 | }) |
| 25 | test('exact pinned Claude literal and Codex regex matcher differences govern actual redemption',async()=>{ |
| 26 | let calls=0;const module=createHarnessModule({async request(){calls++;return output('tool_call_before','{}')}},owner) |
| 27 | assert.deepEqual(await module.run(params(metadata('tool_call_before','claude-code','PreToolUse',{matcher:'write',query:'write_file'})),signal()),{ok:true,hook_skipped:true});assert.equal(calls,0) |
| 28 | assert.equal((await module.run(params(metadata('tool_call_before','codex','PreToolUse',{matcher:'write',query:'write_file'})),signal())).hook_completed,true);assert.equal(calls,1);await module.dispose() |
| 29 | }) |
| 30 | test('hookSpecificOutput discriminator fences permission proposals and allow never approves',async()=>{ |
| 31 | let stdout=JSON.stringify({hookSpecificOutput:{hookEventName:'PostToolUse',permissionDecision:'deny'}});const module=createHarnessModule({async request(){return output('tool_call_before',stdout)}},owner) |
| 32 | assert.deepEqual(JSON.parse((await module.run(params(metadata()),signal())).proposal),{}) |
| 33 | stdout=JSON.stringify({hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow'}});assert.deepEqual(JSON.parse((await module.run(params(metadata()),signal())).proposal),{});await module.dispose() |
| 34 | }) |
| 35 | test('Claude ask maps to Rust proposal while Codex ignores ask; exit2 block is retained',async()=>{ |
| 36 | let exit=0;let stdout=JSON.stringify({hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'ask',permissionDecisionReason:'confirm'}});const module=createHarnessModule({async request(){return output('tool_call_before',stdout,exit,'reason')}},owner) |
| 37 | assert.deepEqual(JSON.parse((await module.run(params(metadata()),signal())).proposal),{decision:'ask',reason:'confirm'}) |
| 38 | assert.deepEqual(JSON.parse((await module.run(params(metadata('tool_call_before','codex')),signal())).proposal),{}) |
| 39 | exit=2;stdout='';assert.deepEqual(JSON.parse((await module.run(params(metadata()),signal())).proposal),{decision:'deny',reason:'reason'});await module.dispose() |
| 40 | }) |
| 41 | test('prompt block is a bounded submit proposal without falsifying process exit',async()=>{ |
| 42 | const module=createHarnessModule({async request(){return output('message_submit',JSON.stringify({decision:'block',reason:'blocked'}))}},owner) |
| 43 | assert.deepEqual(JSON.parse((await module.run(params(metadata('message_submit','claude-code','UserPromptSubmit')),signal())).proposal),{block:true,reason:'blocked'});await module.dispose() |
| 44 | }) |
| 45 | test('observer steering is explicitly unsupported and malformed/oversized output refuses',async()=>{ |
| 46 | for(const stdout of [JSON.stringify({continue:false}),JSON.stringify({decision:'block'}),'x'.repeat(65537)]){const module=createHarnessModule({async request(){return output('turn_end',stdout)}},owner);await assert.rejects(module.run(params(metadata('turn_end','claude-code','Stop')),signal()),/unavailable|invalid dialect/);await module.dispose()} |
| 47 | }) |
| 48 | test('hook deadline cancellation and disposal settle an unresponsive broker without fallback',async()=>{ |
| 49 | const module=createHarnessModule({request(){return new Promise(()=>{})}},owner);await assert.rejects(module.run(params(metadata(),{deadline_ms:5}),signal()),/cancelled/) |
| 50 | const pending=module.run(params(metadata()),signal());const rejection=assert.rejects(pending,/cancelled/);await module.dispose();await rejection |
| 51 | }) |
| 52 | test('reviewed Claude config uses pinned parser and creates real core catalog contribution definitions',()=>{ |
| 53 | const f=fixture({hooks:{PreToolUse:[{matcher:'write|edit',hooks:[{type:'command',command:'${CLAUDE_PLUGIN_ROOT}/check',timeout:3}]}],SubagentStart:[{hooks:[{command:'true'}]}],Stop:[{hooks:[{command:'true'}]}]}}) |
| 54 | try{const ctx=context();reviewedHookModule('claude-code',f.root,f.files).apply(ctx,{configPath:'hooks.json'});assert.equal(ctx.rows.length,3);assert.equal(ctx.rows[0].hook.command,`${f.root}/check`);assert.equal(ctx.rows[0].matcher,'write|edit');assert.equal(ctx.rows[0].hook.timeout_secs,3);assert.equal(ctx.rows[1].hook.event,'turn_end');assert.equal(ctx.warnings.length,1);assert.equal(ctx.rows[2].hook.event,'subagent_spawn')}finally{f.cleanup()} |
| 55 | }) |
| 56 | test('changed bytes, external path, symlink and unsupported async/noncommand config never register',()=>{ |
| 57 | for(const dialect of ['claude-code','codex']){const f=fixture({hooks:{PreToolUse:[{hooks:[{command:'true',...(dialect==='codex'?{async:true}:{type:'prompt'})}]}]}});try{const ctx=context();assert.throws(()=>reviewedHookModule(dialect,f.root,f.files).apply(ctx,{configPath:'hooks.json'}),/unsupported/);assert.equal(ctx.rows.length,0);writeFileSync(join(f.root,'hooks.json'),'{}');assert.throws(()=>reviewedHookModule(dialect,f.root,f.files).apply(ctx,{configPath:'hooks.json'}),/changed/);assert.throws(()=>reviewedHookModule(dialect,f.root,f.files).apply(ctx,{configPath:'../elsewhere.json'}),/closure/)}finally{f.cleanup()}} |
| 58 | const f=fixture({hooks:{PreToolUse:[{hooks:[{command:'true'}]}]}});try{symlinkSync(join(f.root,'hooks.json'),join(f.root,'link.json'));const files={...f.files,'link.json':f.files['hooks.json']};assert.throws(()=>reviewedHookModule('claude-code',f.root,files).apply(context(),{configPath:'link.json'}),/closure/)}finally{f.cleanup()} |
| 59 | }) |
| 60 |