| 1 | /** Pinned configuration/matcher semantics adapted onto the existing core hook catalog. No agent/session runtime. */ |
| 2 | import { createHash } from 'node:crypto' |
| 3 | import { readFileSync,lstatSync } from 'node:fs' |
| 4 | import { resolve,relative,isAbsolute,sep } from 'node:path' |
| 5 | import { isUnlinkedInside } from './canonical-path.ts' |
| 6 | // Re-exported for the pure path-normalization unit test (test/canonical-path.test.mjs). |
| 7 | export { insideKey, pathKey, samePath, stripVerbatim, unlinkedKeyMatches } from './canonical-path.ts' |
| 8 | import { parseClaudeCodeConfig } from './upstream/hooks/hooks-claude-code/src/config.ts' |
| 9 | import { parseCodexConfig } from './upstream/hooks/hooks-codex/src/config.ts' |
| 10 | const EVENTS:Record<string,string>={SessionStart:'session_start',UserPromptSubmit:'message_submit',PreToolUse:'tool_call_before',PostToolUse:'tool_call_after',Stop:'turn_end',SubagentStart:'subagent_spawn',SubagentStop:'subagent_complete'} |
| 11 | export function reviewedHookModule(dialect:'claude-code'|'codex',root:string,files:Readonly<Record<string,string>>) { |
| 12 | return {name:`hooks-${dialect}`,inject:['shellHooks'],apply(ctx:any,config:any) { |
| 13 | if(!config || typeof config.configPath!=='string')throw new Error('hook bridge needs its reviewed configPath') |
| 14 | const path=resolve(root,config.configPath);const inside=relative(root,path).split(sep).join('/') |
| 15 | if(!inside || inside.startsWith('../') || isAbsolute(inside) || !files[inside] || !isUnlinkedInside(root,inside,path) || !lstatSync(path).isFile())throw new Error('hook config is absent from the reviewed regular-file closure') |
| 16 | const bytes=readFileSync(path) |
| 17 | if(bytes.length>1024*1024 || createHash('sha256').update(bytes).digest('hex')!==files[inside])throw new Error('hook config changed after review or exceeds 1 MiB') |
| 18 | // The only substitutions are the sealed bundle and current per-call core workspace. |
| 19 | // Project-dir values cannot name another ambient project during activation. |
| 20 | if(config.projectDir!==undefined)throw new Error('explicit projectDir is unsupported; each process uses its current core workspace') |
| 21 | if(config.pluginRoot!==undefined && config.pluginRoot!=='.' && config.pluginRoot!==root)throw new Error('pluginRoot must be this reviewed bundle root') |
| 22 | const raw:unknown=JSON.parse(bytes.toString('utf8')) |
| 23 | const parsed=dialect==='claude-code'?parseClaudeCodeConfig(raw,{pluginRoot:root}):parseCodexConfig(raw) |
| 24 | if(parsed.skipped.length)throw new Error('hook config contains unsupported non-command or asynchronous hooks') |
| 25 | let count=0 |
| 26 | for(const [point,groups] of Object.entries(parsed.config)) { |
| 27 | if(point==='Stop')ctx.logger.warn('Stop runs at the core TurnEnd observer; forced continuation is unsupported') |
| 28 | for(const group of groups)for(const hook of group.hooks) { |
| 29 | if(++count>128)throw new Error('hook bridge exceeds 128 commands') |
| 30 | const seconds=hook.timeoutSec ?? ((config.defaultTimeoutMs ?? 600000)/1000) |
| 31 | if(!Number.isSafeInteger(seconds) || seconds<1 || seconds>86400)throw new Error('hook timeout must be 1–86400 whole seconds') |
| 32 | // Per-event payload and environment are produced in Rust from the actual caller. |
| 33 | ctx.shellHooks.register({dialect,point,...(group.matcher===undefined?{}:{matcher:group.matcher}),hook:{event:EVENTS[point],command:hook.command,timeout_secs:seconds,background:false,continue_on_error:false,name:`${dialect}:${point}:${count}`}}) |
| 34 | } |
| 35 | } |
| 36 | if(count===0)throw new Error('reviewed hook config has no supported command hooks') |
| 37 | }} |
| 38 | } |
| 39 |