返回 CodeWhale
shell-hooks.ts
根目录 / crates / tui / extension-host / src / dsh / shell-hooks.ts
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
39 lines TYPESCRIPT