| 1 | // src/dsh/shell-hooks.ts |
| 2 | import { createHash } from "node:crypto"; |
| 3 | import { readFileSync, lstatSync } from "node:fs"; |
| 4 | import { resolve, relative, isAbsolute, sep } from "node:path"; |
| 5 | |
| 6 | // src/dsh/canonical-path.ts |
| 7 | import { realpathSync } from "node:fs"; |
| 8 | import { posix, win32 } from "node:path"; |
| 9 | function canonicalPath(path, platform = process.platform) { |
| 10 | if (platform !== "win32") return realpathSync(path); |
| 11 | try { |
| 12 | return stripVerbatim(realpathSync.native(path), platform); |
| 13 | } catch (error) { |
| 14 | const code = error && typeof error === "object" && "code" in error ? error.code : void 0; |
| 15 | if (code === "EPERM" || code === "EACCES") { |
| 16 | return stripVerbatim(win32.normalize(path), platform); |
| 17 | } |
| 18 | throw error; |
| 19 | } |
| 20 | } |
| 21 | function stripVerbatim(path, platform = process.platform) { |
| 22 | if (platform !== "win32") return path; |
| 23 | if (/^[\\/]{2}\?[\\/]UNC[\\/]/i.test(path)) return `\\\\${path.slice(8)}`; |
| 24 | if (/^[\\/]{2}\?[\\/]/.test(path)) return path.slice(4); |
| 25 | return path; |
| 26 | } |
| 27 | function pathKey(path, platform = process.platform) { |
| 28 | if (platform !== "win32") return posix.normalize(path); |
| 29 | return win32.normalize(stripVerbatim(path, platform)).toLowerCase(); |
| 30 | } |
| 31 | function samePath(a, b, platform = process.platform) { |
| 32 | return pathKey(a, platform) === pathKey(b, platform); |
| 33 | } |
| 34 | function insideKey(root, target, platform = process.platform) { |
| 35 | const path = platform === "win32" ? win32 : posix; |
| 36 | const inside = path.relative(stripVerbatim(root, platform), stripVerbatim(target, platform)); |
| 37 | if (inside === ".." || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside)) return void 0; |
| 38 | return platform === "win32" ? inside.split(win32.sep).join("/") : inside; |
| 39 | } |
| 40 | function isUnlinkedInside(root, key, target, platform = process.platform) { |
| 41 | let canonicalRoot, canonicalTarget; |
| 42 | try { |
| 43 | canonicalRoot = canonicalPath(root, platform); |
| 44 | canonicalTarget = canonicalPath(target, platform); |
| 45 | } catch { |
| 46 | return false; |
| 47 | } |
| 48 | return unlinkedKeyMatches(canonicalRoot, key, canonicalTarget, platform); |
| 49 | } |
| 50 | function unlinkedKeyMatches(canonicalRoot, key, canonicalTarget, platform = process.platform) { |
| 51 | if (!key || key.split("/").some((part) => !part || part === "." || part === "..")) return false; |
| 52 | const path = platform === "win32" ? win32 : posix; |
| 53 | return samePath(path.join(stripVerbatim(canonicalRoot, platform), ...key.split("/")), canonicalTarget, platform); |
| 54 | } |
| 55 | |
| 56 | // src/dsh/upstream/hooks/hook-protocol/src/matcher.ts |
| 57 | function isMatchAll(matcher) { |
| 58 | return matcher === void 0 || matcher === "" || matcher === "*"; |
| 59 | } |
| 60 | var CLAUDE_LITERAL = /^[A-Za-z0-9_|]+$/; |
| 61 | function compileRegex(pattern) { |
| 62 | try { |
| 63 | return new RegExp(pattern); |
| 64 | } catch (_syntaxError) { |
| 65 | return void 0; |
| 66 | } |
| 67 | } |
| 68 | function matcherDiagnostic(matcher, mode) { |
| 69 | if (isMatchAll(matcher)) return void 0; |
| 70 | const pattern = matcher; |
| 71 | if (mode === "claude-code" && CLAUDE_LITERAL.test(pattern)) return void 0; |
| 72 | return compileRegex(pattern) === void 0 ? `invalid ${mode} regex matcher ${JSON.stringify(pattern)}` : void 0; |
| 73 | } |
| 74 | |
| 75 | // src/dsh/upstream/hooks/hooks-claude-code/src/config.ts |
| 76 | var CLAUDE_EVENTS = [ |
| 77 | "SessionStart", |
| 78 | "UserPromptSubmit", |
| 79 | "PreToolUse", |
| 80 | "PostToolUse", |
| 81 | "Stop", |
| 82 | "SubagentStart", |
| 83 | "SubagentStop" |
| 84 | ]; |
| 85 | function asObject(value) { |
| 86 | return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0; |
| 87 | } |
| 88 | function substituteCommand(command, vars) { |
| 89 | let out = command; |
| 90 | if (vars.pluginRoot !== void 0) out = out.split("${CLAUDE_PLUGIN_ROOT}").join(vars.pluginRoot); |
| 91 | if (vars.projectDir !== void 0) out = out.split("${CLAUDE_PROJECT_DIR}").join(vars.projectDir); |
| 92 | return out; |
| 93 | } |
| 94 | function parseClaudeCodeConfig(raw, vars = {}) { |
| 95 | const config = {}; |
| 96 | const skipped = []; |
| 97 | const root = asObject(raw); |
| 98 | const hooksMap = root ? asObject(root.hooks) ?? root : void 0; |
| 99 | if (!hooksMap) return { config, skipped }; |
| 100 | for (const event of CLAUDE_EVENTS) { |
| 101 | const rawGroups = hooksMap[event]; |
| 102 | if (!Array.isArray(rawGroups)) continue; |
| 103 | const groups = []; |
| 104 | for (const rawGroup of rawGroups) { |
| 105 | const group = asObject(rawGroup); |
| 106 | if (!group || !Array.isArray(group.hooks)) continue; |
| 107 | const commands = []; |
| 108 | for (const rawHook of group.hooks) { |
| 109 | const hook = asObject(rawHook); |
| 110 | if (!hook) continue; |
| 111 | const type = typeof hook.type === "string" ? hook.type : "command"; |
| 112 | if (type !== "command") { |
| 113 | skipped.push({ event, type }); |
| 114 | continue; |
| 115 | } |
| 116 | if (typeof hook.command !== "string") continue; |
| 117 | commands.push({ |
| 118 | command: substituteCommand(hook.command, vars), |
| 119 | ...typeof hook.timeout === "number" ? { timeoutSec: hook.timeout } : {} |
| 120 | }); |
| 121 | } |
| 122 | if (commands.length === 0) continue; |
| 123 | const matcher = event === "UserPromptSubmit" || event === "Stop" ? void 0 : typeof group.matcher === "string" ? group.matcher : void 0; |
| 124 | const diagnostic = matcherDiagnostic(matcher, "claude-code"); |
| 125 | if (diagnostic !== void 0) throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`); |
| 126 | groups.push({ |
| 127 | ...matcher !== void 0 ? { matcher } : {}, |
| 128 | hooks: commands |
| 129 | }); |
| 130 | } |
| 131 | if (groups.length > 0) config[event] = groups; |
| 132 | } |
| 133 | return { config, skipped }; |
| 134 | } |
| 135 | |
| 136 | // src/dsh/upstream/hooks/hooks-codex/src/config.ts |
| 137 | var CODEX_EVENTS = ["PreToolUse", "PostToolUse", "SessionStart", "UserPromptSubmit", "Stop"]; |
| 138 | function asObject2(value) { |
| 139 | return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0; |
| 140 | } |
| 141 | function parseCodexConfig(raw) { |
| 142 | const config = {}; |
| 143 | const skipped = []; |
| 144 | const root = asObject2(raw); |
| 145 | const hooksMap = root ? asObject2(root.hooks) ?? root : void 0; |
| 146 | if (!hooksMap) return { config, skipped }; |
| 147 | for (const event of CODEX_EVENTS) { |
| 148 | const rawGroups = hooksMap[event]; |
| 149 | if (!Array.isArray(rawGroups)) continue; |
| 150 | const groups = []; |
| 151 | for (const rawGroup of rawGroups) { |
| 152 | const group = asObject2(rawGroup); |
| 153 | if (!group || !Array.isArray(group.hooks)) continue; |
| 154 | const commands = []; |
| 155 | for (const rawHook of group.hooks) { |
| 156 | const hook = asObject2(rawHook); |
| 157 | if (!hook) continue; |
| 158 | const type = typeof hook.type === "string" ? hook.type : "command"; |
| 159 | if (type !== "command") { |
| 160 | skipped.push({ event, reason: `unsupported "${type}" hook` }); |
| 161 | continue; |
| 162 | } |
| 163 | if (hook.async === true) { |
| 164 | skipped.push({ event, reason: "async hook" }); |
| 165 | continue; |
| 166 | } |
| 167 | if (typeof hook.command !== "string") continue; |
| 168 | const timeout = typeof hook.timeout === "number" ? hook.timeout : typeof hook.timeoutSec === "number" ? hook.timeoutSec : void 0; |
| 169 | commands.push({ command: hook.command, ...timeout !== void 0 ? { timeoutSec: timeout } : {} }); |
| 170 | } |
| 171 | if (commands.length === 0) continue; |
| 172 | const matcher = event === "UserPromptSubmit" || event === "Stop" ? void 0 : typeof group.matcher === "string" ? group.matcher : void 0; |
| 173 | const diagnostic = matcherDiagnostic(matcher, "codex"); |
| 174 | if (diagnostic !== void 0) throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`); |
| 175 | groups.push({ ...matcher !== void 0 ? { matcher } : {}, hooks: commands }); |
| 176 | } |
| 177 | if (groups.length > 0) config[event] = groups; |
| 178 | } |
| 179 | return { config, skipped }; |
| 180 | } |
| 181 | |
| 182 | // src/dsh/shell-hooks.ts |
| 183 | var EVENTS = { SessionStart: "session_start", UserPromptSubmit: "message_submit", PreToolUse: "tool_call_before", PostToolUse: "tool_call_after", Stop: "turn_end", SubagentStart: "subagent_spawn", SubagentStop: "subagent_complete" }; |
| 184 | function reviewedHookModule(dialect, root, files) { |
| 185 | return { name: `hooks-${dialect}`, inject: ["shellHooks"], apply(ctx, config) { |
| 186 | if (!config || typeof config.configPath !== "string") throw new Error("hook bridge needs its reviewed configPath"); |
| 187 | const path = resolve(root, config.configPath); |
| 188 | const inside = relative(root, path).split(sep).join("/"); |
| 189 | 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"); |
| 190 | const bytes = readFileSync(path); |
| 191 | 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"); |
| 192 | if (config.projectDir !== void 0) throw new Error("explicit projectDir is unsupported; each process uses its current core workspace"); |
| 193 | if (config.pluginRoot !== void 0 && config.pluginRoot !== "." && config.pluginRoot !== root) throw new Error("pluginRoot must be this reviewed bundle root"); |
| 194 | const raw = JSON.parse(bytes.toString("utf8")); |
| 195 | const parsed = dialect === "claude-code" ? parseClaudeCodeConfig(raw, { pluginRoot: root }) : parseCodexConfig(raw); |
| 196 | if (parsed.skipped.length) throw new Error("hook config contains unsupported non-command or asynchronous hooks"); |
| 197 | let count = 0; |
| 198 | for (const [point, groups] of Object.entries(parsed.config)) { |
| 199 | if (point === "Stop") ctx.logger.warn("Stop runs at the core TurnEnd observer; forced continuation is unsupported"); |
| 200 | for (const group of groups) for (const hook of group.hooks) { |
| 201 | if (++count > 128) throw new Error("hook bridge exceeds 128 commands"); |
| 202 | const seconds = hook.timeoutSec ?? (config.defaultTimeoutMs ?? 6e5) / 1e3; |
| 203 | if (!Number.isSafeInteger(seconds) || seconds < 1 || seconds > 86400) throw new Error("hook timeout must be 1–86400 whole seconds"); |
| 204 | ctx.shellHooks.register({ dialect, point, ...group.matcher === void 0 ? {} : { matcher: group.matcher }, hook: { event: EVENTS[point], command: hook.command, timeout_secs: seconds, background: false, continue_on_error: false, name: `${dialect}:${point}:${count}` } }); |
| 205 | } |
| 206 | } |
| 207 | if (count === 0) throw new Error("reviewed hook config has no supported command hooks"); |
| 208 | } }; |
| 209 | } |
| 210 | export { |
| 211 | insideKey, |
| 212 | pathKey, |
| 213 | reviewedHookModule, |
| 214 | samePath, |
| 215 | stripVerbatim, |
| 216 | unlinkedKeyMatches |
| 217 | }; |
| 218 |