impel-cli 0.16.3 → 0.16.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,242 @@
1
+ import fs from "node:fs";
2
+ import crypto from "node:crypto";
3
+ import path from "node:path";
4
+
5
+ import { impelCliInvocation } from "./selfInvocation.js";
6
+
7
+ const MANAGED_MARKER = "--impel-managed-session-hook-v1";
8
+ const CODEX_TRUST_START = "# >>> impel managed session hook trust >>>";
9
+ const CODEX_TRUST_END = "# <<< impel managed session hook trust <<<";
10
+
11
+ export const CLAUDE_SESSION_EVENTS = Object.freeze([
12
+ "SessionStart",
13
+ "UserPromptSubmit",
14
+ "PostToolUse",
15
+ "PostToolUseFailure",
16
+ "SubagentStart",
17
+ "SubagentStop",
18
+ "PreCompact",
19
+ "PostCompact",
20
+ "Stop",
21
+ "StopFailure",
22
+ "SessionEnd",
23
+ ]);
24
+
25
+ export const CODEX_SESSION_EVENTS = Object.freeze([
26
+ "SessionStart",
27
+ "UserPromptSubmit",
28
+ "PostToolUse",
29
+ "SubagentStart",
30
+ "SubagentStop",
31
+ "PreCompact",
32
+ "PostCompact",
33
+ "Stop",
34
+ ]);
35
+
36
+ function ensurePrivateDirectory(directory) {
37
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
38
+ try {
39
+ fs.chmodSync(directory, 0o700);
40
+ } catch {
41
+ // Best effort on platforms where chmod is unavailable.
42
+ }
43
+ }
44
+
45
+ function readJsonObject(filePath) {
46
+ if (!fs.existsSync(filePath)) return {};
47
+ const raw = fs.readFileSync(filePath, "utf8").trim();
48
+ if (!raw) return {};
49
+ try {
50
+ const value = JSON.parse(raw);
51
+ if (!value || Array.isArray(value) || typeof value !== "object") throw new Error();
52
+ return value;
53
+ } catch {
54
+ throw new Error(`${filePath} exists but isn't a valid JSON object. Fix or remove it, then re-run.`);
55
+ }
56
+ }
57
+
58
+ function writePrivateJson(filePath, value) {
59
+ ensurePrivateDirectory(path.dirname(filePath));
60
+ const temporaryPath = `${filePath}.tmp-${process.pid}`;
61
+ try {
62
+ fs.writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
63
+ fs.renameSync(temporaryPath, filePath);
64
+ try {
65
+ fs.chmodSync(filePath, 0o600);
66
+ } catch {
67
+ // Best effort on platforms where chmod is unavailable.
68
+ }
69
+ } finally {
70
+ try {
71
+ fs.rmSync(temporaryPath, { force: true });
72
+ } catch {
73
+ // The successful rename already removed the temporary file.
74
+ }
75
+ }
76
+ }
77
+
78
+ function managedInvocation(provider, surface, tenantId) {
79
+ return impelCliInvocation([
80
+ "sessions",
81
+ "hook",
82
+ "--provider",
83
+ provider,
84
+ "--surface",
85
+ surface,
86
+ "--tenant",
87
+ tenantId,
88
+ MANAGED_MARKER,
89
+ ]);
90
+ }
91
+
92
+ function validTenantId(tenantId) {
93
+ return typeof tenantId === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(tenantId);
94
+ }
95
+
96
+ function handlerIsManaged(handler) {
97
+ if (!handler || typeof handler !== "object") return false;
98
+ if (Array.isArray(handler.args)) return handler.args.includes(MANAGED_MARKER);
99
+ return typeof handler.command === "string" && handler.command.includes(MANAGED_MARKER);
100
+ }
101
+
102
+ function mergeEventHook(existing, event, handler) {
103
+ const groups = Array.isArray(existing?.[event]) ? existing[event] : [];
104
+ const preserved = [];
105
+ for (const group of groups) {
106
+ if (!group || typeof group !== "object") {
107
+ preserved.push(group);
108
+ continue;
109
+ }
110
+ const hooks = Array.isArray(group.hooks)
111
+ ? group.hooks.filter((candidate) => !handlerIsManaged(candidate))
112
+ : [];
113
+ if (hooks.length > 0 || !Array.isArray(group.hooks)) preserved.push({ ...group, hooks });
114
+ }
115
+ preserved.push({ hooks: [handler] });
116
+ return preserved;
117
+ }
118
+
119
+ export function ensureClaudeSessionHooks(configDir, tenantId, surface = "claude_cli") {
120
+ const settingsPath = path.join(configDir, "settings.json");
121
+ if (!validTenantId(tenantId)) return { settingsPath, events: [], skipped: true };
122
+ const settings = readJsonObject(settingsPath);
123
+ const invocation = managedInvocation("claude_code", surface, tenantId);
124
+ const handler = {
125
+ type: "command",
126
+ command: invocation.command,
127
+ args: invocation.args,
128
+ timeout: 15,
129
+ async: true,
130
+ };
131
+ const hooks = settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks)
132
+ ? { ...settings.hooks }
133
+ : {};
134
+ for (const event of CLAUDE_SESSION_EVENTS) hooks[event] = mergeEventHook(hooks, event, handler);
135
+ settings.hooks = hooks;
136
+ writePrivateJson(settingsPath, settings);
137
+ return { settingsPath, events: [...CLAUDE_SESSION_EVENTS] };
138
+ }
139
+
140
+ function shellQuote(value) {
141
+ return `'${String(value).replaceAll("'", `'"'"'`)}'`;
142
+ }
143
+
144
+ function windowsCommandQuote(value) {
145
+ const text = String(value);
146
+ if (/[\r\n\0%]/u.test(text)) throw new Error("cannot safely write this Windows hook command");
147
+ return `"${text.replaceAll('"', '""')}"`;
148
+ }
149
+
150
+ function canonicalize(value) {
151
+ if (Array.isArray(value)) return value.map(canonicalize);
152
+ if (!value || typeof value !== "object") return value;
153
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]));
154
+ }
155
+
156
+ function codexHookHash(event, handler) {
157
+ const command = process.platform === "win32" && handler.commandWindows
158
+ ? handler.commandWindows
159
+ : handler.command;
160
+ const identity = canonicalize({
161
+ event_name: event.replace(/([a-z0-9])([A-Z])/gu, "$1_$2").toLowerCase(),
162
+ hooks: [{
163
+ type: "command",
164
+ command,
165
+ timeout: handler.timeout,
166
+ async: false,
167
+ }],
168
+ });
169
+ return `sha256:${crypto.createHash("sha256").update(JSON.stringify(identity)).digest("hex")}`;
170
+ }
171
+
172
+ function tomlString(value) {
173
+ return JSON.stringify(String(value));
174
+ }
175
+
176
+ function replaceManagedTrustBlock(configPath, entries) {
177
+ const current = fs.existsSync(configPath) ? fs.readFileSync(configPath, "utf8") : "";
178
+ const start = current.indexOf(CODEX_TRUST_START);
179
+ let without = current;
180
+ if (start !== -1) {
181
+ const end = current.indexOf(CODEX_TRUST_END, start);
182
+ if (end === -1) throw new Error(`${configPath} has an incomplete Impel session-hook trust block`);
183
+ without = current.slice(0, start) + current.slice(end + CODEX_TRUST_END.length);
184
+ }
185
+ const block = [CODEX_TRUST_START];
186
+ for (const entry of entries) {
187
+ block.push(
188
+ `[hooks.state.${tomlString(entry.key)}]`,
189
+ `trusted_hash = ${tomlString(entry.hash)}`,
190
+ "",
191
+ );
192
+ }
193
+ block.push(CODEX_TRUST_END);
194
+ const next = `${without.trimEnd()}${without.trim() ? "\n\n" : ""}${block.join("\n")}\n`;
195
+ ensurePrivateDirectory(path.dirname(configPath));
196
+ const temporaryPath = `${configPath}.tmp-${process.pid}`;
197
+ try {
198
+ fs.writeFileSync(temporaryPath, next, { mode: 0o600 });
199
+ fs.renameSync(temporaryPath, configPath);
200
+ try { fs.chmodSync(configPath, 0o600); } catch { /* Best effort on Windows. */ }
201
+ } finally {
202
+ try { fs.rmSync(temporaryPath, { force: true }); } catch { /* Rename removed it. */ }
203
+ }
204
+ }
205
+
206
+ export function ensureCodexSessionHooks(codexHome, tenantId, surface = "codex_cli") {
207
+ const hooksPath = path.join(codexHome, "hooks.json");
208
+ if (!validTenantId(tenantId)) return { hooksPath, events: [], skipped: true };
209
+ const document = readJsonObject(hooksPath);
210
+ const invocation = managedInvocation("codex", surface, tenantId);
211
+ const unixCommand = [invocation.command, ...invocation.args].map(shellQuote).join(" ");
212
+ const windowsTokens = [invocation.command, ...invocation.args].map(windowsCommandQuote).join(" ");
213
+ // Codex executes commandWindows with `cmd.exe /C`. The outer quote preserves
214
+ // a quoted executable path when it contains spaces.
215
+ const windowsCommand = `"${windowsTokens}"`;
216
+ const handler = {
217
+ type: "command",
218
+ command: unixCommand,
219
+ commandWindows: windowsCommand,
220
+ timeout: 15,
221
+ };
222
+ const hooks = document.hooks && typeof document.hooks === "object" && !Array.isArray(document.hooks)
223
+ ? { ...document.hooks }
224
+ : {};
225
+ const trustEntries = [];
226
+ for (const event of CODEX_SESSION_EVENTS) {
227
+ hooks[event] = mergeEventHook(hooks, event, handler);
228
+ const groupIndex = hooks[event].length - 1;
229
+ trustEntries.push({ event, groupIndex, hash: codexHookHash(event, handler) });
230
+ }
231
+ document.hooks = hooks;
232
+ writePrivateJson(hooksPath, document);
233
+ const canonicalHooksPath = fs.realpathSync(hooksPath);
234
+ for (const entry of trustEntries) {
235
+ entry.key = `${canonicalHooksPath}:${entry.event.replace(/([a-z0-9])([A-Z])/gu, "$1_$2").toLowerCase()}:${entry.groupIndex}:0`;
236
+ delete entry.event;
237
+ delete entry.groupIndex;
238
+ }
239
+ const configPath = path.join(codexHome, "config.toml");
240
+ replaceManagedTrustBlock(configPath, trustEntries);
241
+ return { hooksPath, configPath, events: [...CODEX_SESSION_EVENTS], trustEntries };
242
+ }