返回 CodeWhale
shell-hooks.mjs
根目录 / crates / tui / extension-host / dist / shell-hooks.mjs
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
218 lines Plain Text