@hasna/hooks 0.9.0 → 0.9.1
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.
- package/README.md +33 -5
- package/bin/hooks-mcp.js +4862 -0
- package/bin/index.js +283 -109
- package/bin/serve.js +23 -12
- package/dist/db/index.d.ts +8 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +2870 -2757
- package/dist/lib/db-writer.d.ts +30 -1
- package/dist/lib/local-opt-in.d.ts +26 -0
- package/dist/lib/sync.d.ts +17 -1
- package/dist/sdk/index.d.ts +103 -0
- package/dist/sdk/index.js +1050 -0
- package/dist/storage.js +15 -1
- package/hooks/hook-trash-guard/README.md +20 -5
- package/hooks/hook-trash-guard/package.json +1 -1
- package/hooks/hook-trash-guard/src/hook.ts +45 -17
- package/package.json +7 -2
- package/scripts/validate-package.ts +31 -4
package/bin/hooks-mcp.js
ADDED
|
@@ -0,0 +1,4862 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __returnValue = (v) => v;
|
|
5
|
+
function __exportSetter(name, newValue) {
|
|
6
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
7
|
+
}
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, {
|
|
11
|
+
get: all[name],
|
|
12
|
+
enumerable: true,
|
|
13
|
+
configurable: true,
|
|
14
|
+
set: __exportSetter.bind(all, name)
|
|
15
|
+
});
|
|
16
|
+
};
|
|
17
|
+
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
18
|
+
var __require = import.meta.require;
|
|
19
|
+
|
|
20
|
+
// src/lib/app-home.ts
|
|
21
|
+
import { existsSync } from "fs";
|
|
22
|
+
import { homedir } from "os";
|
|
23
|
+
import { join, resolve } from "path";
|
|
24
|
+
import { homedir as pathsResolverHomedir } from "os";
|
|
25
|
+
import { join as pathsResolverJoin } from "path";
|
|
26
|
+
function pathsResolverAssertApp(app) {
|
|
27
|
+
if (typeof app !== "string" || app.length === 0) {
|
|
28
|
+
throw new TypeError("paths: app must be a non-empty string");
|
|
29
|
+
}
|
|
30
|
+
if (!PATHS_RESOLVER_APP_SLUG_RE.test(app)) {
|
|
31
|
+
throw new TypeError(`paths: invalid app slug "${app}" \u2014 expected lowercase kebab-case ([a-z0-9]+(-[a-z0-9]+)*)`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function pathsResolverAssertKind(kind) {
|
|
35
|
+
if (!Object.keys(PATHS_RESOLVER_KIND_ENV).includes(kind)) {
|
|
36
|
+
throw new TypeError(`paths: invalid path kind "${kind}" \u2014 expected one of ${Object.keys(PATHS_RESOLVER_KIND_ENV).join(", ")}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function pathsResolverBaseDir(kind, options) {
|
|
40
|
+
pathsResolverAssertKind(kind);
|
|
41
|
+
const env = options.env ?? process.env;
|
|
42
|
+
const override = env[PATHS_RESOLVER_KIND_ENV[kind]];
|
|
43
|
+
if (typeof override === "string" && override.length > 0)
|
|
44
|
+
return override;
|
|
45
|
+
const home = options.home ?? pathsResolverHomedir();
|
|
46
|
+
const platform = options.platform ?? process.platform;
|
|
47
|
+
if (platform === "darwin") {
|
|
48
|
+
switch (kind) {
|
|
49
|
+
case "config":
|
|
50
|
+
case "data":
|
|
51
|
+
return pathsResolverJoin(home, "Library", "Application Support", "Hasna");
|
|
52
|
+
case "cache":
|
|
53
|
+
return pathsResolverJoin(home, "Library", "Caches", "Hasna");
|
|
54
|
+
case "state":
|
|
55
|
+
return pathsResolverJoin(home, "Library", "Logs", "Hasna");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
switch (kind) {
|
|
59
|
+
case "config":
|
|
60
|
+
return pathsResolverJoin(home, ".config", "hasna");
|
|
61
|
+
case "data":
|
|
62
|
+
return pathsResolverJoin(home, ".local", "share", "hasna");
|
|
63
|
+
case "state":
|
|
64
|
+
return pathsResolverJoin(home, ".local", "state", "hasna");
|
|
65
|
+
case "cache":
|
|
66
|
+
return pathsResolverJoin(home, ".cache", "hasna");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function pathsResolverResolve(kind, options) {
|
|
70
|
+
pathsResolverAssertApp(options.app);
|
|
71
|
+
const appSegment = options.internal === true ? pathsResolverJoin("internal", options.app) : options.app;
|
|
72
|
+
return pathsResolverJoin(pathsResolverBaseDir(kind, options), appSegment);
|
|
73
|
+
}
|
|
74
|
+
function dataDir(options) {
|
|
75
|
+
return pathsResolverResolve("data", options);
|
|
76
|
+
}
|
|
77
|
+
function getHomeDir(env = process.env) {
|
|
78
|
+
return env.HOME || env.USERPROFILE || homedir();
|
|
79
|
+
}
|
|
80
|
+
function getResolverDataRoot(env = process.env) {
|
|
81
|
+
return dataDir({ app: "hooks", home: getHomeDir(env), env });
|
|
82
|
+
}
|
|
83
|
+
function getLegacyDataRoot(env = process.env) {
|
|
84
|
+
return join(getHomeDir(env), ".hasna", "hooks");
|
|
85
|
+
}
|
|
86
|
+
function adoptResolverDataRoot(resolved, env = process.env) {
|
|
87
|
+
const dataOverride = env.HASNA_DATA_HOME;
|
|
88
|
+
if (typeof dataOverride === "string" && dataOverride.trim().length > 0)
|
|
89
|
+
return true;
|
|
90
|
+
return existsSync(join(resolved, STORE_MARKER));
|
|
91
|
+
}
|
|
92
|
+
function getExplicitDataDir(env = process.env) {
|
|
93
|
+
const dir = env.HASNA_HOOKS_DATA_DIR ?? env.HOOKS_DATA_DIR;
|
|
94
|
+
if (typeof dir === "string" && dir.trim().length > 0)
|
|
95
|
+
return dir.trim();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
function getExactDataRoot(env = process.env) {
|
|
99
|
+
const dir = env.HASNA_HOOKS_HOME?.trim() || env.HOOKS_HOME?.trim();
|
|
100
|
+
if (dir)
|
|
101
|
+
return resolve(dir);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
function getEffectiveDataRoot(env = process.env) {
|
|
105
|
+
const explicit = getExplicitDataDir(env);
|
|
106
|
+
if (explicit)
|
|
107
|
+
return explicit;
|
|
108
|
+
const exact = getExactDataRoot(env);
|
|
109
|
+
if (exact)
|
|
110
|
+
return exact;
|
|
111
|
+
const resolved = getResolverDataRoot(env);
|
|
112
|
+
return adoptResolverDataRoot(resolved, env) ? resolve(resolved) : getLegacyDataRoot(env);
|
|
113
|
+
}
|
|
114
|
+
function getReportedDbPath(env = process.env) {
|
|
115
|
+
const explicit = getExplicitDbPath(env);
|
|
116
|
+
if (explicit)
|
|
117
|
+
return explicit;
|
|
118
|
+
return join(getEffectiveDataRoot(env), "hooks.db");
|
|
119
|
+
}
|
|
120
|
+
function getExplicitDbPath(env = process.env) {
|
|
121
|
+
const db = env.HASNA_HOOKS_DB_PATH ?? env.HOOKS_DB_PATH;
|
|
122
|
+
if (typeof db === "string" && db.trim().length > 0)
|
|
123
|
+
return db.trim();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
var PATHS_RESOLVER_KIND_ENV, PATHS_RESOLVER_APP_SLUG_RE, STORE_MARKER = "hooks.db";
|
|
127
|
+
var init_app_home = __esm(() => {
|
|
128
|
+
PATHS_RESOLVER_KIND_ENV = {
|
|
129
|
+
config: "HASNA_CONFIG_HOME",
|
|
130
|
+
data: "HASNA_DATA_HOME",
|
|
131
|
+
state: "HASNA_STATE_HOME",
|
|
132
|
+
cache: "HASNA_CACHE_HOME"
|
|
133
|
+
};
|
|
134
|
+
PATHS_RESOLVER_APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// src/lib/registry.ts
|
|
138
|
+
function getHooksByCategory(category) {
|
|
139
|
+
return HOOKS.filter((h) => h.category === category);
|
|
140
|
+
}
|
|
141
|
+
function getHookEvents(hook) {
|
|
142
|
+
return hook.events && hook.events.length > 0 ? hook.events : [hook.event];
|
|
143
|
+
}
|
|
144
|
+
function searchHooks(query) {
|
|
145
|
+
const q = query.toLowerCase();
|
|
146
|
+
return HOOKS.filter((h) => h.name.toLowerCase().includes(q) || h.displayName.toLowerCase().includes(q) || h.description.toLowerCase().includes(q) || h.tags.some((t) => t.includes(q)));
|
|
147
|
+
}
|
|
148
|
+
function getHook(name) {
|
|
149
|
+
return HOOKS.find((h) => h.name === name);
|
|
150
|
+
}
|
|
151
|
+
var CATEGORIES, HOOKS;
|
|
152
|
+
var init_registry = __esm(() => {
|
|
153
|
+
init_app_home();
|
|
154
|
+
CATEGORIES = [
|
|
155
|
+
"Git Safety",
|
|
156
|
+
"Code Quality",
|
|
157
|
+
"Security",
|
|
158
|
+
"Notifications",
|
|
159
|
+
"Context Management",
|
|
160
|
+
"Workflow Automation",
|
|
161
|
+
"Environment",
|
|
162
|
+
"Permissions",
|
|
163
|
+
"Observability",
|
|
164
|
+
"Agent Teams"
|
|
165
|
+
];
|
|
166
|
+
HOOKS = [
|
|
167
|
+
{
|
|
168
|
+
name: "gitguard",
|
|
169
|
+
displayName: "Git Guard",
|
|
170
|
+
description: "Blocks destructive git operations like reset --hard, push --force, clean -f",
|
|
171
|
+
version: "0.1.0",
|
|
172
|
+
category: "Git Safety",
|
|
173
|
+
event: "PreToolUse",
|
|
174
|
+
matcher: "Bash",
|
|
175
|
+
tags: ["git", "safety", "destructive", "guard"]
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
name: "branchprotect",
|
|
179
|
+
displayName: "Branch Protect",
|
|
180
|
+
description: "Prevents editing files directly on main/master branch",
|
|
181
|
+
version: "0.1.0",
|
|
182
|
+
category: "Git Safety",
|
|
183
|
+
event: "PreToolUse",
|
|
184
|
+
matcher: "Write|Edit|NotebookEdit",
|
|
185
|
+
tags: ["git", "branch", "protection", "main"]
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
name: "checkpoint",
|
|
189
|
+
displayName: "Checkpoint",
|
|
190
|
+
description: "Creates shadow git snapshots before file modifications for easy rollback",
|
|
191
|
+
version: "0.1.0",
|
|
192
|
+
category: "Git Safety",
|
|
193
|
+
event: "PreToolUse",
|
|
194
|
+
matcher: "Write|Edit|NotebookEdit",
|
|
195
|
+
tags: ["git", "snapshot", "rollback", "backup"]
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
name: "worktree-guard",
|
|
199
|
+
displayName: "Worktree Guard",
|
|
200
|
+
description: "Warns on feature work outside managed repos worktrees and blocks scoped destructive operations",
|
|
201
|
+
version: "0.1.0",
|
|
202
|
+
category: "Git Safety",
|
|
203
|
+
event: "PreToolUse",
|
|
204
|
+
matcher: "^(Bash|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch|mcp__.*)$",
|
|
205
|
+
tags: ["git", "worktree", "repos", "multi-agent", "safety", "dangerous-ops"]
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
name: "workspace-repos-guard",
|
|
209
|
+
displayName: "Workspace Repos Guard",
|
|
210
|
+
description: "Blocks writes and deletes that break the protected repo-checkout structure (~/.hasna/repos/clones canonical, plus legacy ~/workspace/repos during the migration window): writes to a protected root, top-level entry creation, non-org folders, and any delete under a protected root",
|
|
211
|
+
version: "0.1.0",
|
|
212
|
+
category: "Git Safety",
|
|
213
|
+
event: "PreToolUse",
|
|
214
|
+
matcher: "^(Bash|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch)$",
|
|
215
|
+
tags: ["workspace", "repos", "structure", "guard", "safety", "orgs", "multi-agent"]
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
name: "trash-guard",
|
|
219
|
+
displayName: "Trash Guard",
|
|
220
|
+
description: "Rewrites rm issued through Bash into `trash guard`, so the delete lands in a recoverable trash store; refuses the delete when there is nothing to redirect to (trash absent, a delete verb it cannot rewrite, or the protected class: /, ~, ~/.hasna, ~/.ssh, ~/.aws)",
|
|
221
|
+
version: "0.1.1",
|
|
222
|
+
category: "Git Safety",
|
|
223
|
+
event: "PreToolUse",
|
|
224
|
+
matcher: "^(Bash|apply_patch|ApplyPatch|functions\\.apply_patch)$",
|
|
225
|
+
tags: ["rm", "delete", "trash", "recoverable", "guard", "safety", "bash"],
|
|
226
|
+
rewritesInput: true,
|
|
227
|
+
timeoutSeconds: 5
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
name: "checktests",
|
|
231
|
+
displayName: "Check Tests",
|
|
232
|
+
description: "Checks for missing tests after file edits",
|
|
233
|
+
version: "0.1.6",
|
|
234
|
+
category: "Code Quality",
|
|
235
|
+
event: "PostToolUse",
|
|
236
|
+
matcher: "Edit|Write|NotebookEdit",
|
|
237
|
+
tags: ["tests", "coverage", "quality"]
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
name: "checklint",
|
|
241
|
+
displayName: "Check Lint",
|
|
242
|
+
description: "Runs linting after file edits and creates tasks for errors",
|
|
243
|
+
version: "0.1.7",
|
|
244
|
+
category: "Code Quality",
|
|
245
|
+
event: "PostToolUse",
|
|
246
|
+
matcher: "Edit|Write|NotebookEdit",
|
|
247
|
+
tags: ["lint", "style", "quality"]
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
name: "checkfiles",
|
|
251
|
+
displayName: "Check Files",
|
|
252
|
+
description: "Runs headless agent to review files and create tasks",
|
|
253
|
+
version: "0.1.4",
|
|
254
|
+
category: "Code Quality",
|
|
255
|
+
event: "PostToolUse",
|
|
256
|
+
matcher: "Edit|Write|NotebookEdit",
|
|
257
|
+
tags: ["review", "files", "quality"]
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
name: "checkbugs",
|
|
261
|
+
displayName: "Check Bugs",
|
|
262
|
+
description: "Checks for bugs via headless agent",
|
|
263
|
+
version: "0.1.6",
|
|
264
|
+
category: "Code Quality",
|
|
265
|
+
event: "PostToolUse",
|
|
266
|
+
matcher: "Edit|Write|NotebookEdit",
|
|
267
|
+
tags: ["bugs", "analysis", "quality"]
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
name: "checkdocs",
|
|
271
|
+
displayName: "Check Docs",
|
|
272
|
+
description: "Checks for missing documentation and creates tasks",
|
|
273
|
+
version: "0.2.1",
|
|
274
|
+
category: "Code Quality",
|
|
275
|
+
event: "PostToolUse",
|
|
276
|
+
matcher: "Edit|Write|NotebookEdit",
|
|
277
|
+
tags: ["docs", "documentation", "quality"]
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
name: "checktasks",
|
|
281
|
+
displayName: "Check Tasks",
|
|
282
|
+
description: "Validates task completion and tracks progress",
|
|
283
|
+
version: "1.0.8",
|
|
284
|
+
category: "Code Quality",
|
|
285
|
+
event: "PostToolUse",
|
|
286
|
+
matcher: "Edit|Write|NotebookEdit",
|
|
287
|
+
tags: ["tasks", "tracking", "quality"]
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
name: "checksecurity",
|
|
291
|
+
displayName: "Check Security",
|
|
292
|
+
description: "Runs security checks via headless agents",
|
|
293
|
+
version: "0.1.6",
|
|
294
|
+
category: "Security",
|
|
295
|
+
event: "PostToolUse",
|
|
296
|
+
matcher: "Edit|Write|NotebookEdit",
|
|
297
|
+
tags: ["security", "audit", "vulnerabilities"]
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
name: "packageage",
|
|
301
|
+
displayName: "Package Age",
|
|
302
|
+
description: "Checks package age before install to prevent typosquatting",
|
|
303
|
+
version: "0.1.1",
|
|
304
|
+
category: "Security",
|
|
305
|
+
event: "PreToolUse",
|
|
306
|
+
matcher: "Bash",
|
|
307
|
+
tags: ["npm", "packages", "typosquatting", "supply-chain"]
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
name: "pre-bash",
|
|
311
|
+
displayName: "Pre Bash",
|
|
312
|
+
description: "Codewith Bash gate for staged secrets scans before commit/push and comms rechecks before risky operations",
|
|
313
|
+
version: "0.1.0",
|
|
314
|
+
category: "Security",
|
|
315
|
+
event: "PreToolUse",
|
|
316
|
+
matcher: "Bash",
|
|
317
|
+
tags: ["codewith", "bash", "secrets", "gitleaks", "risky-ops"]
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
name: "scanoutput",
|
|
321
|
+
displayName: "Scan Output",
|
|
322
|
+
description: "Scans tool output for credential shapes and warns. Detection only \u2014 the output is already persisted when this runs; it cannot redact or prevent",
|
|
323
|
+
version: "0.1.0",
|
|
324
|
+
category: "Security",
|
|
325
|
+
event: "PostToolUse",
|
|
326
|
+
matcher: "Bash",
|
|
327
|
+
tags: ["security", "credentials", "secrets", "detection", "audit"]
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
name: "phonenotify",
|
|
331
|
+
displayName: "Phone Notify",
|
|
332
|
+
description: "Sends push notifications to phone via ntfy.sh",
|
|
333
|
+
version: "0.1.0",
|
|
334
|
+
category: "Notifications",
|
|
335
|
+
event: "Stop",
|
|
336
|
+
matcher: "",
|
|
337
|
+
tags: ["notification", "phone", "push", "ntfy"]
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
name: "agentmessages",
|
|
341
|
+
displayName: "Agent Messages",
|
|
342
|
+
description: "Inter-agent messaging integration for service-message",
|
|
343
|
+
version: "0.1.0",
|
|
344
|
+
category: "Notifications",
|
|
345
|
+
event: "Stop",
|
|
346
|
+
matcher: "",
|
|
347
|
+
tags: ["messaging", "agents", "inter-agent"]
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
name: "contextrefresh",
|
|
351
|
+
displayName: "Context Refresh",
|
|
352
|
+
description: "Re-injects important context every N prompts to prevent drift",
|
|
353
|
+
version: "0.1.0",
|
|
354
|
+
category: "Context Management",
|
|
355
|
+
event: "Notification",
|
|
356
|
+
matcher: "",
|
|
357
|
+
tags: ["context", "memory", "prompts", "refresh"]
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
name: "precompact",
|
|
361
|
+
displayName: "Pre-Compact",
|
|
362
|
+
description: "Saves session state before context compaction",
|
|
363
|
+
version: "0.1.0",
|
|
364
|
+
category: "Context Management",
|
|
365
|
+
event: "Notification",
|
|
366
|
+
matcher: "",
|
|
367
|
+
tags: ["context", "compaction", "state", "backup"]
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
name: "knowledge-context",
|
|
371
|
+
displayName: "Knowledge Context",
|
|
372
|
+
description: "Injects deterministic Knowledge context packs into Codewith SessionStart, UserPromptSubmit, and SubagentStart",
|
|
373
|
+
version: "0.1.6",
|
|
374
|
+
category: "Context Management",
|
|
375
|
+
event: "SessionStart",
|
|
376
|
+
events: ["SessionStart", "UserPromptSubmit", "SubagentStart"],
|
|
377
|
+
matcher: "",
|
|
378
|
+
tags: ["knowledge", "context", "codewith", "deterministic", "session-start", "prompt", "subagent"]
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
name: "autoformat",
|
|
382
|
+
displayName: "Auto Format",
|
|
383
|
+
description: "Runs project formatter (Prettier, Biome, Ruff, Black, gofmt) after file edits",
|
|
384
|
+
version: "0.1.0",
|
|
385
|
+
category: "Workflow Automation",
|
|
386
|
+
event: "PostToolUse",
|
|
387
|
+
matcher: "Edit|Write",
|
|
388
|
+
tags: ["format", "prettier", "biome", "ruff", "black", "gofmt", "style"]
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
name: "autostage",
|
|
392
|
+
displayName: "Auto Stage",
|
|
393
|
+
description: "Automatically git-stages files after Claude edits them",
|
|
394
|
+
version: "0.1.0",
|
|
395
|
+
category: "Workflow Automation",
|
|
396
|
+
event: "PostToolUse",
|
|
397
|
+
matcher: "Edit|Write",
|
|
398
|
+
tags: ["git", "stage", "add", "auto"]
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
name: "tddguard",
|
|
402
|
+
displayName: "TDD Guard",
|
|
403
|
+
description: "Blocks implementation edits unless corresponding test files exist",
|
|
404
|
+
version: "0.1.0",
|
|
405
|
+
category: "Workflow Automation",
|
|
406
|
+
event: "PreToolUse",
|
|
407
|
+
matcher: "Edit|Write",
|
|
408
|
+
tags: ["tdd", "tests", "red-green-refactor", "enforcement"]
|
|
409
|
+
},
|
|
410
|
+
{
|
|
411
|
+
name: "envsetup",
|
|
412
|
+
displayName: "Env Setup",
|
|
413
|
+
description: "Warns when nvm, virtualenv, asdf, or rbenv may need activation before commands",
|
|
414
|
+
version: "0.1.0",
|
|
415
|
+
category: "Environment",
|
|
416
|
+
event: "PreToolUse",
|
|
417
|
+
matcher: "Bash",
|
|
418
|
+
tags: ["nvm", "virtualenv", "asdf", "rbenv", "environment", "python", "node"]
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
name: "permissionguard",
|
|
422
|
+
displayName: "Permission Guard",
|
|
423
|
+
description: "Auto-approves safe read-only commands and blocks dangerous operations",
|
|
424
|
+
version: "0.1.0",
|
|
425
|
+
category: "Permissions",
|
|
426
|
+
event: "PreToolUse",
|
|
427
|
+
matcher: "Bash",
|
|
428
|
+
tags: ["permission", "allowlist", "blocklist", "safety", "auto-approve"]
|
|
429
|
+
},
|
|
430
|
+
{
|
|
431
|
+
name: "protectfiles",
|
|
432
|
+
displayName: "Protect Files",
|
|
433
|
+
description: "Blocks access to .env, secrets, SSH keys, and lock files",
|
|
434
|
+
version: "0.1.0",
|
|
435
|
+
category: "Permissions",
|
|
436
|
+
event: "PreToolUse",
|
|
437
|
+
matcher: "Edit|Write|Read|Bash",
|
|
438
|
+
tags: ["security", "env", "secrets", "keys", "lock-files", "protect"]
|
|
439
|
+
},
|
|
440
|
+
{
|
|
441
|
+
name: "promptguard",
|
|
442
|
+
displayName: "Prompt Guard",
|
|
443
|
+
description: "Blocks prompt injection attempts and credential access requests",
|
|
444
|
+
version: "0.1.0",
|
|
445
|
+
category: "Permissions",
|
|
446
|
+
event: "PreToolUse",
|
|
447
|
+
matcher: "",
|
|
448
|
+
tags: ["prompt", "injection", "security", "validation", "guard"]
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
name: "prompt-guard",
|
|
452
|
+
displayName: "Prompt Guard (Codewith)",
|
|
453
|
+
description: "Codewith UserPromptSubmit guard for obvious pasted prompt-injection and fake policy/freeze instructions",
|
|
454
|
+
version: "0.1.0",
|
|
455
|
+
category: "Permissions",
|
|
456
|
+
event: "UserPromptSubmit",
|
|
457
|
+
matcher: "",
|
|
458
|
+
tags: ["codewith", "prompt", "injection", "policy", "freeze"]
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
name: "desktopnotify",
|
|
462
|
+
displayName: "Desktop Notify",
|
|
463
|
+
description: "Sends native desktop notifications via osascript (macOS) or notify-send (Linux)",
|
|
464
|
+
version: "0.1.0",
|
|
465
|
+
category: "Notifications",
|
|
466
|
+
event: "Stop",
|
|
467
|
+
matcher: "",
|
|
468
|
+
tags: ["notification", "desktop", "macos", "linux", "native"]
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
name: "slacknotify",
|
|
472
|
+
displayName: "Slack Notify",
|
|
473
|
+
description: "Sends Slack webhook notifications when Claude finishes",
|
|
474
|
+
version: "0.1.0",
|
|
475
|
+
category: "Notifications",
|
|
476
|
+
event: "Stop",
|
|
477
|
+
matcher: "",
|
|
478
|
+
tags: ["notification", "slack", "webhook", "team"]
|
|
479
|
+
},
|
|
480
|
+
{
|
|
481
|
+
name: "soundnotify",
|
|
482
|
+
displayName: "Sound Notify",
|
|
483
|
+
description: "Plays a system sound when Claude finishes (macOS/Linux)",
|
|
484
|
+
version: "0.1.0",
|
|
485
|
+
category: "Notifications",
|
|
486
|
+
event: "Stop",
|
|
487
|
+
matcher: "",
|
|
488
|
+
tags: ["notification", "sound", "audio", "alert"]
|
|
489
|
+
},
|
|
490
|
+
{
|
|
491
|
+
name: "sessionlog",
|
|
492
|
+
displayName: "Session Log",
|
|
493
|
+
description: `Logs every tool call to SQLite (${getReportedDbPath()})`,
|
|
494
|
+
version: "0.1.0",
|
|
495
|
+
category: "Observability",
|
|
496
|
+
event: "PostToolUse",
|
|
497
|
+
matcher: "",
|
|
498
|
+
tags: ["logging", "audit", "session", "history", "jsonl"]
|
|
499
|
+
},
|
|
500
|
+
{
|
|
501
|
+
name: "commandlog",
|
|
502
|
+
displayName: "Command Log",
|
|
503
|
+
description: `Logs every Bash command to SQLite (${getReportedDbPath()})`,
|
|
504
|
+
version: "0.1.0",
|
|
505
|
+
category: "Observability",
|
|
506
|
+
event: "PostToolUse",
|
|
507
|
+
matcher: "Bash",
|
|
508
|
+
tags: ["logging", "bash", "commands", "audit"]
|
|
509
|
+
},
|
|
510
|
+
{
|
|
511
|
+
name: "costwatch",
|
|
512
|
+
displayName: "Cost Watch",
|
|
513
|
+
description: "Estimates session token usage, persists cost history to SQLite, and warns on budget overrun",
|
|
514
|
+
version: "0.1.0",
|
|
515
|
+
category: "Observability",
|
|
516
|
+
event: "Stop",
|
|
517
|
+
matcher: "",
|
|
518
|
+
tags: ["cost", "tokens", "budget", "usage", "monitoring"]
|
|
519
|
+
},
|
|
520
|
+
{
|
|
521
|
+
name: "errornotify",
|
|
522
|
+
displayName: "Error Notify",
|
|
523
|
+
description: `Detects tool failures and logs errors to SQLite (${getReportedDbPath()})`,
|
|
524
|
+
version: "0.1.0",
|
|
525
|
+
category: "Observability",
|
|
526
|
+
event: "PostToolUse",
|
|
527
|
+
matcher: "",
|
|
528
|
+
tags: ["errors", "failures", "logging", "debugging"]
|
|
529
|
+
},
|
|
530
|
+
{
|
|
531
|
+
name: "spiral-detector",
|
|
532
|
+
displayName: "Spiral Detector",
|
|
533
|
+
description: "Interrupts sessions after five consecutive identical command failures",
|
|
534
|
+
version: "0.1.0",
|
|
535
|
+
category: "Observability",
|
|
536
|
+
event: "PostToolUse",
|
|
537
|
+
matcher: "",
|
|
538
|
+
tags: ["spiral", "failures", "retries", "safety", "interrupt"]
|
|
539
|
+
},
|
|
540
|
+
{
|
|
541
|
+
name: "stylescheck",
|
|
542
|
+
displayName: "Styles Check",
|
|
543
|
+
description: "Blocks frontend files with design anti-patterns: hardcoded colors, magic font sizes, inline styles",
|
|
544
|
+
version: "0.1.0",
|
|
545
|
+
category: "Code Quality",
|
|
546
|
+
event: "PreToolUse",
|
|
547
|
+
matcher: "Write|Edit",
|
|
548
|
+
tags: ["design", "styles", "frontend", "css", "tailwind", "design-system", "anti-patterns"]
|
|
549
|
+
},
|
|
550
|
+
{
|
|
551
|
+
name: "taskgate",
|
|
552
|
+
displayName: "Task Gate",
|
|
553
|
+
description: "Validates task completion criteria before allowing tasks to be marked done",
|
|
554
|
+
version: "0.1.0",
|
|
555
|
+
category: "Agent Teams",
|
|
556
|
+
event: "PostToolUse",
|
|
557
|
+
matcher: "",
|
|
558
|
+
tags: ["tasks", "completion", "gate", "quality", "agent-teams"]
|
|
559
|
+
},
|
|
560
|
+
{
|
|
561
|
+
name: "session-start",
|
|
562
|
+
displayName: "Session Start",
|
|
563
|
+
description: "Codewith SessionStart digest for blockers, announcements, identity registration, and heartbeat context",
|
|
564
|
+
version: "0.1.0",
|
|
565
|
+
category: "Agent Teams",
|
|
566
|
+
event: "SessionStart",
|
|
567
|
+
matcher: "",
|
|
568
|
+
tags: ["codewith", "session", "context", "conversations", "heartbeat"]
|
|
569
|
+
},
|
|
570
|
+
{
|
|
571
|
+
name: "stop-sync",
|
|
572
|
+
displayName: "Stop Sync",
|
|
573
|
+
description: "Codewith Stop turn-end heartbeat and best-effort task evidence helper",
|
|
574
|
+
version: "0.1.0",
|
|
575
|
+
category: "Agent Teams",
|
|
576
|
+
event: "Stop",
|
|
577
|
+
matcher: "",
|
|
578
|
+
tags: ["codewith", "stop", "heartbeat", "todos", "turn-end"]
|
|
579
|
+
},
|
|
580
|
+
{
|
|
581
|
+
name: "typecheck-gate",
|
|
582
|
+
displayName: "Typecheck Gate",
|
|
583
|
+
description: "Runs TypeScript type checking on Stop and blocks if type errors are found",
|
|
584
|
+
version: "0.1.0",
|
|
585
|
+
category: "Code Quality",
|
|
586
|
+
event: "Stop",
|
|
587
|
+
matcher: "",
|
|
588
|
+
tags: ["typescript", "typecheck", "types", "quality", "gate"]
|
|
589
|
+
},
|
|
590
|
+
{
|
|
591
|
+
name: "affected-tests",
|
|
592
|
+
displayName: "Affected Tests",
|
|
593
|
+
description: "Maps edited files to their test files and runs them automatically after edits",
|
|
594
|
+
version: "0.1.0",
|
|
595
|
+
category: "Code Quality",
|
|
596
|
+
event: "PostToolUse",
|
|
597
|
+
matcher: "Edit|Write|NotebookEdit",
|
|
598
|
+
tags: ["tests", "test-runner", "affected", "quality"]
|
|
599
|
+
},
|
|
600
|
+
{
|
|
601
|
+
name: "conflict-detect",
|
|
602
|
+
displayName: "Conflict Detect",
|
|
603
|
+
description: "Blocks edits on files with unresolved git merge conflict markers",
|
|
604
|
+
version: "0.1.0",
|
|
605
|
+
category: "Git Safety",
|
|
606
|
+
event: "PreToolUse",
|
|
607
|
+
matcher: "Edit|Write",
|
|
608
|
+
tags: ["git", "conflicts", "merge", "safety"]
|
|
609
|
+
},
|
|
610
|
+
{
|
|
611
|
+
name: "failure-to-task",
|
|
612
|
+
displayName: "Failure to Task",
|
|
613
|
+
description: "Creates a todo task automatically when test or build commands fail",
|
|
614
|
+
version: "0.1.0",
|
|
615
|
+
category: "Workflow Automation",
|
|
616
|
+
event: "PostToolUse",
|
|
617
|
+
matcher: "Bash",
|
|
618
|
+
tags: ["failures", "tasks", "tests", "build", "automation"]
|
|
619
|
+
},
|
|
620
|
+
{
|
|
621
|
+
name: "filelock",
|
|
622
|
+
displayName: "File Lock",
|
|
623
|
+
description: "Auto-checks file locks before edits to prevent multi-agent conflicts",
|
|
624
|
+
version: "0.1.0",
|
|
625
|
+
category: "Agent Teams",
|
|
626
|
+
event: "PreToolUse",
|
|
627
|
+
matcher: "Edit|Write|NotebookEdit",
|
|
628
|
+
tags: ["locking", "coordination", "multi-agent", "conflict"]
|
|
629
|
+
},
|
|
630
|
+
{
|
|
631
|
+
name: "announce-start",
|
|
632
|
+
displayName: "Announce Start",
|
|
633
|
+
description: "On SessionStart, auto-registers agent, reads messages, and announces to space",
|
|
634
|
+
version: "0.2.0",
|
|
635
|
+
category: "Agent Teams",
|
|
636
|
+
event: "SessionStart",
|
|
637
|
+
matcher: "",
|
|
638
|
+
tags: ["announcement", "start", "register", "messages", "agent-teams"]
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
name: "announce-stop",
|
|
642
|
+
displayName: "Announce Stop",
|
|
643
|
+
description: "On Stop, releases file locks, posts session summary, and updates task statuses",
|
|
644
|
+
version: "0.1.0",
|
|
645
|
+
category: "Agent Teams",
|
|
646
|
+
event: "Stop",
|
|
647
|
+
matcher: "",
|
|
648
|
+
tags: ["announcement", "stop", "locks", "summary", "agent-teams"]
|
|
649
|
+
},
|
|
650
|
+
{
|
|
651
|
+
name: "dm-inject",
|
|
652
|
+
displayName: "DM Inject",
|
|
653
|
+
description: "Injects unread direct messages into agent context on Notification events",
|
|
654
|
+
version: "0.1.0",
|
|
655
|
+
category: "Agent Teams",
|
|
656
|
+
event: "Notification",
|
|
657
|
+
matcher: "",
|
|
658
|
+
tags: ["messages", "dm", "context", "inject", "agent-teams"]
|
|
659
|
+
},
|
|
660
|
+
{
|
|
661
|
+
name: "fleet-catchup",
|
|
662
|
+
displayName: "Fleet Catchup",
|
|
663
|
+
description: "On SessionStart, injects unread blockers, channel notifications, and the announcements digest into context (deterministic CLI reads, fail-open)",
|
|
664
|
+
version: "0.1.0",
|
|
665
|
+
category: "Agent Teams",
|
|
666
|
+
event: "SessionStart",
|
|
667
|
+
matcher: "",
|
|
668
|
+
tags: ["fleet", "catchup", "blockers", "announcements", "context", "agent-teams"]
|
|
669
|
+
},
|
|
670
|
+
{
|
|
671
|
+
name: "agent-rules-version-check",
|
|
672
|
+
displayName: "Agent Rules Version Check",
|
|
673
|
+
description: "On SessionStart, compares the rendered hasna:agent-operating-rules sentinel version against configs state and warns on mismatch",
|
|
674
|
+
version: "0.1.0",
|
|
675
|
+
category: "Agent Teams",
|
|
676
|
+
event: "SessionStart",
|
|
677
|
+
matcher: "",
|
|
678
|
+
tags: ["fleet", "rules", "version", "sentinel", "configs", "drift", "agent-teams"]
|
|
679
|
+
},
|
|
680
|
+
{
|
|
681
|
+
name: "fleet-blockers-gate",
|
|
682
|
+
displayName: "Fleet Blockers Gate",
|
|
683
|
+
description: "Before tool calls, blocks mutating tools while an unread [FREEZE] blocker is active (TTL-cached conversations check, fail-open <500ms)",
|
|
684
|
+
version: "0.1.0",
|
|
685
|
+
category: "Agent Teams",
|
|
686
|
+
event: "PreToolUse",
|
|
687
|
+
matcher: "",
|
|
688
|
+
tags: ["fleet", "freeze", "blockers", "gate", "safety", "agent-teams"]
|
|
689
|
+
}
|
|
690
|
+
];
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
// src/config.ts
|
|
694
|
+
import { join as join2 } from "path";
|
|
695
|
+
function getHooksDataDir() {
|
|
696
|
+
return getEffectiveDataRoot();
|
|
697
|
+
}
|
|
698
|
+
function getCustomHooksDir() {
|
|
699
|
+
return join2(getHooksDataDir(), "hooks");
|
|
700
|
+
}
|
|
701
|
+
function getLockPath() {
|
|
702
|
+
const explicit = process.env.HASNA_HOOKS_LOCK_PATH ?? process.env.HOOKS_LOCK_PATH;
|
|
703
|
+
if (explicit)
|
|
704
|
+
return explicit;
|
|
705
|
+
return join2(getHooksDataDir(), "hooks.lock");
|
|
706
|
+
}
|
|
707
|
+
var init_config = __esm(() => {
|
|
708
|
+
init_app_home();
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
// src/lib/semver.ts
|
|
712
|
+
var NUMERIC_ID = "(0|[1-9]\\d{0,15})", ALNUM_ID = "\\d*[A-Za-z-][0-9A-Za-z-]*", SEMVER_PATTERN;
|
|
713
|
+
var init_semver = __esm(() => {
|
|
714
|
+
SEMVER_PATTERN = new RegExp(`^${NUMERIC_ID}\\.${NUMERIC_ID}\\.${NUMERIC_ID}` + `(?:-(?:${NUMERIC_ID}|${ALNUM_ID})(?:\\.(?:${NUMERIC_ID}|${ALNUM_ID}))*)?` + `(?:\\+[0-9A-Za-z.-]+)?$`);
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
// src/lib/manifest.ts
|
|
718
|
+
import { existsSync as existsSync2, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "fs";
|
|
719
|
+
import { basename, dirname, isAbsolute, join as join3, normalize, resolve as resolve2, sep } from "path";
|
|
720
|
+
import { z } from "zod";
|
|
721
|
+
function parseManifest(json) {
|
|
722
|
+
const parsed = JSON.parse(json);
|
|
723
|
+
return manifestSchema.parse(parsed);
|
|
724
|
+
}
|
|
725
|
+
function shortManifestName(name) {
|
|
726
|
+
return name.startsWith("hook-") ? name.slice("hook-".length) : name;
|
|
727
|
+
}
|
|
728
|
+
function isWithin(child, parent) {
|
|
729
|
+
return child === parent || child.startsWith(parent + sep);
|
|
730
|
+
}
|
|
731
|
+
function assertContainedLexical(target, root, raw) {
|
|
732
|
+
const lexical = resolve2(target);
|
|
733
|
+
const rootLexical = resolve2(root);
|
|
734
|
+
if (!isWithin(lexical, rootLexical)) {
|
|
735
|
+
throw new ScriptContainmentError(target, root, raw);
|
|
736
|
+
}
|
|
737
|
+
return lexical;
|
|
738
|
+
}
|
|
739
|
+
function realpathNearest(path) {
|
|
740
|
+
const tail = [];
|
|
741
|
+
let cur = path;
|
|
742
|
+
while (!existsSync2(cur)) {
|
|
743
|
+
const parent = dirname(cur);
|
|
744
|
+
if (parent === cur)
|
|
745
|
+
break;
|
|
746
|
+
tail.unshift(basename(cur));
|
|
747
|
+
cur = parent;
|
|
748
|
+
}
|
|
749
|
+
return join3(realpathSync(cur), ...tail);
|
|
750
|
+
}
|
|
751
|
+
function assertContainedReal(root, target, raw) {
|
|
752
|
+
const rootReal = realpathSync(root);
|
|
753
|
+
const targetReal = realpathNearest(target);
|
|
754
|
+
if (!isWithin(targetReal, rootReal)) {
|
|
755
|
+
throw new ScriptContainmentError(target, root, raw);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
function assertContained(target, root, raw) {
|
|
759
|
+
const lexical = assertContainedLexical(target, root, raw);
|
|
760
|
+
assertContainedReal(root, lexical, raw);
|
|
761
|
+
return lexical;
|
|
762
|
+
}
|
|
763
|
+
function isInlineScript(manifest) {
|
|
764
|
+
return manifest.script_kind === "inline" || manifest.script_kind === undefined && manifest.script.includes(`
|
|
765
|
+
`);
|
|
766
|
+
}
|
|
767
|
+
function scriptRelFor(manifest) {
|
|
768
|
+
if (isInlineScript(manifest)) {
|
|
769
|
+
const ext = manifest.script.trim().startsWith("#!") ? ".sh" : ".ts";
|
|
770
|
+
return `script${ext}`;
|
|
771
|
+
}
|
|
772
|
+
const rel = normalize(manifest.script);
|
|
773
|
+
if (isAbsolute(rel)) {
|
|
774
|
+
throw new Error(`manifest script must be relative or inline, got absolute path '${manifest.script}'`);
|
|
775
|
+
}
|
|
776
|
+
return rel;
|
|
777
|
+
}
|
|
778
|
+
function resolveScript(manifest, manifestDir) {
|
|
779
|
+
if (isInlineScript(manifest))
|
|
780
|
+
return { path: scriptRelFor(manifest), content: manifest.script };
|
|
781
|
+
const rel = scriptRelFor(manifest);
|
|
782
|
+
const resolved = assertContained(join3(manifestDir, rel), manifestDir, manifest.script);
|
|
783
|
+
if (!existsSync2(resolved)) {
|
|
784
|
+
throw new Error(`manifest script file not found: ${resolved}`);
|
|
785
|
+
}
|
|
786
|
+
return { path: rel, content: readFileSync(resolved, "utf-8") };
|
|
787
|
+
}
|
|
788
|
+
function customHookDir(name) {
|
|
789
|
+
return join3(getCustomHooksDir(), shortManifestName(name));
|
|
790
|
+
}
|
|
791
|
+
function readCustomManifest(name) {
|
|
792
|
+
const dir = customHookDir(name);
|
|
793
|
+
const manifestPath = join3(dir, "manifest.json");
|
|
794
|
+
if (!existsSync2(manifestPath))
|
|
795
|
+
return;
|
|
796
|
+
try {
|
|
797
|
+
const manifest = parseManifest(readFileSync(manifestPath, "utf-8"));
|
|
798
|
+
const script = resolveScript(manifest, dir);
|
|
799
|
+
const scriptIsInline = isInlineScript(manifest);
|
|
800
|
+
return { manifest, scriptPath: join3(dir, script.path), scriptContent: script.content, scriptIsInline };
|
|
801
|
+
} catch (err) {
|
|
802
|
+
if (err instanceof ScriptContainmentError)
|
|
803
|
+
throw err;
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
var HOOK_NAME_RE, manifestSchema, ScriptContainmentError;
|
|
808
|
+
var init_manifest = __esm(() => {
|
|
809
|
+
init_config();
|
|
810
|
+
init_semver();
|
|
811
|
+
HOOK_NAME_RE = /^[\w-]+$/;
|
|
812
|
+
manifestSchema = z.object({
|
|
813
|
+
name: z.string().regex(HOOK_NAME_RE, "hook name must be /^[\\w-]+$/"),
|
|
814
|
+
version: z.string().regex(SEMVER_PATTERN, "version must be full semver (e.g. 1.2.3 or 1.2.3-beta.1)"),
|
|
815
|
+
description: z.string().optional(),
|
|
816
|
+
events: z.array(z.string()).min(1, "at least one event is required"),
|
|
817
|
+
script: z.string().min(1, "script must name a relative path or inline content"),
|
|
818
|
+
script_kind: z.enum(["inline", "file"]).optional(),
|
|
819
|
+
args: z.array(z.string()).optional(),
|
|
820
|
+
timeout_ms: z.number().int().positive().optional(),
|
|
821
|
+
env: z.record(z.string(), z.string()).optional()
|
|
822
|
+
});
|
|
823
|
+
ScriptContainmentError = class ScriptContainmentError extends Error {
|
|
824
|
+
constructor(path, hookDir, raw) {
|
|
825
|
+
super(raw && raw !== path ? `manifest script escapes the hook directory: '${path}' (from '${raw}') resolves outside '${hookDir}'` : `manifest script escapes the hook directory: '${path}' resolves outside '${hookDir}'`);
|
|
826
|
+
this.name = "ScriptContainmentError";
|
|
827
|
+
}
|
|
828
|
+
};
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
// src/lib/resolve.ts
|
|
832
|
+
var exports_resolve = {};
|
|
833
|
+
__export(exports_resolve, {
|
|
834
|
+
resolveScriptPath: () => resolveScriptPath,
|
|
835
|
+
resolveHookMeta: () => resolveHookMeta,
|
|
836
|
+
resolveHookDir: () => resolveHookDir,
|
|
837
|
+
resolveHook: () => resolveHook,
|
|
838
|
+
resolveBundledHooksDir: () => resolveBundledHooksDir
|
|
839
|
+
});
|
|
840
|
+
import { dirname as dirname2, join as join4 } from "path";
|
|
841
|
+
import { existsSync as existsSync3 } from "fs";
|
|
842
|
+
import { fileURLToPath } from "url";
|
|
843
|
+
function resolveBundledHooksDir(moduleDir = dirname2(fileURLToPath(import.meta.url))) {
|
|
844
|
+
const sourceLayout = join4(moduleDir, "..", "..", "hooks");
|
|
845
|
+
if (existsSync3(join4(sourceLayout, "hook-gitguard")))
|
|
846
|
+
return sourceLayout;
|
|
847
|
+
return join4(moduleDir, "..", "hooks");
|
|
848
|
+
}
|
|
849
|
+
function resolveHookDir(name) {
|
|
850
|
+
const custom = readCustomManifest(name);
|
|
851
|
+
if (custom)
|
|
852
|
+
return customHookDir(name);
|
|
853
|
+
const meta = getHook(shortManifestName(name));
|
|
854
|
+
if (!meta)
|
|
855
|
+
return;
|
|
856
|
+
const short = shortManifestName(name);
|
|
857
|
+
const bundledBase = resolveBundledHooksDir();
|
|
858
|
+
const direct = join4(bundledBase, short);
|
|
859
|
+
if (existsSync3(direct))
|
|
860
|
+
return direct;
|
|
861
|
+
const prefixed = join4(bundledBase, `hook-${short}`);
|
|
862
|
+
return existsSync3(prefixed) ? prefixed : undefined;
|
|
863
|
+
}
|
|
864
|
+
function resolveScriptPath(name) {
|
|
865
|
+
const custom = readCustomManifest(name);
|
|
866
|
+
if (custom)
|
|
867
|
+
return custom.scriptPath;
|
|
868
|
+
const dir = resolveHookDir(name);
|
|
869
|
+
if (!dir)
|
|
870
|
+
return;
|
|
871
|
+
const script = join4(dir, "src", "hook.ts");
|
|
872
|
+
return existsSync3(script) ? script : undefined;
|
|
873
|
+
}
|
|
874
|
+
function resolveHookMeta(name) {
|
|
875
|
+
const custom = readCustomManifest(name);
|
|
876
|
+
if (custom) {
|
|
877
|
+
const { manifest } = custom;
|
|
878
|
+
return {
|
|
879
|
+
name: shortManifestName(manifest.name),
|
|
880
|
+
displayName: manifest.description ? manifest.name : manifest.name,
|
|
881
|
+
description: manifest.description ?? "Custom hook",
|
|
882
|
+
version: manifest.version,
|
|
883
|
+
category: "Workflow Automation",
|
|
884
|
+
event: manifest.events[0] ?? "PostToolUse",
|
|
885
|
+
events: manifest.events,
|
|
886
|
+
matcher: "",
|
|
887
|
+
tags: ["custom"]
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
return getHook(shortManifestName(name));
|
|
891
|
+
}
|
|
892
|
+
function resolveHook(name) {
|
|
893
|
+
const custom = readCustomManifest(name);
|
|
894
|
+
if (custom) {
|
|
895
|
+
const meta2 = resolveHookMeta(name);
|
|
896
|
+
return {
|
|
897
|
+
name: shortManifestName(custom.manifest.name),
|
|
898
|
+
source: "custom",
|
|
899
|
+
version: custom.manifest.version,
|
|
900
|
+
events: custom.manifest.events,
|
|
901
|
+
description: custom.manifest.description ?? "Custom hook",
|
|
902
|
+
scriptPath: custom.scriptPath,
|
|
903
|
+
meta: meta2,
|
|
904
|
+
timeoutMs: custom.manifest.timeout_ms ?? null
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
const meta = getHook(shortManifestName(name));
|
|
908
|
+
const scriptPath = resolveScriptPath(name);
|
|
909
|
+
if (!meta || !scriptPath)
|
|
910
|
+
return;
|
|
911
|
+
return {
|
|
912
|
+
name: shortManifestName(name),
|
|
913
|
+
source: "bundled",
|
|
914
|
+
version: meta.version,
|
|
915
|
+
events: meta.events && meta.events.length > 0 ? meta.events : [meta.event],
|
|
916
|
+
description: meta.description,
|
|
917
|
+
scriptPath,
|
|
918
|
+
meta,
|
|
919
|
+
timeoutMs: null
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
var init_resolve = __esm(() => {
|
|
923
|
+
init_registry();
|
|
924
|
+
init_manifest();
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
// src/db/schema.ts
|
|
928
|
+
var CREATE_HOOK_EVENTS_TABLE = `
|
|
929
|
+
CREATE TABLE IF NOT EXISTS hook_events (
|
|
930
|
+
id TEXT PRIMARY KEY,
|
|
931
|
+
timestamp TEXT NOT NULL,
|
|
932
|
+
session_id TEXT NOT NULL,
|
|
933
|
+
hook_name TEXT NOT NULL,
|
|
934
|
+
event_type TEXT NOT NULL CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit', 'SubagentStart')),
|
|
935
|
+
tool_name TEXT,
|
|
936
|
+
tool_input TEXT,
|
|
937
|
+
result TEXT CHECK (result IN ('continue', 'block', NULL)),
|
|
938
|
+
error TEXT,
|
|
939
|
+
duration_ms INTEGER,
|
|
940
|
+
project_dir TEXT,
|
|
941
|
+
metadata TEXT
|
|
942
|
+
)
|
|
943
|
+
`, CREATE_INDEXES;
|
|
944
|
+
var init_schema = __esm(() => {
|
|
945
|
+
CREATE_INDEXES = [
|
|
946
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_timestamp ON hook_events (timestamp)`,
|
|
947
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_session_id ON hook_events (session_id)`,
|
|
948
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_hook_name ON hook_events (hook_name)`,
|
|
949
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_event_type ON hook_events (event_type)`,
|
|
950
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_errors ON hook_events (timestamp) WHERE error IS NOT NULL`
|
|
951
|
+
];
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
// src/db/migrations/001_initial.ts
|
|
955
|
+
function up(db) {
|
|
956
|
+
db.exec(CREATE_HOOK_EVENTS_TABLE);
|
|
957
|
+
for (const idx of CREATE_INDEXES) {
|
|
958
|
+
db.exec(idx);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
var init_001_initial = __esm(() => {
|
|
962
|
+
init_schema();
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
// src/db/migrations/002_session_events.ts
|
|
966
|
+
function up2(db) {
|
|
967
|
+
const row = db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get("hook_events");
|
|
968
|
+
if (!row?.sql)
|
|
969
|
+
return;
|
|
970
|
+
if (row.sql.includes("SessionStart"))
|
|
971
|
+
return;
|
|
972
|
+
db.exec("BEGIN");
|
|
973
|
+
try {
|
|
974
|
+
db.exec("ALTER TABLE hook_events RENAME TO hook_events_old");
|
|
975
|
+
db.exec(CREATE_HOOK_EVENTS_TABLE);
|
|
976
|
+
db.exec(`INSERT INTO hook_events
|
|
977
|
+
(id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata)
|
|
978
|
+
SELECT id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata
|
|
979
|
+
FROM hook_events_old`);
|
|
980
|
+
db.exec("DROP TABLE hook_events_old");
|
|
981
|
+
for (const idx of CREATE_INDEXES) {
|
|
982
|
+
db.exec(idx);
|
|
983
|
+
}
|
|
984
|
+
db.exec("COMMIT");
|
|
985
|
+
} catch (err) {
|
|
986
|
+
db.exec("ROLLBACK");
|
|
987
|
+
throw err;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
var init_002_session_events = __esm(() => {
|
|
991
|
+
init_schema();
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
// src/db/migrations/003_user_prompt_submit_event.ts
|
|
995
|
+
function up3(db) {
|
|
996
|
+
const row = db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get("hook_events");
|
|
997
|
+
if (!row?.sql)
|
|
998
|
+
return;
|
|
999
|
+
if (row.sql.includes("UserPromptSubmit"))
|
|
1000
|
+
return;
|
|
1001
|
+
db.exec("BEGIN");
|
|
1002
|
+
try {
|
|
1003
|
+
db.exec("ALTER TABLE hook_events RENAME TO hook_events_old");
|
|
1004
|
+
db.exec(CREATE_HOOK_EVENTS_TABLE);
|
|
1005
|
+
db.exec(`INSERT INTO hook_events
|
|
1006
|
+
(id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata)
|
|
1007
|
+
SELECT id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata
|
|
1008
|
+
FROM hook_events_old`);
|
|
1009
|
+
db.exec("DROP TABLE hook_events_old");
|
|
1010
|
+
for (const idx of CREATE_INDEXES) {
|
|
1011
|
+
db.exec(idx);
|
|
1012
|
+
}
|
|
1013
|
+
db.exec("COMMIT");
|
|
1014
|
+
} catch (err) {
|
|
1015
|
+
db.exec("ROLLBACK");
|
|
1016
|
+
throw err;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
var init_003_user_prompt_submit_event = __esm(() => {
|
|
1020
|
+
init_schema();
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
// src/db/migrations/004_hooks_table.ts
|
|
1024
|
+
function up4(db) {
|
|
1025
|
+
db.exec(CREATE_HOOKS_TABLE);
|
|
1026
|
+
for (const idx of CREATE_HOOKS_INDEXES) {
|
|
1027
|
+
db.exec(idx);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
var CREATE_HOOKS_TABLE = `
|
|
1031
|
+
CREATE TABLE IF NOT EXISTS hooks (
|
|
1032
|
+
id TEXT PRIMARY KEY,
|
|
1033
|
+
name TEXT NOT NULL,
|
|
1034
|
+
version TEXT NOT NULL,
|
|
1035
|
+
sha256 TEXT NOT NULL,
|
|
1036
|
+
source_type TEXT NOT NULL,
|
|
1037
|
+
source_ref TEXT,
|
|
1038
|
+
installed_at TEXT NOT NULL,
|
|
1039
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
1040
|
+
last_verified_at TEXT
|
|
1041
|
+
)
|
|
1042
|
+
`, CREATE_HOOKS_INDEXES;
|
|
1043
|
+
var init_004_hooks_table = __esm(() => {
|
|
1044
|
+
CREATE_HOOKS_INDEXES = [
|
|
1045
|
+
`CREATE INDEX IF NOT EXISTS idx_hooks_name ON hooks (name)`
|
|
1046
|
+
];
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
// src/db/migrations/005_subagent_start_event.ts
|
|
1050
|
+
function up5(db) {
|
|
1051
|
+
const row = db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get("hook_events");
|
|
1052
|
+
if (!row?.sql)
|
|
1053
|
+
return;
|
|
1054
|
+
if (row.sql.includes("SubagentStart"))
|
|
1055
|
+
return;
|
|
1056
|
+
db.exec("BEGIN");
|
|
1057
|
+
try {
|
|
1058
|
+
db.exec("ALTER TABLE hook_events RENAME TO hook_events_old");
|
|
1059
|
+
db.exec(CREATE_HOOK_EVENTS_TABLE);
|
|
1060
|
+
db.exec(`INSERT INTO hook_events
|
|
1061
|
+
(id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata)
|
|
1062
|
+
SELECT id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata
|
|
1063
|
+
FROM hook_events_old`);
|
|
1064
|
+
db.exec("DROP TABLE hook_events_old");
|
|
1065
|
+
for (const idx of CREATE_INDEXES) {
|
|
1066
|
+
db.exec(idx);
|
|
1067
|
+
}
|
|
1068
|
+
db.exec("COMMIT");
|
|
1069
|
+
} catch (err) {
|
|
1070
|
+
db.exec("ROLLBACK");
|
|
1071
|
+
throw err;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
var init_005_subagent_start_event = __esm(() => {
|
|
1075
|
+
init_schema();
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
// src/db/migrations/index.ts
|
|
1079
|
+
function ensureMigrationsTable(db) {
|
|
1080
|
+
db.exec(`
|
|
1081
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
1082
|
+
version TEXT PRIMARY KEY,
|
|
1083
|
+
applied_at TEXT NOT NULL
|
|
1084
|
+
)
|
|
1085
|
+
`);
|
|
1086
|
+
}
|
|
1087
|
+
function getApplied(db) {
|
|
1088
|
+
const rows = db.query("SELECT version FROM schema_migrations").all();
|
|
1089
|
+
return new Set(rows.map((r) => r.version));
|
|
1090
|
+
}
|
|
1091
|
+
function runMigrations(db) {
|
|
1092
|
+
ensureMigrationsTable(db);
|
|
1093
|
+
const applied = getApplied(db);
|
|
1094
|
+
for (const migration of MIGRATIONS) {
|
|
1095
|
+
if (applied.has(migration.version))
|
|
1096
|
+
continue;
|
|
1097
|
+
migration.up(db);
|
|
1098
|
+
db.run("INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)", [
|
|
1099
|
+
migration.version,
|
|
1100
|
+
new Date().toISOString()
|
|
1101
|
+
]);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
var MIGRATIONS;
|
|
1105
|
+
var init_migrations = __esm(() => {
|
|
1106
|
+
init_001_initial();
|
|
1107
|
+
init_002_session_events();
|
|
1108
|
+
init_003_user_prompt_submit_event();
|
|
1109
|
+
init_004_hooks_table();
|
|
1110
|
+
init_005_subagent_start_event();
|
|
1111
|
+
MIGRATIONS = [
|
|
1112
|
+
{ version: "001_initial", up },
|
|
1113
|
+
{ version: "002_session_events", up: up2 },
|
|
1114
|
+
{ version: "003_user_prompt_submit_event", up: up3 },
|
|
1115
|
+
{ version: "004_hooks_table", up: up4 },
|
|
1116
|
+
{ version: "005_subagent_start_event", up: up5 }
|
|
1117
|
+
];
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
// src/db/legacy-import.ts
|
|
1121
|
+
import { existsSync as existsSync4, readdirSync as readdirSync2, readFileSync as readFileSync2 } from "fs";
|
|
1122
|
+
import { join as join5 } from "path";
|
|
1123
|
+
import { homedir as homedir2 } from "os";
|
|
1124
|
+
function ensureMetaTable(db) {
|
|
1125
|
+
db.exec(`
|
|
1126
|
+
CREATE TABLE IF NOT EXISTS _meta (
|
|
1127
|
+
key TEXT PRIMARY KEY,
|
|
1128
|
+
value TEXT NOT NULL
|
|
1129
|
+
)
|
|
1130
|
+
`);
|
|
1131
|
+
}
|
|
1132
|
+
function isAlreadyDone(db) {
|
|
1133
|
+
ensureMetaTable(db);
|
|
1134
|
+
const row = db.query("SELECT value FROM _meta WHERE key = ?").get(META_KEY);
|
|
1135
|
+
return row?.value === "1";
|
|
1136
|
+
}
|
|
1137
|
+
function markDone(db) {
|
|
1138
|
+
db.run("INSERT OR REPLACE INTO _meta (key, value) VALUES (?, ?)", [META_KEY, "1"]);
|
|
1139
|
+
}
|
|
1140
|
+
function nanoid() {
|
|
1141
|
+
return crypto.randomUUID().replace(/-/g, "").slice(0, 21);
|
|
1142
|
+
}
|
|
1143
|
+
function importJsonlFile(db, filePath) {
|
|
1144
|
+
let count = 0;
|
|
1145
|
+
try {
|
|
1146
|
+
const lines = readFileSync2(filePath, "utf-8").split(`
|
|
1147
|
+
`).filter(Boolean);
|
|
1148
|
+
for (const line of lines) {
|
|
1149
|
+
try {
|
|
1150
|
+
const entry = JSON.parse(line);
|
|
1151
|
+
db.run(`INSERT OR IGNORE INTO hook_events
|
|
1152
|
+
(id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, project_dir)
|
|
1153
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
1154
|
+
nanoid(),
|
|
1155
|
+
entry.timestamp ?? new Date().toISOString(),
|
|
1156
|
+
entry.session_id ?? "legacy",
|
|
1157
|
+
"sessionlog",
|
|
1158
|
+
"PostToolUse",
|
|
1159
|
+
entry.tool_name ?? null,
|
|
1160
|
+
entry.tool_input ? String(entry.tool_input).slice(0, 500) : null,
|
|
1161
|
+
null
|
|
1162
|
+
]);
|
|
1163
|
+
count++;
|
|
1164
|
+
} catch {}
|
|
1165
|
+
}
|
|
1166
|
+
} catch {}
|
|
1167
|
+
return count;
|
|
1168
|
+
}
|
|
1169
|
+
function importErrorsLog(db, filePath) {
|
|
1170
|
+
let count = 0;
|
|
1171
|
+
try {
|
|
1172
|
+
const lines = readFileSync2(filePath, "utf-8").split(`
|
|
1173
|
+
`).filter(Boolean);
|
|
1174
|
+
const linePattern = /^\[(.+?)\]\s+(?:\[session:(\S+)\]\s+)?(.+?)\s+\u2014\s+(.+)$/;
|
|
1175
|
+
for (const line of lines) {
|
|
1176
|
+
try {
|
|
1177
|
+
const m = line.match(linePattern);
|
|
1178
|
+
if (!m)
|
|
1179
|
+
continue;
|
|
1180
|
+
const [, timestamp, sessionPrefix, , errorMsg] = m;
|
|
1181
|
+
db.run(`INSERT OR IGNORE INTO hook_events
|
|
1182
|
+
(id, timestamp, session_id, hook_name, event_type, error)
|
|
1183
|
+
VALUES (?, ?, ?, ?, ?, ?)`, [
|
|
1184
|
+
nanoid(),
|
|
1185
|
+
timestamp,
|
|
1186
|
+
sessionPrefix ? `legacy-${sessionPrefix}` : "legacy",
|
|
1187
|
+
"errornotify",
|
|
1188
|
+
"PostToolUse",
|
|
1189
|
+
errorMsg.slice(0, 500)
|
|
1190
|
+
]);
|
|
1191
|
+
count++;
|
|
1192
|
+
} catch {}
|
|
1193
|
+
}
|
|
1194
|
+
} catch {}
|
|
1195
|
+
return count;
|
|
1196
|
+
}
|
|
1197
|
+
function runLegacyImport(db, homeDir = homedir2()) {
|
|
1198
|
+
try {
|
|
1199
|
+
if (isAlreadyDone(db))
|
|
1200
|
+
return;
|
|
1201
|
+
let total = 0;
|
|
1202
|
+
const claudeProjectsDir = join5(homeDir, ".claude", "projects");
|
|
1203
|
+
if (existsSync4(claudeProjectsDir)) {
|
|
1204
|
+
try {
|
|
1205
|
+
const projectDirs = readdirSync2(claudeProjectsDir);
|
|
1206
|
+
for (const dir of projectDirs) {
|
|
1207
|
+
const projectDir = join5(claudeProjectsDir, dir);
|
|
1208
|
+
try {
|
|
1209
|
+
const files = readdirSync2(projectDir);
|
|
1210
|
+
for (const file of files) {
|
|
1211
|
+
if (file.match(/^session-log-\d{4}-\d{2}-\d{2}\.jsonl$/)) {
|
|
1212
|
+
total += importJsonlFile(db, join5(projectDir, file));
|
|
1213
|
+
}
|
|
1214
|
+
if (file === "errors.log") {
|
|
1215
|
+
total += importErrorsLog(db, join5(projectDir, file));
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
} catch {}
|
|
1219
|
+
}
|
|
1220
|
+
} catch {}
|
|
1221
|
+
}
|
|
1222
|
+
markDone(db);
|
|
1223
|
+
if (total > 0) {
|
|
1224
|
+
process.stderr.write(`[hooks] Imported ${total} legacy log entries into SQLite.
|
|
1225
|
+
`);
|
|
1226
|
+
}
|
|
1227
|
+
} catch (err) {
|
|
1228
|
+
process.stderr.write(`[hooks] Legacy import failed (non-fatal): ${err}
|
|
1229
|
+
`);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
var META_KEY = "legacy_import_done";
|
|
1233
|
+
var init_legacy_import = () => {};
|
|
1234
|
+
|
|
1235
|
+
// src/db/retention.ts
|
|
1236
|
+
function runRetention(db, days) {
|
|
1237
|
+
const envDays = parseInt(process.env.HOOKS_RETENTION_DAYS ?? "30");
|
|
1238
|
+
const retentionDays = days ?? (isNaN(envDays) || envDays <= 0 ? 30 : envDays);
|
|
1239
|
+
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString();
|
|
1240
|
+
try {
|
|
1241
|
+
db.run("DELETE FROM hook_events WHERE timestamp < ?", [cutoff]);
|
|
1242
|
+
const changes = db.query("SELECT changes() as changes").get()?.changes ?? 0;
|
|
1243
|
+
return changes;
|
|
1244
|
+
} catch {
|
|
1245
|
+
return 0;
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// src/db/index.ts
|
|
1250
|
+
var exports_db = {};
|
|
1251
|
+
__export(exports_db, {
|
|
1252
|
+
refuseLocalStore: () => refuseLocalStore,
|
|
1253
|
+
localStoreRefusalMessage: () => localStoreRefusalMessage,
|
|
1254
|
+
isLocalStoreRefused: () => isLocalStoreRefused,
|
|
1255
|
+
getDbPath: () => getDbPath,
|
|
1256
|
+
getDb: () => getDb,
|
|
1257
|
+
createTestDb: () => createTestDb,
|
|
1258
|
+
closeDb: () => closeDb,
|
|
1259
|
+
allowLocalStore: () => allowLocalStore
|
|
1260
|
+
});
|
|
1261
|
+
import { Database } from "bun:sqlite";
|
|
1262
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync2, cpSync } from "fs";
|
|
1263
|
+
import { join as join6 } from "path";
|
|
1264
|
+
function refuseLocalStore(message) {
|
|
1265
|
+
localStoreRefusal = message;
|
|
1266
|
+
}
|
|
1267
|
+
function allowLocalStore() {
|
|
1268
|
+
localStoreRefusal = null;
|
|
1269
|
+
}
|
|
1270
|
+
function isLocalStoreRefused() {
|
|
1271
|
+
return localStoreRefusal !== null;
|
|
1272
|
+
}
|
|
1273
|
+
function localStoreRefusalMessage() {
|
|
1274
|
+
return localStoreRefusal;
|
|
1275
|
+
}
|
|
1276
|
+
function resolveDataDir() {
|
|
1277
|
+
const effective = getEffectiveDataRoot();
|
|
1278
|
+
const oldDir = join6(getHomeDir(), ".hooks");
|
|
1279
|
+
if (!existsSync5(effective) && existsSync5(oldDir)) {
|
|
1280
|
+
mkdirSync2(effective, { recursive: true });
|
|
1281
|
+
cpSync(oldDir, effective, { recursive: true });
|
|
1282
|
+
}
|
|
1283
|
+
return effective;
|
|
1284
|
+
}
|
|
1285
|
+
function getDbPath() {
|
|
1286
|
+
const explicitDb = process.env.HASNA_HOOKS_DB_PATH ?? process.env.HOOKS_DB_PATH;
|
|
1287
|
+
if (explicitDb)
|
|
1288
|
+
return explicitDb;
|
|
1289
|
+
const dataDir2 = resolveDataDir();
|
|
1290
|
+
return join6(dataDir2, "hooks.db");
|
|
1291
|
+
}
|
|
1292
|
+
function ensureDir(dbPath) {
|
|
1293
|
+
const dir = dbPath.substring(0, dbPath.lastIndexOf("/"));
|
|
1294
|
+
if (dir && !existsSync5(dir)) {
|
|
1295
|
+
mkdirSync2(dir, { recursive: true });
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
function getDb() {
|
|
1299
|
+
if (localStoreRefusal !== null)
|
|
1300
|
+
throw new Error(localStoreRefusal);
|
|
1301
|
+
if (instance)
|
|
1302
|
+
return instance;
|
|
1303
|
+
const dbPath = getDbPath();
|
|
1304
|
+
const isNew = dbPath === ":memory:" || !existsSync5(dbPath);
|
|
1305
|
+
ensureDir(dbPath);
|
|
1306
|
+
instance = new Database(dbPath);
|
|
1307
|
+
instance.exec("PRAGMA busy_timeout=5000");
|
|
1308
|
+
instance.exec("PRAGMA journal_mode=WAL");
|
|
1309
|
+
instance.exec("PRAGMA foreign_keys=ON");
|
|
1310
|
+
runMigrations(instance);
|
|
1311
|
+
runRetention(instance);
|
|
1312
|
+
instance.exec(`CREATE TABLE IF NOT EXISTS feedback (
|
|
1313
|
+
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
|
1314
|
+
message TEXT NOT NULL,
|
|
1315
|
+
email TEXT,
|
|
1316
|
+
category TEXT DEFAULT 'general',
|
|
1317
|
+
version TEXT,
|
|
1318
|
+
machine_id TEXT,
|
|
1319
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1320
|
+
)`);
|
|
1321
|
+
if (isNew) {
|
|
1322
|
+
runLegacyImport(instance);
|
|
1323
|
+
}
|
|
1324
|
+
return instance;
|
|
1325
|
+
}
|
|
1326
|
+
function closeDb() {
|
|
1327
|
+
if (instance) {
|
|
1328
|
+
instance.close();
|
|
1329
|
+
instance = null;
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
function createTestDb() {
|
|
1333
|
+
const db = new Database(":memory:");
|
|
1334
|
+
db.exec("PRAGMA journal_mode=WAL");
|
|
1335
|
+
db.exec("PRAGMA foreign_keys=ON");
|
|
1336
|
+
return db;
|
|
1337
|
+
}
|
|
1338
|
+
var instance = null, localStoreRefusal = null;
|
|
1339
|
+
var init_db = __esm(() => {
|
|
1340
|
+
init_app_home();
|
|
1341
|
+
init_migrations();
|
|
1342
|
+
init_legacy_import();
|
|
1343
|
+
});
|
|
1344
|
+
|
|
1345
|
+
// src/lib/redact.ts
|
|
1346
|
+
function redactText(value) {
|
|
1347
|
+
let out = value;
|
|
1348
|
+
for (const pattern of SECRET_SHAPES) {
|
|
1349
|
+
out = out.replace(pattern, REDACTED);
|
|
1350
|
+
}
|
|
1351
|
+
return out;
|
|
1352
|
+
}
|
|
1353
|
+
function redactValue(value) {
|
|
1354
|
+
if (typeof value === "string")
|
|
1355
|
+
return redactText(value);
|
|
1356
|
+
if (Array.isArray(value))
|
|
1357
|
+
return value.map(redactValue);
|
|
1358
|
+
if (value !== null && typeof value === "object") {
|
|
1359
|
+
const out = {};
|
|
1360
|
+
for (const [key, child] of Object.entries(value)) {
|
|
1361
|
+
out[key] = SECRET_KEY_NAMES.test(key) ? REDACTED : redactValue(child);
|
|
1362
|
+
}
|
|
1363
|
+
return out;
|
|
1364
|
+
}
|
|
1365
|
+
return value;
|
|
1366
|
+
}
|
|
1367
|
+
function redactEventPayload(text) {
|
|
1368
|
+
if (text === null || text === undefined)
|
|
1369
|
+
return null;
|
|
1370
|
+
try {
|
|
1371
|
+
const parsed = JSON.parse(text);
|
|
1372
|
+
return redactText(JSON.stringify(redactValue(parsed)));
|
|
1373
|
+
} catch {
|
|
1374
|
+
return redactText(text);
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
function projectEventRowForRead(row) {
|
|
1378
|
+
const out = { ...row };
|
|
1379
|
+
for (const field of ["tool_input", "error", "metadata"]) {
|
|
1380
|
+
const value = out[field];
|
|
1381
|
+
if (typeof value === "string")
|
|
1382
|
+
out[field] = redactEventPayload(value);
|
|
1383
|
+
else if (value === null)
|
|
1384
|
+
out[field] = null;
|
|
1385
|
+
}
|
|
1386
|
+
return out;
|
|
1387
|
+
}
|
|
1388
|
+
var REDACTED = "[REDACTED]", SECRET_KEY_NAMES, SECRET_SHAPES;
|
|
1389
|
+
var init_redact = __esm(() => {
|
|
1390
|
+
SECRET_KEY_NAMES = /^(key|keys|token|tokens|secret|secrets|password|passwd|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret|refresh[_-]?token|authorization|auth|cookie|credential|credentials|session[_-]?id|otp|mfa[_-]?code|two[_-]?fa[_-]?code|verification[_-]?code|database_url|db_url|connection_string|dsn)$/i;
|
|
1391
|
+
SECRET_SHAPES = [
|
|
1392
|
+
/\bsk-(?:proj|svc|ant)-[A-Za-z0-9_-]{10,}/g,
|
|
1393
|
+
/\bsk-[A-Za-z0-9]{16,}/g,
|
|
1394
|
+
/\btpe_[A-Za-z0-9]{8,}/g,
|
|
1395
|
+
/\b(?:rk|sk)_(?:live|test)_[A-Za-z0-9]{8,}/g,
|
|
1396
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}/g,
|
|
1397
|
+
/gh[pousr]_[A-Za-z0-9]{20,}/g,
|
|
1398
|
+
/xox[baprs]-[A-Za-z0-9-]{20,}/g,
|
|
1399
|
+
/AKIA[0-9A-Z]{16}/g,
|
|
1400
|
+
/ASIA[0-9A-Z]{16}/g,
|
|
1401
|
+
/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
|
|
1402
|
+
/-----BEGIN [A-Z0-9 ]*PRIVAT[E] KEY-----/g,
|
|
1403
|
+
/-----BEGIN OPENSSH PRIVAT[E] KEY-----/g,
|
|
1404
|
+
/\bBearer\s+[A-Za-z0-9._~+/=-]{6,}/g,
|
|
1405
|
+
/\b[a-z][a-z0-9+.-]*:\/\/[^/\s:@]+:[^/\s@]+@/gi,
|
|
1406
|
+
/\b(?:password|passwd|pwd|token|api[_-]?key|secret|access[_-]?key|client[_-]?secret|authorization|auth|credential|database_url|db_url|connection_string|dsn)\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;"']{6,})/gi
|
|
1407
|
+
];
|
|
1408
|
+
});
|
|
1409
|
+
|
|
1410
|
+
// ../../node_modules/.bun/@hasna+contracts@1.0.2+e0db24ae03127387/node_modules/@hasna/contracts/dist/client/transport.js
|
|
1411
|
+
import { isIP } from "net";
|
|
1412
|
+
import { spawnSync } from "child_process";
|
|
1413
|
+
import { closeSync, fstatSync, openSync, readFileSync as readFileSync6 } from "fs";
|
|
1414
|
+
import { O_NOFOLLOW, O_NONBLOCK, O_RDONLY } from "constants";
|
|
1415
|
+
import { createRequire } from "module";
|
|
1416
|
+
import { hostname as osHostname } from "os";
|
|
1417
|
+
import { isAbsolute as isAbsolute2, join as join9 } from "path";
|
|
1418
|
+
function envToken(name) {
|
|
1419
|
+
return name.toUpperCase().replace(/-/g, "_");
|
|
1420
|
+
}
|
|
1421
|
+
function clientTransportEnvKeys(name) {
|
|
1422
|
+
const envSegment = envToken(name);
|
|
1423
|
+
return {
|
|
1424
|
+
apiUrlKeys: [`HASNA_${envSegment}_API_URL`, `${envSegment}_API_URL`],
|
|
1425
|
+
apiKeyKeys: [`HASNA_${envSegment}_API_KEY`, `${envSegment}_API_KEY`]
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1428
|
+
function credentialOverrideEnvKey(name) {
|
|
1429
|
+
return `HASNA_${envToken(name)}_API_KEY_OVERRIDE`;
|
|
1430
|
+
}
|
|
1431
|
+
function credentialPointerEnvKey(name) {
|
|
1432
|
+
return `HASNA_${envToken(name)}_API_KEY_REF`;
|
|
1433
|
+
}
|
|
1434
|
+
function homeDir(env) {
|
|
1435
|
+
const home = env.HOME?.trim();
|
|
1436
|
+
return home ? home : null;
|
|
1437
|
+
}
|
|
1438
|
+
function absoluteOverride(env, key) {
|
|
1439
|
+
const value = env[key]?.trim();
|
|
1440
|
+
return value && isAbsolute2(value) ? value : null;
|
|
1441
|
+
}
|
|
1442
|
+
function hasnaHomeDir(env) {
|
|
1443
|
+
const override = absoluteOverride(env, HASNA_HOME_ENV_KEY);
|
|
1444
|
+
if (override)
|
|
1445
|
+
return override;
|
|
1446
|
+
const home = homeDir(env);
|
|
1447
|
+
return home ? join9(home, HASNA_HOME_DIR) : null;
|
|
1448
|
+
}
|
|
1449
|
+
function appConfigDir(name, env) {
|
|
1450
|
+
const configRoot = absoluteOverride(env, HASNA_CONFIG_HOME_ENV_KEY);
|
|
1451
|
+
if (configRoot)
|
|
1452
|
+
return join9(configRoot, name);
|
|
1453
|
+
const root = hasnaHomeDir(env);
|
|
1454
|
+
return root ? join9(root, name, CONFIG_SUBDIR) : null;
|
|
1455
|
+
}
|
|
1456
|
+
function credentialDiskSourceList(name, env, profile = null) {
|
|
1457
|
+
if (!SAFE_APP_SLUG.test(name))
|
|
1458
|
+
return [];
|
|
1459
|
+
const directory = appConfigDir(name, env);
|
|
1460
|
+
if (!directory)
|
|
1461
|
+
return [];
|
|
1462
|
+
const file = profile ? `${CREDENTIALS_FILE}-${profile}` : CREDENTIALS_FILE;
|
|
1463
|
+
return [{ path: join9(directory, file), tier: "disk" }];
|
|
1464
|
+
}
|
|
1465
|
+
function credentialDiskSources(name, env) {
|
|
1466
|
+
return credentialDiskSourceList(name, env, null).map((s) => s.path);
|
|
1467
|
+
}
|
|
1468
|
+
function profileDiskSources(name, env, profile) {
|
|
1469
|
+
return credentialDiskSourceList(name, env, profile).map((s) => s.path);
|
|
1470
|
+
}
|
|
1471
|
+
function parseEnvFile(text) {
|
|
1472
|
+
const values = new Map;
|
|
1473
|
+
const unusable = new Set;
|
|
1474
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
1475
|
+
const line = rawLine.trim();
|
|
1476
|
+
if (line.length === 0 || line.startsWith("#"))
|
|
1477
|
+
continue;
|
|
1478
|
+
const withoutExport = line.startsWith("export ") ? line.slice("export ".length).trim() : line;
|
|
1479
|
+
const equals = withoutExport.indexOf("=");
|
|
1480
|
+
if (equals <= 0)
|
|
1481
|
+
continue;
|
|
1482
|
+
const key = withoutExport.slice(0, equals).trim();
|
|
1483
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
1484
|
+
continue;
|
|
1485
|
+
let value = withoutExport.slice(equals + 1).trim();
|
|
1486
|
+
const quote = value[0];
|
|
1487
|
+
if (quote === '"' || quote === "'") {
|
|
1488
|
+
if (value.length < 2 || !value.endsWith(quote)) {
|
|
1489
|
+
unusable.add(key);
|
|
1490
|
+
continue;
|
|
1491
|
+
}
|
|
1492
|
+
value = value.slice(1, -1);
|
|
1493
|
+
}
|
|
1494
|
+
if (value.trim().length === 0) {
|
|
1495
|
+
unusable.add(key);
|
|
1496
|
+
continue;
|
|
1497
|
+
}
|
|
1498
|
+
if (values.has(key) && values.get(key) !== value)
|
|
1499
|
+
unusable.add(key);
|
|
1500
|
+
values.set(key, value);
|
|
1501
|
+
}
|
|
1502
|
+
return { values, unusable };
|
|
1503
|
+
}
|
|
1504
|
+
function configFileModeAllowed(mode) {
|
|
1505
|
+
const permissions = mode & 4095;
|
|
1506
|
+
return permissions === 256 || permissions === 384;
|
|
1507
|
+
}
|
|
1508
|
+
function configFileReadsCoherent(before, after) {
|
|
1509
|
+
return before.dev === after.dev && before.ino === after.ino && before.size === after.size && before.mtimeMs === after.mtimeMs && before.ctimeMs === after.ctimeMs;
|
|
1510
|
+
}
|
|
1511
|
+
function readAppConfigFile(path) {
|
|
1512
|
+
const unsafe = (reason) => {
|
|
1513
|
+
throw new CredentialFileUnsafeError(path, reason);
|
|
1514
|
+
};
|
|
1515
|
+
let fd = -1;
|
|
1516
|
+
try {
|
|
1517
|
+
fd = openSync(path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
|
|
1518
|
+
} catch (error) {
|
|
1519
|
+
const code = error.code;
|
|
1520
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
1521
|
+
return null;
|
|
1522
|
+
if (code === "ELOOP")
|
|
1523
|
+
unsafe("the path is a symlink");
|
|
1524
|
+
unsafe(`the path could not be opened (${code ?? "unknown error"})`);
|
|
1525
|
+
}
|
|
1526
|
+
try {
|
|
1527
|
+
const before = fstatSync(fd);
|
|
1528
|
+
if (!before.isFile())
|
|
1529
|
+
unsafe("the path is not a regular file");
|
|
1530
|
+
if (!configFileModeAllowed(before.mode)) {
|
|
1531
|
+
unsafe(`permission mode ${(before.mode & 4095).toString(8).padStart(4, "0")} is not owner-only 0400 or 0600`);
|
|
1532
|
+
}
|
|
1533
|
+
const uid = process.getuid?.() ?? process.geteuid?.();
|
|
1534
|
+
if (uid !== undefined && before.uid !== uid)
|
|
1535
|
+
unsafe("the file is not owned by the current user");
|
|
1536
|
+
if (before.size > MAX_CREDENTIAL_FILE_BYTES)
|
|
1537
|
+
unsafe("the file exceeds the size limit");
|
|
1538
|
+
const bytes = readFileSync6(fd);
|
|
1539
|
+
const after = fstatSync(fd);
|
|
1540
|
+
if (!configFileReadsCoherent(before, after)) {
|
|
1541
|
+
unsafe("the file changed while being read");
|
|
1542
|
+
}
|
|
1543
|
+
return parseEnvFile(bytes.toString("utf8"));
|
|
1544
|
+
} finally {
|
|
1545
|
+
if (fd !== -1)
|
|
1546
|
+
closeSync(fd);
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
function readCredentialFile(path, apiKeyKeys) {
|
|
1550
|
+
const parsed = readAppConfigFile(path);
|
|
1551
|
+
if (!parsed)
|
|
1552
|
+
return null;
|
|
1553
|
+
for (const key of apiKeyKeys) {
|
|
1554
|
+
if (parsed.unusable.has(key)) {
|
|
1555
|
+
throw new CredentialFileUnsafeError(path, `${key} is declared but blank or malformed`);
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
const values = apiKeyKeys.map((key) => parsed.values.get(key)?.trim()).filter((value) => Boolean(value));
|
|
1559
|
+
if (new Set(values).size > 1) {
|
|
1560
|
+
throw new CredentialFileUnsafeError(path, "credential aliases disagree");
|
|
1561
|
+
}
|
|
1562
|
+
return values[0] ?? null;
|
|
1563
|
+
}
|
|
1564
|
+
function appConfigDiskValue(name, env, keys) {
|
|
1565
|
+
const wanted = keys.filter((key) => !CREDENTIAL_SHAPED_KEY.test(key));
|
|
1566
|
+
if (wanted.length === 0)
|
|
1567
|
+
return null;
|
|
1568
|
+
for (const path of credentialDiskSources(name, env)) {
|
|
1569
|
+
const parsed = readAppConfigFile(path);
|
|
1570
|
+
if (!parsed)
|
|
1571
|
+
continue;
|
|
1572
|
+
if (wanted.some((key) => parsed.unusable.has(key))) {
|
|
1573
|
+
return { key: wanted.find((key) => parsed.unusable.has(key)), value: "", path, unusable: true };
|
|
1574
|
+
}
|
|
1575
|
+
const values = wanted.map((key) => parsed.values.get(key)?.trim()).filter((value) => Boolean(value));
|
|
1576
|
+
if (new Set(values).size > 1)
|
|
1577
|
+
throw new CredentialFileUnsafeError(path, "configuration aliases disagree");
|
|
1578
|
+
for (const key of wanted) {
|
|
1579
|
+
if (parsed.unusable.has(key))
|
|
1580
|
+
return { key, value: "", path, unusable: true };
|
|
1581
|
+
const value = parsed.values.get(key)?.trim();
|
|
1582
|
+
if (value)
|
|
1583
|
+
return { key, value, path };
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
return null;
|
|
1587
|
+
}
|
|
1588
|
+
function assertUsableCredential(appName, source, value) {
|
|
1589
|
+
if (VAULT_POINTER_SHAPE.test(value)) {
|
|
1590
|
+
throw new CredentialResolutionError(appName, `The credential from ${source} looks like a secrets-vault pointer (a path-shaped reference like ` + `'namespace/app/live/api_key'). A vault path is NEVER accepted as a literal API key. ` + `Use ${credentialPointerEnvKey(appName)} to resolve the key through the vault, or provide the actual key value.`, [source]);
|
|
1591
|
+
}
|
|
1592
|
+
if (!ILLEGAL_IN_HEADER_VALUE.test(value))
|
|
1593
|
+
return;
|
|
1594
|
+
throw new CredentialResolutionError(appName, `The credential from ${source} contains characters that cannot be sent in an HTTP header ` + `(a control character or non-ASCII byte). A file written with CR-only line endings is the usual ` + `cause. Rewrite that credential file with one LF-terminated KEY=value line. ` + `The value is not shown here, and is deliberately never logged.`, [source]);
|
|
1595
|
+
}
|
|
1596
|
+
function sealCredential(fields) {
|
|
1597
|
+
const { apiKey } = fields;
|
|
1598
|
+
const visible = {
|
|
1599
|
+
tier: fields.tier,
|
|
1600
|
+
source: fields.source,
|
|
1601
|
+
deliberate: fields.deliberate,
|
|
1602
|
+
diskCandidates: Object.freeze([...fields.diskCandidates]),
|
|
1603
|
+
warning: fields.warning
|
|
1604
|
+
};
|
|
1605
|
+
const sealed = { ...visible };
|
|
1606
|
+
Object.defineProperty(sealed, "apiKey", {
|
|
1607
|
+
value: apiKey,
|
|
1608
|
+
enumerable: false,
|
|
1609
|
+
writable: false,
|
|
1610
|
+
configurable: false
|
|
1611
|
+
});
|
|
1612
|
+
if (fields.pointerVaultKey !== undefined) {
|
|
1613
|
+
Object.defineProperty(sealed, "pointerVaultKey", {
|
|
1614
|
+
value: fields.pointerVaultKey,
|
|
1615
|
+
enumerable: false,
|
|
1616
|
+
writable: false,
|
|
1617
|
+
configurable: false
|
|
1618
|
+
});
|
|
1619
|
+
}
|
|
1620
|
+
Object.defineProperty(sealed, INSPECT_CUSTOM, {
|
|
1621
|
+
value: () => ({ ...visible, apiKey: "[redacted]" }),
|
|
1622
|
+
enumerable: false,
|
|
1623
|
+
writable: false,
|
|
1624
|
+
configurable: false
|
|
1625
|
+
});
|
|
1626
|
+
Object.defineProperty(sealed, CREDENTIAL_SEAL, {
|
|
1627
|
+
value: true,
|
|
1628
|
+
enumerable: false,
|
|
1629
|
+
writable: false,
|
|
1630
|
+
configurable: false
|
|
1631
|
+
});
|
|
1632
|
+
return Object.freeze(sealed);
|
|
1633
|
+
}
|
|
1634
|
+
function firstEnvValue(env, keys) {
|
|
1635
|
+
for (const key of keys) {
|
|
1636
|
+
if (!Object.prototype.hasOwnProperty.call(env, key))
|
|
1637
|
+
continue;
|
|
1638
|
+
const value = env[key]?.trim();
|
|
1639
|
+
if (value)
|
|
1640
|
+
return { key, value };
|
|
1641
|
+
}
|
|
1642
|
+
return null;
|
|
1643
|
+
}
|
|
1644
|
+
function isAmbientEnvironment(env) {
|
|
1645
|
+
return env === process.env || env[AMBIENT_ENVIRONMENT] === true;
|
|
1646
|
+
}
|
|
1647
|
+
function defaultKeychainRunner(argv) {
|
|
1648
|
+
const result = spawnSync(KEYCHAIN_SECURITY_BIN, [...argv], {
|
|
1649
|
+
encoding: "utf8",
|
|
1650
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1651
|
+
timeout: KEYCHAIN_SPAWN_TIMEOUT_MS
|
|
1652
|
+
});
|
|
1653
|
+
return {
|
|
1654
|
+
status: result.status,
|
|
1655
|
+
stdout: result.stdout ?? "",
|
|
1656
|
+
stderr: result.error ? result.error.message : result.stderr ?? ""
|
|
1657
|
+
};
|
|
1658
|
+
}
|
|
1659
|
+
function keychainTierEnabled(env, options) {
|
|
1660
|
+
if ((options.platform ?? process.platform) !== "darwin")
|
|
1661
|
+
return false;
|
|
1662
|
+
if (options.enabled !== undefined)
|
|
1663
|
+
return options.enabled;
|
|
1664
|
+
return options.run !== undefined || isAmbientEnvironment(env);
|
|
1665
|
+
}
|
|
1666
|
+
function keychainAccount(env, options) {
|
|
1667
|
+
const station = env[KEYCHAIN_STATION_ENV_KEY]?.trim();
|
|
1668
|
+
if (station)
|
|
1669
|
+
return station;
|
|
1670
|
+
const host = (options.hostname ?? osHostname)().split(".")[0]?.trim() ?? "";
|
|
1671
|
+
if (host)
|
|
1672
|
+
return host;
|
|
1673
|
+
const user = env.USER?.trim();
|
|
1674
|
+
return user || null;
|
|
1675
|
+
}
|
|
1676
|
+
function keychainFailureHint(text) {
|
|
1677
|
+
const line = text.split(/\r?\n/).find((entry) => entry.trim().length > 0)?.trim() ?? "";
|
|
1678
|
+
const clean = line.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 200);
|
|
1679
|
+
return clean ? `: ${clean}` : "";
|
|
1680
|
+
}
|
|
1681
|
+
function readKeychainItem(name, env, kind, options) {
|
|
1682
|
+
if (!SAFE_APP_SLUG.test(name) || !keychainTierEnabled(env, options))
|
|
1683
|
+
return null;
|
|
1684
|
+
const account = keychainAccount(env, options);
|
|
1685
|
+
if (!account)
|
|
1686
|
+
return null;
|
|
1687
|
+
const service = `${KEYCHAIN_SERVICE_PREFIX}.${name}.${kind}`;
|
|
1688
|
+
const source = `keychain:${service}@${account}`;
|
|
1689
|
+
const run = options.run ?? defaultKeychainRunner;
|
|
1690
|
+
let result;
|
|
1691
|
+
try {
|
|
1692
|
+
result = run(["find-generic-password", "-a", account, "-s", service, "-w"]);
|
|
1693
|
+
} catch (error) {
|
|
1694
|
+
const reason = keychainFailureHint(error instanceof Error ? error.message : String(error));
|
|
1695
|
+
throw new CredentialResolutionError(name, `The Keychain lookup for ${source} could not run${reason}. A Keychain failure is never resolved ` + `around: fix the keychain, or delete the item to fall through to the credential on disk.`, [source]);
|
|
1696
|
+
}
|
|
1697
|
+
if (result.status === KEYCHAIN_ITEM_NOT_FOUND_STATUS)
|
|
1698
|
+
return null;
|
|
1699
|
+
if (result.status !== 0) {
|
|
1700
|
+
throw new CredentialResolutionError(name, `The Keychain lookup for ${source} failed (security exited ` + `${result.status ?? "without a status"}${keychainFailureHint(result.stderr)}). A Keychain item that ` + `exists but cannot be read is never resolved around: unlock the keychain, run from a session that ` + `may use it, or delete the item to fall through to the credential on disk.`, [source]);
|
|
1701
|
+
}
|
|
1702
|
+
const value = result.stdout.trim();
|
|
1703
|
+
if (!value) {
|
|
1704
|
+
throw new CredentialResolutionError(name, `${source} exists but holds an empty value; a declared item never falls through to another ` + `identity. Store a value in it or delete the item.`, [source]);
|
|
1705
|
+
}
|
|
1706
|
+
return { value, source };
|
|
1707
|
+
}
|
|
1708
|
+
function keychainConfigValue(name, env, options = {}) {
|
|
1709
|
+
return readKeychainItem(name, env, "api-url", options);
|
|
1710
|
+
}
|
|
1711
|
+
function snapshotClientEnvironment(name, env) {
|
|
1712
|
+
const keys = clientTransportEnvKeys(name);
|
|
1713
|
+
const ambient = isAmbientEnvironment(env);
|
|
1714
|
+
const snapshot = Object.create(null);
|
|
1715
|
+
for (const key of [
|
|
1716
|
+
...keys.apiUrlKeys,
|
|
1717
|
+
...keys.apiKeyKeys,
|
|
1718
|
+
credentialOverrideEnvKey(name),
|
|
1719
|
+
credentialPointerEnvKey(name),
|
|
1720
|
+
CREDENTIAL_PROFILE_ENV_KEY,
|
|
1721
|
+
"HOME",
|
|
1722
|
+
HASNA_HOME_ENV_KEY,
|
|
1723
|
+
HASNA_CONFIG_HOME_ENV_KEY,
|
|
1724
|
+
KEYCHAIN_STATION_ENV_KEY,
|
|
1725
|
+
"USER"
|
|
1726
|
+
]) {
|
|
1727
|
+
const descriptor = Object.getOwnPropertyDescriptor(env, key);
|
|
1728
|
+
if (!descriptor)
|
|
1729
|
+
continue;
|
|
1730
|
+
if (!("value" in descriptor)) {
|
|
1731
|
+
throw new CredentialResolutionError(name, `${key} is accessor-backed; client configuration requires own data properties.`, [key]);
|
|
1732
|
+
}
|
|
1733
|
+
if (descriptor.value !== undefined && typeof descriptor.value !== "string") {
|
|
1734
|
+
throw new CredentialResolutionError(name, `${key} must be a string data property.`, [key]);
|
|
1735
|
+
}
|
|
1736
|
+
snapshot[key] = descriptor.value;
|
|
1737
|
+
}
|
|
1738
|
+
if (ambient) {
|
|
1739
|
+
Object.defineProperty(snapshot, AMBIENT_ENVIRONMENT, {
|
|
1740
|
+
value: true,
|
|
1741
|
+
enumerable: false,
|
|
1742
|
+
writable: false,
|
|
1743
|
+
configurable: false
|
|
1744
|
+
});
|
|
1745
|
+
}
|
|
1746
|
+
return Object.freeze(snapshot);
|
|
1747
|
+
}
|
|
1748
|
+
function resolveCredential(name, env, options = {}) {
|
|
1749
|
+
env = snapshotClientEnvironment(name, env);
|
|
1750
|
+
const { apiKeyKeys } = clientTransportEnvKeys(name);
|
|
1751
|
+
const diskPaths = credentialDiskSources(name, env);
|
|
1752
|
+
if (options.apiKey !== undefined) {
|
|
1753
|
+
const explicitKey = options.apiKey.trim();
|
|
1754
|
+
if (!explicitKey) {
|
|
1755
|
+
throw new CredentialResolutionError(name, "The explicit apiKey argument is blank; an explicit credential never falls through to another identity.", ["explicit apiKey argument"]);
|
|
1756
|
+
}
|
|
1757
|
+
assertUsableCredential(name, "the explicit apiKey argument", explicitKey);
|
|
1758
|
+
return sealCredential({
|
|
1759
|
+
apiKey: explicitKey,
|
|
1760
|
+
tier: "argument",
|
|
1761
|
+
source: "explicit apiKey argument",
|
|
1762
|
+
deliberate: true,
|
|
1763
|
+
diskCandidates: diskPaths,
|
|
1764
|
+
warning: null
|
|
1765
|
+
});
|
|
1766
|
+
}
|
|
1767
|
+
const overrideKeyName = credentialOverrideEnvKey(name);
|
|
1768
|
+
const overrideRaw = Object.prototype.hasOwnProperty.call(env, overrideKeyName) ? env[overrideKeyName] : undefined;
|
|
1769
|
+
if (overrideRaw !== undefined) {
|
|
1770
|
+
const override = overrideRaw.trim();
|
|
1771
|
+
if (!override) {
|
|
1772
|
+
throw new CredentialResolutionError(name, `${overrideKeyName} is set but empty. It is a deliberate override, so it is not resolved around: ` + `either give it a real key or unset it to fall back to the credential on disk.`, [overrideKeyName]);
|
|
1773
|
+
}
|
|
1774
|
+
assertUsableCredential(name, overrideKeyName, override);
|
|
1775
|
+
return sealCredential({
|
|
1776
|
+
apiKey: override,
|
|
1777
|
+
tier: "override",
|
|
1778
|
+
source: overrideKeyName,
|
|
1779
|
+
deliberate: true,
|
|
1780
|
+
diskCandidates: diskPaths,
|
|
1781
|
+
warning: null
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1784
|
+
const pointerKeyName = credentialPointerEnvKey(name);
|
|
1785
|
+
const pointerRaw = Object.prototype.hasOwnProperty.call(env, pointerKeyName) ? env[pointerKeyName] : undefined;
|
|
1786
|
+
if (pointerRaw !== undefined) {
|
|
1787
|
+
const pointer = pointerRaw.trim();
|
|
1788
|
+
if (!pointer) {
|
|
1789
|
+
throw new CredentialResolutionError(name, `${pointerKeyName} is set but empty. It is a deliberate vault pointer, so it is not resolved around: ` + `either give it a vault item key or unset it to fall back to the credential on disk.`, [pointerKeyName]);
|
|
1790
|
+
}
|
|
1791
|
+
if (!VAULT_POINTER_SHAPE.test(pointer)) {
|
|
1792
|
+
throw new CredentialResolutionError(name, `${pointerKeyName} must name a vault ITEM KEY (a path-shaped reference like ` + `'namespace/app/live/api_key'), not a credential value. A pointer that carries a literal is refused.`, [pointerKeyName]);
|
|
1793
|
+
}
|
|
1794
|
+
return sealCredential({
|
|
1795
|
+
apiKey: "",
|
|
1796
|
+
pointerVaultKey: pointer,
|
|
1797
|
+
tier: "pointer",
|
|
1798
|
+
source: pointerKeyName,
|
|
1799
|
+
deliberate: true,
|
|
1800
|
+
diskCandidates: diskPaths,
|
|
1801
|
+
warning: null
|
|
1802
|
+
});
|
|
1803
|
+
}
|
|
1804
|
+
if (options.profile !== undefined && !options.profile.trim()) {
|
|
1805
|
+
throw new CredentialResolutionError(name, "The explicit profile argument is blank; an explicit identity selection never falls through.", ["explicit profile argument"]);
|
|
1806
|
+
}
|
|
1807
|
+
const profileRaw = Object.prototype.hasOwnProperty.call(env, CREDENTIAL_PROFILE_ENV_KEY) ? env[CREDENTIAL_PROFILE_ENV_KEY] : undefined;
|
|
1808
|
+
if (profileRaw !== undefined && !profileRaw.trim()) {
|
|
1809
|
+
throw new CredentialResolutionError(name, `${CREDENTIAL_PROFILE_ENV_KEY} is set but blank.`, [CREDENTIAL_PROFILE_ENV_KEY]);
|
|
1810
|
+
}
|
|
1811
|
+
const profile = options.profile?.trim() || profileRaw?.trim();
|
|
1812
|
+
if (profile) {
|
|
1813
|
+
const profileSource = options.profile?.trim() ? "explicit profile argument" : CREDENTIAL_PROFILE_ENV_KEY;
|
|
1814
|
+
if (!SAFE_PROFILE.test(profile)) {
|
|
1815
|
+
throw new CredentialResolutionError(name, `Profile name from ${profileSource} is not usable in a path. ` + `Use letters, digits, dot, dash, or underscore.`, [profileSource]);
|
|
1816
|
+
}
|
|
1817
|
+
const paths = profileDiskSources(name, env, profile);
|
|
1818
|
+
for (const path of paths) {
|
|
1819
|
+
const value = readCredentialFile(path, apiKeyKeys);
|
|
1820
|
+
if (value) {
|
|
1821
|
+
assertUsableCredential(name, path, value);
|
|
1822
|
+
return sealCredential({
|
|
1823
|
+
apiKey: value,
|
|
1824
|
+
tier: "profile",
|
|
1825
|
+
source: path,
|
|
1826
|
+
deliberate: true,
|
|
1827
|
+
diskCandidates: paths,
|
|
1828
|
+
warning: null
|
|
1829
|
+
});
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
throw new CredentialResolutionError(name, `Profile '${profile}' (from ${profileSource}) has no ${apiKeyKeys[0]} for '${name}'. ` + `Looked in: ${paths.join(", ") || "<no HOME in this environment>"}. ` + `A profile names WHICH identity to use, so it is never resolved around \u2014 ` + `create the profile's credential file or unset ${CREDENTIAL_PROFILE_ENV_KEY}.`, paths);
|
|
1833
|
+
}
|
|
1834
|
+
const definedEnvEntries = apiKeyKeys.filter((key) => Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined).map((key) => ({ key, value: String(env[key]).trim() }));
|
|
1835
|
+
const blankEnv = definedEnvEntries.find((entry) => entry.value.length === 0);
|
|
1836
|
+
if (blankEnv) {
|
|
1837
|
+
throw new CredentialResolutionError(name, `${blankEnv.key} is set but blank; a declared credential never falls through to another alias or identity.`, [blankEnv.key]);
|
|
1838
|
+
}
|
|
1839
|
+
if (definedEnvEntries.length > 1 && new Set(definedEnvEntries.map((entry) => entry.value)).size > 1) {
|
|
1840
|
+
throw new CredentialResolutionError(name, `${definedEnvEntries.map((entry) => entry.key).join(" and ")} disagree; credential aliases must be identical or only one may be set.`, definedEnvEntries.map((entry) => entry.key));
|
|
1841
|
+
}
|
|
1842
|
+
const envHit = firstEnvValue(env, apiKeyKeys);
|
|
1843
|
+
const keychainHit = readKeychainItem(name, env, "api-key", options.keychain ?? {});
|
|
1844
|
+
if (keychainHit) {
|
|
1845
|
+
assertUsableCredential(name, keychainHit.source, keychainHit.value);
|
|
1846
|
+
const warning = envHit && envHit.value !== keychainHit.value ? `Credential sources disagree for '${name}': ${keychainHit.source} and ${envHit.key} hold ` + `different keys. ${keychainHit.source} wins, because the Keychain is re-read on every call while ` + `an environment variable is a snapshot. Reconcile them \u2014 a rotation that updated only one leaves ` + `the other to fail 401 wherever it is loaded first.` : null;
|
|
1847
|
+
return sealCredential({
|
|
1848
|
+
apiKey: keychainHit.value,
|
|
1849
|
+
tier: "keychain",
|
|
1850
|
+
source: keychainHit.source,
|
|
1851
|
+
deliberate: false,
|
|
1852
|
+
diskCandidates: diskPaths,
|
|
1853
|
+
warning
|
|
1854
|
+
});
|
|
1855
|
+
}
|
|
1856
|
+
const diskSourceList = credentialDiskSourceList(name, env, null);
|
|
1857
|
+
const diskHits = diskSourceList.map((src) => ({ src, value: readCredentialFile(src.path, apiKeyKeys) })).filter((hit) => hit.value !== null);
|
|
1858
|
+
if (diskHits.length > 0) {
|
|
1859
|
+
const winner = diskHits[0];
|
|
1860
|
+
assertUsableCredential(name, winner.src.path, winner.value);
|
|
1861
|
+
const divergentSources = [
|
|
1862
|
+
...diskHits.slice(1).filter((hit) => hit.value !== winner.value).map((hit) => hit.src.path),
|
|
1863
|
+
...envHit && envHit.value !== winner.value ? [envHit.key] : []
|
|
1864
|
+
];
|
|
1865
|
+
const warning = divergentSources.length > 0 ? `Credential sources disagree for '${name}': ${winner.src.path} and ` + `${divergentSources.join(", ")} hold different keys. ${winner.src.path} wins, because a file on ` + `disk is re-read on every call while an environment variable is a snapshot. Reconcile them \u2014 ` + `a rotation that updated only one leaves the other to fail 401 wherever it is loaded first.` : null;
|
|
1866
|
+
return sealCredential({
|
|
1867
|
+
apiKey: winner.value,
|
|
1868
|
+
tier: winner.src.tier,
|
|
1869
|
+
source: winner.src.path,
|
|
1870
|
+
deliberate: false,
|
|
1871
|
+
diskCandidates: diskPaths,
|
|
1872
|
+
warning
|
|
1873
|
+
});
|
|
1874
|
+
}
|
|
1875
|
+
if (envHit) {
|
|
1876
|
+
assertUsableCredential(name, envHit.key, envHit.value);
|
|
1877
|
+
return sealCredential({
|
|
1878
|
+
apiKey: envHit.value,
|
|
1879
|
+
tier: "env",
|
|
1880
|
+
source: envHit.key,
|
|
1881
|
+
deliberate: false,
|
|
1882
|
+
diskCandidates: diskPaths,
|
|
1883
|
+
warning: null
|
|
1884
|
+
});
|
|
1885
|
+
}
|
|
1886
|
+
return null;
|
|
1887
|
+
}
|
|
1888
|
+
function defaultFleetGatewayBaseUrl(name) {
|
|
1889
|
+
return `${DEFAULT_FLEET_GATEWAY_ORIGIN}/${validateAppSlug(name)}`;
|
|
1890
|
+
}
|
|
1891
|
+
function isValidDnsDomain(value) {
|
|
1892
|
+
if (value.length === 0 || value.length > 253 || ASCII_CONTROL_PATTERN.test(value) || /[^\x00-\x7f]/.test(value)) {
|
|
1893
|
+
return false;
|
|
1894
|
+
}
|
|
1895
|
+
return value.split(".").every((label) => label.length <= 63 && !label.startsWith("xn--") && DNS_LABEL_PATTERN.test(label));
|
|
1896
|
+
}
|
|
1897
|
+
function validateAppSlug(name) {
|
|
1898
|
+
if (name.length > 63 || !DNS_LABEL_PATTERN.test(name)) {
|
|
1899
|
+
throw new Error("App name must be one lowercase DNS label.");
|
|
1900
|
+
}
|
|
1901
|
+
return name;
|
|
1902
|
+
}
|
|
1903
|
+
function rawAuthority(value) {
|
|
1904
|
+
const match = /^[a-z][a-z0-9+.-]*:\/\//i.exec(value);
|
|
1905
|
+
if (!match)
|
|
1906
|
+
throw new Error("API URL must be absolute.");
|
|
1907
|
+
const afterScheme = value.slice(match[0].length);
|
|
1908
|
+
const boundary = afterScheme.search(/[/?#]/);
|
|
1909
|
+
const authority = boundary === -1 ? afterScheme : afterScheme.slice(0, boundary);
|
|
1910
|
+
if (!authority)
|
|
1911
|
+
throw new Error("API URL must include a hostname.");
|
|
1912
|
+
return authority;
|
|
1913
|
+
}
|
|
1914
|
+
function assertCanonicalPort(port) {
|
|
1915
|
+
if (!/^[0-9]+$/.test(port) || port.length > 1 && port.startsWith("0")) {
|
|
1916
|
+
throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
|
|
1917
|
+
}
|
|
1918
|
+
const numericPort = Number(port);
|
|
1919
|
+
if (!Number.isSafeInteger(numericPort) || numericPort < 1 || numericPort > 65535) {
|
|
1920
|
+
throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
function canonicalAuthorityHostname(authority) {
|
|
1924
|
+
let rawHostname;
|
|
1925
|
+
if (authority.startsWith("[")) {
|
|
1926
|
+
const closingBracket = authority.indexOf("]");
|
|
1927
|
+
if (closingBracket === -1) {
|
|
1928
|
+
throw new Error("API URL authority must contain a canonical hostname.");
|
|
1929
|
+
}
|
|
1930
|
+
rawHostname = authority.slice(0, closingBracket + 1);
|
|
1931
|
+
const portSuffix = authority.slice(closingBracket + 1);
|
|
1932
|
+
if (portSuffix) {
|
|
1933
|
+
if (!portSuffix.startsWith(":")) {
|
|
1934
|
+
throw new Error("API URL authority must contain a canonical hostname and port.");
|
|
1935
|
+
}
|
|
1936
|
+
assertCanonicalPort(portSuffix.slice(1));
|
|
1937
|
+
}
|
|
1938
|
+
if (isIP(rawHostname.slice(1, -1)) !== 6) {
|
|
1939
|
+
throw new Error("API URL authority must contain a canonical IPv6 literal.");
|
|
1940
|
+
}
|
|
1941
|
+
} else {
|
|
1942
|
+
const firstColon = authority.indexOf(":");
|
|
1943
|
+
const lastColon = authority.lastIndexOf(":");
|
|
1944
|
+
if (firstColon !== lastColon) {
|
|
1945
|
+
throw new Error("IPv6 API URL authorities must use brackets.");
|
|
1946
|
+
}
|
|
1947
|
+
if (lastColon !== -1) {
|
|
1948
|
+
const port = authority.slice(lastColon + 1);
|
|
1949
|
+
assertCanonicalPort(port);
|
|
1950
|
+
rawHostname = authority.slice(0, lastColon);
|
|
1951
|
+
} else {
|
|
1952
|
+
rawHostname = authority;
|
|
1953
|
+
}
|
|
1954
|
+
const ipVersion = isIP(rawHostname);
|
|
1955
|
+
const numericAddressParts = rawHostname.split(".");
|
|
1956
|
+
const looksLikeNonCanonicalIpv4 = numericAddressParts.every((part) => /^(?:0x[0-9a-f]+|[0-9]+)$/i.test(part));
|
|
1957
|
+
if (ipVersion !== 4 && looksLikeNonCanonicalIpv4 || ipVersion !== 4 && !isValidDnsDomain(rawHostname.toLowerCase())) {
|
|
1958
|
+
throw new Error("API URL authority must contain a canonical ASCII hostname.");
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
return rawHostname.toLowerCase();
|
|
1962
|
+
}
|
|
1963
|
+
function isDeliberateLoopbackHttpAuthority(authority) {
|
|
1964
|
+
return /^(?:localhost|127\.0\.0\.1|\[::1\])(?::[0-9]+)?$/i.test(authority);
|
|
1965
|
+
}
|
|
1966
|
+
function toV1BaseUrl(apiUrl) {
|
|
1967
|
+
if (ASCII_CONTROL_PATTERN.test(apiUrl)) {
|
|
1968
|
+
throw new Error("API URL must not contain ASCII control characters.");
|
|
1969
|
+
}
|
|
1970
|
+
const input = apiUrl.trim();
|
|
1971
|
+
const authority = rawAuthority(input);
|
|
1972
|
+
if (authority.includes("@") || authority.includes("\\") || authority.includes("%") || /[^\x00-\x7f]/.test(authority)) {
|
|
1973
|
+
throw new Error("API URL authority must be canonical ASCII without credentials.");
|
|
1974
|
+
}
|
|
1975
|
+
const canonicalHostname = canonicalAuthorityHostname(authority);
|
|
1976
|
+
const url = new URL(input);
|
|
1977
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
1978
|
+
throw new Error("API URL must use http or https.");
|
|
1979
|
+
}
|
|
1980
|
+
if (url.username || url.password) {
|
|
1981
|
+
throw new Error("API URL must not include credentials.");
|
|
1982
|
+
}
|
|
1983
|
+
if (!url.hostname || url.hostname.endsWith(".")) {
|
|
1984
|
+
throw new Error("API URL must include a canonical hostname.");
|
|
1985
|
+
}
|
|
1986
|
+
if (url.hostname.toLowerCase() !== canonicalHostname) {
|
|
1987
|
+
throw new Error("API URL authority must not rely on parser hostname normalization.");
|
|
1988
|
+
}
|
|
1989
|
+
if (url.hostname.split(".").some((label) => label.toLowerCase().startsWith("xn--"))) {
|
|
1990
|
+
throw new Error("API URL must not use IDN or punycode hostnames.");
|
|
1991
|
+
}
|
|
1992
|
+
if (url.protocol === "http:" && !isDeliberateLoopbackHttpAuthority(authority)) {
|
|
1993
|
+
throw new Error("API URL may use http only for an exact loopback authority.");
|
|
1994
|
+
}
|
|
1995
|
+
if (url.search || url.hash) {
|
|
1996
|
+
throw new Error("API URL must not include a query string or fragment.");
|
|
1997
|
+
}
|
|
1998
|
+
let path = url.pathname.replace(/\/+$/, "");
|
|
1999
|
+
if (path.endsWith("/v1"))
|
|
2000
|
+
path = path.slice(0, -"/v1".length);
|
|
2001
|
+
url.pathname = `${path}/v1`;
|
|
2002
|
+
return url.toString().replace(/\/+$/, "");
|
|
2003
|
+
}
|
|
2004
|
+
function resolveClientTransportSnapshot(name, env = process.env, options = {}) {
|
|
2005
|
+
env = snapshotClientEnvironment(name, env);
|
|
2006
|
+
const keys = clientTransportEnvKeys(name);
|
|
2007
|
+
const definedUrlEntries = keys.apiUrlKeys.filter((key) => Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined).map((key) => ({ key, raw: String(env[key]) }));
|
|
2008
|
+
const blankUrl = definedUrlEntries.find((entry) => entry.raw.trim().length === 0);
|
|
2009
|
+
if (blankUrl) {
|
|
2010
|
+
throw new ClientTransportConfigurationError(name, `${blankUrl.key} is set but blank; public clients require an explicit HTTPS API URL and never select local storage.`, [blankUrl.key]);
|
|
2011
|
+
}
|
|
2012
|
+
const controlledUrl = definedUrlEntries.find((entry) => ASCII_CONTROL_PATTERN.test(entry.raw));
|
|
2013
|
+
if (controlledUrl) {
|
|
2014
|
+
throw new ClientTransportConfigurationError(name, `${controlledUrl.key} contains ASCII control characters.`, [controlledUrl.key]);
|
|
2015
|
+
}
|
|
2016
|
+
const usableUrlEntries = definedUrlEntries.map((entry) => ({ key: entry.key, value: entry.raw.trim() }));
|
|
2017
|
+
if (usableUrlEntries.length > 1 && new Set(usableUrlEntries.map((entry) => entry.value)).size > 1) {
|
|
2018
|
+
throw new ClientTransportConfigurationError(name, `${usableUrlEntries.map((entry) => entry.key).join(" and ")} disagree; client authority aliases must be identical or only one may be set.`, usableUrlEntries.map((entry) => entry.key));
|
|
2019
|
+
}
|
|
2020
|
+
const envUrlHit = usableUrlEntries[0] ?? null;
|
|
2021
|
+
const keychainUrlHit = keychainConfigValue(name, env, options.credentials?.keychain);
|
|
2022
|
+
const diskConfigUrlHit = appConfigDiskValue(name, env, keys.apiUrlKeys);
|
|
2023
|
+
if (diskConfigUrlHit?.unusable) {
|
|
2024
|
+
throw new ClientTransportConfigurationError(name, `${diskConfigUrlHit.key} in ${diskConfigUrlHit.path} is declared but blank or malformed; public clients require a valid HTTPS service authority.`, [diskConfigUrlHit.path]);
|
|
2025
|
+
}
|
|
2026
|
+
const urlCandidates = [
|
|
2027
|
+
...envUrlHit ? [envUrlHit] : [],
|
|
2028
|
+
...keychainUrlHit ? [{ key: keychainUrlHit.source, value: keychainUrlHit.value }] : [],
|
|
2029
|
+
...diskConfigUrlHit ? [{ key: diskConfigUrlHit.path, value: diskConfigUrlHit.value.trim() }] : []
|
|
2030
|
+
];
|
|
2031
|
+
const configuredUrl = urlCandidates[0] ?? null;
|
|
2032
|
+
const divergentUrls = urlCandidates.filter((candidate) => candidate.value !== configuredUrl?.value);
|
|
2033
|
+
if (configuredUrl && divergentUrls.length > 0) {
|
|
2034
|
+
throw new ClientTransportConfigurationError(name, `${configuredUrl.key} and ${divergentUrls.map((candidate) => candidate.key).join(" and ")} select different service authorities; refusing to send a credential written for one authority to the other.`, urlCandidates.map((candidate) => candidate.key));
|
|
2035
|
+
}
|
|
2036
|
+
const warnings = [];
|
|
2037
|
+
if (configuredUrl && !envUrlHit) {
|
|
2038
|
+
warnings.push(`No ${keys.apiUrlKeys[0]} in the environment; the server URL in ${configuredUrl.key} was used, so this client connects to the server. ` + `Keep that entry aligned with the intended service authority.`);
|
|
2039
|
+
}
|
|
2040
|
+
const credential = resolveCredential(name, env, options.credentials);
|
|
2041
|
+
if (!credential) {
|
|
2042
|
+
const diskHint = credentialDiskSourcesForMessage(name, env);
|
|
2043
|
+
const lead = configuredUrl ? `${configuredUrl.key} selects the HTTP server for '${name}', but no API key could be resolved` : `${keys.apiUrlKeys[0]} is not set and no API key could be resolved for '${name}'; a credential is required before the default fleet gateway authority applies`;
|
|
2044
|
+
warnings.push(`${lead}; refusing to create an unauthenticated client \u2014 public clients never fall back to SQLite or another local store. ` + `Looked in the Keychain (macOS only), then for a credential file at ${diskHint}, then for ${keys.apiKeyKeys[0]} in the environment.`);
|
|
2045
|
+
throw new ClientTransportConfigurationError(name, warnings.join(" "), [configuredUrl?.key ?? keys.apiUrlKeys[0]]);
|
|
2046
|
+
}
|
|
2047
|
+
if (credential.warning)
|
|
2048
|
+
warnings.push(credential.warning);
|
|
2049
|
+
let urlHit;
|
|
2050
|
+
if (configuredUrl) {
|
|
2051
|
+
urlHit = configuredUrl;
|
|
2052
|
+
} else {
|
|
2053
|
+
try {
|
|
2054
|
+
urlHit = { key: DEFAULT_AUTHORITY_SOURCE, value: defaultFleetGatewayBaseUrl(name) };
|
|
2055
|
+
} catch (error) {
|
|
2056
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2057
|
+
throw new ClientTransportConfigurationError(name, `No ${keys.apiUrlKeys[0]} is configured and the default fleet gateway authority cannot be composed for '${name}': ${message}`, [keys.apiUrlKeys[0]]);
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
const apiUrlSource = urlHit.key;
|
|
2061
|
+
let baseUrl;
|
|
2062
|
+
try {
|
|
2063
|
+
baseUrl = toV1BaseUrl(urlHit.value);
|
|
2064
|
+
} catch (error) {
|
|
2065
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2066
|
+
throw new ClientTransportConfigurationError(name, `Invalid API URL from ${apiUrlSource}: ${message}`, [apiUrlSource]);
|
|
2067
|
+
}
|
|
2068
|
+
return {
|
|
2069
|
+
resolution: {
|
|
2070
|
+
transport: "http",
|
|
2071
|
+
transportSource: urlHit.key,
|
|
2072
|
+
baseUrl,
|
|
2073
|
+
apiUrlSource,
|
|
2074
|
+
apiKeyPresent: true,
|
|
2075
|
+
apiKeySource: credential.source,
|
|
2076
|
+
apiKeyTier: credential.tier,
|
|
2077
|
+
misconfigured: false,
|
|
2078
|
+
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
2079
|
+
},
|
|
2080
|
+
credential
|
|
2081
|
+
};
|
|
2082
|
+
}
|
|
2083
|
+
function resolveClientTransport(name, env = process.env, options = {}) {
|
|
2084
|
+
return resolveClientTransportSnapshot(name, env, options).resolution;
|
|
2085
|
+
}
|
|
2086
|
+
function credentialDiskSourcesForMessage(name, env) {
|
|
2087
|
+
const paths = credentialDiskSources(name, env);
|
|
2088
|
+
return paths.length > 0 ? paths.join(" or ") : "<no HOME or HASNA_HOME set in this environment, so no credential file was consulted>";
|
|
2089
|
+
}
|
|
2090
|
+
var CREDENTIAL_PROFILE_ENV_KEY = "HASNA_PROFILE", CredentialResolutionError, CredentialFileUnsafeError, HASNA_HOME_ENV_KEY = "HASNA_HOME", HASNA_CONFIG_HOME_ENV_KEY = "HASNA_CONFIG_HOME", KEYCHAIN_STATION_ENV_KEY = "HASNA_STATION", HASNA_HOME_DIR = ".hasna", CONFIG_SUBDIR = "config", CREDENTIALS_FILE = "credentials", KEYCHAIN_SECURITY_BIN = "/usr/bin/security", KEYCHAIN_SERVICE_PREFIX = "hasna.credentials", KEYCHAIN_ITEM_NOT_FOUND_STATUS = 44, KEYCHAIN_SPAWN_TIMEOUT_MS = 1e4, MAX_CREDENTIAL_FILE_BYTES, SAFE_APP_SLUG, SAFE_PROFILE, ILLEGAL_IN_HEADER_VALUE, VAULT_POINTER_SHAPE, CREDENTIAL_SHAPED_KEY, INSPECT_CUSTOM, CREDENTIAL_SEAL, AMBIENT_ENVIRONMENT, SECRETS_PACKAGE_SPECIFIER, requireSecretsSdk, DEFAULT_FLEET_GATEWAY_ORIGIN = "https://api.hasna.com", DEFAULT_AUTHORITY_SOURCE = "default", ASCII_CONTROL_PATTERN, DNS_LABEL_PATTERN, ClientTransportConfigurationError, IDEMPOTENT_METHODS, AUTHORITY_OVERRIDE_HEADERS;
|
|
2091
|
+
var init_transport = __esm(() => {
|
|
2092
|
+
CredentialResolutionError = class CredentialResolutionError extends Error {
|
|
2093
|
+
appName;
|
|
2094
|
+
attempted;
|
|
2095
|
+
constructor(appName, message, attempted) {
|
|
2096
|
+
super(message);
|
|
2097
|
+
this.name = "CredentialResolutionError";
|
|
2098
|
+
this.appName = appName;
|
|
2099
|
+
this.attempted = attempted;
|
|
2100
|
+
}
|
|
2101
|
+
};
|
|
2102
|
+
CredentialFileUnsafeError = class CredentialFileUnsafeError extends Error {
|
|
2103
|
+
path;
|
|
2104
|
+
constructor(path, reason) {
|
|
2105
|
+
super(`Refusing unsafe credential/config file ${path}: ${reason}.`);
|
|
2106
|
+
this.name = "CredentialFileUnsafeError";
|
|
2107
|
+
this.path = path;
|
|
2108
|
+
}
|
|
2109
|
+
};
|
|
2110
|
+
MAX_CREDENTIAL_FILE_BYTES = 64 * 1024;
|
|
2111
|
+
SAFE_APP_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
2112
|
+
SAFE_PROFILE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
|
2113
|
+
ILLEGAL_IN_HEADER_VALUE = /[^\t\x20-\x7e]/;
|
|
2114
|
+
VAULT_POINTER_SHAPE = /^[a-z0-9][a-z0-9-]*(?:\/[a-z0-9][a-z0-9-_.]*){2,}$/;
|
|
2115
|
+
CREDENTIAL_SHAPED_KEY = /(?:^|_)(?:API_KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)(?:_|$)/;
|
|
2116
|
+
INSPECT_CUSTOM = Symbol.for("nodejs.util.inspect.custom");
|
|
2117
|
+
CREDENTIAL_SEAL = Symbol.for("hasna:contracts:sealedCredential");
|
|
2118
|
+
AMBIENT_ENVIRONMENT = Symbol.for("hasna:contracts:ambientClientEnvironment");
|
|
2119
|
+
SECRETS_PACKAGE_SPECIFIER = "@hasna/" + "secrets";
|
|
2120
|
+
requireSecretsSdk = createRequire(import.meta.url);
|
|
2121
|
+
ASCII_CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
|
2122
|
+
DNS_LABEL_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
2123
|
+
ClientTransportConfigurationError = class ClientTransportConfigurationError extends Error {
|
|
2124
|
+
appName;
|
|
2125
|
+
sources;
|
|
2126
|
+
constructor(appName, message, sources = []) {
|
|
2127
|
+
super(message);
|
|
2128
|
+
this.name = "ClientTransportConfigurationError";
|
|
2129
|
+
this.appName = appName;
|
|
2130
|
+
this.sources = Object.freeze([...sources]);
|
|
2131
|
+
}
|
|
2132
|
+
};
|
|
2133
|
+
IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
2134
|
+
AUTHORITY_OVERRIDE_HEADERS = new Set([
|
|
2135
|
+
"host",
|
|
2136
|
+
":authority",
|
|
2137
|
+
"forwarded",
|
|
2138
|
+
"x-forwarded-host",
|
|
2139
|
+
"x-original-host"
|
|
2140
|
+
]);
|
|
2141
|
+
});
|
|
2142
|
+
|
|
2143
|
+
// src/lib/local-opt-in.ts
|
|
2144
|
+
function isHooksLocalOptIn(env = process.env) {
|
|
2145
|
+
return HOOKS_LOCAL_OPT_IN_ENV_KEYS.some((key) => (env[key] ?? "").trim() !== "");
|
|
2146
|
+
}
|
|
2147
|
+
function hooksAuthorityEnvKeys() {
|
|
2148
|
+
const keys = clientTransportEnvKeys("hooks");
|
|
2149
|
+
return [
|
|
2150
|
+
...keys.apiUrlKeys,
|
|
2151
|
+
...keys.apiKeyKeys,
|
|
2152
|
+
credentialOverrideEnvKey("hooks"),
|
|
2153
|
+
credentialPointerEnvKey("hooks"),
|
|
2154
|
+
CREDENTIAL_PROFILE_ENV_KEY
|
|
2155
|
+
];
|
|
2156
|
+
}
|
|
2157
|
+
function hasHooksEnvAuthorityIntent(env = process.env) {
|
|
2158
|
+
return hooksAuthorityEnvKeys().some((key) => (env[key] ?? "").trim() !== "");
|
|
2159
|
+
}
|
|
2160
|
+
function selectsHooksLocalStore(env = process.env) {
|
|
2161
|
+
return !hasHooksEnvAuthorityIntent(env) && isHooksLocalOptIn(env);
|
|
2162
|
+
}
|
|
2163
|
+
function hooksHostedRouteLocalStoreRefusal(authority) {
|
|
2164
|
+
return "REMOTE_COMMAND_UNSUPPORTED: this command is local-only \u2014 it reads the on-box SQLite store " + `(hooks.db), which the hosted hooks registry authority (${authority}) does not serve; local SQLite ` + `is opt-in only (${HOOKS_LOCAL_OPT_IN_HINT}) and is disabled by default \u2014 refusing to open a local ` + "store on the hosted route.";
|
|
2165
|
+
}
|
|
2166
|
+
function hooksEventSinkRefusal() {
|
|
2167
|
+
return "REMOTE_COMMAND_UNSUPPORTED: hook event not recorded \u2014 the hook event log is local-only (on-box " + "SQLite hooks.db) and the hosted hooks registry has no event route; local SQLite is opt-in only " + `(${HOOKS_LOCAL_OPT_IN_HINT}) and is disabled by default. Nothing was written.`;
|
|
2168
|
+
}
|
|
2169
|
+
function hooksResolverEnv(env) {
|
|
2170
|
+
const blanks = hooksAuthorityEnvKeys().filter((key) => (key in env) && (env[key] ?? "").trim() === "");
|
|
2171
|
+
if (blanks.length === 0)
|
|
2172
|
+
return env;
|
|
2173
|
+
const next = { ...env };
|
|
2174
|
+
for (const key of blanks)
|
|
2175
|
+
delete next[key];
|
|
2176
|
+
return next;
|
|
2177
|
+
}
|
|
2178
|
+
function isAmbientHooksEnv(env) {
|
|
2179
|
+
if (typeof process !== "undefined" && env === process.env)
|
|
2180
|
+
return true;
|
|
2181
|
+
return env[CONTRACTS_AMBIENT_ENVIRONMENT] === true;
|
|
2182
|
+
}
|
|
2183
|
+
function hooksResolverInputs(env, credentials = {}) {
|
|
2184
|
+
const normalised = hooksResolverEnv(env);
|
|
2185
|
+
if (normalised === env)
|
|
2186
|
+
return { env: normalised, credentials };
|
|
2187
|
+
const keychain = { ...credentials.keychain };
|
|
2188
|
+
if (keychain.enabled === undefined && keychain.run === undefined) {
|
|
2189
|
+
keychain.enabled = isAmbientHooksEnv(env);
|
|
2190
|
+
}
|
|
2191
|
+
return { env: normalised, credentials: { ...credentials, keychain } };
|
|
2192
|
+
}
|
|
2193
|
+
var HOOKS_LOCAL_OPT_IN_ENV_KEYS, HOOKS_LOCAL_OPT_IN_HINT = "HASNA_HOOKS_LOCAL=1 (alias HOOKS_LOCAL=1)", CONTRACTS_AMBIENT_ENVIRONMENT;
|
|
2194
|
+
var init_local_opt_in = __esm(() => {
|
|
2195
|
+
init_transport();
|
|
2196
|
+
HOOKS_LOCAL_OPT_IN_ENV_KEYS = ["HASNA_HOOKS_LOCAL", "HOOKS_LOCAL"];
|
|
2197
|
+
CONTRACTS_AMBIENT_ENVIRONMENT = Symbol.for("hasna:contracts:ambientClientEnvironment");
|
|
2198
|
+
});
|
|
2199
|
+
|
|
2200
|
+
// src/lib/hook-env.ts
|
|
2201
|
+
import { dirname as dirname4, isAbsolute as isAbsolute3 } from "path";
|
|
2202
|
+
import { existsSync as existsSync9, realpathSync as realpathSync2, statSync } from "fs";
|
|
2203
|
+
import { tmpdir } from "os";
|
|
2204
|
+
function isDeniedEnvName(name) {
|
|
2205
|
+
const upper = name.toUpperCase();
|
|
2206
|
+
if (DENY_PREFIXES.some((prefix) => upper.startsWith(prefix)))
|
|
2207
|
+
return true;
|
|
2208
|
+
if (DENY_SUFFIXES.some((suffix) => upper.endsWith(suffix)))
|
|
2209
|
+
return true;
|
|
2210
|
+
return DENY_CONTAINS.some((token) => upper.includes(token));
|
|
2211
|
+
}
|
|
2212
|
+
function isInterpreterInjectionEnvName(name) {
|
|
2213
|
+
if (INTERPRETER_INJECTION.has(name))
|
|
2214
|
+
return true;
|
|
2215
|
+
return INTERPRETER_INJECTION_PREFIXES.some((prefix) => name.startsWith(prefix));
|
|
2216
|
+
}
|
|
2217
|
+
function isBashFunctionEnvName(name) {
|
|
2218
|
+
return name.startsWith("BASH_FUNC_");
|
|
2219
|
+
}
|
|
2220
|
+
function isUnsafePathEntry(entry, home) {
|
|
2221
|
+
if (entry === "" || !isAbsolute3(entry))
|
|
2222
|
+
return true;
|
|
2223
|
+
if (home && (entry === home || entry.startsWith(home + "/")))
|
|
2224
|
+
return true;
|
|
2225
|
+
if (entry === "/tmp" || entry.startsWith("/tmp/"))
|
|
2226
|
+
return true;
|
|
2227
|
+
if (entry === "/var/tmp" || entry.startsWith("/var/tmp/"))
|
|
2228
|
+
return true;
|
|
2229
|
+
const canonical = (path) => {
|
|
2230
|
+
try {
|
|
2231
|
+
return realpathSync2(path);
|
|
2232
|
+
} catch {
|
|
2233
|
+
return path;
|
|
2234
|
+
}
|
|
2235
|
+
};
|
|
2236
|
+
const resolved = canonical(entry);
|
|
2237
|
+
for (const root of [home, tmpdir(), "/tmp", "/var/tmp"].filter((value) => Boolean(value))) {
|
|
2238
|
+
const blocked = canonical(root);
|
|
2239
|
+
if (resolved === blocked || resolved.startsWith(blocked + "/"))
|
|
2240
|
+
return true;
|
|
2241
|
+
}
|
|
2242
|
+
try {
|
|
2243
|
+
if ((statSync(entry).mode & 2) !== 0)
|
|
2244
|
+
return true;
|
|
2245
|
+
} catch {}
|
|
2246
|
+
return false;
|
|
2247
|
+
}
|
|
2248
|
+
function sanitizeHookPath(pathValue, home) {
|
|
2249
|
+
const baseline = PATH_BASELINE.slice();
|
|
2250
|
+
if (process.platform === "darwin" && existsSync9("/opt/homebrew/bin"))
|
|
2251
|
+
baseline.push("/opt/homebrew/bin");
|
|
2252
|
+
const bunDir = dirname4(process.execPath);
|
|
2253
|
+
const seen = new Set;
|
|
2254
|
+
const out = [];
|
|
2255
|
+
for (const dir of baseline) {
|
|
2256
|
+
if (seen.has(dir))
|
|
2257
|
+
continue;
|
|
2258
|
+
seen.add(dir);
|
|
2259
|
+
out.push(dir);
|
|
2260
|
+
}
|
|
2261
|
+
if (!seen.has(bunDir)) {
|
|
2262
|
+
seen.add(bunDir);
|
|
2263
|
+
out.push(bunDir);
|
|
2264
|
+
}
|
|
2265
|
+
for (const entry of pathValue.split(":")) {
|
|
2266
|
+
if (seen.has(entry))
|
|
2267
|
+
continue;
|
|
2268
|
+
if (isUnsafePathEntry(entry, home))
|
|
2269
|
+
continue;
|
|
2270
|
+
seen.add(entry);
|
|
2271
|
+
out.push(entry);
|
|
2272
|
+
}
|
|
2273
|
+
return out.join(":");
|
|
2274
|
+
}
|
|
2275
|
+
function buildHookEnv(source, extra) {
|
|
2276
|
+
const out = {};
|
|
2277
|
+
for (const name of ALLOWLIST) {
|
|
2278
|
+
if (name === "PATH")
|
|
2279
|
+
continue;
|
|
2280
|
+
const value = source[name] ?? extra?.[name];
|
|
2281
|
+
if (value !== undefined && value !== null)
|
|
2282
|
+
out[name] = value;
|
|
2283
|
+
}
|
|
2284
|
+
const pathOverride = extra?.PATH;
|
|
2285
|
+
if (pathOverride !== undefined && pathOverride !== null) {
|
|
2286
|
+
out.PATH = pathOverride;
|
|
2287
|
+
} else {
|
|
2288
|
+
const parentPath = source.PATH;
|
|
2289
|
+
if (parentPath !== undefined && parentPath !== null) {
|
|
2290
|
+
out.PATH = sanitizeHookPath(parentPath, source.HOME ?? extra?.HOME);
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
for (const [from, to] of CONFIG_PROJECTIONS) {
|
|
2294
|
+
const value = source[from] ?? extra?.[from];
|
|
2295
|
+
if (value !== undefined && value !== null)
|
|
2296
|
+
out[to] = value;
|
|
2297
|
+
}
|
|
2298
|
+
const merged = { ...source, ...extra ?? {} };
|
|
2299
|
+
for (const [name, value] of Object.entries(merged)) {
|
|
2300
|
+
if (out[name] !== undefined)
|
|
2301
|
+
continue;
|
|
2302
|
+
if (isDeniedEnvName(name))
|
|
2303
|
+
continue;
|
|
2304
|
+
if (isInterpreterInjectionEnvName(name))
|
|
2305
|
+
continue;
|
|
2306
|
+
if (isBashFunctionEnvName(name))
|
|
2307
|
+
continue;
|
|
2308
|
+
if (value !== undefined && value !== null)
|
|
2309
|
+
out[name] = value;
|
|
2310
|
+
}
|
|
2311
|
+
return out;
|
|
2312
|
+
}
|
|
2313
|
+
var ALLOWLIST, INTERPRETER_INJECTION, INTERPRETER_INJECTION_PREFIXES, DENY_SUFFIXES, DENY_PREFIXES, DENY_CONTAINS, PATH_BASELINE, CONFIG_PROJECTIONS;
|
|
2314
|
+
var init_hook_env = __esm(() => {
|
|
2315
|
+
ALLOWLIST = new Set(["PATH", "HOME", "LANG", "TZ", "SHELL", "TERM", "USER", "PWD"]);
|
|
2316
|
+
INTERPRETER_INJECTION = new Set([
|
|
2317
|
+
"BASH_ENV",
|
|
2318
|
+
"ENV",
|
|
2319
|
+
"BASHOPTS",
|
|
2320
|
+
"SHELLOPTS",
|
|
2321
|
+
"NODE_OPTIONS",
|
|
2322
|
+
"NODE_PATH",
|
|
2323
|
+
"PYTHONSTARTUP",
|
|
2324
|
+
"PYTHONINSPECT",
|
|
2325
|
+
"PYTHONPATH",
|
|
2326
|
+
"PYTHONHOME",
|
|
2327
|
+
"LD_PRELOAD",
|
|
2328
|
+
"LD_LIBRARY_PATH",
|
|
2329
|
+
"GCONV_PATH",
|
|
2330
|
+
"LOCPATH",
|
|
2331
|
+
"PERL5OPT",
|
|
2332
|
+
"RUBYOPT",
|
|
2333
|
+
"NODE_EXTRA_CA_CERTS",
|
|
2334
|
+
"NODE_TLS_REJECT_UNAUTHORIZED",
|
|
2335
|
+
"SSL_CERT_FILE",
|
|
2336
|
+
"SSL_CERT_DIR",
|
|
2337
|
+
"CURL_CA_BUNDLE",
|
|
2338
|
+
"GIT_SSL_CAINFO",
|
|
2339
|
+
"AWS_CA_BUNDLE"
|
|
2340
|
+
]);
|
|
2341
|
+
INTERPRETER_INJECTION_PREFIXES = ["GIT_CONFIG_"];
|
|
2342
|
+
DENY_SUFFIXES = ["KEY", "TOKEN", "SECRET", "PASSWORD", "PASSWD", "CREDENTIAL", "CREDENTIALS", "URL", "URI"];
|
|
2343
|
+
DENY_PREFIXES = [
|
|
2344
|
+
"HASNA_",
|
|
2345
|
+
"AWS_",
|
|
2346
|
+
"AZURE_",
|
|
2347
|
+
"GCP_",
|
|
2348
|
+
"VAULT_",
|
|
2349
|
+
"GOOGLE_",
|
|
2350
|
+
"OPENAI_",
|
|
2351
|
+
"ANTHROPIC_",
|
|
2352
|
+
"POSTGRES_",
|
|
2353
|
+
"MYSQL_",
|
|
2354
|
+
"REDIS_",
|
|
2355
|
+
"MONGO_",
|
|
2356
|
+
"MEMENTOS_"
|
|
2357
|
+
];
|
|
2358
|
+
DENY_CONTAINS = ["DATABASE_URL"];
|
|
2359
|
+
PATH_BASELINE = ["/usr/bin", "/bin", "/usr/local/bin", "/sbin", "/usr/sbin"];
|
|
2360
|
+
CONFIG_PROJECTIONS = [
|
|
2361
|
+
["HASNA_HOOKS_LOCAL", "HOOKS_LOCAL"],
|
|
2362
|
+
["HASNA_HOOKS_DATA_DIR", "HOOKS_DATA_DIR"],
|
|
2363
|
+
["HASNA_HOOKS_DB_PATH", "HOOKS_DB_PATH"],
|
|
2364
|
+
["HASNA_HOOKS_LOCK_PATH", "HOOKS_LOCK_PATH"],
|
|
2365
|
+
["HASNA_HOOKS_CONFIG_PATH", "HOOKS_CONFIG_PATH"]
|
|
2366
|
+
];
|
|
2367
|
+
});
|
|
2368
|
+
|
|
2369
|
+
// src/lib/run.ts
|
|
2370
|
+
var exports_run = {};
|
|
2371
|
+
__export(exports_run, {
|
|
2372
|
+
executeVerifiedScript: () => executeVerifiedScript,
|
|
2373
|
+
HookTimeoutError: () => HookTimeoutError
|
|
2374
|
+
});
|
|
2375
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
2376
|
+
import { rmSync as rmSync2, writeFileSync as writeFileSync5 } from "fs";
|
|
2377
|
+
import { basename as basename2, dirname as dirname5, isAbsolute as isAbsolute4, join as join10 } from "path";
|
|
2378
|
+
function extensionOf(scriptPath) {
|
|
2379
|
+
const base = basename2(scriptPath);
|
|
2380
|
+
const dot = base.lastIndexOf(".");
|
|
2381
|
+
return dot > 0 ? base.slice(dot + 1) : "ts";
|
|
2382
|
+
}
|
|
2383
|
+
function shebangInterpreter(firstLine) {
|
|
2384
|
+
if (!firstLine.startsWith("#!"))
|
|
2385
|
+
return null;
|
|
2386
|
+
const rest = firstLine.slice(2).trim();
|
|
2387
|
+
if (!rest)
|
|
2388
|
+
return null;
|
|
2389
|
+
const parts = rest.split(/\s+/);
|
|
2390
|
+
if (parts[0] === "env" || parts[0] === "/usr/bin/env") {
|
|
2391
|
+
return parts[1] && parts[1] !== "-S" ? basename2(parts[1]) : parts[2] ? basename2(parts[2]) : null;
|
|
2392
|
+
}
|
|
2393
|
+
return basename2(parts[0]);
|
|
2394
|
+
}
|
|
2395
|
+
function interpreterFor(name, scriptPath, content) {
|
|
2396
|
+
const ext = extensionOf(scriptPath);
|
|
2397
|
+
const firstLine = content.subarray(0, 512).toString("utf8").split(`
|
|
2398
|
+
`, 1)[0] ?? "";
|
|
2399
|
+
const interp = shebangInterpreter(firstLine);
|
|
2400
|
+
if (interp) {
|
|
2401
|
+
if (interp === "bash" || interp === "sh")
|
|
2402
|
+
return { command: ["/bin/bash"], tempExt: "sh" };
|
|
2403
|
+
if (interp === "node" || interp === "bun")
|
|
2404
|
+
return { command: [process.execPath, "run"], tempExt: BUN_EXTENSIONS.has(ext) ? ext : "ts" };
|
|
2405
|
+
throw new Error(`Refusing to run hook '${name}': shebang '${firstLine}' is not a recognized interpreter (supported: bash/sh, node/bun)`);
|
|
2406
|
+
}
|
|
2407
|
+
if (BASH_EXTENSIONS.has(ext))
|
|
2408
|
+
return { command: ["/bin/bash"], tempExt: "sh" };
|
|
2409
|
+
if (BUN_EXTENSIONS.has(ext))
|
|
2410
|
+
return { command: [process.execPath, "run"], tempExt: ext };
|
|
2411
|
+
throw new Error(`Refusing to run hook '${name}': unsupported script extension '.${ext}' (supported: .sh .bash .ts .tsx .js .jsx .mjs .cjs .mts .cts, or a recognized shebang)`);
|
|
2412
|
+
}
|
|
2413
|
+
async function readPipeWithDeadline(stream, deadlineMs) {
|
|
2414
|
+
const reader = stream.getReader();
|
|
2415
|
+
const chunks = [];
|
|
2416
|
+
const deadline = Date.now() + deadlineMs;
|
|
2417
|
+
try {
|
|
2418
|
+
while (true) {
|
|
2419
|
+
const remaining = deadline - Date.now();
|
|
2420
|
+
if (remaining <= 0)
|
|
2421
|
+
break;
|
|
2422
|
+
let result;
|
|
2423
|
+
try {
|
|
2424
|
+
result = await Promise.race([
|
|
2425
|
+
reader.read(),
|
|
2426
|
+
new Promise((_, reject) => {
|
|
2427
|
+
const t = setTimeout(() => reject(new Error("pipe-drain-deadline")), remaining);
|
|
2428
|
+
t.unref?.();
|
|
2429
|
+
})
|
|
2430
|
+
]);
|
|
2431
|
+
} catch {
|
|
2432
|
+
break;
|
|
2433
|
+
}
|
|
2434
|
+
if (result.done)
|
|
2435
|
+
break;
|
|
2436
|
+
chunks.push(new TextDecoder().decode(result.value));
|
|
2437
|
+
}
|
|
2438
|
+
} finally {
|
|
2439
|
+
try {
|
|
2440
|
+
await reader.cancel();
|
|
2441
|
+
} catch {}
|
|
2442
|
+
}
|
|
2443
|
+
return chunks.join("");
|
|
2444
|
+
}
|
|
2445
|
+
async function executeVerifiedScript(options) {
|
|
2446
|
+
const scriptDir = dirname5(options.scriptPath);
|
|
2447
|
+
const interpreter = interpreterFor(options.name, options.scriptPath, options.content);
|
|
2448
|
+
const tempPath = join10(scriptDir, `.hooks-verified-${randomBytes2(12).toString("hex")}.${interpreter.tempExt}`);
|
|
2449
|
+
let proc = null;
|
|
2450
|
+
let timedOut = false;
|
|
2451
|
+
const timeoutTimer = options.timeout ? setTimeout(() => {
|
|
2452
|
+
timedOut = true;
|
|
2453
|
+
killGroup(proc, { leaderAlive: proc !== null && proc.exitCode === null });
|
|
2454
|
+
}, options.timeout) : null;
|
|
2455
|
+
try {
|
|
2456
|
+
writeFileSync5(tempPath, options.content, { flag: "wx", mode: 384 });
|
|
2457
|
+
proc = Bun.spawn([...interpreter.command, isAbsolute4(tempPath) ? tempPath : `./${tempPath}`, ...options.args ?? []], {
|
|
2458
|
+
stdin: new Response(options.stdin),
|
|
2459
|
+
stdout: "pipe",
|
|
2460
|
+
stderr: "pipe",
|
|
2461
|
+
env: buildHookEnv(process.env, options.env),
|
|
2462
|
+
detached: true
|
|
2463
|
+
});
|
|
2464
|
+
const exitCode = await proc.exited;
|
|
2465
|
+
if (timedOut) {
|
|
2466
|
+
throw new HookTimeoutError(options.timeout);
|
|
2467
|
+
}
|
|
2468
|
+
const [stdoutText, stderrText] = await Promise.all([
|
|
2469
|
+
readPipeWithDeadline(proc.stdout, 750),
|
|
2470
|
+
readPipeWithDeadline(proc.stderr, 750)
|
|
2471
|
+
]);
|
|
2472
|
+
killGroup(proc, { leaderAlive: false });
|
|
2473
|
+
return { stdout: stdoutText, stderr: stderrText, exitCode };
|
|
2474
|
+
} finally {
|
|
2475
|
+
if (timeoutTimer)
|
|
2476
|
+
clearTimeout(timeoutTimer);
|
|
2477
|
+
try {
|
|
2478
|
+
rmSync2(tempPath, { force: true });
|
|
2479
|
+
} catch {}
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
function killGroup(proc, options) {
|
|
2483
|
+
if (!proc)
|
|
2484
|
+
return;
|
|
2485
|
+
const pgid = proc.pid;
|
|
2486
|
+
for (let pass = 0;pass < 5; pass++) {
|
|
2487
|
+
let members = [];
|
|
2488
|
+
try {
|
|
2489
|
+
const res = Bun.spawnSync(["ps", "-eo", "pid=,pgid="], { stdout: "pipe", stderr: "pipe" });
|
|
2490
|
+
if (res.exitCode === 0) {
|
|
2491
|
+
members = res.stdout.toString().split(`
|
|
2492
|
+
`).map((line) => line.trim().split(/\s+/)).filter((cols) => cols.length === 2 && cols[1] === String(pgid)).map((cols) => Number(cols[0])).filter((pid) => Number.isInteger(pid) && pid > 1).filter((pid) => options.leaderAlive || pid !== proc.pid);
|
|
2493
|
+
}
|
|
2494
|
+
} catch {}
|
|
2495
|
+
if (members.length === 0)
|
|
2496
|
+
break;
|
|
2497
|
+
let killed = 0;
|
|
2498
|
+
for (const pid of members) {
|
|
2499
|
+
try {
|
|
2500
|
+
const probe = Bun.spawnSync(["ps", "-o", "pid=,pgid=", "-p", String(pid)], { stdout: "pipe", stderr: "pipe" });
|
|
2501
|
+
const cols = probe.stdout.toString().trim().split(/\s+/);
|
|
2502
|
+
if (cols.length === 2 && cols[1] === String(pgid)) {
|
|
2503
|
+
process.kill(pid, "SIGKILL");
|
|
2504
|
+
killed++;
|
|
2505
|
+
}
|
|
2506
|
+
} catch {}
|
|
2507
|
+
}
|
|
2508
|
+
if (killed === 0)
|
|
2509
|
+
break;
|
|
2510
|
+
}
|
|
2511
|
+
if (options.leaderAlive) {
|
|
2512
|
+
try {
|
|
2513
|
+
process.kill(proc.pid, "SIGKILL");
|
|
2514
|
+
} catch {}
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
var HookTimeoutError, BASH_EXTENSIONS, BUN_EXTENSIONS;
|
|
2518
|
+
var init_run = __esm(() => {
|
|
2519
|
+
init_hook_env();
|
|
2520
|
+
HookTimeoutError = class HookTimeoutError extends Error {
|
|
2521
|
+
timeoutMs;
|
|
2522
|
+
constructor(timeoutMs) {
|
|
2523
|
+
super(`Hook timed out after ${timeoutMs}ms (process group killed)`);
|
|
2524
|
+
this.name = "HookTimeoutError";
|
|
2525
|
+
this.timeoutMs = timeoutMs;
|
|
2526
|
+
}
|
|
2527
|
+
};
|
|
2528
|
+
BASH_EXTENSIONS = new Set(["sh", "bash"]);
|
|
2529
|
+
BUN_EXTENSIONS = new Set(["ts", "tsx", "js", "jsx", "mjs", "cjs", "mts", "cts"]);
|
|
2530
|
+
});
|
|
2531
|
+
|
|
2532
|
+
// src/lib/db-writer.ts
|
|
2533
|
+
var exports_db_writer = {};
|
|
2534
|
+
__export(exports_db_writer, {
|
|
2535
|
+
writeHookEvent: () => writeHookEvent,
|
|
2536
|
+
resolveHookEventSink: () => resolveHookEventSink,
|
|
2537
|
+
resolveEventType: () => resolveEventType,
|
|
2538
|
+
recordHookRun: () => recordHookRun,
|
|
2539
|
+
normalizeEventType: () => normalizeEventType,
|
|
2540
|
+
__resetHookEventSinkNotice: () => __resetHookEventSinkNotice
|
|
2541
|
+
});
|
|
2542
|
+
function resolveHookEventSink(env = process.env) {
|
|
2543
|
+
if (isLocalStoreRefused())
|
|
2544
|
+
return { kind: "refused", reason: localStoreRefusalMessage() ?? hooksEventSinkRefusal() };
|
|
2545
|
+
if (selectsHooksLocalStore(env))
|
|
2546
|
+
return { kind: "local" };
|
|
2547
|
+
return { kind: "refused", reason: hooksEventSinkRefusal() };
|
|
2548
|
+
}
|
|
2549
|
+
function __resetHookEventSinkNotice() {
|
|
2550
|
+
sinkRefusalPrinted = false;
|
|
2551
|
+
}
|
|
2552
|
+
function nanoid2() {
|
|
2553
|
+
return crypto.randomUUID().replace(/-/g, "").slice(0, 21);
|
|
2554
|
+
}
|
|
2555
|
+
function writeHookEvent(event) {
|
|
2556
|
+
const sink = resolveHookEventSink();
|
|
2557
|
+
if (sink.kind !== "local") {
|
|
2558
|
+
if (!sinkRefusalPrinted) {
|
|
2559
|
+
sinkRefusalPrinted = true;
|
|
2560
|
+
process.stderr.write(`[hooks db-writer] ${sink.reason}
|
|
2561
|
+
`);
|
|
2562
|
+
}
|
|
2563
|
+
return;
|
|
2564
|
+
}
|
|
2565
|
+
try {
|
|
2566
|
+
const db = getDb();
|
|
2567
|
+
const id = nanoid2();
|
|
2568
|
+
const timestamp = event.timestamp ?? new Date().toISOString();
|
|
2569
|
+
const toolInput = redactEventPayload(event.tool_input);
|
|
2570
|
+
const error = redactEventPayload(event.error);
|
|
2571
|
+
const metadata = redactEventPayload(event.metadata);
|
|
2572
|
+
db.run(`INSERT INTO hook_events
|
|
2573
|
+
(id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata)
|
|
2574
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
2575
|
+
id,
|
|
2576
|
+
timestamp,
|
|
2577
|
+
event.session_id,
|
|
2578
|
+
event.hook_name,
|
|
2579
|
+
event.event_type,
|
|
2580
|
+
event.tool_name ?? null,
|
|
2581
|
+
toolInput ? toolInput.slice(0, 500) : null,
|
|
2582
|
+
event.result ?? null,
|
|
2583
|
+
error ?? null,
|
|
2584
|
+
event.duration_ms ?? null,
|
|
2585
|
+
event.project_dir ?? null,
|
|
2586
|
+
metadata ?? null
|
|
2587
|
+
]);
|
|
2588
|
+
} catch (err) {
|
|
2589
|
+
process.stderr.write(`[hooks db-writer] failed to write event: ${err}
|
|
2590
|
+
`);
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
function normalizeEventType(value) {
|
|
2594
|
+
if (typeof value !== "string")
|
|
2595
|
+
return null;
|
|
2596
|
+
if (ALLOWED_EVENT_TYPES.has(value))
|
|
2597
|
+
return value;
|
|
2598
|
+
const bare = value.split(":")[0] ?? "";
|
|
2599
|
+
return ALLOWED_EVENT_TYPES.has(bare) ? bare : null;
|
|
2600
|
+
}
|
|
2601
|
+
function resolveEventType(inputEvent, fallbackEvent) {
|
|
2602
|
+
const fromInput = normalizeEventType(inputEvent);
|
|
2603
|
+
if (fromInput)
|
|
2604
|
+
return fromInput;
|
|
2605
|
+
return normalizeEventType(fallbackEvent ?? null);
|
|
2606
|
+
}
|
|
2607
|
+
function recordHookRun(record) {
|
|
2608
|
+
const eventType = record.eventType ? normalizeEventType(record.eventType) : null;
|
|
2609
|
+
if (!eventType) {
|
|
2610
|
+
process.stderr.write(`[hooks db-writer] skipped event for '${record.hookName}': unsupported event type '${String(record.eventType)}'
|
|
2611
|
+
`);
|
|
2612
|
+
return;
|
|
2613
|
+
}
|
|
2614
|
+
const metadata = {
|
|
2615
|
+
...record.metadata ?? {},
|
|
2616
|
+
version: record.version ?? null,
|
|
2617
|
+
sha256: record.sha256 ?? null,
|
|
2618
|
+
exit_code: record.exitCode
|
|
2619
|
+
};
|
|
2620
|
+
writeHookEvent({
|
|
2621
|
+
session_id: record.sessionId ?? "cli",
|
|
2622
|
+
hook_name: record.hookName,
|
|
2623
|
+
event_type: eventType,
|
|
2624
|
+
tool_name: record.toolName ?? null,
|
|
2625
|
+
tool_input: record.toolInput !== undefined ? JSON.stringify(record.toolInput) : null,
|
|
2626
|
+
result: record.result ?? null,
|
|
2627
|
+
error: record.error ?? null,
|
|
2628
|
+
duration_ms: record.durationMs,
|
|
2629
|
+
project_dir: record.projectDir ?? null,
|
|
2630
|
+
metadata: JSON.stringify(metadata)
|
|
2631
|
+
});
|
|
2632
|
+
}
|
|
2633
|
+
var sinkRefusalPrinted = false, ALLOWED_EVENT_TYPES;
|
|
2634
|
+
var init_db_writer = __esm(() => {
|
|
2635
|
+
init_db();
|
|
2636
|
+
init_local_opt_in();
|
|
2637
|
+
init_redact();
|
|
2638
|
+
ALLOWED_EVENT_TYPES = new Set([
|
|
2639
|
+
"PreToolUse",
|
|
2640
|
+
"PostToolUse",
|
|
2641
|
+
"Stop",
|
|
2642
|
+
"Notification",
|
|
2643
|
+
"SessionStart",
|
|
2644
|
+
"SessionEnd",
|
|
2645
|
+
"UserPromptSubmit",
|
|
2646
|
+
"SubagentStart"
|
|
2647
|
+
]);
|
|
2648
|
+
});
|
|
2649
|
+
|
|
2650
|
+
// src/mcp/server.ts
|
|
2651
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2652
|
+
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
2653
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
2654
|
+
import { z as z2 } from "zod";
|
|
2655
|
+
init_registry();
|
|
2656
|
+
import { existsSync as existsSync10, readFileSync as readFileSync7 } from "fs";
|
|
2657
|
+
import { join as join11, dirname as dirname6 } from "path";
|
|
2658
|
+
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
2659
|
+
|
|
2660
|
+
// src/lib/installer.ts
|
|
2661
|
+
init_registry();
|
|
2662
|
+
init_resolve();
|
|
2663
|
+
init_manifest();
|
|
2664
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync4, readFileSync as readFileSync4, readdirSync as readdirSync3, rmdirSync, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
|
|
2665
|
+
import { join as join7, dirname as dirname3 } from "path";
|
|
2666
|
+
import { homedir as homedir3 } from "os";
|
|
2667
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
2668
|
+
|
|
2669
|
+
// src/lib/registration.ts
|
|
2670
|
+
init_resolve();
|
|
2671
|
+
function matchersOverlap(a, b) {
|
|
2672
|
+
return a === b || a.includes(b) || b.includes(a);
|
|
2673
|
+
}
|
|
2674
|
+
function hookEventsOf(info) {
|
|
2675
|
+
return info.events?.length ? info.events : info.event ? [info.event] : [];
|
|
2676
|
+
}
|
|
2677
|
+
function findRewriteOverlaps(names, lookup) {
|
|
2678
|
+
const rewriting = names.map((name) => ({ name, info: lookup(name) })).filter((row) => Boolean(row.info?.rewritesInput && row.info.matcher));
|
|
2679
|
+
const overlaps = [];
|
|
2680
|
+
for (let i = 0;i < rewriting.length; i++) {
|
|
2681
|
+
for (let j = i + 1;j < rewriting.length; j++) {
|
|
2682
|
+
const a = rewriting[i];
|
|
2683
|
+
const b = rewriting[j];
|
|
2684
|
+
const aEvents = hookEventsOf(a.info);
|
|
2685
|
+
const bEvents = hookEventsOf(b.info);
|
|
2686
|
+
const event = aEvents.find((name) => name === "PreToolUse" && bEvents.includes(name));
|
|
2687
|
+
if (!event)
|
|
2688
|
+
continue;
|
|
2689
|
+
const aMatcher = a.info.matcher;
|
|
2690
|
+
const bMatcher = b.info.matcher;
|
|
2691
|
+
if (!matchersOverlap(aMatcher.toLowerCase(), bMatcher.toLowerCase()))
|
|
2692
|
+
continue;
|
|
2693
|
+
overlaps.push({ hooks: [a.name, b.name], event, matchers: [aMatcher, bMatcher] });
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
return overlaps;
|
|
2697
|
+
}
|
|
2698
|
+
function safeMatcherTest(pattern, value) {
|
|
2699
|
+
try {
|
|
2700
|
+
return new RegExp(pattern).test(value);
|
|
2701
|
+
} catch {
|
|
2702
|
+
return false;
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2705
|
+
function hookRegisteredInSettings(settings, name, eventSpec, hookMatcher) {
|
|
2706
|
+
const separator = eventSpec.indexOf(":");
|
|
2707
|
+
const eventName = separator === -1 ? eventSpec : eventSpec.slice(0, separator);
|
|
2708
|
+
const specMatcher = separator === -1 ? "" : eventSpec.slice(separator + 1);
|
|
2709
|
+
const matcher = specMatcher || hookMatcher || "";
|
|
2710
|
+
const hooks = settings.hooks?.[eventName];
|
|
2711
|
+
if (!Array.isArray(hooks))
|
|
2712
|
+
return false;
|
|
2713
|
+
return hooks.some((entry) => entry?.hooks?.some((h) => {
|
|
2714
|
+
const match = h?.command?.match(/^hooks run ([\w-]+)/);
|
|
2715
|
+
if (!match || match[1] !== name)
|
|
2716
|
+
return false;
|
|
2717
|
+
if (!matcher)
|
|
2718
|
+
return true;
|
|
2719
|
+
const entryMatcher = typeof entry.matcher === "string" ? entry.matcher : "";
|
|
2720
|
+
if (!entryMatcher)
|
|
2721
|
+
return true;
|
|
2722
|
+
return entryMatcher === matcher || safeMatcherTest(entryMatcher, matcher) || safeMatcherTest(matcher, entryMatcher);
|
|
2723
|
+
}));
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
// src/lib/store.ts
|
|
2727
|
+
import { createHash } from "crypto";
|
|
2728
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2, renameSync } from "fs";
|
|
2729
|
+
init_db();
|
|
2730
|
+
init_config();
|
|
2731
|
+
import { randomBytes } from "crypto";
|
|
2732
|
+
function sha256Of(input) {
|
|
2733
|
+
return createHash("sha256").update(input).digest("hex");
|
|
2734
|
+
}
|
|
2735
|
+
function storeDb() {
|
|
2736
|
+
return isLocalStoreRefused() ? null : getDb();
|
|
2737
|
+
}
|
|
2738
|
+
function getHookRecord(db, name) {
|
|
2739
|
+
return db.query("SELECT * FROM hooks WHERE name = ?").get(name);
|
|
2740
|
+
}
|
|
2741
|
+
function upsertHookRecord(db, record) {
|
|
2742
|
+
const existing = getHookRecord(db, record.name);
|
|
2743
|
+
const id = existing?.id ?? record.name;
|
|
2744
|
+
const installedAt = existing?.installed_at ?? new Date().toISOString();
|
|
2745
|
+
const enabled = record.enabled ?? existing?.enabled ?? 1;
|
|
2746
|
+
db.run(`INSERT INTO hooks (id, name, version, sha256, source_type, source_ref, installed_at, enabled, last_verified_at)
|
|
2747
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2748
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
2749
|
+
version = excluded.version,
|
|
2750
|
+
sha256 = excluded.sha256,
|
|
2751
|
+
source_type = excluded.source_type,
|
|
2752
|
+
source_ref = excluded.source_ref,
|
|
2753
|
+
enabled = excluded.enabled,
|
|
2754
|
+
last_verified_at = excluded.last_verified_at`, [
|
|
2755
|
+
id,
|
|
2756
|
+
record.name,
|
|
2757
|
+
record.version,
|
|
2758
|
+
record.sha256,
|
|
2759
|
+
record.source_type,
|
|
2760
|
+
record.source_ref ?? existing?.source_ref ?? null,
|
|
2761
|
+
installedAt,
|
|
2762
|
+
enabled,
|
|
2763
|
+
record.last_verified_at ?? null
|
|
2764
|
+
]);
|
|
2765
|
+
}
|
|
2766
|
+
class LockFileError extends Error {
|
|
2767
|
+
constructor(message) {
|
|
2768
|
+
super(message);
|
|
2769
|
+
this.name = "LockFileError";
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
function assertLockShape(parsed, path) {
|
|
2773
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
2774
|
+
throw new LockFileError(`hooks.lock (${path}) is malformed: expected a JSON object with a "hooks" map. ` + `Repair: fix the file by hand, or if it is unrecoverable move it aside and run 'hooks sync' to rebuild it. ` + `Nothing was trusted or rewritten \u2014 hooks are left in the refuse-to-run state until the lock is repaired.`);
|
|
2775
|
+
}
|
|
2776
|
+
const hooks = parsed.hooks;
|
|
2777
|
+
if (hooks === null || typeof hooks !== "object" || Array.isArray(hooks)) {
|
|
2778
|
+
throw new LockFileError(`hooks.lock (${path}) is malformed: missing or invalid "hooks" map. ` + `Repair: fix the file by hand, or if it is unrecoverable move it aside and run 'hooks sync' to rebuild it. ` + `Nothing was trusted or rewritten \u2014 hooks are left in the refuse-to-run state until the lock is repaired.`);
|
|
2779
|
+
}
|
|
2780
|
+
for (const [name, entry] of Object.entries(hooks)) {
|
|
2781
|
+
const e = entry;
|
|
2782
|
+
if (e === null || typeof e !== "object" || typeof e.version !== "string" || typeof e.sha256 !== "string") {
|
|
2783
|
+
throw new LockFileError(`hooks.lock (${path}) is malformed: entry for '${name}' is not a valid pin (version and sha256 strings required). ` + `Repair: fix the file by hand, or if it is unrecoverable move it aside and run 'hooks sync' to rebuild it.`);
|
|
2784
|
+
}
|
|
2785
|
+
}
|
|
2786
|
+
}
|
|
2787
|
+
function readLock() {
|
|
2788
|
+
const path = getLockPath();
|
|
2789
|
+
if (!existsSync6(path))
|
|
2790
|
+
return { hooks: {} };
|
|
2791
|
+
let raw;
|
|
2792
|
+
try {
|
|
2793
|
+
raw = readFileSync3(path, "utf-8");
|
|
2794
|
+
} catch {
|
|
2795
|
+
throw new LockFileError(`hooks.lock (${path}) could not be read. Repair: fix permissions or move the file aside and run 'hooks sync'.`);
|
|
2796
|
+
}
|
|
2797
|
+
let parsed;
|
|
2798
|
+
try {
|
|
2799
|
+
parsed = JSON.parse(raw);
|
|
2800
|
+
} catch {
|
|
2801
|
+
throw new LockFileError(`hooks.lock (${path}) is malformed: not valid JSON (${String((raw ?? "").slice(0, 80))}). ` + `Repair: fix the file by hand, or if it is unrecoverable move it aside and run 'hooks sync' to rebuild it. ` + `Nothing was trusted or rewritten \u2014 hooks are left in the refuse-to-run state until the lock is repaired.`);
|
|
2802
|
+
}
|
|
2803
|
+
assertLockShape(parsed, path);
|
|
2804
|
+
return parsed;
|
|
2805
|
+
}
|
|
2806
|
+
function writeLock(lock) {
|
|
2807
|
+
const path = getLockPath();
|
|
2808
|
+
mkdirSync3(path.substring(0, path.lastIndexOf("/")) || ".", { recursive: true });
|
|
2809
|
+
const sorted = {};
|
|
2810
|
+
for (const name of Object.keys(lock.hooks).sort()) {
|
|
2811
|
+
sorted[name] = lock.hooks[name];
|
|
2812
|
+
}
|
|
2813
|
+
const tmp = `${path}.tmp-${randomBytes(8).toString("hex")}`;
|
|
2814
|
+
writeFileSync2(tmp, JSON.stringify({ hooks: sorted }, null, 2) + `
|
|
2815
|
+
`, "utf-8");
|
|
2816
|
+
renameSync(tmp, path);
|
|
2817
|
+
return path;
|
|
2818
|
+
}
|
|
2819
|
+
function setPinnedHook(name, entry) {
|
|
2820
|
+
const lock = readLock();
|
|
2821
|
+
lock.hooks[name] = entry;
|
|
2822
|
+
return writeLock(lock);
|
|
2823
|
+
}
|
|
2824
|
+
function getPinnedHook(name) {
|
|
2825
|
+
return readLock().hooks[name];
|
|
2826
|
+
}
|
|
2827
|
+
function checkScriptHash(name, actual) {
|
|
2828
|
+
const db = storeDb();
|
|
2829
|
+
const record = db ? getHookRecord(db, name) : null;
|
|
2830
|
+
const pin = getPinnedHook(name);
|
|
2831
|
+
const expected = record?.sha256 ?? pin?.sha256 ?? null;
|
|
2832
|
+
if (expected !== null && expected !== actual) {
|
|
2833
|
+
return { ok: false, pinned: true, expected, actual, name };
|
|
2834
|
+
}
|
|
2835
|
+
if (expected === null) {
|
|
2836
|
+
const now2 = new Date().toISOString();
|
|
2837
|
+
const version = pin?.version ?? record?.version ?? "0.0.0";
|
|
2838
|
+
const source = pin?.source ?? record?.source_type ?? "local";
|
|
2839
|
+
if (db) {
|
|
2840
|
+
upsertHookRecord(db, {
|
|
2841
|
+
name,
|
|
2842
|
+
version,
|
|
2843
|
+
sha256: actual,
|
|
2844
|
+
source_type: source,
|
|
2845
|
+
last_verified_at: now2
|
|
2846
|
+
});
|
|
2847
|
+
}
|
|
2848
|
+
setPinnedHook(name, { version, sha256: actual, source });
|
|
2849
|
+
return { ok: true, pinned: false, expected: null, actual, name };
|
|
2850
|
+
}
|
|
2851
|
+
const now = new Date().toISOString();
|
|
2852
|
+
if (db) {
|
|
2853
|
+
upsertHookRecord(db, {
|
|
2854
|
+
name,
|
|
2855
|
+
version: record?.version ?? pin?.version ?? "0.0.0",
|
|
2856
|
+
sha256: expected,
|
|
2857
|
+
source_type: record?.source_type ?? pin?.source ?? "local",
|
|
2858
|
+
last_verified_at: now
|
|
2859
|
+
});
|
|
2860
|
+
}
|
|
2861
|
+
return { ok: true, pinned: true, expected, actual, name };
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2864
|
+
// src/lib/installer.ts
|
|
2865
|
+
var __dirname2 = dirname3(fileURLToPath2(import.meta.url));
|
|
2866
|
+
var HOOKS_DIR = existsSync7(join7(__dirname2, "..", "..", "hooks", "hook-gitguard")) ? join7(__dirname2, "..", "..", "hooks") : join7(__dirname2, "..", "hooks");
|
|
2867
|
+
function normalizeHookName(name) {
|
|
2868
|
+
return name.startsWith("hook-") ? name : `hook-${name}`;
|
|
2869
|
+
}
|
|
2870
|
+
function shortHookName(name) {
|
|
2871
|
+
return name.startsWith("hook-") ? name.slice("hook-".length) : name;
|
|
2872
|
+
}
|
|
2873
|
+
function removeHookEntriesByName(entries, hookName) {
|
|
2874
|
+
return entries.filter((entry) => !entry.hooks?.some((h) => {
|
|
2875
|
+
const match = h.command?.match(/^hooks run ([\w-]+)/);
|
|
2876
|
+
return match && match[1] === hookName;
|
|
2877
|
+
}));
|
|
2878
|
+
}
|
|
2879
|
+
var EVENT_MAP = {
|
|
2880
|
+
claude: {
|
|
2881
|
+
PreToolUse: "PreToolUse",
|
|
2882
|
+
PostToolUse: "PostToolUse",
|
|
2883
|
+
Stop: "Stop",
|
|
2884
|
+
Notification: "Notification",
|
|
2885
|
+
SessionStart: "SessionStart",
|
|
2886
|
+
SessionEnd: "SessionEnd",
|
|
2887
|
+
UserPromptSubmit: null,
|
|
2888
|
+
SubagentStart: null
|
|
2889
|
+
},
|
|
2890
|
+
gemini: {
|
|
2891
|
+
PreToolUse: "BeforeTool",
|
|
2892
|
+
PostToolUse: "AfterTool",
|
|
2893
|
+
Stop: "AfterAgent",
|
|
2894
|
+
Notification: "Notification",
|
|
2895
|
+
SessionStart: null,
|
|
2896
|
+
SessionEnd: null,
|
|
2897
|
+
UserPromptSubmit: null,
|
|
2898
|
+
SubagentStart: null
|
|
2899
|
+
},
|
|
2900
|
+
codewith: {
|
|
2901
|
+
PreToolUse: "PreToolUse",
|
|
2902
|
+
PostToolUse: "PostToolUse",
|
|
2903
|
+
Stop: "Stop",
|
|
2904
|
+
Notification: null,
|
|
2905
|
+
SessionStart: "SessionStart",
|
|
2906
|
+
SessionEnd: null,
|
|
2907
|
+
UserPromptSubmit: "UserPromptSubmit",
|
|
2908
|
+
SubagentStart: "SubagentStart"
|
|
2909
|
+
}
|
|
2910
|
+
};
|
|
2911
|
+
function getTargetSettingsDir(target) {
|
|
2912
|
+
if (target === "codewith")
|
|
2913
|
+
return ".codewith";
|
|
2914
|
+
if (target === "gemini")
|
|
2915
|
+
return ".gemini";
|
|
2916
|
+
return ".claude";
|
|
2917
|
+
}
|
|
2918
|
+
function getGlobalSettingsPathOverride(target) {
|
|
2919
|
+
if (target === "claude")
|
|
2920
|
+
return process.env.HASNA_HOOKS_CLAUDE_SETTINGS_PATH;
|
|
2921
|
+
if (target === "gemini")
|
|
2922
|
+
return process.env.HASNA_HOOKS_GEMINI_SETTINGS_PATH;
|
|
2923
|
+
return;
|
|
2924
|
+
}
|
|
2925
|
+
function getSettingsPath(scope = "global", target = "claude", codewithConfigPath) {
|
|
2926
|
+
if (target === "codewith" && codewithConfigPath)
|
|
2927
|
+
return codewithConfigPath;
|
|
2928
|
+
if (target === "codewith" && scope === "global" && process.env.HASNA_HOOKS_CODEWITH_CONFIG_PATH) {
|
|
2929
|
+
return process.env.HASNA_HOOKS_CODEWITH_CONFIG_PATH;
|
|
2930
|
+
}
|
|
2931
|
+
const globalOverride = scope === "global" ? getGlobalSettingsPathOverride(target) : undefined;
|
|
2932
|
+
if (globalOverride)
|
|
2933
|
+
return globalOverride;
|
|
2934
|
+
const dir = getTargetSettingsDir(target);
|
|
2935
|
+
if (scope === "project") {
|
|
2936
|
+
return target === "codewith" ? join7(process.cwd(), dir, "config.toml") : join7(process.cwd(), dir, "settings.json");
|
|
2937
|
+
}
|
|
2938
|
+
return target === "codewith" ? join7(homedir3(), dir, "config.toml") : join7(homedir3(), dir, "settings.json");
|
|
2939
|
+
}
|
|
2940
|
+
function getHookPath(name) {
|
|
2941
|
+
const shortName = shortHookName(name);
|
|
2942
|
+
const resolved = resolveHookDir(shortName);
|
|
2943
|
+
if (resolved)
|
|
2944
|
+
return resolved;
|
|
2945
|
+
return join7(HOOKS_DIR, normalizeHookName(shortName));
|
|
2946
|
+
}
|
|
2947
|
+
function hookExists(name) {
|
|
2948
|
+
return existsSync7(getHookPath(name));
|
|
2949
|
+
}
|
|
2950
|
+
function readSettings(scope = "global", target = "claude") {
|
|
2951
|
+
const path = getSettingsPath(scope, target);
|
|
2952
|
+
try {
|
|
2953
|
+
if (existsSync7(path)) {
|
|
2954
|
+
return JSON.parse(readFileSync4(path, "utf-8"));
|
|
2955
|
+
}
|
|
2956
|
+
} catch (error) {
|
|
2957
|
+
console.warn(`[hooks] Failed to read settings at ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2958
|
+
}
|
|
2959
|
+
return {};
|
|
2960
|
+
}
|
|
2961
|
+
function writeSettings(settings, scope = "global", target = "claude") {
|
|
2962
|
+
const path = getSettingsPath(scope, target);
|
|
2963
|
+
const dir = dirname3(path);
|
|
2964
|
+
if (!existsSync7(dir)) {
|
|
2965
|
+
mkdirSync4(dir, { recursive: true });
|
|
2966
|
+
}
|
|
2967
|
+
writeFileSync3(path, JSON.stringify(settings, null, 2) + `
|
|
2968
|
+
`);
|
|
2969
|
+
}
|
|
2970
|
+
function getTargetEventName(internalEvent, target) {
|
|
2971
|
+
return EVENT_MAP[target]?.[internalEvent] ?? null;
|
|
2972
|
+
}
|
|
2973
|
+
function isEventSupported(internalEvent, target) {
|
|
2974
|
+
return getTargetEventName(internalEvent, target) !== null;
|
|
2975
|
+
}
|
|
2976
|
+
function tomlString(value) {
|
|
2977
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
|
2978
|
+
}
|
|
2979
|
+
function codewithMatcher(matcher) {
|
|
2980
|
+
if (!matcher)
|
|
2981
|
+
return;
|
|
2982
|
+
if (matcher.startsWith("^"))
|
|
2983
|
+
return matcher;
|
|
2984
|
+
if (matcher.includes("|"))
|
|
2985
|
+
return `^(${matcher})$`;
|
|
2986
|
+
return `^${matcher}$`;
|
|
2987
|
+
}
|
|
2988
|
+
function codewithTimeout(name) {
|
|
2989
|
+
switch (name) {
|
|
2990
|
+
case "knowledge-context":
|
|
2991
|
+
return 6;
|
|
2992
|
+
case "session-start":
|
|
2993
|
+
return 8;
|
|
2994
|
+
case "pre-bash":
|
|
2995
|
+
return 20;
|
|
2996
|
+
case "prompt-guard":
|
|
2997
|
+
return 3;
|
|
2998
|
+
case "worktree-guard":
|
|
2999
|
+
case "trash-guard":
|
|
3000
|
+
case "stop-sync":
|
|
3001
|
+
return 5;
|
|
3002
|
+
default:
|
|
3003
|
+
return 10;
|
|
3004
|
+
}
|
|
3005
|
+
}
|
|
3006
|
+
function codewithStatusMessage(name) {
|
|
3007
|
+
switch (name) {
|
|
3008
|
+
case "knowledge-context":
|
|
3009
|
+
return "Loading Knowledge context";
|
|
3010
|
+
case "session-start":
|
|
3011
|
+
return "Checking Hasna session context";
|
|
3012
|
+
case "pre-bash":
|
|
3013
|
+
return "Checking Bash safety";
|
|
3014
|
+
case "prompt-guard":
|
|
3015
|
+
return "Checking prompt safety";
|
|
3016
|
+
case "worktree-guard":
|
|
3017
|
+
return "Checking worktree safety";
|
|
3018
|
+
case "trash-guard":
|
|
3019
|
+
return "Redirecting rm into trash";
|
|
3020
|
+
case "stop-sync":
|
|
3021
|
+
return "Syncing turn-end heartbeat";
|
|
3022
|
+
default:
|
|
3023
|
+
return `Running ${name}`;
|
|
3024
|
+
}
|
|
3025
|
+
}
|
|
3026
|
+
function buildCodewithTomlFragment(name, profile) {
|
|
3027
|
+
const shortName = shortHookName(name);
|
|
3028
|
+
const meta = resolveHookMeta(shortName);
|
|
3029
|
+
if (!meta)
|
|
3030
|
+
throw new Error(`Hook '${shortName}' not found`);
|
|
3031
|
+
const command = profile ? `hooks run ${shortName} --profile ${profile}` : `hooks run ${shortName}`;
|
|
3032
|
+
const matcher = codewithMatcher(meta.matcher);
|
|
3033
|
+
const fragments = [];
|
|
3034
|
+
for (const event of getHookEvents(meta)) {
|
|
3035
|
+
const eventKey = getTargetEventName(event, "codewith");
|
|
3036
|
+
if (!eventKey) {
|
|
3037
|
+
throw new Error(`Hook '${shortName}' uses event '${event}', which is not supported by the Codewith target`);
|
|
3038
|
+
}
|
|
3039
|
+
const lines = [
|
|
3040
|
+
`[[hooks.${eventKey}]]`
|
|
3041
|
+
];
|
|
3042
|
+
if (matcher)
|
|
3043
|
+
lines.push(`matcher = ${tomlString(matcher)}`);
|
|
3044
|
+
lines.push("", `[[hooks.${eventKey}.hooks]]`, `type = "command"`, `command = ${tomlString(command)}`, `timeout = ${codewithTimeout(shortName)}`, `statusMessage = ${tomlString(codewithStatusMessage(shortName))}`);
|
|
3045
|
+
fragments.push(lines.join(`
|
|
3046
|
+
`));
|
|
3047
|
+
}
|
|
3048
|
+
return `${fragments.join(`
|
|
3049
|
+
|
|
3050
|
+
`)}
|
|
3051
|
+
`;
|
|
3052
|
+
}
|
|
3053
|
+
function readCodewithConfig(scope, configPath) {
|
|
3054
|
+
const path = getSettingsPath(scope, "codewith", configPath);
|
|
3055
|
+
try {
|
|
3056
|
+
return existsSync7(path) ? readFileSync4(path, "utf-8") : "";
|
|
3057
|
+
} catch {
|
|
3058
|
+
return "";
|
|
3059
|
+
}
|
|
3060
|
+
}
|
|
3061
|
+
function appendCodewithFragment(fragment, scope, configPath) {
|
|
3062
|
+
const path = getSettingsPath(scope, "codewith", configPath);
|
|
3063
|
+
const dir = dirname3(path);
|
|
3064
|
+
if (!existsSync7(dir))
|
|
3065
|
+
mkdirSync4(dir, { recursive: true });
|
|
3066
|
+
const existing = existsSync7(path) ? readFileSync4(path, "utf-8") : "";
|
|
3067
|
+
const sep2 = existing.trim() ? `
|
|
3068
|
+
|
|
3069
|
+
` : "";
|
|
3070
|
+
writeFileSync3(path, `${existing.replace(/\s*$/, "")}${sep2}${fragment}`);
|
|
3071
|
+
return path;
|
|
3072
|
+
}
|
|
3073
|
+
function detectConflict(name, scope, target) {
|
|
3074
|
+
const meta = resolveHookMeta(name);
|
|
3075
|
+
if (!meta || !meta.matcher)
|
|
3076
|
+
return;
|
|
3077
|
+
const events = new Set(getHookEvents(meta));
|
|
3078
|
+
const registered = getRegisteredHooksForTarget(scope, target);
|
|
3079
|
+
for (const existingName of registered) {
|
|
3080
|
+
if (existingName === name)
|
|
3081
|
+
continue;
|
|
3082
|
+
const existing = getHook(existingName);
|
|
3083
|
+
if (!existing || !existing.matcher)
|
|
3084
|
+
continue;
|
|
3085
|
+
if (!getHookEvents(existing).some((event) => events.has(event)))
|
|
3086
|
+
continue;
|
|
3087
|
+
if (matchersOverlap(meta.matcher.toLowerCase(), existing.matcher.toLowerCase())) {
|
|
3088
|
+
return `conflicts with '${existingName}' (same event ${meta.event}, overlapping matcher '${existing.matcher}')`;
|
|
3089
|
+
}
|
|
3090
|
+
}
|
|
3091
|
+
return;
|
|
3092
|
+
}
|
|
3093
|
+
function detectRewriteConflict(name, scope, target, lookup = getHook) {
|
|
3094
|
+
const meta = resolveHookMeta(name);
|
|
3095
|
+
if (!meta?.matcher || !meta.rewritesInput)
|
|
3096
|
+
return;
|
|
3097
|
+
if (!getHookEvents(meta).includes("PreToolUse"))
|
|
3098
|
+
return;
|
|
3099
|
+
for (const existingName of getRegisteredHooksForTarget(scope, target)) {
|
|
3100
|
+
if (existingName === name)
|
|
3101
|
+
continue;
|
|
3102
|
+
const existing = lookup(existingName);
|
|
3103
|
+
if (!existing?.matcher || !existing.rewritesInput)
|
|
3104
|
+
continue;
|
|
3105
|
+
if (!getHookEvents(existing).includes("PreToolUse"))
|
|
3106
|
+
continue;
|
|
3107
|
+
if (!matchersOverlap(meta.matcher.toLowerCase(), existing.matcher.toLowerCase()))
|
|
3108
|
+
continue;
|
|
3109
|
+
return `both '${name}' and '${existingName}' rewrite the tool input on overlapping PreToolUse matchers ('${meta.matcher}' / '${existing.matcher}'). Only one rewrite per tool call is applied, so one guard would silently disappear \u2014 install one of them, or narrow a matcher so they no longer overlap.`;
|
|
3110
|
+
}
|
|
3111
|
+
return;
|
|
3112
|
+
}
|
|
3113
|
+
function installForTarget(name, scope, overwrite, target, profile, codewithMode = "fragment", codewithConfigPath) {
|
|
3114
|
+
const shortName = shortHookName(name);
|
|
3115
|
+
if (!hookExists(shortName)) {
|
|
3116
|
+
return { hook: shortName, success: false, error: `Hook '${shortName}' not found`, target };
|
|
3117
|
+
}
|
|
3118
|
+
if (target === "codewith") {
|
|
3119
|
+
try {
|
|
3120
|
+
const fragment = buildCodewithTomlFragment(shortName, profile);
|
|
3121
|
+
if (codewithMode !== "write") {
|
|
3122
|
+
return {
|
|
3123
|
+
hook: shortName,
|
|
3124
|
+
success: true,
|
|
3125
|
+
scope,
|
|
3126
|
+
target,
|
|
3127
|
+
fragment,
|
|
3128
|
+
applied: false,
|
|
3129
|
+
note: "Codewith install is fragment-only by default; configs should own applying this TOML."
|
|
3130
|
+
};
|
|
3131
|
+
}
|
|
3132
|
+
if (!codewithConfigPath) {
|
|
3133
|
+
return {
|
|
3134
|
+
hook: shortName,
|
|
3135
|
+
success: false,
|
|
3136
|
+
error: "Direct Codewith writes require an explicit --codewith-config path; refusing to write default ~/.codewith/config.toml.",
|
|
3137
|
+
scope,
|
|
3138
|
+
target,
|
|
3139
|
+
fragment,
|
|
3140
|
+
applied: false
|
|
3141
|
+
};
|
|
3142
|
+
}
|
|
3143
|
+
const existing = readCodewithConfig(scope, codewithConfigPath);
|
|
3144
|
+
if (!overwrite && new RegExp(`command\\s*=\\s*["']hooks run ${shortName}(?:\\s|["'])`).test(existing)) {
|
|
3145
|
+
return { hook: shortName, success: false, error: "Already installed. Use --overwrite to append another fragment.", scope, target };
|
|
3146
|
+
}
|
|
3147
|
+
const path = appendCodewithFragment(fragment, scope, codewithConfigPath);
|
|
3148
|
+
return {
|
|
3149
|
+
hook: shortName,
|
|
3150
|
+
success: true,
|
|
3151
|
+
scope,
|
|
3152
|
+
target,
|
|
3153
|
+
fragment,
|
|
3154
|
+
applied: true,
|
|
3155
|
+
configPath: path,
|
|
3156
|
+
note: "Direct Codewith config write was explicitly requested; prefer configs for managed machines."
|
|
3157
|
+
};
|
|
3158
|
+
} catch (error) {
|
|
3159
|
+
return {
|
|
3160
|
+
hook: shortName,
|
|
3161
|
+
success: false,
|
|
3162
|
+
error: error instanceof Error ? error.message : "Unknown error",
|
|
3163
|
+
target
|
|
3164
|
+
};
|
|
3165
|
+
}
|
|
3166
|
+
}
|
|
3167
|
+
const registered = getRegisteredHooksForTarget(scope, target);
|
|
3168
|
+
if (registered.includes(shortName) && !overwrite) {
|
|
3169
|
+
return { hook: shortName, success: false, error: "Already installed. Use --overwrite to replace.", scope, target };
|
|
3170
|
+
}
|
|
3171
|
+
const conflict = detectConflict(shortName, scope, target);
|
|
3172
|
+
const rewriteConflict = detectRewriteConflict(shortName, scope, target);
|
|
3173
|
+
if (rewriteConflict) {
|
|
3174
|
+
return { hook: shortName, success: false, error: `Refused: ${rewriteConflict}`, scope, target };
|
|
3175
|
+
}
|
|
3176
|
+
try {
|
|
3177
|
+
registerHook(shortName, scope, target, profile);
|
|
3178
|
+
return { hook: shortName, success: true, scope, target, ...conflict ? { conflict } : {} };
|
|
3179
|
+
} catch (error) {
|
|
3180
|
+
return {
|
|
3181
|
+
hook: shortName,
|
|
3182
|
+
success: false,
|
|
3183
|
+
error: error instanceof Error ? error.message : "Unknown error",
|
|
3184
|
+
target
|
|
3185
|
+
};
|
|
3186
|
+
}
|
|
3187
|
+
}
|
|
3188
|
+
function installHook(name, options = {}) {
|
|
3189
|
+
const { scope = "global", overwrite = false, target = "claude", profile, codewithMode = "fragment", codewithConfigPath } = options;
|
|
3190
|
+
if (target === "all") {
|
|
3191
|
+
const shortName = shortHookName(name);
|
|
3192
|
+
const meta = resolveHookMeta(shortName);
|
|
3193
|
+
if (meta) {
|
|
3194
|
+
const unsupportedTargets = ["claude", "codewith"].filter((agentTarget) => getHookEvents(meta).some((event) => !isEventSupported(event, agentTarget)));
|
|
3195
|
+
if (unsupportedTargets.length > 0) {
|
|
3196
|
+
return {
|
|
3197
|
+
hook: shortName,
|
|
3198
|
+
success: false,
|
|
3199
|
+
error: `Event(s) '${getHookEvents(meta).join(", ")}' are not supported by target(s): ${unsupportedTargets.join(", ")}`,
|
|
3200
|
+
target: "all"
|
|
3201
|
+
};
|
|
3202
|
+
}
|
|
3203
|
+
}
|
|
3204
|
+
const claudeResult = installForTarget(name, scope, overwrite, "claude", profile);
|
|
3205
|
+
if (!claudeResult.success) {
|
|
3206
|
+
return {
|
|
3207
|
+
...claudeResult,
|
|
3208
|
+
error: `Failed for target 'claude': ${claudeResult.error}`,
|
|
3209
|
+
target: "all"
|
|
3210
|
+
};
|
|
3211
|
+
}
|
|
3212
|
+
const codewithResult = installForTarget(name, scope, overwrite, "codewith", profile, codewithMode, codewithConfigPath);
|
|
3213
|
+
if (!codewithResult.success) {
|
|
3214
|
+
return {
|
|
3215
|
+
...codewithResult,
|
|
3216
|
+
error: `Failed for target 'codewith': ${codewithResult.error}`,
|
|
3217
|
+
target: "all"
|
|
3218
|
+
};
|
|
3219
|
+
}
|
|
3220
|
+
return { ...claudeResult, target: "all" };
|
|
3221
|
+
}
|
|
3222
|
+
return installForTarget(name, scope, overwrite, target, profile, codewithMode, codewithConfigPath);
|
|
3223
|
+
}
|
|
3224
|
+
function registerHook(name, scope = "global", target = "claude", profile) {
|
|
3225
|
+
const meta = resolveHookMeta(name);
|
|
3226
|
+
if (!meta)
|
|
3227
|
+
return;
|
|
3228
|
+
const eventKeys = getHookEvents(meta).map((event) => {
|
|
3229
|
+
const eventKey = getTargetEventName(event, target);
|
|
3230
|
+
if (eventKey === null) {
|
|
3231
|
+
throw new Error(`Event '${event}' is not supported by target '${target}'`);
|
|
3232
|
+
}
|
|
3233
|
+
return eventKey;
|
|
3234
|
+
});
|
|
3235
|
+
const uniqueEventKeys = [...new Set(eventKeys)];
|
|
3236
|
+
if (uniqueEventKeys.length === 0) {
|
|
3237
|
+
throw new Error(`Hook '${name}' has no installable events for target '${target}'`);
|
|
3238
|
+
}
|
|
3239
|
+
const settings = readSettings(scope, target);
|
|
3240
|
+
if (!settings.hooks)
|
|
3241
|
+
settings.hooks = {};
|
|
3242
|
+
removeHookFromAllEvents(settings, name);
|
|
3243
|
+
const hookCommand = profile ? `hooks run ${name} --profile ${profile}` : `hooks run ${name}`;
|
|
3244
|
+
for (const eventKey of uniqueEventKeys) {
|
|
3245
|
+
if (!settings.hooks[eventKey])
|
|
3246
|
+
settings.hooks[eventKey] = [];
|
|
3247
|
+
const hookEntry = { type: "command", command: hookCommand };
|
|
3248
|
+
if (typeof meta.timeoutSeconds === "number" && meta.timeoutSeconds > 0) {
|
|
3249
|
+
hookEntry.timeout = meta.timeoutSeconds;
|
|
3250
|
+
}
|
|
3251
|
+
const entry = {
|
|
3252
|
+
hooks: [hookEntry]
|
|
3253
|
+
};
|
|
3254
|
+
if (meta.matcher) {
|
|
3255
|
+
entry.matcher = meta.matcher;
|
|
3256
|
+
}
|
|
3257
|
+
settings.hooks[eventKey].push(entry);
|
|
3258
|
+
}
|
|
3259
|
+
writeSettings(settings, scope, target);
|
|
3260
|
+
}
|
|
3261
|
+
function removeHookFromAllEvents(settings, name) {
|
|
3262
|
+
if (!settings.hooks)
|
|
3263
|
+
return;
|
|
3264
|
+
for (const key of Object.keys(settings.hooks)) {
|
|
3265
|
+
settings.hooks[key] = removeHookEntriesByName(settings.hooks[key], name);
|
|
3266
|
+
if (settings.hooks[key].length === 0) {
|
|
3267
|
+
delete settings.hooks[key];
|
|
3268
|
+
}
|
|
3269
|
+
}
|
|
3270
|
+
}
|
|
3271
|
+
function unregisterHook(name, scope = "global", target = "claude") {
|
|
3272
|
+
const settings = readSettings(scope, target);
|
|
3273
|
+
if (!settings.hooks)
|
|
3274
|
+
return;
|
|
3275
|
+
removeHookFromAllEvents(settings, name);
|
|
3276
|
+
if (Object.keys(settings.hooks).length === 0) {
|
|
3277
|
+
delete settings.hooks;
|
|
3278
|
+
}
|
|
3279
|
+
writeSettings(settings, scope, target);
|
|
3280
|
+
}
|
|
3281
|
+
function getRegisteredHooksForTarget(scope = "global", target = "claude") {
|
|
3282
|
+
if (target === "codewith") {
|
|
3283
|
+
const config = readCodewithConfig(scope);
|
|
3284
|
+
const registered2 = [];
|
|
3285
|
+
const re = /command\s*=\s*["']hooks run ([\w-]+)(?:\s+--profile\s+[\w-]+)?["']/g;
|
|
3286
|
+
let match;
|
|
3287
|
+
while (match = re.exec(config)) {
|
|
3288
|
+
registered2.push(match[1]);
|
|
3289
|
+
}
|
|
3290
|
+
return [...new Set(registered2)];
|
|
3291
|
+
}
|
|
3292
|
+
const settings = readSettings(scope, target);
|
|
3293
|
+
if (!settings.hooks)
|
|
3294
|
+
return [];
|
|
3295
|
+
const registered = [];
|
|
3296
|
+
for (const eventKey of Object.keys(settings.hooks)) {
|
|
3297
|
+
for (const entry of settings.hooks[eventKey]) {
|
|
3298
|
+
for (const hook of entry.hooks || []) {
|
|
3299
|
+
const newMatch = hook.command?.match(/^hooks run ([\w-]+)(?:\s+--profile\s+[\w-]+)?$/);
|
|
3300
|
+
const oldMatch = hook.command?.match(/^hook-([\w-]+)$/);
|
|
3301
|
+
const match = newMatch || oldMatch;
|
|
3302
|
+
if (match) {
|
|
3303
|
+
registered.push(match[1]);
|
|
3304
|
+
}
|
|
3305
|
+
}
|
|
3306
|
+
}
|
|
3307
|
+
}
|
|
3308
|
+
return [...new Set(registered)];
|
|
3309
|
+
}
|
|
3310
|
+
function getRegisteredHooks(scope = "global") {
|
|
3311
|
+
return getRegisteredHooksForTarget(scope, "claude");
|
|
3312
|
+
}
|
|
3313
|
+
function removeHook(name, scope = "global", target = "claude") {
|
|
3314
|
+
const shortName = shortHookName(name);
|
|
3315
|
+
if (target === "all") {
|
|
3316
|
+
const claudeRemoved = removeHookForTarget(shortName, scope, "claude");
|
|
3317
|
+
const geminiRemoved = removeHookForTarget(shortName, scope, "gemini");
|
|
3318
|
+
return claudeRemoved || geminiRemoved;
|
|
3319
|
+
}
|
|
3320
|
+
if (target === "codewith") {
|
|
3321
|
+
return false;
|
|
3322
|
+
}
|
|
3323
|
+
return removeHookForTarget(shortName, scope, target);
|
|
3324
|
+
}
|
|
3325
|
+
function removeHookForTarget(name, scope, target) {
|
|
3326
|
+
const registered = getRegisteredHooksForTarget(scope, target);
|
|
3327
|
+
if (!registered.includes(name)) {
|
|
3328
|
+
return false;
|
|
3329
|
+
}
|
|
3330
|
+
unregisterHook(name, scope, target);
|
|
3331
|
+
return true;
|
|
3332
|
+
}
|
|
3333
|
+
|
|
3334
|
+
// src/lib/profiles.ts
|
|
3335
|
+
init_app_home();
|
|
3336
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync4, readdirSync as readdirSync4, rmSync, cpSync as cpSync2 } from "fs";
|
|
3337
|
+
import { join as join8 } from "path";
|
|
3338
|
+
function getProfilesDir() {
|
|
3339
|
+
const newDir = join8(getEffectiveDataRoot(), "profiles");
|
|
3340
|
+
const oldDir = join8(getHomeDir(), ".hooks", "profiles");
|
|
3341
|
+
if (!existsSync8(newDir) && existsSync8(oldDir)) {
|
|
3342
|
+
mkdirSync5(newDir, { recursive: true });
|
|
3343
|
+
cpSync2(oldDir, newDir, { recursive: true });
|
|
3344
|
+
}
|
|
3345
|
+
return newDir;
|
|
3346
|
+
}
|
|
3347
|
+
function ensureProfilesDir() {
|
|
3348
|
+
const dir = getProfilesDir();
|
|
3349
|
+
if (!existsSync8(dir)) {
|
|
3350
|
+
mkdirSync5(dir, { recursive: true });
|
|
3351
|
+
}
|
|
3352
|
+
}
|
|
3353
|
+
function profilePath(id) {
|
|
3354
|
+
return join8(getProfilesDir(), `${id}.json`);
|
|
3355
|
+
}
|
|
3356
|
+
function shortUuid() {
|
|
3357
|
+
return crypto.randomUUID().slice(0, 8);
|
|
3358
|
+
}
|
|
3359
|
+
function createProfile(input) {
|
|
3360
|
+
ensureProfilesDir();
|
|
3361
|
+
const id = shortUuid();
|
|
3362
|
+
const now = new Date().toISOString();
|
|
3363
|
+
const profile = {
|
|
3364
|
+
agent_id: id,
|
|
3365
|
+
agent_type: input.agent_type,
|
|
3366
|
+
created_at: now,
|
|
3367
|
+
last_seen_at: now,
|
|
3368
|
+
preferences: {}
|
|
3369
|
+
};
|
|
3370
|
+
if (input.name) {
|
|
3371
|
+
profile.name = input.name;
|
|
3372
|
+
}
|
|
3373
|
+
writeFileSync4(profilePath(id), JSON.stringify(profile, null, 2) + `
|
|
3374
|
+
`);
|
|
3375
|
+
return profile;
|
|
3376
|
+
}
|
|
3377
|
+
function getProfile(id) {
|
|
3378
|
+
const path = profilePath(id);
|
|
3379
|
+
try {
|
|
3380
|
+
if (!existsSync8(path))
|
|
3381
|
+
return null;
|
|
3382
|
+
return JSON.parse(readFileSync5(path, "utf-8"));
|
|
3383
|
+
} catch {
|
|
3384
|
+
return null;
|
|
3385
|
+
}
|
|
3386
|
+
}
|
|
3387
|
+
function listProfiles() {
|
|
3388
|
+
const dir = getProfilesDir();
|
|
3389
|
+
if (!existsSync8(dir))
|
|
3390
|
+
return [];
|
|
3391
|
+
try {
|
|
3392
|
+
const files = readdirSync4(dir).filter((f) => f.endsWith(".json"));
|
|
3393
|
+
const profiles = [];
|
|
3394
|
+
for (const file of files) {
|
|
3395
|
+
try {
|
|
3396
|
+
const content = readFileSync5(join8(dir, file), "utf-8");
|
|
3397
|
+
profiles.push(JSON.parse(content));
|
|
3398
|
+
} catch {}
|
|
3399
|
+
}
|
|
3400
|
+
return profiles.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
|
|
3401
|
+
} catch {
|
|
3402
|
+
return [];
|
|
3403
|
+
}
|
|
3404
|
+
}
|
|
3405
|
+
|
|
3406
|
+
// src/mcp/server.ts
|
|
3407
|
+
init_manifest();
|
|
3408
|
+
init_resolve();
|
|
3409
|
+
init_redact();
|
|
3410
|
+
|
|
3411
|
+
// src/mcp/authority.ts
|
|
3412
|
+
init_db();
|
|
3413
|
+
init_local_opt_in();
|
|
3414
|
+
|
|
3415
|
+
// src/lib/transport.ts
|
|
3416
|
+
init_transport();
|
|
3417
|
+
init_config();
|
|
3418
|
+
init_local_opt_in();
|
|
3419
|
+
var localNoticePrinted = false;
|
|
3420
|
+
function announceLocal(notice, reason) {
|
|
3421
|
+
if (localNoticePrinted)
|
|
3422
|
+
return;
|
|
3423
|
+
localNoticePrinted = true;
|
|
3424
|
+
const line = `hooks: LOCAL mode \u2014 ${reason}; using the bundled registry and the local store at ` + `${getHooksDataDir()}, not a remote registry. Set HASNA_HOOKS_API_KEY (or the Keychain item ` + `hasna.credentials.hooks.api-key / ~/.hasna/hooks/config/credentials) to go remote.`;
|
|
3425
|
+
if (notice)
|
|
3426
|
+
notice(line);
|
|
3427
|
+
else if (typeof process !== "undefined")
|
|
3428
|
+
process.stderr.write(`${line}
|
|
3429
|
+
`);
|
|
3430
|
+
}
|
|
3431
|
+
function hooksRegistryOrigin(v1BaseUrl) {
|
|
3432
|
+
return v1BaseUrl.replace(/\/+$/, "").replace(/\/v1$/, "");
|
|
3433
|
+
}
|
|
3434
|
+
function rethrowHooksAuthorityFailure(error) {
|
|
3435
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3436
|
+
const name = error instanceof Error ? error.name : "";
|
|
3437
|
+
if (name === "CredentialResolutionError" || name === "CredentialFileUnsafeError") {
|
|
3438
|
+
throw new Error(`REMOTE_API_CREDENTIAL_INVALID: ${message} There is no local fallback: ` + "local mode is opt-in only (HASNA_HOOKS_LOCAL=1) and is disabled by default \u2014 failing closed", { cause: error });
|
|
3439
|
+
}
|
|
3440
|
+
if (/no API key could be resolved/.test(message)) {
|
|
3441
|
+
if (/is not set and no API key could be resolved/.test(message)) {
|
|
3442
|
+
throw new Error("REMOTE_API_CONFIG_MISSING: no hooks registry credential resolved from the Keychain item " + `hasna.credentials.hooks.api-key, ~/.hasna/hooks/config/credentials, or HASNA_HOOKS_API_KEY. ${message} ` + "There is no local fallback: local mode is opt-in only (HASNA_HOOKS_LOCAL=1, alias HOOKS_LOCAL=1) " + "and is disabled by default \u2014 failing closed instead of serving the local store", { cause: error });
|
|
3443
|
+
}
|
|
3444
|
+
throw new Error("REMOTE_API_KEY_MISSING: the remote hooks registry requires HASNA_HOOKS_API_KEY, the Keychain item " + `hasna.credentials.hooks.api-key, or ~/.hasna/hooks/config/credentials. ${message} ` + "There is no local fallback: local mode is opt-in only (HASNA_HOOKS_LOCAL=1) and is disabled by default \u2014 failing closed", { cause: error });
|
|
3445
|
+
}
|
|
3446
|
+
throw new Error(`REMOTE_API_URL_INVALID: ${message} local mode fallback is disabled`, { cause: error });
|
|
3447
|
+
}
|
|
3448
|
+
function resolveHooksTransport(env = process.env, options = {}) {
|
|
3449
|
+
if (selectsHooksLocalStore(env)) {
|
|
3450
|
+
announceLocal(options.notice, "HASNA_HOOKS_LOCAL is set and nothing configures a registry authority");
|
|
3451
|
+
return { mode: "local", source: "local-opt-in", authority: null };
|
|
3452
|
+
}
|
|
3453
|
+
const { env: resolverEnv, credentials } = hooksResolverInputs(env, options.credentials);
|
|
3454
|
+
const tier1 = options.credentials?.apiKey;
|
|
3455
|
+
const credentialOptions = tier1 !== undefined ? { ...credentials, apiKey: tier1 } : credentials;
|
|
3456
|
+
let resolution;
|
|
3457
|
+
let credential;
|
|
3458
|
+
try {
|
|
3459
|
+
credential = resolveCredential("hooks", resolverEnv, credentialOptions);
|
|
3460
|
+
resolution = resolveClientTransport("hooks", resolverEnv, {
|
|
3461
|
+
credentials: credential ? { ...credentialOptions, apiKey: credential.apiKey } : credentialOptions
|
|
3462
|
+
});
|
|
3463
|
+
} catch (error) {
|
|
3464
|
+
rethrowHooksAuthorityFailure(error);
|
|
3465
|
+
}
|
|
3466
|
+
if (!credential) {
|
|
3467
|
+
throw new Error("REMOTE_API_CONFIG_MISSING: no hooks registry credential resolved; " + "local mode fallback is disabled");
|
|
3468
|
+
}
|
|
3469
|
+
return {
|
|
3470
|
+
mode: "remote",
|
|
3471
|
+
source: `${credential.source}+${resolution.apiUrlSource ?? "default"}`,
|
|
3472
|
+
authority: {
|
|
3473
|
+
origin: hooksRegistryOrigin(resolution.baseUrl),
|
|
3474
|
+
apiKey: credential.apiKey,
|
|
3475
|
+
apiUrlSource: resolution.apiUrlSource,
|
|
3476
|
+
apiKeySource: credential.source,
|
|
3477
|
+
apiKeyTier: credential.tier,
|
|
3478
|
+
v1BaseUrl: resolution.baseUrl,
|
|
3479
|
+
warning: resolution.warning
|
|
3480
|
+
}
|
|
3481
|
+
};
|
|
3482
|
+
}
|
|
3483
|
+
|
|
3484
|
+
// src/mcp/authority.ts
|
|
3485
|
+
function resolveHooksMcpAuthority(env = process.env) {
|
|
3486
|
+
const transport = resolveHooksTransport(env);
|
|
3487
|
+
if (transport.mode === "local" || !transport.authority) {
|
|
3488
|
+
return { route: "local", source: "local-opt-in" };
|
|
3489
|
+
}
|
|
3490
|
+
return { route: "hosted", v1BaseUrl: transport.authority.v1BaseUrl, source: transport.source };
|
|
3491
|
+
}
|
|
3492
|
+
function decideHooksMcpAuthority(env = process.env) {
|
|
3493
|
+
const authority = resolveHooksMcpAuthority(env);
|
|
3494
|
+
if (authority.route === "hosted") {
|
|
3495
|
+
refuseLocalStore(hooksHostedRouteLocalStoreRefusal(authority.v1BaseUrl));
|
|
3496
|
+
}
|
|
3497
|
+
return authority;
|
|
3498
|
+
}
|
|
3499
|
+
|
|
3500
|
+
// src/db/storage-sync.ts
|
|
3501
|
+
init_db();
|
|
3502
|
+
|
|
3503
|
+
// src/db/pg-migrations.ts
|
|
3504
|
+
var PG_MIGRATIONS = [
|
|
3505
|
+
`CREATE TABLE IF NOT EXISTS hook_events (
|
|
3506
|
+
id TEXT PRIMARY KEY,
|
|
3507
|
+
timestamp TEXT NOT NULL,
|
|
3508
|
+
session_id TEXT NOT NULL,
|
|
3509
|
+
hook_name TEXT NOT NULL,
|
|
3510
|
+
event_type TEXT NOT NULL CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit', 'SubagentStart')),
|
|
3511
|
+
tool_name TEXT,
|
|
3512
|
+
tool_input TEXT,
|
|
3513
|
+
result TEXT CHECK (result IN ('continue', 'block', NULL)),
|
|
3514
|
+
error TEXT,
|
|
3515
|
+
duration_ms INTEGER,
|
|
3516
|
+
project_dir TEXT,
|
|
3517
|
+
metadata TEXT
|
|
3518
|
+
)`,
|
|
3519
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_timestamp ON hook_events (timestamp)`,
|
|
3520
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_session_id ON hook_events (session_id)`,
|
|
3521
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_hook_name ON hook_events (hook_name)`,
|
|
3522
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_event_type ON hook_events (event_type)`,
|
|
3523
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_errors ON hook_events (timestamp) WHERE error IS NOT NULL`,
|
|
3524
|
+
`CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
3525
|
+
version TEXT PRIMARY KEY,
|
|
3526
|
+
applied_at TEXT NOT NULL
|
|
3527
|
+
)`,
|
|
3528
|
+
`CREATE TABLE IF NOT EXISTS _meta (
|
|
3529
|
+
key TEXT PRIMARY KEY,
|
|
3530
|
+
value TEXT NOT NULL
|
|
3531
|
+
)`,
|
|
3532
|
+
`CREATE TABLE IF NOT EXISTS feedback (
|
|
3533
|
+
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
3534
|
+
message TEXT NOT NULL,
|
|
3535
|
+
email TEXT,
|
|
3536
|
+
category TEXT DEFAULT 'general',
|
|
3537
|
+
version TEXT,
|
|
3538
|
+
machine_id TEXT,
|
|
3539
|
+
created_at TEXT NOT NULL DEFAULT NOW()::text
|
|
3540
|
+
)`,
|
|
3541
|
+
`ALTER TABLE hook_events DROP CONSTRAINT IF EXISTS hook_events_event_type_check`,
|
|
3542
|
+
`ALTER TABLE hook_events ADD CONSTRAINT hook_events_event_type_check
|
|
3543
|
+
CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit'))`,
|
|
3544
|
+
`ALTER TABLE hook_events DROP CONSTRAINT IF EXISTS hook_events_event_type_check`,
|
|
3545
|
+
`ALTER TABLE hook_events ADD CONSTRAINT hook_events_event_type_check
|
|
3546
|
+
CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit', 'SubagentStart'))`
|
|
3547
|
+
];
|
|
3548
|
+
|
|
3549
|
+
// src/db/remote-storage.ts
|
|
3550
|
+
import pg from "pg";
|
|
3551
|
+
function translatePlaceholders(sql) {
|
|
3552
|
+
let index = 0;
|
|
3553
|
+
return sql.replace(/\?/g, () => `$${++index}`);
|
|
3554
|
+
}
|
|
3555
|
+
function normalizeParams(params) {
|
|
3556
|
+
const flat = params.length === 1 && Array.isArray(params[0]) ? params[0] : params;
|
|
3557
|
+
return flat.map((value) => value === undefined ? null : value);
|
|
3558
|
+
}
|
|
3559
|
+
function sslConfigFor(connectionString) {
|
|
3560
|
+
const wantsSsl = wantsSslFor(connectionString);
|
|
3561
|
+
if (!wantsSsl)
|
|
3562
|
+
return;
|
|
3563
|
+
const insecure = process.env.HASNA_HOOKS_PG_INSECURE_TLS === "1" || process.env.HOOKS_PG_INSECURE_TLS === "1";
|
|
3564
|
+
if (insecure && false) {}
|
|
3565
|
+
return insecure ? { rejectUnauthorized: false } : { rejectUnauthorized: true };
|
|
3566
|
+
}
|
|
3567
|
+
function wantsSslFor(connectionString) {
|
|
3568
|
+
let raw;
|
|
3569
|
+
try {
|
|
3570
|
+
raw = new URL(connectionString).search.replace(/^\?/, "");
|
|
3571
|
+
} catch {
|
|
3572
|
+
raw = connectionString;
|
|
3573
|
+
}
|
|
3574
|
+
try {
|
|
3575
|
+
raw = decodeURIComponent(raw);
|
|
3576
|
+
} catch {}
|
|
3577
|
+
const paramValue = (name) => {
|
|
3578
|
+
const match = raw.match(new RegExp(`(?:^|[&\\s])${name}\\s*=\\s*([^&\\s]+)`, "i"));
|
|
3579
|
+
return match ? match[1].toLowerCase() : undefined;
|
|
3580
|
+
};
|
|
3581
|
+
const sslmode = paramValue("sslmode");
|
|
3582
|
+
if (sslmode !== undefined)
|
|
3583
|
+
return sslmode !== "disable" && sslmode !== "allow";
|
|
3584
|
+
const ssl = paramValue("ssl");
|
|
3585
|
+
if (ssl !== undefined)
|
|
3586
|
+
return ssl !== "false" && ssl !== "0" && ssl !== "no";
|
|
3587
|
+
return false;
|
|
3588
|
+
}
|
|
3589
|
+
|
|
3590
|
+
class PgAdapterAsync {
|
|
3591
|
+
pool;
|
|
3592
|
+
constructor(connectionString) {
|
|
3593
|
+
this.pool = new pg.Pool({ connectionString, ssl: sslConfigFor(connectionString) });
|
|
3594
|
+
}
|
|
3595
|
+
async run(sql, ...params) {
|
|
3596
|
+
const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params));
|
|
3597
|
+
return { changes: result.rowCount ?? 0 };
|
|
3598
|
+
}
|
|
3599
|
+
async all(sql, ...params) {
|
|
3600
|
+
const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params));
|
|
3601
|
+
return result.rows;
|
|
3602
|
+
}
|
|
3603
|
+
async close() {
|
|
3604
|
+
await this.pool.end();
|
|
3605
|
+
}
|
|
3606
|
+
}
|
|
3607
|
+
|
|
3608
|
+
// src/db/storage-sync.ts
|
|
3609
|
+
init_redact();
|
|
3610
|
+
var STORAGE_TABLES = [
|
|
3611
|
+
"hook_events",
|
|
3612
|
+
"schema_migrations",
|
|
3613
|
+
"_meta",
|
|
3614
|
+
"feedback"
|
|
3615
|
+
];
|
|
3616
|
+
var STORAGE_BACKENDS = ["sqlite", "postgresql"];
|
|
3617
|
+
var HOOKS_STORAGE_ENV = "HASNA_HOOKS_DATABASE_URL";
|
|
3618
|
+
var HOOKS_STORAGE_FALLBACK_ENV = "HOOKS_DATABASE_URL";
|
|
3619
|
+
var HOOKS_STORAGE_BACKEND_ENV = "HASNA_HOOKS_STORAGE_BACKEND";
|
|
3620
|
+
var HOOKS_STORAGE_BACKEND_FALLBACK_ENV = "HOOKS_STORAGE_BACKEND";
|
|
3621
|
+
var STORAGE_DATABASE_ENV = [HOOKS_STORAGE_ENV, HOOKS_STORAGE_FALLBACK_ENV];
|
|
3622
|
+
var RETIRED_STORAGE_MODE_ENV = ["HASNA_HOOKS_STORAGE_MODE", "HOOKS_STORAGE_MODE"];
|
|
3623
|
+
var PRIMARY_KEYS = {
|
|
3624
|
+
hook_events: ["id"],
|
|
3625
|
+
schema_migrations: ["version"],
|
|
3626
|
+
_meta: ["key"],
|
|
3627
|
+
feedback: ["id"]
|
|
3628
|
+
};
|
|
3629
|
+
function readEnv(name) {
|
|
3630
|
+
const value = process.env[name]?.trim();
|
|
3631
|
+
return value || undefined;
|
|
3632
|
+
}
|
|
3633
|
+
var RETIRED_MODE_REPLACEMENT = {
|
|
3634
|
+
local: "sqlite",
|
|
3635
|
+
hybrid: "postgresql",
|
|
3636
|
+
remote: "postgresql",
|
|
3637
|
+
self_hosted: "postgresql",
|
|
3638
|
+
"self-hosted": "postgresql",
|
|
3639
|
+
selfhosted: "postgresql",
|
|
3640
|
+
cloud: "postgresql"
|
|
3641
|
+
};
|
|
3642
|
+
var BACKEND_ALIASES = {
|
|
3643
|
+
sqlite: "sqlite",
|
|
3644
|
+
sqlite3: "sqlite",
|
|
3645
|
+
postgresql: "postgresql",
|
|
3646
|
+
postgres: "postgresql",
|
|
3647
|
+
pg: "postgresql"
|
|
3648
|
+
};
|
|
3649
|
+
function assertNoRetiredModeEnv() {
|
|
3650
|
+
for (const name of RETIRED_STORAGE_MODE_ENV) {
|
|
3651
|
+
const value = readEnv(name);
|
|
3652
|
+
if (!value)
|
|
3653
|
+
continue;
|
|
3654
|
+
const replacement = RETIRED_MODE_REPLACEMENT[value.trim().toLowerCase()];
|
|
3655
|
+
const mapping = replacement ? `${value} maps to ${replacement}` : `use one of ${STORAGE_BACKENDS.join(", ")}`;
|
|
3656
|
+
throw new Error(`${name} is a retired deployment-mode variable and is no longer read. ` + `Hooks storage is a data-backend switch, not a deployment mode: ` + `set ${HOOKS_STORAGE_BACKEND_ENV} to ${STORAGE_BACKENDS.join(" or ")} instead (${mapping}), ` + `then unset ${name}.`);
|
|
3657
|
+
}
|
|
3658
|
+
}
|
|
3659
|
+
function normalizeStorageBackend(value, envName) {
|
|
3660
|
+
if (value === undefined)
|
|
3661
|
+
return;
|
|
3662
|
+
const normalized = value.trim().toLowerCase();
|
|
3663
|
+
if (normalized === "")
|
|
3664
|
+
return;
|
|
3665
|
+
const backend = BACKEND_ALIASES[normalized];
|
|
3666
|
+
if (backend)
|
|
3667
|
+
return backend;
|
|
3668
|
+
const replacement = RETIRED_MODE_REPLACEMENT[normalized];
|
|
3669
|
+
if (replacement) {
|
|
3670
|
+
throw new Error(`${envName}=${value} names a retired deployment mode. ` + `local/hybrid/remote/self_hosted/cloud were removed: hooks storage now selects a data ` + `backend only. Set ${envName}=${replacement} instead.`);
|
|
3671
|
+
}
|
|
3672
|
+
throw new Error(`${envName}=${value} is not a known hooks storage backend. ` + `Set ${HOOKS_STORAGE_BACKEND_ENV} to one of ${STORAGE_BACKENDS.join(", ")}.`);
|
|
3673
|
+
}
|
|
3674
|
+
function getStorageDatabaseEnvName() {
|
|
3675
|
+
for (const name of STORAGE_DATABASE_ENV) {
|
|
3676
|
+
if (readEnv(name))
|
|
3677
|
+
return name;
|
|
3678
|
+
}
|
|
3679
|
+
return null;
|
|
3680
|
+
}
|
|
3681
|
+
function getStorageDatabaseEnv() {
|
|
3682
|
+
const name = getStorageDatabaseEnvName();
|
|
3683
|
+
return name ? { name } : null;
|
|
3684
|
+
}
|
|
3685
|
+
function getStorageDatabaseUrl() {
|
|
3686
|
+
const env = getStorageDatabaseEnv();
|
|
3687
|
+
return env ? readEnv(env.name) ?? null : null;
|
|
3688
|
+
}
|
|
3689
|
+
function getStorageBackend() {
|
|
3690
|
+
assertNoRetiredModeEnv();
|
|
3691
|
+
const backend = normalizeStorageBackend(readEnv(HOOKS_STORAGE_BACKEND_ENV), HOOKS_STORAGE_BACKEND_ENV) ?? normalizeStorageBackend(readEnv(HOOKS_STORAGE_BACKEND_FALLBACK_ENV), HOOKS_STORAGE_BACKEND_FALLBACK_ENV);
|
|
3692
|
+
if (backend)
|
|
3693
|
+
return backend;
|
|
3694
|
+
return getStorageDatabaseUrl() ? "postgresql" : "sqlite";
|
|
3695
|
+
}
|
|
3696
|
+
async function getStoragePg() {
|
|
3697
|
+
const url = getStorageDatabaseUrl();
|
|
3698
|
+
if (!url) {
|
|
3699
|
+
throw new Error("Missing HASNA_HOOKS_DATABASE_URL or HOOKS_DATABASE_URL");
|
|
3700
|
+
}
|
|
3701
|
+
return new PgAdapterAsync(url);
|
|
3702
|
+
}
|
|
3703
|
+
async function runStorageMigrations(remote) {
|
|
3704
|
+
await remote.run("CREATE EXTENSION IF NOT EXISTS pgcrypto");
|
|
3705
|
+
for (const sql of PG_MIGRATIONS)
|
|
3706
|
+
await remote.run(sql);
|
|
3707
|
+
}
|
|
3708
|
+
async function storagePush(options) {
|
|
3709
|
+
const remote = await getStoragePg();
|
|
3710
|
+
const db = getDb();
|
|
3711
|
+
try {
|
|
3712
|
+
await runStorageMigrations(remote);
|
|
3713
|
+
const results = [];
|
|
3714
|
+
for (const table of resolveTables(options?.tables)) {
|
|
3715
|
+
results.push(await pushTable(db, remote, table));
|
|
3716
|
+
}
|
|
3717
|
+
recordSyncMeta(db, "push", results);
|
|
3718
|
+
return results;
|
|
3719
|
+
} finally {
|
|
3720
|
+
await remote.close();
|
|
3721
|
+
}
|
|
3722
|
+
}
|
|
3723
|
+
async function storagePull(options) {
|
|
3724
|
+
const remote = await getStoragePg();
|
|
3725
|
+
const db = getDb();
|
|
3726
|
+
try {
|
|
3727
|
+
await runStorageMigrations(remote);
|
|
3728
|
+
const results = [];
|
|
3729
|
+
for (const table of resolveTables(options?.tables)) {
|
|
3730
|
+
results.push(await pullTable(remote, db, table));
|
|
3731
|
+
}
|
|
3732
|
+
recordSyncMeta(db, "pull", results);
|
|
3733
|
+
return results;
|
|
3734
|
+
} finally {
|
|
3735
|
+
await remote.close();
|
|
3736
|
+
}
|
|
3737
|
+
}
|
|
3738
|
+
async function storageSync(options) {
|
|
3739
|
+
const pull = await storagePull(options);
|
|
3740
|
+
const push = await storagePush(options);
|
|
3741
|
+
return { pull, push };
|
|
3742
|
+
}
|
|
3743
|
+
function getSyncMetaAll() {
|
|
3744
|
+
const db = getDb();
|
|
3745
|
+
ensureSyncMetaTable(db);
|
|
3746
|
+
return db.query("SELECT table_name, last_synced_at, direction FROM _hooks_sync_meta ORDER BY table_name, direction").all();
|
|
3747
|
+
}
|
|
3748
|
+
function getStorageStatus() {
|
|
3749
|
+
const activeEnv = getStorageDatabaseEnv();
|
|
3750
|
+
return {
|
|
3751
|
+
configured: Boolean(activeEnv),
|
|
3752
|
+
backend: getStorageBackend(),
|
|
3753
|
+
env: STORAGE_DATABASE_ENV,
|
|
3754
|
+
activeEnv: activeEnv?.name ?? null,
|
|
3755
|
+
service: "hooks",
|
|
3756
|
+
tables: STORAGE_TABLES,
|
|
3757
|
+
sync: getSyncMetaAll()
|
|
3758
|
+
};
|
|
3759
|
+
}
|
|
3760
|
+
function resolveTables(tables) {
|
|
3761
|
+
if (!tables || tables.length === 0)
|
|
3762
|
+
return [...STORAGE_TABLES];
|
|
3763
|
+
const allowed = new Set(STORAGE_TABLES);
|
|
3764
|
+
const requested = tables.map((table) => table.trim()).filter(Boolean);
|
|
3765
|
+
const invalid = requested.filter((table) => !allowed.has(table));
|
|
3766
|
+
if (invalid.length > 0)
|
|
3767
|
+
throw new Error(`Unknown hooks sync table(s): ${invalid.join(", ")}`);
|
|
3768
|
+
return requested;
|
|
3769
|
+
}
|
|
3770
|
+
async function pushTable(db, remote, table) {
|
|
3771
|
+
const result = { table, rowsRead: 0, rowsWritten: 0, errors: [] };
|
|
3772
|
+
try {
|
|
3773
|
+
if (!tableExists(db, table))
|
|
3774
|
+
return result;
|
|
3775
|
+
const rows = db.query(`SELECT * FROM ${quoteIdent(table)}`).all();
|
|
3776
|
+
result.rowsRead = rows.length;
|
|
3777
|
+
if (rows.length === 0)
|
|
3778
|
+
return result;
|
|
3779
|
+
const projected = table === "hook_events" ? rows.map((row) => ({
|
|
3780
|
+
...row,
|
|
3781
|
+
tool_input: typeof row.tool_input === "string" ? redactEventPayload(row.tool_input) : row.tool_input,
|
|
3782
|
+
error: typeof row.error === "string" ? redactEventPayload(row.error) : row.error,
|
|
3783
|
+
metadata: typeof row.metadata === "string" ? redactEventPayload(row.metadata) : row.metadata
|
|
3784
|
+
})) : rows;
|
|
3785
|
+
const remoteColumns = await getRemoteColumns(remote, table);
|
|
3786
|
+
const columns = filterRemoteColumns(remoteColumns, Object.keys(projected[0]));
|
|
3787
|
+
result.rowsWritten = await upsertPg(remote, table, columns, projected, remoteColumns);
|
|
3788
|
+
} catch (error) {
|
|
3789
|
+
result.errors.push(error instanceof Error ? error.message : String(error));
|
|
3790
|
+
}
|
|
3791
|
+
return result;
|
|
3792
|
+
}
|
|
3793
|
+
async function pullTable(remote, db, table) {
|
|
3794
|
+
const result = { table, rowsRead: 0, rowsWritten: 0, errors: [] };
|
|
3795
|
+
try {
|
|
3796
|
+
if (!tableExists(db, table))
|
|
3797
|
+
return result;
|
|
3798
|
+
const rows = await remote.all(`SELECT * FROM ${quoteIdent(table)}`);
|
|
3799
|
+
result.rowsRead = rows.length;
|
|
3800
|
+
if (rows.length === 0)
|
|
3801
|
+
return result;
|
|
3802
|
+
const projected = table === "hook_events" ? rows.map((row) => ({
|
|
3803
|
+
...row,
|
|
3804
|
+
tool_input: typeof row.tool_input === "string" ? redactEventPayload(row.tool_input) : row.tool_input,
|
|
3805
|
+
error: typeof row.error === "string" ? redactEventPayload(row.error) : row.error,
|
|
3806
|
+
metadata: typeof row.metadata === "string" ? redactEventPayload(row.metadata) : row.metadata
|
|
3807
|
+
})) : rows;
|
|
3808
|
+
const columns = filterLocalColumns(db, table, Object.keys(projected[0]));
|
|
3809
|
+
result.rowsWritten = upsertSqlite(db, table, columns, projected);
|
|
3810
|
+
} catch (error) {
|
|
3811
|
+
result.errors.push(error instanceof Error ? error.message : String(error));
|
|
3812
|
+
}
|
|
3813
|
+
return result;
|
|
3814
|
+
}
|
|
3815
|
+
async function getRemoteColumns(remote, table) {
|
|
3816
|
+
const rows = await remote.all("SELECT column_name, data_type FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ?", table);
|
|
3817
|
+
return new Map(rows.map((row) => [row.column_name, row.data_type]));
|
|
3818
|
+
}
|
|
3819
|
+
function filterRemoteColumns(remoteColumns, columns) {
|
|
3820
|
+
if (remoteColumns.size === 0)
|
|
3821
|
+
return columns;
|
|
3822
|
+
return columns.filter((column) => remoteColumns.has(column));
|
|
3823
|
+
}
|
|
3824
|
+
function filterLocalColumns(db, table, columns) {
|
|
3825
|
+
const rows = db.query(`PRAGMA table_info(${quoteIdent(table)})`).all();
|
|
3826
|
+
const allowed = new Set(rows.map((row) => row.name));
|
|
3827
|
+
return columns.filter((column) => allowed.has(column));
|
|
3828
|
+
}
|
|
3829
|
+
async function upsertPg(remote, table, columns, rows, remoteColumns) {
|
|
3830
|
+
if (columns.length === 0)
|
|
3831
|
+
return 0;
|
|
3832
|
+
const primaryKeys = PRIMARY_KEYS[table];
|
|
3833
|
+
const columnList = columns.map(quoteIdent).join(", ");
|
|
3834
|
+
const placeholders = columns.map(() => "?").join(", ");
|
|
3835
|
+
const keyList = primaryKeys.map(quoteIdent).join(", ");
|
|
3836
|
+
const updateColumns = columns.filter((column) => !primaryKeys.includes(column));
|
|
3837
|
+
const fallbackKey = primaryKeys[0];
|
|
3838
|
+
const setClause = updateColumns.length > 0 ? updateColumns.map((column) => `${quoteIdent(column)} = EXCLUDED.${quoteIdent(column)}`).join(", ") : `${quoteIdent(fallbackKey)} = EXCLUDED.${quoteIdent(fallbackKey)}`;
|
|
3839
|
+
for (const row of rows) {
|
|
3840
|
+
await remote.run(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders})
|
|
3841
|
+
ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}`, ...columns.map((column) => coerceForPg(row[column], remoteColumns.get(column))));
|
|
3842
|
+
}
|
|
3843
|
+
return rows.length;
|
|
3844
|
+
}
|
|
3845
|
+
function upsertSqlite(db, table, columns, rows) {
|
|
3846
|
+
if (columns.length === 0)
|
|
3847
|
+
return 0;
|
|
3848
|
+
const primaryKeys = PRIMARY_KEYS[table];
|
|
3849
|
+
const columnList = columns.map(quoteIdent).join(", ");
|
|
3850
|
+
const placeholders = columns.map(() => "?").join(", ");
|
|
3851
|
+
const keyList = primaryKeys.map(quoteIdent).join(", ");
|
|
3852
|
+
const updateColumns = columns.filter((column) => !primaryKeys.includes(column));
|
|
3853
|
+
const fallbackKey = primaryKeys[0];
|
|
3854
|
+
const setClause = updateColumns.length > 0 ? updateColumns.map((column) => `${quoteIdent(column)} = excluded.${quoteIdent(column)}`).join(", ") : `${quoteIdent(fallbackKey)} = excluded.${quoteIdent(fallbackKey)}`;
|
|
3855
|
+
const statement = db.query(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders})
|
|
3856
|
+
ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}`);
|
|
3857
|
+
const insert = db.transaction((batch) => {
|
|
3858
|
+
for (const row of batch)
|
|
3859
|
+
statement.run(...columns.map((column) => coerceForSqlite(row[column])));
|
|
3860
|
+
});
|
|
3861
|
+
insert(rows);
|
|
3862
|
+
return rows.length;
|
|
3863
|
+
}
|
|
3864
|
+
function recordSyncMeta(db, direction, results) {
|
|
3865
|
+
ensureSyncMetaTable(db);
|
|
3866
|
+
const now = new Date().toISOString();
|
|
3867
|
+
const statement = db.query(`
|
|
3868
|
+
INSERT INTO _hooks_sync_meta (table_name, last_synced_at, direction)
|
|
3869
|
+
VALUES (?, ?, ?)
|
|
3870
|
+
ON CONFLICT(table_name, direction) DO UPDATE SET last_synced_at = excluded.last_synced_at
|
|
3871
|
+
`);
|
|
3872
|
+
for (const result of results) {
|
|
3873
|
+
if (result.errors.length > 0)
|
|
3874
|
+
continue;
|
|
3875
|
+
statement.run(result.table, now, direction);
|
|
3876
|
+
}
|
|
3877
|
+
}
|
|
3878
|
+
function ensureSyncMetaTable(db) {
|
|
3879
|
+
db.exec(`
|
|
3880
|
+
CREATE TABLE IF NOT EXISTS _hooks_sync_meta (
|
|
3881
|
+
table_name TEXT NOT NULL,
|
|
3882
|
+
last_synced_at TEXT,
|
|
3883
|
+
direction TEXT NOT NULL CHECK(direction IN ('push', 'pull')),
|
|
3884
|
+
PRIMARY KEY (table_name, direction)
|
|
3885
|
+
)
|
|
3886
|
+
`);
|
|
3887
|
+
}
|
|
3888
|
+
function tableExists(db, table) {
|
|
3889
|
+
const row = db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
|
|
3890
|
+
return Boolean(row);
|
|
3891
|
+
}
|
|
3892
|
+
function quoteIdent(identifier) {
|
|
3893
|
+
return `"${identifier.replace(/"/g, '""')}"`;
|
|
3894
|
+
}
|
|
3895
|
+
function coerceForPg(value, dataType) {
|
|
3896
|
+
if (value === undefined || value === null)
|
|
3897
|
+
return null;
|
|
3898
|
+
if (dataType === "boolean") {
|
|
3899
|
+
if (typeof value === "boolean")
|
|
3900
|
+
return value;
|
|
3901
|
+
if (typeof value === "number")
|
|
3902
|
+
return value !== 0;
|
|
3903
|
+
if (typeof value === "string")
|
|
3904
|
+
return value === "1" || value.toLowerCase() === "true";
|
|
3905
|
+
}
|
|
3906
|
+
if (value instanceof Date)
|
|
3907
|
+
return value.toISOString();
|
|
3908
|
+
if (Buffer.isBuffer(value) || value instanceof Uint8Array)
|
|
3909
|
+
return value;
|
|
3910
|
+
if (typeof value === "object")
|
|
3911
|
+
return JSON.stringify(value);
|
|
3912
|
+
return value;
|
|
3913
|
+
}
|
|
3914
|
+
function coerceForSqlite(value) {
|
|
3915
|
+
if (value === undefined || value === null)
|
|
3916
|
+
return null;
|
|
3917
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "bigint" || typeof value === "boolean")
|
|
3918
|
+
return value;
|
|
3919
|
+
if (value instanceof Date)
|
|
3920
|
+
return value.toISOString();
|
|
3921
|
+
if (Buffer.isBuffer(value) || value instanceof Uint8Array)
|
|
3922
|
+
return value;
|
|
3923
|
+
if (typeof value === "object")
|
|
3924
|
+
return JSON.stringify(value);
|
|
3925
|
+
return String(value);
|
|
3926
|
+
}
|
|
3927
|
+
// src/mcp/server.ts
|
|
3928
|
+
var __dirname3 = dirname6(fileURLToPath3(import.meta.url));
|
|
3929
|
+
var pkg = { name: "@hasna/hooks", version: "0.0.0" };
|
|
3930
|
+
try {
|
|
3931
|
+
for (const rel of ["../../package.json", "../package.json", "../../../package.json"]) {
|
|
3932
|
+
const p = join11(__dirname3, rel);
|
|
3933
|
+
if (existsSync10(p)) {
|
|
3934
|
+
pkg = JSON.parse(readFileSync7(p, "utf-8"));
|
|
3935
|
+
break;
|
|
3936
|
+
}
|
|
3937
|
+
}
|
|
3938
|
+
} catch {}
|
|
3939
|
+
async function runVerifiedHook(name, stdin, timeoutMs) {
|
|
3940
|
+
const { resolveHook: resolveHook2, resolveScriptPath: resolveScriptPath2 } = await Promise.resolve().then(() => (init_resolve(), exports_resolve));
|
|
3941
|
+
const resolved = resolveHook2(name);
|
|
3942
|
+
const hookScript = resolveScriptPath2(name);
|
|
3943
|
+
if (!resolved || !hookScript || !existsSync10(hookScript)) {
|
|
3944
|
+
throw new Error(`Hook '${name}' not found`);
|
|
3945
|
+
}
|
|
3946
|
+
const content = readFileSync7(hookScript);
|
|
3947
|
+
const sha = sha256Of(content);
|
|
3948
|
+
const check = checkScriptHash(name, sha);
|
|
3949
|
+
if (!check.ok) {
|
|
3950
|
+
throw new Error(`Hook '${name}' script changed since it was trusted (sha256 ${check.expected} != ${check.actual}). Run 'hooks trust ${name}' to trust the new content.`);
|
|
3951
|
+
}
|
|
3952
|
+
const { executeVerifiedScript: executeVerifiedScript2, HookTimeoutError: HookTimeoutError2 } = await Promise.resolve().then(() => (init_run(), exports_run));
|
|
3953
|
+
const started = Date.now();
|
|
3954
|
+
let ran;
|
|
3955
|
+
try {
|
|
3956
|
+
ran = await executeVerifiedScript2({ name, scriptPath: hookScript, content, stdin, timeout: timeoutMs });
|
|
3957
|
+
} catch (err) {
|
|
3958
|
+
try {
|
|
3959
|
+
const { recordHookRun: recordHookRun2, resolveEventType: resolveEventType2 } = await Promise.resolve().then(() => (init_db_writer(), exports_db_writer));
|
|
3960
|
+
let inputJson = {};
|
|
3961
|
+
try {
|
|
3962
|
+
inputJson = JSON.parse(stdin);
|
|
3963
|
+
} catch {}
|
|
3964
|
+
recordHookRun2({
|
|
3965
|
+
hookName: name,
|
|
3966
|
+
eventType: resolveEventType2(inputJson.hook_event_name, resolved.events[0] ?? "PostToolUse"),
|
|
3967
|
+
version: resolved.version,
|
|
3968
|
+
sha256: sha,
|
|
3969
|
+
sessionId: typeof inputJson.session_id === "string" ? inputJson.session_id : null,
|
|
3970
|
+
toolName: typeof inputJson.tool_name === "string" ? inputJson.tool_name : null,
|
|
3971
|
+
toolInput: inputJson.tool_input,
|
|
3972
|
+
error: err instanceof Error ? err.message.slice(0, 500) : String(err),
|
|
3973
|
+
exitCode: -1,
|
|
3974
|
+
durationMs: Date.now() - started,
|
|
3975
|
+
projectDir: process.cwd()
|
|
3976
|
+
});
|
|
3977
|
+
} catch {}
|
|
3978
|
+
if (err instanceof HookTimeoutError2)
|
|
3979
|
+
throw err;
|
|
3980
|
+
throw err;
|
|
3981
|
+
}
|
|
3982
|
+
const durationMs = Date.now() - started;
|
|
3983
|
+
try {
|
|
3984
|
+
const { recordHookRun: recordHookRun2, resolveEventType: resolveEventType2 } = await Promise.resolve().then(() => (init_db_writer(), exports_db_writer));
|
|
3985
|
+
let inputJson = {};
|
|
3986
|
+
try {
|
|
3987
|
+
inputJson = JSON.parse(stdin);
|
|
3988
|
+
} catch {}
|
|
3989
|
+
let outputJson = {};
|
|
3990
|
+
try {
|
|
3991
|
+
outputJson = JSON.parse(ran.stdout);
|
|
3992
|
+
} catch {}
|
|
3993
|
+
const blocked = outputJson.decision === "block" || outputJson.continue === false;
|
|
3994
|
+
recordHookRun2({
|
|
3995
|
+
hookName: name,
|
|
3996
|
+
eventType: resolveEventType2(inputJson.hook_event_name, resolved.events[0] ?? "PostToolUse"),
|
|
3997
|
+
version: resolved.version,
|
|
3998
|
+
sha256: sha,
|
|
3999
|
+
sessionId: typeof inputJson.session_id === "string" ? inputJson.session_id : null,
|
|
4000
|
+
toolName: typeof inputJson.tool_name === "string" ? inputJson.tool_name : null,
|
|
4001
|
+
toolInput: inputJson.tool_input,
|
|
4002
|
+
result: blocked ? "block" : "continue",
|
|
4003
|
+
error: ran.exitCode !== 0 ? (ran.stderr || `hook exited with code ${ran.exitCode}`).slice(0, 500) : null,
|
|
4004
|
+
exitCode: ran.exitCode,
|
|
4005
|
+
durationMs,
|
|
4006
|
+
projectDir: process.cwd()
|
|
4007
|
+
});
|
|
4008
|
+
} catch {}
|
|
4009
|
+
return ran;
|
|
4010
|
+
}
|
|
4011
|
+
function formatInstallResults(results, extra) {
|
|
4012
|
+
const installed = results.filter((r) => r.success).map((r) => r.hook);
|
|
4013
|
+
const failed = results.filter((r) => !r.success).map((r) => ({ hook: r.hook, error: r.error }));
|
|
4014
|
+
return {
|
|
4015
|
+
content: [{
|
|
4016
|
+
type: "text",
|
|
4017
|
+
text: JSON.stringify({ installed, failed, total: results.length, success: installed.length, ...extra })
|
|
4018
|
+
}]
|
|
4019
|
+
};
|
|
4020
|
+
}
|
|
4021
|
+
function truncateText(value, max = 160) {
|
|
4022
|
+
const text = String(value ?? "").replace(/\s+/g, " ").trim();
|
|
4023
|
+
if (text.length <= max)
|
|
4024
|
+
return text;
|
|
4025
|
+
return `${text.slice(0, Math.max(0, max - 3))}...`;
|
|
4026
|
+
}
|
|
4027
|
+
function boundedLimit(value, fallback, max) {
|
|
4028
|
+
if (!Number.isFinite(value) || !value || value <= 0)
|
|
4029
|
+
return fallback;
|
|
4030
|
+
return Math.min(Math.floor(value), max);
|
|
4031
|
+
}
|
|
4032
|
+
function compactHook(hook) {
|
|
4033
|
+
return {
|
|
4034
|
+
name: hook.name,
|
|
4035
|
+
event: hook.event,
|
|
4036
|
+
matcher: hook.matcher,
|
|
4037
|
+
category: hook.category
|
|
4038
|
+
};
|
|
4039
|
+
}
|
|
4040
|
+
function compactHookResult(hooks, limit, detail) {
|
|
4041
|
+
const visible = hooks.slice(0, limit).map(compactHook);
|
|
4042
|
+
const omitted = Math.max(0, hooks.length - visible.length);
|
|
4043
|
+
return {
|
|
4044
|
+
hooks: visible,
|
|
4045
|
+
count: visible.length,
|
|
4046
|
+
total: hooks.length,
|
|
4047
|
+
omitted,
|
|
4048
|
+
hint: omitted > 0 ? `Use limit, compact:false, or ${detail} for more detail.` : `Use compact:false or ${detail} for full details.`
|
|
4049
|
+
};
|
|
4050
|
+
}
|
|
4051
|
+
function compactEvent(row) {
|
|
4052
|
+
return {
|
|
4053
|
+
id: row.id,
|
|
4054
|
+
timestamp: row.timestamp,
|
|
4055
|
+
hook_name: row.hook_name,
|
|
4056
|
+
tool_name: row.tool_name ?? undefined,
|
|
4057
|
+
session_id: row.session_id ? String(row.session_id).slice(0, 12) : undefined,
|
|
4058
|
+
error: row.error ? truncateText(row.error, 180) : undefined,
|
|
4059
|
+
tool_input_preview: row.tool_input ? truncateText(row.tool_input, 180) : undefined
|
|
4060
|
+
};
|
|
4061
|
+
}
|
|
4062
|
+
function projectLogRows(rows) {
|
|
4063
|
+
return rows.map((row) => projectEventRowForRead({ ...row }));
|
|
4064
|
+
}
|
|
4065
|
+
var _hooksAgents = new Map;
|
|
4066
|
+
function createHooksServer() {
|
|
4067
|
+
const server = new McpServer({
|
|
4068
|
+
name: "@hasna/hooks",
|
|
4069
|
+
version: pkg.version
|
|
4070
|
+
});
|
|
4071
|
+
const defineTool = (name, description, schema, handler) => server.tool(name, description, schema, handler);
|
|
4072
|
+
defineTool("hooks_list", "List available hooks. Compact by default; pass compact:false for full HookMeta objects.", {
|
|
4073
|
+
category: z2.string().optional().describe("Filter by category name (e.g. 'Git Safety', 'Code Quality', 'Security')"),
|
|
4074
|
+
compact: z2.boolean().default(true).describe("Return compact summaries by default. Set false for full fields."),
|
|
4075
|
+
limit: z2.number().default(25).describe("Max compact rows to return")
|
|
4076
|
+
}, async ({ category, compact, limit }) => {
|
|
4077
|
+
const maxRows = boundedLimit(limit, 25, 100);
|
|
4078
|
+
if (category) {
|
|
4079
|
+
const cat = CATEGORIES.find((c) => c.toLowerCase() === category.toLowerCase());
|
|
4080
|
+
if (!cat) {
|
|
4081
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: `Unknown category: ${category}`, available: [...CATEGORIES] }) }] };
|
|
4082
|
+
}
|
|
4083
|
+
const hooks = getHooksByCategory(cat);
|
|
4084
|
+
return { content: [{ type: "text", text: JSON.stringify(compact ? compactHookResult(hooks, maxRows, "hooks_info") : hooks) }] };
|
|
4085
|
+
}
|
|
4086
|
+
const result = {};
|
|
4087
|
+
for (const cat of CATEGORIES) {
|
|
4088
|
+
result[cat] = getHooksByCategory(cat);
|
|
4089
|
+
}
|
|
4090
|
+
return { content: [{ type: "text", text: JSON.stringify(compact ? compactHookResult(HOOKS, maxRows, "hooks_info") : result) }] };
|
|
4091
|
+
});
|
|
4092
|
+
defineTool("hooks_search", "Search hooks. Compact by default; pass compact:false for full HookMeta objects.", {
|
|
4093
|
+
query: z2.string().describe("Search query"),
|
|
4094
|
+
compact: z2.boolean().default(true).describe("Return compact summaries by default. Set false for full fields."),
|
|
4095
|
+
limit: z2.number().default(10).describe("Max compact rows to return")
|
|
4096
|
+
}, async ({ query, compact, limit }) => {
|
|
4097
|
+
const results = searchHooks(query);
|
|
4098
|
+
const out = compact ? compactHookResult(results, boundedLimit(limit, 10, 100), "hooks_info") : results;
|
|
4099
|
+
return { content: [{ type: "text", text: JSON.stringify(out) }] };
|
|
4100
|
+
});
|
|
4101
|
+
defineTool("hooks_info", "Get detailed information about a specific hook including install status", { name: z2.string().describe("Hook name (e.g. 'gitguard', 'checkpoint')") }, async ({ name }) => {
|
|
4102
|
+
const meta = getHook(name);
|
|
4103
|
+
if (!meta) {
|
|
4104
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: `Hook '${name}' not found` }) }] };
|
|
4105
|
+
}
|
|
4106
|
+
const globalInstalled = getRegisteredHooks("global").includes(meta.name);
|
|
4107
|
+
const projectInstalled = getRegisteredHooks("project").includes(meta.name);
|
|
4108
|
+
return { content: [{ type: "text", text: JSON.stringify({ ...meta, global: globalInstalled, project: projectInstalled }) }] };
|
|
4109
|
+
});
|
|
4110
|
+
defineTool("hooks_install", "Install one or more hooks by registering them in agent settings", {
|
|
4111
|
+
hooks: z2.array(z2.string()).describe("Hook names to install"),
|
|
4112
|
+
scope: z2.enum(["global", "project"]).default("global").describe("Install scope"),
|
|
4113
|
+
overwrite: z2.boolean().default(false).describe("Overwrite if already installed"),
|
|
4114
|
+
profile: z2.string().optional().describe("Agent profile ID to scope hooks to")
|
|
4115
|
+
}, async ({ hooks, scope, overwrite, profile }) => {
|
|
4116
|
+
const results = hooks.map((name) => installHook(name, { scope, overwrite, profile }));
|
|
4117
|
+
return formatInstallResults(results, { scope, profile });
|
|
4118
|
+
});
|
|
4119
|
+
defineTool("hooks_install_category", "Install all hooks in a category", {
|
|
4120
|
+
category: z2.string().describe("Category name"),
|
|
4121
|
+
scope: z2.enum(["global", "project"]).default("global").describe("Install scope"),
|
|
4122
|
+
overwrite: z2.boolean().default(false).describe("Overwrite if already installed")
|
|
4123
|
+
}, async ({ category, scope, overwrite }) => {
|
|
4124
|
+
const cat = CATEGORIES.find((c) => c.toLowerCase() === category.toLowerCase());
|
|
4125
|
+
if (!cat) {
|
|
4126
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: `Unknown category: ${category}`, available: [...CATEGORIES] }) }] };
|
|
4127
|
+
}
|
|
4128
|
+
const hooks = getHooksByCategory(cat).map((h) => h.name);
|
|
4129
|
+
const results = hooks.map((name) => installHook(name, { scope, overwrite }));
|
|
4130
|
+
return formatInstallResults(results, { category: cat, scope });
|
|
4131
|
+
});
|
|
4132
|
+
defineTool("hooks_install_all", "Install all available hooks", {
|
|
4133
|
+
scope: z2.enum(["global", "project"]).default("global").describe("Install scope"),
|
|
4134
|
+
overwrite: z2.boolean().default(false).describe("Overwrite if already installed")
|
|
4135
|
+
}, async ({ scope, overwrite }) => {
|
|
4136
|
+
const results = HOOKS.map((h) => installHook(h.name, { scope, overwrite }));
|
|
4137
|
+
return formatInstallResults(results, { scope });
|
|
4138
|
+
});
|
|
4139
|
+
defineTool("hooks_remove", "Remove (unregister) a hook from agent settings", {
|
|
4140
|
+
name: z2.string().describe("Hook name to remove"),
|
|
4141
|
+
scope: z2.enum(["global", "project"]).default("global").describe("Scope to remove from")
|
|
4142
|
+
}, async ({ name, scope }) => {
|
|
4143
|
+
const removed = removeHook(name, scope);
|
|
4144
|
+
return { content: [{ type: "text", text: JSON.stringify({ hook: name, removed, scope }) }] };
|
|
4145
|
+
});
|
|
4146
|
+
defineTool("hooks_doctor", "Check health of installed hooks \u2014 verifies hook source exists, settings are correct", {
|
|
4147
|
+
scope: z2.enum(["global", "project"]).default("global").describe("Scope to check")
|
|
4148
|
+
}, async ({ scope }) => {
|
|
4149
|
+
const settingsPath = getSettingsPath(scope);
|
|
4150
|
+
const issues = [];
|
|
4151
|
+
const healthy = [];
|
|
4152
|
+
const settingsExist = existsSync10(settingsPath);
|
|
4153
|
+
if (!settingsExist) {
|
|
4154
|
+
issues.push({ hook: "(settings)", issue: `${settingsPath} not found`, severity: "warning" });
|
|
4155
|
+
}
|
|
4156
|
+
const registered = getRegisteredHooks(scope);
|
|
4157
|
+
for (const name of registered) {
|
|
4158
|
+
const custom = readCustomManifest(name);
|
|
4159
|
+
const meta = custom ? resolveHookMeta(name) : getHook(name);
|
|
4160
|
+
let hookHealthy = true;
|
|
4161
|
+
if (custom) {
|
|
4162
|
+
if (!existsSync10(custom.scriptPath)) {
|
|
4163
|
+
issues.push({ hook: name, issue: `Custom hook script missing: ${custom.scriptPath}`, severity: "error" });
|
|
4164
|
+
hookHealthy = false;
|
|
4165
|
+
}
|
|
4166
|
+
} else {
|
|
4167
|
+
if (!hookExists(name)) {
|
|
4168
|
+
issues.push({ hook: name, issue: "Hook not found in @hasna/hooks package", severity: "error" });
|
|
4169
|
+
continue;
|
|
4170
|
+
}
|
|
4171
|
+
const hookDir = getHookPath(name);
|
|
4172
|
+
if (!existsSync10(join11(hookDir, "src", "hook.ts"))) {
|
|
4173
|
+
issues.push({ hook: name, issue: "Missing src/hook.ts in package", severity: "error" });
|
|
4174
|
+
hookHealthy = false;
|
|
4175
|
+
}
|
|
4176
|
+
}
|
|
4177
|
+
if (meta && settingsExist) {
|
|
4178
|
+
try {
|
|
4179
|
+
const settings = JSON.parse(readFileSync7(settingsPath, "utf-8"));
|
|
4180
|
+
if (!hookRegisteredInSettings(settings, name, meta.event, meta.matcher)) {
|
|
4181
|
+
const eventName = meta.event.split(":")[0];
|
|
4182
|
+
issues.push({ hook: name, issue: `Not registered under correct event (${eventName})`, severity: "error" });
|
|
4183
|
+
hookHealthy = false;
|
|
4184
|
+
}
|
|
4185
|
+
} catch {}
|
|
4186
|
+
}
|
|
4187
|
+
if (hookHealthy)
|
|
4188
|
+
healthy.push(name);
|
|
4189
|
+
}
|
|
4190
|
+
for (const overlap of findRewriteOverlaps(registered, (name) => getHook(name))) {
|
|
4191
|
+
issues.push({
|
|
4192
|
+
hook: overlap.hooks.join(" + "),
|
|
4193
|
+
issue: `Both rewrite the tool input on overlapping ${overlap.event} matchers ('${overlap.matchers[0]}' / '${overlap.matchers[1]}'); only one rewrite is applied per tool call, so one of these guards is silently disarmed. Remove one, or narrow a matcher so they no longer overlap.`,
|
|
4194
|
+
severity: "error"
|
|
4195
|
+
});
|
|
4196
|
+
}
|
|
4197
|
+
return { content: [{ type: "text", text: JSON.stringify({ healthy: issues.length === 0, healthy_hooks: healthy, issues, registered, scope }) }] };
|
|
4198
|
+
});
|
|
4199
|
+
defineTool("hooks_categories", "List all hook categories with counts", {}, async () => {
|
|
4200
|
+
const result = CATEGORIES.map((cat) => ({
|
|
4201
|
+
name: cat,
|
|
4202
|
+
count: getHooksByCategory(cat).length
|
|
4203
|
+
}));
|
|
4204
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
4205
|
+
});
|
|
4206
|
+
defineTool("hooks_docs", "Get documentation. Hook README content is summarized by default; pass verbose:true for the full README.", {
|
|
4207
|
+
name: z2.string().optional().describe("Hook name for specific docs, omit for general docs"),
|
|
4208
|
+
verbose: z2.boolean().default(false).describe("Return full hook README when true")
|
|
4209
|
+
}, async ({ name, verbose }) => {
|
|
4210
|
+
if (name) {
|
|
4211
|
+
const meta = getHook(name);
|
|
4212
|
+
if (!meta) {
|
|
4213
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: `Hook '${name}' not found` }) }] };
|
|
4214
|
+
}
|
|
4215
|
+
const hookPath = getHookPath(name);
|
|
4216
|
+
const readmePath = join11(hookPath, "README.md");
|
|
4217
|
+
let readme = "";
|
|
4218
|
+
if (existsSync10(readmePath)) {
|
|
4219
|
+
readme = readFileSync7(readmePath, "utf-8");
|
|
4220
|
+
}
|
|
4221
|
+
if (verbose) {
|
|
4222
|
+
return { content: [{ type: "text", text: JSON.stringify({ ...meta, readme }) }] };
|
|
4223
|
+
}
|
|
4224
|
+
return {
|
|
4225
|
+
content: [{
|
|
4226
|
+
type: "text",
|
|
4227
|
+
text: JSON.stringify({
|
|
4228
|
+
...compactHook(meta),
|
|
4229
|
+
displayName: meta.displayName,
|
|
4230
|
+
version: meta.version,
|
|
4231
|
+
description: truncateText(meta.description, 220),
|
|
4232
|
+
readme_preview: truncateText(readme, 500),
|
|
4233
|
+
readme_lines: readme ? readme.split(`
|
|
4234
|
+
`).length : 0,
|
|
4235
|
+
hint: "Call hooks_docs with verbose:true for the full README or hooks_info for metadata."
|
|
4236
|
+
})
|
|
4237
|
+
}]
|
|
4238
|
+
};
|
|
4239
|
+
}
|
|
4240
|
+
return {
|
|
4241
|
+
content: [{
|
|
4242
|
+
type: "text",
|
|
4243
|
+
text: JSON.stringify({
|
|
4244
|
+
overview: "Hooks are scripts that run at specific points in an AI coding agent session. Install @hasna/hooks globally, then register hooks \u2014 no files are copied to your project.",
|
|
4245
|
+
events: {
|
|
4246
|
+
PreToolUse: "Fires before a tool executes. Can block the operation.",
|
|
4247
|
+
PostToolUse: "Fires after a tool executes. Runs asynchronously.",
|
|
4248
|
+
Stop: "Fires when the agent finishes responding. Useful for notifications.",
|
|
4249
|
+
Notification: "Fires on notification events like context compaction.",
|
|
4250
|
+
SessionStart: "Fires when a session starts or resumes. Can inject context.",
|
|
4251
|
+
SessionEnd: "Fires when a session terminates. Useful for cleanup and final announcements."
|
|
4252
|
+
},
|
|
4253
|
+
commands: {
|
|
4254
|
+
install: "hooks install <name>",
|
|
4255
|
+
installProject: "hooks install <name> --project",
|
|
4256
|
+
installAll: "hooks install --all",
|
|
4257
|
+
remove: "hooks remove <name>",
|
|
4258
|
+
list: "hooks list",
|
|
4259
|
+
search: "hooks search <query>",
|
|
4260
|
+
doctor: "hooks doctor"
|
|
4261
|
+
}
|
|
4262
|
+
})
|
|
4263
|
+
}]
|
|
4264
|
+
};
|
|
4265
|
+
});
|
|
4266
|
+
defineTool("hooks_registered", "Get currently registered hooks for a scope. Compact by default.", {
|
|
4267
|
+
scope: z2.enum(["global", "project"]).default("global").describe("Scope to check"),
|
|
4268
|
+
compact: z2.boolean().default(true).describe("Return compact summaries by default. Set false for descriptions."),
|
|
4269
|
+
limit: z2.number().default(25).describe("Max compact rows to return")
|
|
4270
|
+
}, async ({ scope, compact, limit }) => {
|
|
4271
|
+
const registered = getRegisteredHooks(scope);
|
|
4272
|
+
const result = registered.map((name) => {
|
|
4273
|
+
const meta = getHook(name);
|
|
4274
|
+
return { name, event: meta?.event, matcher: meta?.matcher ?? "", version: meta?.version, description: meta?.description };
|
|
4275
|
+
});
|
|
4276
|
+
if (!compact)
|
|
4277
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
4278
|
+
const maxRows = boundedLimit(limit, 25, 100);
|
|
4279
|
+
const visible = result.slice(0, maxRows).map((hook) => ({
|
|
4280
|
+
name: hook.name,
|
|
4281
|
+
event: hook.event,
|
|
4282
|
+
matcher: hook.matcher,
|
|
4283
|
+
version: hook.version
|
|
4284
|
+
}));
|
|
4285
|
+
return {
|
|
4286
|
+
content: [{
|
|
4287
|
+
type: "text",
|
|
4288
|
+
text: JSON.stringify({
|
|
4289
|
+
hooks: visible,
|
|
4290
|
+
count: visible.length,
|
|
4291
|
+
total: result.length,
|
|
4292
|
+
omitted: Math.max(0, result.length - visible.length),
|
|
4293
|
+
hint: "Use compact:false for descriptions or hooks_info for one hook."
|
|
4294
|
+
})
|
|
4295
|
+
}]
|
|
4296
|
+
};
|
|
4297
|
+
});
|
|
4298
|
+
defineTool("hooks_run", "Execute a hook programmatically with the given input and return its output", {
|
|
4299
|
+
name: z2.string().describe("Hook name (e.g. 'gitguard', 'checkpoint')"),
|
|
4300
|
+
input: z2.record(z2.string(), z2.unknown()).default(() => ({})).describe("Hook input as JSON object (HookInput)"),
|
|
4301
|
+
profile: z2.string().optional().describe("Agent profile ID to inject into hook input"),
|
|
4302
|
+
timeout_ms: z2.number().int().positive().max(600000).optional().describe("Timeout in milliseconds (default: the hook's manifest timeout_ms or 10000)")
|
|
4303
|
+
}, async ({ name, input, profile, timeout_ms }) => {
|
|
4304
|
+
const { resolveHook: resolveHook2 } = await Promise.resolve().then(() => (init_resolve(), exports_resolve));
|
|
4305
|
+
const resolved = resolveHook2(name);
|
|
4306
|
+
if (!resolved) {
|
|
4307
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: `Hook '${name}' not found` }) }] };
|
|
4308
|
+
}
|
|
4309
|
+
const effectiveTimeout = timeout_ms ?? resolved.timeoutMs ?? 1e4;
|
|
4310
|
+
let hookInput = { ...input };
|
|
4311
|
+
if (profile) {
|
|
4312
|
+
const p = getProfile(profile);
|
|
4313
|
+
if (p) {
|
|
4314
|
+
hookInput.agent = {
|
|
4315
|
+
agent_id: p.agent_id,
|
|
4316
|
+
agent_type: p.agent_type,
|
|
4317
|
+
name: p.name,
|
|
4318
|
+
preferences: p.preferences
|
|
4319
|
+
};
|
|
4320
|
+
}
|
|
4321
|
+
}
|
|
4322
|
+
try {
|
|
4323
|
+
const ran = await runVerifiedHook(name, JSON.stringify(hookInput), effectiveTimeout);
|
|
4324
|
+
const stdoutText = ran.stdout;
|
|
4325
|
+
const stderrText = ran.stderr;
|
|
4326
|
+
let output = {};
|
|
4327
|
+
try {
|
|
4328
|
+
output = JSON.parse(stdoutText);
|
|
4329
|
+
} catch {
|
|
4330
|
+
output = stdoutText ? { raw: stdoutText } : {};
|
|
4331
|
+
}
|
|
4332
|
+
return {
|
|
4333
|
+
content: [{
|
|
4334
|
+
type: "text",
|
|
4335
|
+
text: JSON.stringify({
|
|
4336
|
+
hook: name,
|
|
4337
|
+
output,
|
|
4338
|
+
stderr: stderrText || undefined,
|
|
4339
|
+
exitCode: ran.exitCode,
|
|
4340
|
+
timeout_ms: effectiveTimeout
|
|
4341
|
+
})
|
|
4342
|
+
}]
|
|
4343
|
+
};
|
|
4344
|
+
} catch (err) {
|
|
4345
|
+
const { HookTimeoutError: HookTimeoutError2 } = await Promise.resolve().then(() => (init_run(), exports_run));
|
|
4346
|
+
if (err instanceof HookTimeoutError2) {
|
|
4347
|
+
return {
|
|
4348
|
+
content: [{
|
|
4349
|
+
type: "text",
|
|
4350
|
+
text: JSON.stringify({ hook: name, timedOut: true, timeout_ms: effectiveTimeout, error: err.message })
|
|
4351
|
+
}]
|
|
4352
|
+
};
|
|
4353
|
+
}
|
|
4354
|
+
return {
|
|
4355
|
+
content: [{
|
|
4356
|
+
type: "text",
|
|
4357
|
+
text: JSON.stringify({ hook: name, error: err instanceof Error ? err.message : String(err), trust_failed: true })
|
|
4358
|
+
}]
|
|
4359
|
+
};
|
|
4360
|
+
}
|
|
4361
|
+
});
|
|
4362
|
+
defineTool("hooks_update", "Re-register installed hooks to pick up new package version (reinstalls with overwrite)", {
|
|
4363
|
+
hooks: z2.array(z2.string()).optional().describe("Hook names to update (omit to update all installed hooks)"),
|
|
4364
|
+
scope: z2.enum(["global", "project"]).default("global").describe("Scope to update")
|
|
4365
|
+
}, async ({ hooks, scope }) => {
|
|
4366
|
+
const installed = getRegisteredHooks(scope);
|
|
4367
|
+
const toUpdate = hooks && hooks.length > 0 ? hooks : installed;
|
|
4368
|
+
if (toUpdate.length === 0) {
|
|
4369
|
+
return { content: [{ type: "text", text: JSON.stringify({ updated: [], error: "No hooks installed" }) }] };
|
|
4370
|
+
}
|
|
4371
|
+
const results = toUpdate.map((name) => {
|
|
4372
|
+
if (!installed.includes(name)) {
|
|
4373
|
+
return { hook: name, success: false, error: "Not installed" };
|
|
4374
|
+
}
|
|
4375
|
+
return installHook(name, { scope, overwrite: true });
|
|
4376
|
+
});
|
|
4377
|
+
const updated = results.filter((r) => r.success).map((r) => r.hook);
|
|
4378
|
+
const failed = results.filter((r) => !r.success).map((r) => ({ hook: r.hook, error: r.error }));
|
|
4379
|
+
return { content: [{ type: "text", text: JSON.stringify({ updated, failed, total: results.length }) }] };
|
|
4380
|
+
});
|
|
4381
|
+
defineTool("hooks_context", "Get compact agent context in one call: installed hooks, active profile summary, settings path, and doctor status.", {
|
|
4382
|
+
scope: z2.enum(["global", "project"]).default("global").describe("Scope to inspect"),
|
|
4383
|
+
profile: z2.string().optional().describe("Agent profile ID to include in context"),
|
|
4384
|
+
verbose: z2.boolean().default(false).describe("Include full profile preferences/details when true")
|
|
4385
|
+
}, async ({ scope, profile, verbose }) => {
|
|
4386
|
+
const settingsPath = getSettingsPath(scope);
|
|
4387
|
+
const registered = getRegisteredHooks(scope);
|
|
4388
|
+
const hooks = registered.map((name) => {
|
|
4389
|
+
const meta = getHook(name);
|
|
4390
|
+
return { name, event: meta?.event, matcher: meta?.matcher ?? "", version: meta?.version };
|
|
4391
|
+
});
|
|
4392
|
+
const issues = [];
|
|
4393
|
+
for (const name of registered) {
|
|
4394
|
+
if (!hookExists(name)) {
|
|
4395
|
+
issues.push({ hook: name, issue: "Hook not found in package", severity: "error" });
|
|
4396
|
+
}
|
|
4397
|
+
}
|
|
4398
|
+
const healthy = issues.length === 0;
|
|
4399
|
+
const ctx = {
|
|
4400
|
+
scope,
|
|
4401
|
+
settings_path: settingsPath,
|
|
4402
|
+
settings_exists: existsSync10(settingsPath),
|
|
4403
|
+
registered_hooks: hooks,
|
|
4404
|
+
hook_count: hooks.length,
|
|
4405
|
+
healthy,
|
|
4406
|
+
issues,
|
|
4407
|
+
version: pkg.version
|
|
4408
|
+
};
|
|
4409
|
+
if (profile) {
|
|
4410
|
+
const p = getProfile(profile);
|
|
4411
|
+
ctx.profile = p && !verbose ? { agent_id: p.agent_id, agent_type: p.agent_type, name: p.name } : p ?? null;
|
|
4412
|
+
if (p && !verbose)
|
|
4413
|
+
ctx.profile_hint = "Use verbose:true for profile preferences.";
|
|
4414
|
+
}
|
|
4415
|
+
return { content: [{ type: "text", text: JSON.stringify(ctx) }] };
|
|
4416
|
+
});
|
|
4417
|
+
defineTool("hooks_preview", "Simulate which installed PreToolUse hooks would fire for a given tool call and what decision each returns. Use this to understand your hook environment before taking an action.", {
|
|
4418
|
+
tool_name: z2.string().describe("Tool name to simulate (e.g. 'Bash', 'Write', 'Edit')"),
|
|
4419
|
+
tool_input: z2.record(z2.string(), z2.unknown()).default(() => ({})).describe("Tool input to pass to matching hooks"),
|
|
4420
|
+
scope: z2.enum(["global", "project"]).default("global").describe("Scope to check"),
|
|
4421
|
+
timeout_ms: z2.number().int().positive().max(600000).optional().describe("Per-hook timeout in milliseconds (default: the hook's manifest timeout_ms or 5000)")
|
|
4422
|
+
}, async ({ tool_name, tool_input, scope, timeout_ms }) => {
|
|
4423
|
+
const registered = getRegisteredHooks(scope);
|
|
4424
|
+
const matchingHooks = registered.filter((name) => {
|
|
4425
|
+
const meta = resolveHookMeta(name) ?? getHook(name);
|
|
4426
|
+
if (!meta || meta.event !== "PreToolUse")
|
|
4427
|
+
return false;
|
|
4428
|
+
if (!meta.matcher)
|
|
4429
|
+
return true;
|
|
4430
|
+
try {
|
|
4431
|
+
return new RegExp(meta.matcher).test(tool_name);
|
|
4432
|
+
} catch {
|
|
4433
|
+
return false;
|
|
4434
|
+
}
|
|
4435
|
+
});
|
|
4436
|
+
if (matchingHooks.length === 0) {
|
|
4437
|
+
return { content: [{ type: "text", text: JSON.stringify({ tool_name, matching_hooks: [], result: "no_hooks_match", decision: "approve" }) }] };
|
|
4438
|
+
}
|
|
4439
|
+
const input = { tool_name, tool_input };
|
|
4440
|
+
const results = await Promise.all(matchingHooks.map(async (name) => {
|
|
4441
|
+
const { resolveHook: resolveHook2 } = await Promise.resolve().then(() => (init_resolve(), exports_resolve));
|
|
4442
|
+
const resolved = resolveHook2(name);
|
|
4443
|
+
const effectiveTimeout = timeout_ms ?? resolved?.timeoutMs ?? 5000;
|
|
4444
|
+
try {
|
|
4445
|
+
const ran = await runVerifiedHook(name, JSON.stringify(input), effectiveTimeout);
|
|
4446
|
+
let output = {};
|
|
4447
|
+
try {
|
|
4448
|
+
output = JSON.parse(ran.stdout);
|
|
4449
|
+
} catch {}
|
|
4450
|
+
return { name, decision: output.decision ?? "approve", reason: output.reason, raw: output };
|
|
4451
|
+
} catch (err) {
|
|
4452
|
+
const { HookTimeoutError: HookTimeoutError2 } = await Promise.resolve().then(() => (init_run(), exports_run));
|
|
4453
|
+
if (err instanceof HookTimeoutError2) {
|
|
4454
|
+
return {
|
|
4455
|
+
name,
|
|
4456
|
+
decision: "block",
|
|
4457
|
+
timedOut: true,
|
|
4458
|
+
timeout_ms: effectiveTimeout,
|
|
4459
|
+
error: `hook timed out after ${effectiveTimeout}ms \u2014 preview blocked (never auto-approved)`
|
|
4460
|
+
};
|
|
4461
|
+
}
|
|
4462
|
+
return { name, decision: "block", error: err instanceof Error ? err.message : String(err) };
|
|
4463
|
+
}
|
|
4464
|
+
}));
|
|
4465
|
+
const blocked = results.find((r) => r.decision === "block");
|
|
4466
|
+
return {
|
|
4467
|
+
content: [{
|
|
4468
|
+
type: "text",
|
|
4469
|
+
text: JSON.stringify({
|
|
4470
|
+
tool_name,
|
|
4471
|
+
matching_hooks: matchingHooks,
|
|
4472
|
+
results,
|
|
4473
|
+
decision: blocked ? "block" : "approve",
|
|
4474
|
+
blocked_by: blocked?.name ?? null,
|
|
4475
|
+
blocked_reason: blocked?.reason ?? null
|
|
4476
|
+
})
|
|
4477
|
+
}]
|
|
4478
|
+
};
|
|
4479
|
+
});
|
|
4480
|
+
defineTool("hooks_setup", "Single-shot agent onboarding: create an agent profile + install recommended hooks in one call. Ideal for agents setting up hooks at session start.", {
|
|
4481
|
+
agent_type: z2.enum(["claude", "gemini", "custom"]).default("claude").describe("Type of AI agent"),
|
|
4482
|
+
name: z2.string().optional().describe("Optional display name for the agent"),
|
|
4483
|
+
hooks: z2.array(z2.string()).optional().describe("Hook names to install (omit for sensible defaults: gitguard, checkpoint, checktests, protectfiles)"),
|
|
4484
|
+
scope: z2.enum(["global", "project"]).default("global").describe("Install scope")
|
|
4485
|
+
}, async ({ agent_type, name, hooks, scope }) => {
|
|
4486
|
+
if (agent_type !== "claude" && agent_type !== "gemini" && agent_type !== "custom") {
|
|
4487
|
+
return {
|
|
4488
|
+
content: [{
|
|
4489
|
+
type: "text",
|
|
4490
|
+
text: JSON.stringify({ error: `unsupported agent_type '${String(agent_type)}' (supported: claude, gemini, custom)` })
|
|
4491
|
+
}],
|
|
4492
|
+
isError: true
|
|
4493
|
+
};
|
|
4494
|
+
}
|
|
4495
|
+
const profile = createProfile({ agent_type, name });
|
|
4496
|
+
const target = agent_type === "gemini" ? "gemini" : "claude";
|
|
4497
|
+
const toInstall = hooks && hooks.length > 0 ? hooks : ["gitguard", "checkpoint", "checktests", "protectfiles"];
|
|
4498
|
+
const results = toInstall.map((h) => installHook(h, { scope, overwrite: false, profile: profile.agent_id, target }));
|
|
4499
|
+
const installed = results.filter((r) => r.success).map((r) => r.hook);
|
|
4500
|
+
const failed = results.filter((r) => !r.success).map((r) => ({ hook: r.hook, error: r.error }));
|
|
4501
|
+
return {
|
|
4502
|
+
content: [{
|
|
4503
|
+
type: "text",
|
|
4504
|
+
text: JSON.stringify({ profile, installed, failed, scope, target, run_with: `hooks run <name> --profile ${profile.agent_id}` })
|
|
4505
|
+
}]
|
|
4506
|
+
};
|
|
4507
|
+
});
|
|
4508
|
+
defineTool("hooks_batch_run", "Run multiple hooks in parallel in a single call. Returns all results at once \u2014 more efficient than N separate hooks_run calls.", {
|
|
4509
|
+
hooks: z2.array(z2.object({
|
|
4510
|
+
name: z2.string().describe("Hook name"),
|
|
4511
|
+
input: z2.record(z2.string(), z2.unknown()).default(() => ({})).describe("Hook input JSON")
|
|
4512
|
+
})).describe("List of hooks to run with their inputs"),
|
|
4513
|
+
timeout_ms: z2.number().int().positive().max(600000).optional().describe("Per-hook timeout in milliseconds (default: the hook's manifest timeout_ms or 10000)")
|
|
4514
|
+
}, async ({ hooks, timeout_ms }) => {
|
|
4515
|
+
const results = await Promise.all(hooks.map(async ({ name, input }) => {
|
|
4516
|
+
const { resolveHook: resolveHook2 } = await Promise.resolve().then(() => (init_resolve(), exports_resolve));
|
|
4517
|
+
const resolved = resolveHook2(name);
|
|
4518
|
+
if (!resolved)
|
|
4519
|
+
return { name, error: `Hook '${name}' not found` };
|
|
4520
|
+
const effectiveTimeout = timeout_ms ?? resolved.timeoutMs ?? 1e4;
|
|
4521
|
+
try {
|
|
4522
|
+
const ran = await runVerifiedHook(name, JSON.stringify(input), effectiveTimeout);
|
|
4523
|
+
const stdoutText = ran.stdout;
|
|
4524
|
+
let output = {};
|
|
4525
|
+
try {
|
|
4526
|
+
output = JSON.parse(stdoutText);
|
|
4527
|
+
} catch {
|
|
4528
|
+
output = stdoutText ? { raw: stdoutText } : {};
|
|
4529
|
+
}
|
|
4530
|
+
return { name, output, exitCode: ran.exitCode, timeout_ms: effectiveTimeout };
|
|
4531
|
+
} catch (err) {
|
|
4532
|
+
const { HookTimeoutError: HookTimeoutError2 } = await Promise.resolve().then(() => (init_run(), exports_run));
|
|
4533
|
+
if (err instanceof HookTimeoutError2) {
|
|
4534
|
+
return { name, error: err.message, timedOut: true, timeout_ms: effectiveTimeout };
|
|
4535
|
+
}
|
|
4536
|
+
return { name, error: err instanceof Error ? err.message : String(err) };
|
|
4537
|
+
}
|
|
4538
|
+
}));
|
|
4539
|
+
return { content: [{ type: "text", text: JSON.stringify({ results, count: results.length }) }] };
|
|
4540
|
+
});
|
|
4541
|
+
defineTool("hooks_disable", "Temporarily disable a registered hook without removing it. Stores disabled list in settings under hooks.__disabled.", {
|
|
4542
|
+
name: z2.string().describe("Hook name to disable"),
|
|
4543
|
+
scope: z2.enum(["global", "project"]).default("global").describe("Scope")
|
|
4544
|
+
}, async ({ name, scope }) => {
|
|
4545
|
+
const settingsPath = getSettingsPath(scope);
|
|
4546
|
+
let settings = {};
|
|
4547
|
+
try {
|
|
4548
|
+
if (existsSync10(settingsPath))
|
|
4549
|
+
settings = JSON.parse(readFileSync7(settingsPath, "utf-8"));
|
|
4550
|
+
} catch {}
|
|
4551
|
+
if (!settings.hooks)
|
|
4552
|
+
settings.hooks = {};
|
|
4553
|
+
const disabled = settings.hooks.__disabled ?? [];
|
|
4554
|
+
if (!disabled.includes(name))
|
|
4555
|
+
disabled.push(name);
|
|
4556
|
+
settings.hooks.__disabled = disabled;
|
|
4557
|
+
const { writeFileSync: writeFileSync6, mkdirSync: mkdirSync6 } = await import("fs");
|
|
4558
|
+
const { dirname: dirname7 } = await import("path");
|
|
4559
|
+
mkdirSync6(dirname7(settingsPath), { recursive: true });
|
|
4560
|
+
writeFileSync6(settingsPath, JSON.stringify(settings, null, 2) + `
|
|
4561
|
+
`);
|
|
4562
|
+
return { content: [{ type: "text", text: JSON.stringify({ hook: name, disabled: true, scope }) }] };
|
|
4563
|
+
});
|
|
4564
|
+
defineTool("hooks_enable", "Re-enable a previously disabled hook.", {
|
|
4565
|
+
name: z2.string().describe("Hook name to enable"),
|
|
4566
|
+
scope: z2.enum(["global", "project"]).default("global").describe("Scope")
|
|
4567
|
+
}, async ({ name, scope }) => {
|
|
4568
|
+
const settingsPath = getSettingsPath(scope);
|
|
4569
|
+
let settings = {};
|
|
4570
|
+
try {
|
|
4571
|
+
if (existsSync10(settingsPath))
|
|
4572
|
+
settings = JSON.parse(readFileSync7(settingsPath, "utf-8"));
|
|
4573
|
+
} catch {}
|
|
4574
|
+
if (settings.hooks?.__disabled) {
|
|
4575
|
+
settings.hooks.__disabled = settings.hooks.__disabled.filter((n) => n !== name);
|
|
4576
|
+
if (settings.hooks.__disabled.length === 0)
|
|
4577
|
+
delete settings.hooks.__disabled;
|
|
4578
|
+
const { writeFileSync: writeFileSync6 } = await import("fs");
|
|
4579
|
+
writeFileSync6(settingsPath, JSON.stringify(settings, null, 2) + `
|
|
4580
|
+
`);
|
|
4581
|
+
}
|
|
4582
|
+
return { content: [{ type: "text", text: JSON.stringify({ hook: name, disabled: false, scope }) }] };
|
|
4583
|
+
});
|
|
4584
|
+
defineTool("hooks_init", "Register a new agent profile \u2014 returns a unique agent_id for use with hook installation and execution", {
|
|
4585
|
+
agent_type: z2.enum(["claude", "gemini", "custom"]).default("claude").describe("Type of AI agent"),
|
|
4586
|
+
name: z2.string().optional().describe("Optional display name for the agent")
|
|
4587
|
+
}, async ({ agent_type, name }) => {
|
|
4588
|
+
const profile = createProfile({ agent_type, name });
|
|
4589
|
+
return { content: [{ type: "text", text: JSON.stringify(profile) }] };
|
|
4590
|
+
});
|
|
4591
|
+
defineTool("hooks_profiles", "List registered agent profiles. Compact by default.", {
|
|
4592
|
+
compact: z2.boolean().default(true).describe("Return compact summaries by default. Set false for full profiles."),
|
|
4593
|
+
limit: z2.number().default(25).describe("Max compact rows to return")
|
|
4594
|
+
}, async ({ compact, limit }) => {
|
|
4595
|
+
const profiles = listProfiles();
|
|
4596
|
+
if (!compact)
|
|
4597
|
+
return { content: [{ type: "text", text: JSON.stringify(profiles) }] };
|
|
4598
|
+
const maxRows = boundedLimit(limit, 25, 100);
|
|
4599
|
+
const visible = profiles.slice(0, maxRows).map((profile) => ({
|
|
4600
|
+
agent_id: profile.agent_id,
|
|
4601
|
+
agent_type: profile.agent_type,
|
|
4602
|
+
name: profile.name,
|
|
4603
|
+
last_seen_at: profile.last_seen_at
|
|
4604
|
+
}));
|
|
4605
|
+
return {
|
|
4606
|
+
content: [{
|
|
4607
|
+
type: "text",
|
|
4608
|
+
text: JSON.stringify({
|
|
4609
|
+
profiles: visible,
|
|
4610
|
+
count: visible.length,
|
|
4611
|
+
total: profiles.length,
|
|
4612
|
+
omitted: Math.max(0, profiles.length - visible.length),
|
|
4613
|
+
hint: "Use compact:false for full profile preferences."
|
|
4614
|
+
})
|
|
4615
|
+
}]
|
|
4616
|
+
};
|
|
4617
|
+
});
|
|
4618
|
+
defineTool("hooks_log_list", "List hook events from SQLite. Compact summaries by default; set compact:false for full event rows.", {
|
|
4619
|
+
hook_name: z2.string().optional().describe("Filter by hook name (e.g. 'sessionlog', 'costwatch')"),
|
|
4620
|
+
session_id: z2.string().optional().describe("Filter by session ID prefix"),
|
|
4621
|
+
limit: z2.number().optional().describe("Max number of events to return. Defaults to 20 compact rows or 50 full rows."),
|
|
4622
|
+
since: z2.string().optional().describe("ISO timestamp or duration string (e.g. '1h', '30m', '7d') to filter from"),
|
|
4623
|
+
compact: z2.boolean().default(true).describe("Return compact event summaries by default. Set false for full rows.")
|
|
4624
|
+
}, async ({ hook_name, session_id, limit, since, compact }) => {
|
|
4625
|
+
const { getDb: getDb2 } = await Promise.resolve().then(() => (init_db(), exports_db));
|
|
4626
|
+
const db = getDb2();
|
|
4627
|
+
const maxRows = boundedLimit(limit, compact ? 20 : 50, compact ? 100 : 500);
|
|
4628
|
+
function parseDuration(s) {
|
|
4629
|
+
const m = s.match(/^(\d+)(s|m|h|d)$/);
|
|
4630
|
+
if (!m)
|
|
4631
|
+
return null;
|
|
4632
|
+
const n = parseInt(m[1]);
|
|
4633
|
+
const ms = { s: 1000, m: 60000, h: 3600000, d: 86400000 }[m[2]];
|
|
4634
|
+
return new Date(Date.now() - n * ms).toISOString();
|
|
4635
|
+
}
|
|
4636
|
+
let sql = "SELECT * FROM hook_events WHERE 1=1";
|
|
4637
|
+
const params = [];
|
|
4638
|
+
if (hook_name) {
|
|
4639
|
+
sql += " AND hook_name = ?";
|
|
4640
|
+
params.push(hook_name);
|
|
4641
|
+
}
|
|
4642
|
+
if (session_id) {
|
|
4643
|
+
sql += " AND session_id LIKE ?";
|
|
4644
|
+
params.push(`${session_id}%`);
|
|
4645
|
+
}
|
|
4646
|
+
if (since) {
|
|
4647
|
+
const ts = since.match(/^\d{4}/) ? since : parseDuration(since);
|
|
4648
|
+
if (ts) {
|
|
4649
|
+
sql += " AND timestamp >= ?";
|
|
4650
|
+
params.push(ts);
|
|
4651
|
+
}
|
|
4652
|
+
}
|
|
4653
|
+
sql += " ORDER BY timestamp DESC LIMIT ?";
|
|
4654
|
+
params.push(maxRows);
|
|
4655
|
+
const rows = db.query(sql).all(...params);
|
|
4656
|
+
const projected = projectLogRows(rows);
|
|
4657
|
+
return {
|
|
4658
|
+
content: [{
|
|
4659
|
+
type: "text",
|
|
4660
|
+
text: JSON.stringify({
|
|
4661
|
+
events: compact ? projected.map(compactEvent) : projected,
|
|
4662
|
+
count: projected.length,
|
|
4663
|
+
compact,
|
|
4664
|
+
hint: compact ? "Use compact:false for full tool_input/output fields." : undefined
|
|
4665
|
+
})
|
|
4666
|
+
}]
|
|
4667
|
+
};
|
|
4668
|
+
});
|
|
4669
|
+
defineTool("hooks_log_tail", "Show recent hook events from SQLite. Compact summaries by default.", {
|
|
4670
|
+
n: z2.number().default(20).describe("Number of most recent events to return"),
|
|
4671
|
+
compact: z2.boolean().default(true).describe("Return compact event summaries by default. Set false for full rows.")
|
|
4672
|
+
}, async ({ n, compact }) => {
|
|
4673
|
+
const { getDb: getDb2 } = await Promise.resolve().then(() => (init_db(), exports_db));
|
|
4674
|
+
const db = getDb2();
|
|
4675
|
+
const maxRows = boundedLimit(n, 20, compact ? 100 : 500);
|
|
4676
|
+
const rows = db.query("SELECT * FROM hook_events ORDER BY timestamp DESC LIMIT ?").all(maxRows);
|
|
4677
|
+
const projected = projectLogRows(rows);
|
|
4678
|
+
return {
|
|
4679
|
+
content: [{
|
|
4680
|
+
type: "text",
|
|
4681
|
+
text: JSON.stringify({
|
|
4682
|
+
events: compact ? projected.map(compactEvent) : projected,
|
|
4683
|
+
count: projected.length,
|
|
4684
|
+
compact,
|
|
4685
|
+
hint: compact ? "Use compact:false for full tool_input/output fields." : undefined
|
|
4686
|
+
})
|
|
4687
|
+
}]
|
|
4688
|
+
};
|
|
4689
|
+
});
|
|
4690
|
+
defineTool("hooks_log_errors", "Show hook events that contain errors. Compact summaries by default.", {
|
|
4691
|
+
since: z2.string().default("24h").describe("Duration string (e.g. '1h', '30m', '7d') or ISO timestamp"),
|
|
4692
|
+
limit: z2.number().optional().describe("Max number of error events to return. Defaults to 20 compact rows or 50 full rows."),
|
|
4693
|
+
compact: z2.boolean().default(true).describe("Return compact event summaries by default. Set false for full rows.")
|
|
4694
|
+
}, async ({ since, limit, compact }) => {
|
|
4695
|
+
const { getDb: getDb2 } = await Promise.resolve().then(() => (init_db(), exports_db));
|
|
4696
|
+
const db = getDb2();
|
|
4697
|
+
const maxRows = boundedLimit(limit, compact ? 20 : 50, compact ? 100 : 500);
|
|
4698
|
+
function parseDuration(s) {
|
|
4699
|
+
const m = s.match(/^(\d+)(s|m|h|d)$/);
|
|
4700
|
+
if (!m)
|
|
4701
|
+
return s;
|
|
4702
|
+
const n = parseInt(m[1]);
|
|
4703
|
+
const ms = { s: 1000, m: 60000, h: 3600000, d: 86400000 }[m[2]];
|
|
4704
|
+
return new Date(Date.now() - n * ms).toISOString();
|
|
4705
|
+
}
|
|
4706
|
+
const ts = since.match(/^\d{4}/) ? since : parseDuration(since);
|
|
4707
|
+
const rows = db.query("SELECT * FROM hook_events WHERE error IS NOT NULL AND timestamp >= ? ORDER BY timestamp DESC LIMIT ?").all(ts, maxRows);
|
|
4708
|
+
const projected = projectLogRows(rows);
|
|
4709
|
+
return {
|
|
4710
|
+
content: [{
|
|
4711
|
+
type: "text",
|
|
4712
|
+
text: JSON.stringify({
|
|
4713
|
+
events: compact ? projected.map(compactEvent) : projected,
|
|
4714
|
+
count: projected.length,
|
|
4715
|
+
compact,
|
|
4716
|
+
hint: compact ? "Use compact:false for full tool_input/output fields." : undefined
|
|
4717
|
+
})
|
|
4718
|
+
}]
|
|
4719
|
+
};
|
|
4720
|
+
});
|
|
4721
|
+
defineTool("hooks_log_summary", "Summarize hook execution: counts per hook, error rates, and recent activity.", {
|
|
4722
|
+
since: z2.string().default("24h").describe("Duration string (e.g. '1h', '24h', '7d') or ISO timestamp")
|
|
4723
|
+
}, async ({ since }) => {
|
|
4724
|
+
const { getDb: getDb2 } = await Promise.resolve().then(() => (init_db(), exports_db));
|
|
4725
|
+
const db = getDb2();
|
|
4726
|
+
function parseDuration(s) {
|
|
4727
|
+
const m = s.match(/^(\d+)(s|m|h|d)$/);
|
|
4728
|
+
if (!m)
|
|
4729
|
+
return s;
|
|
4730
|
+
const n = parseInt(m[1]);
|
|
4731
|
+
const ms = { s: 1000, m: 60000, h: 3600000, d: 86400000 }[m[2]];
|
|
4732
|
+
return new Date(Date.now() - n * ms).toISOString();
|
|
4733
|
+
}
|
|
4734
|
+
const ts = since.match(/^\d{4}/) ? since : parseDuration(since);
|
|
4735
|
+
const totals = db.query("SELECT hook_name, COUNT(*) as total, SUM(CASE WHEN error IS NOT NULL THEN 1 ELSE 0 END) as errors FROM hook_events WHERE timestamp >= ? GROUP BY hook_name ORDER BY total DESC").all(ts);
|
|
4736
|
+
const summary = totals.map((r) => ({
|
|
4737
|
+
hook_name: r.hook_name,
|
|
4738
|
+
total: r.total,
|
|
4739
|
+
errors: r.errors,
|
|
4740
|
+
error_rate: r.total > 0 ? (r.errors / r.total * 100).toFixed(1) + "%" : "0%"
|
|
4741
|
+
}));
|
|
4742
|
+
const grandTotal = totals.reduce((s, r) => s + r.total, 0);
|
|
4743
|
+
const grandErrors = totals.reduce((s, r) => s + r.errors, 0);
|
|
4744
|
+
return {
|
|
4745
|
+
content: [{
|
|
4746
|
+
type: "text",
|
|
4747
|
+
text: JSON.stringify({
|
|
4748
|
+
since: ts,
|
|
4749
|
+
hooks: summary,
|
|
4750
|
+
totals: { events: grandTotal, errors: grandErrors, hooks_active: totals.length }
|
|
4751
|
+
})
|
|
4752
|
+
}]
|
|
4753
|
+
};
|
|
4754
|
+
});
|
|
4755
|
+
defineTool("storage_status", "Show hooks storage sync configuration and local sync history.", {}, async () => ({ content: [{ type: "text", text: JSON.stringify(getStorageStatus()) }] }));
|
|
4756
|
+
defineTool("storage_push", "Push local hook data to storage PostgreSQL.", { tables: z2.array(z2.string()).optional() }, async (params) => ({ content: [{ type: "text", text: JSON.stringify(await storagePush(params.tables ? { tables: params.tables } : undefined)) }] }));
|
|
4757
|
+
defineTool("storage_pull", "Pull hook data from storage PostgreSQL to local SQLite.", { tables: z2.array(z2.string()).optional() }, async (params) => ({ content: [{ type: "text", text: JSON.stringify(await storagePull(params.tables ? { tables: params.tables } : undefined)) }] }));
|
|
4758
|
+
defineTool("storage_sync", "Bidirectional hooks storage sync: pull then push.", { tables: z2.array(z2.string()).optional() }, async (params) => ({ content: [{ type: "text", text: JSON.stringify(await storageSync(params.tables ? { tables: params.tables } : undefined)) }] }));
|
|
4759
|
+
defineTool("send_feedback", "Send feedback about this service", {
|
|
4760
|
+
message: z2.string(),
|
|
4761
|
+
email: z2.string().optional(),
|
|
4762
|
+
category: z2.enum(["bug", "feature", "general"]).optional()
|
|
4763
|
+
}, async (params) => {
|
|
4764
|
+
try {
|
|
4765
|
+
const { getDb: getDb2 } = await Promise.resolve().then(() => (init_db(), exports_db));
|
|
4766
|
+
const db = getDb2();
|
|
4767
|
+
db.run("INSERT INTO feedback (message, email, category, version) VALUES (?, ?, ?, ?)", [
|
|
4768
|
+
params.message,
|
|
4769
|
+
params.email || null,
|
|
4770
|
+
params.category || "general",
|
|
4771
|
+
pkg.version
|
|
4772
|
+
]);
|
|
4773
|
+
return { content: [{ type: "text", text: "Feedback saved. Thank you!" }] };
|
|
4774
|
+
} catch (e) {
|
|
4775
|
+
return { content: [{ type: "text", text: String(e) }], isError: true };
|
|
4776
|
+
}
|
|
4777
|
+
});
|
|
4778
|
+
defineTool("register_agent", "Register an agent session. Returns agent_id. Auto-triggers a heartbeat.", {
|
|
4779
|
+
name: z2.string(),
|
|
4780
|
+
session_id: z2.string().optional()
|
|
4781
|
+
}, async (params) => {
|
|
4782
|
+
const existing = [..._hooksAgents.values()].find((a) => a.name === params.name);
|
|
4783
|
+
if (existing) {
|
|
4784
|
+
existing.last_seen_at = new Date().toISOString();
|
|
4785
|
+
if (params.session_id)
|
|
4786
|
+
existing.session_id = params.session_id;
|
|
4787
|
+
return { content: [{ type: "text", text: JSON.stringify(existing) }] };
|
|
4788
|
+
}
|
|
4789
|
+
const id = Math.random().toString(36).slice(2, 10);
|
|
4790
|
+
const ag = { id, name: params.name, session_id: params.session_id, last_seen_at: new Date().toISOString() };
|
|
4791
|
+
_hooksAgents.set(id, ag);
|
|
4792
|
+
return { content: [{ type: "text", text: JSON.stringify(ag) }] };
|
|
4793
|
+
});
|
|
4794
|
+
defineTool("heartbeat", "Update last_seen_at to signal agent is active.", {
|
|
4795
|
+
agent_id: z2.string()
|
|
4796
|
+
}, async (params) => {
|
|
4797
|
+
const ag = _hooksAgents.get(params.agent_id);
|
|
4798
|
+
if (!ag)
|
|
4799
|
+
return { content: [{ type: "text", text: `Agent not found: ${params.agent_id}` }], isError: true };
|
|
4800
|
+
ag.last_seen_at = new Date().toISOString();
|
|
4801
|
+
return { content: [{ type: "text", text: JSON.stringify({ agent_id: ag.id, last_seen_at: ag.last_seen_at }) }] };
|
|
4802
|
+
});
|
|
4803
|
+
defineTool("set_focus", "Set active project context for this agent session.", {
|
|
4804
|
+
agent_id: z2.string(),
|
|
4805
|
+
project_id: z2.string().optional()
|
|
4806
|
+
}, async (params) => {
|
|
4807
|
+
const ag = _hooksAgents.get(params.agent_id);
|
|
4808
|
+
if (!ag)
|
|
4809
|
+
return { content: [{ type: "text", text: `Agent not found: ${params.agent_id}` }], isError: true };
|
|
4810
|
+
ag.project_id = params.project_id;
|
|
4811
|
+
return { content: [{ type: "text", text: JSON.stringify({ agent_id: ag.id, project_id: ag.project_id ?? null }) }] };
|
|
4812
|
+
});
|
|
4813
|
+
defineTool("list_agents", "List registered agents. Compact by default.", {
|
|
4814
|
+
compact: z2.boolean().default(true).describe("Return compact agent summaries by default. Set false for full records."),
|
|
4815
|
+
limit: z2.number().default(25).describe("Max compact rows to return")
|
|
4816
|
+
}, async ({ compact, limit }) => {
|
|
4817
|
+
const agents = [..._hooksAgents.values()];
|
|
4818
|
+
if (!compact)
|
|
4819
|
+
return { content: [{ type: "text", text: JSON.stringify(agents) }] };
|
|
4820
|
+
const maxRows = boundedLimit(limit, 25, 100);
|
|
4821
|
+
const visible = agents.slice(0, maxRows).map((agent) => ({
|
|
4822
|
+
id: agent.id,
|
|
4823
|
+
name: agent.name,
|
|
4824
|
+
project_id: agent.project_id,
|
|
4825
|
+
last_seen_at: agent.last_seen_at
|
|
4826
|
+
}));
|
|
4827
|
+
return {
|
|
4828
|
+
content: [{
|
|
4829
|
+
type: "text",
|
|
4830
|
+
text: JSON.stringify({
|
|
4831
|
+
agents: visible,
|
|
4832
|
+
count: visible.length,
|
|
4833
|
+
total: agents.length,
|
|
4834
|
+
omitted: Math.max(0, agents.length - visible.length),
|
|
4835
|
+
hint: "Use compact:false for session_id and full records."
|
|
4836
|
+
})
|
|
4837
|
+
}]
|
|
4838
|
+
};
|
|
4839
|
+
});
|
|
4840
|
+
return server;
|
|
4841
|
+
}
|
|
4842
|
+
async function startStdioServer() {
|
|
4843
|
+
try {
|
|
4844
|
+
decideHooksMcpAuthority();
|
|
4845
|
+
} catch (err) {
|
|
4846
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}
|
|
4847
|
+
`);
|
|
4848
|
+
process.exit(1);
|
|
4849
|
+
}
|
|
4850
|
+
try {
|
|
4851
|
+
const server = createHooksServer();
|
|
4852
|
+
const transport = new StdioServerTransport;
|
|
4853
|
+
await server.connect(transport);
|
|
4854
|
+
} catch (err) {
|
|
4855
|
+
process.stderr.write(`[hooks-mcp] Failed to start: ${err instanceof Error ? err.message : String(err)}
|
|
4856
|
+
`);
|
|
4857
|
+
process.exit(1);
|
|
4858
|
+
}
|
|
4859
|
+
}
|
|
4860
|
+
|
|
4861
|
+
// src/mcp/hooks-mcp.ts
|
|
4862
|
+
await startStdioServer();
|