toolfunnel 0.3.0

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.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +324 -0
  3. package/bin/toolfunnel.js +112 -0
  4. package/docs/admin-ui.md +404 -0
  5. package/docs/configuration.md +402 -0
  6. package/docs/hooks-and-gating.md +428 -0
  7. package/docs/logging.md +223 -0
  8. package/docs/management.md +429 -0
  9. package/docs/oauth.md +103 -0
  10. package/docs/setup.md +352 -0
  11. package/docs/tools-and-upstreams.md +469 -0
  12. package/hooks/hooks.manifest.json +27 -0
  13. package/hooks/scripts/.gitkeep +3 -0
  14. package/hooks/scripts/example-audit.js +21 -0
  15. package/hooks/scripts/example-deny-dangerous.js +31 -0
  16. package/mcp/expose.example.json +30 -0
  17. package/mcp/servers/mock-upstream/server.js +170 -0
  18. package/package.json +63 -0
  19. package/src/auth/config.js +172 -0
  20. package/src/auth/install.js +181 -0
  21. package/src/auth/resource-server.js +319 -0
  22. package/src/core/events.js +105 -0
  23. package/src/core/hook-engine.js +275 -0
  24. package/src/core/hook-loader.js +791 -0
  25. package/src/core/hook-runner.js +346 -0
  26. package/src/core/logger.js +169 -0
  27. package/src/core/matcher.js +51 -0
  28. package/src/core/metrics.js +75 -0
  29. package/src/core/tool-registry.js +180 -0
  30. package/src/extend/add-hook.md +142 -0
  31. package/src/extend/add-mcp.md +121 -0
  32. package/src/extend/create-tool.md +132 -0
  33. package/src/extend/howto.js +98 -0
  34. package/src/extend/package.md +154 -0
  35. package/src/mcp/aggregator.js +802 -0
  36. package/src/mcp/expose-store.js +430 -0
  37. package/src/mcp/gated-run.js +194 -0
  38. package/src/mcp/http-transport.js +962 -0
  39. package/src/mcp/mcp-client.js +524 -0
  40. package/src/mcp/protocol.js +466 -0
  41. package/src/mcp/server.js +1240 -0
  42. package/src/packages/packager.js +713 -0
  43. package/src/tools/drift.js +138 -0
  44. package/src/tools/registry.js +468 -0
  45. package/src/tools/tool-state.js +104 -0
  46. package/src/ui/public/app.js +1055 -0
  47. package/src/ui/public/index.html +249 -0
  48. package/src/ui/public/logo.jpg +0 -0
  49. package/src/ui/public/styles.css +535 -0
  50. package/src/ui/server.js +1144 -0
  51. package/tools/scripts/base64.js +139 -0
  52. package/tools/scripts/danger.js +61 -0
  53. package/tools/scripts/echo.js +39 -0
  54. package/tools/scripts/hash.js +101 -0
  55. package/tools/scripts/json.js +109 -0
  56. package/tools/scripts/text-stats.js +131 -0
  57. package/tools/scripts/tf-hook-add.js +152 -0
  58. package/tools/scripts/tf-hook-set.js +113 -0
  59. package/tools/scripts/tf-list.js +195 -0
  60. package/tools/scripts/tf-log.js +83 -0
  61. package/tools/scripts/tf-mcp-add.js +119 -0
  62. package/tools/scripts/tf-mcp-set.js +104 -0
  63. package/tools/scripts/tf-tool-add.js +129 -0
  64. package/tools/scripts/tf-tool-set.js +148 -0
  65. package/tools/scripts/uuid.js +102 -0
  66. package/tools/tools.register.json +171 -0
@@ -0,0 +1,791 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * hook-loader.js — load and persist the hook manifest, resolve runnable specs.
5
+ *
6
+ * Contract: HOOK_ENGINE.md §7 (manifest shape) and §8 (loader API).
7
+ *
8
+ * Responsibilities:
9
+ * - loadManifest(path): read hooks.manifest.json, expand ${HOOKS_DIR} (the absolute
10
+ * src/hooks dir) inside every command string, return { version, hooksDir, hooks: Spec[] }.
11
+ * - enabledHooksFor(event): the enabled specs for a lifecycle event.
12
+ * - setEnabled(id, bool): flip a hook's enabled flag and persist ATOMICALLY (temp + rename).
13
+ * - readScript(id): the hook's source text (for the manager's "open").
14
+ * - writeScript(id, text): save to the COPY under src/hooks/scripts — and REFUSE any
15
+ * resolved path outside src/hooks/scripts (path-traversal guard, isolation rule).
16
+ *
17
+ * Hook Manager v2 additions (see the architecture notes §4) — all ADDITIVE; the API above
18
+ * keeps its exact behaviour so the existing loader tests still pass:
19
+ * - AUTO-DETECT: autodetect() scans <hooksDir>/scripts for hook scripts and reconciles
20
+ * them with the manifest. The FOLDER is the source of truth for what EXISTS; the
21
+ * manifest is CONFIG. Scripts on disk with no manifest entry surface as detected
22
+ * entries (enabled:false, detected:true, event/matcher unknown). Manifest entries
23
+ * whose script is missing on disk are flagged (present:false). Aliased as
24
+ * detect/reconcile/scan/inventory/reconcileScripts.
25
+ * - PERSIST STATE: enabled-state lives in <hooksDir>/hooks.state.json, an overlay keyed
26
+ * by hook id ({ "<id>": bool }). On load it is applied OVER the manifest's enabled
27
+ * flags (overlay WINS — documented precedence). setEnabled writes the overlay
28
+ * atomically (temp+rename) IN ADDITION to mutating the manifest, so toggles survive
29
+ * restarts and stay separate from the inventory.
30
+ *
31
+ * CommonJS only. Node built-ins only.
32
+ */
33
+
34
+ const fs = require('node:fs');
35
+ const path = require('node:path');
36
+
37
+ // Lifecycle event names (frozen) — used to validate addEntry specs against the
38
+ // six supported events. events.js has zero host imports, so this cannot cycle.
39
+ const { EVENT_NAMES } = require('./events');
40
+
41
+ /**
42
+ * Extensions we treat as hook scripts during auto-detect. Everything else in the
43
+ * scripts dir (e.g. *.conf files, READMEs) is config/data, not a hook.
44
+ */
45
+ const HOOK_SCRIPT_EXTS = /\.(sh|js|cjs|mjs|cmd|bat|ps1|py)$/i;
46
+
47
+ /**
48
+ * Resolve the absolute src/hooks directory from the manifest's own location and its
49
+ * declared (possibly relative) hooksDir. The manifest lives at <hooks>/hooks.manifest.json,
50
+ * so its directory IS the hooks dir; we honor an explicit absolute hooksDir if given but
51
+ * otherwise anchor to the manifest file's location (robust against cwd differences).
52
+ *
53
+ * @param {string} manifestPath absolute path to hooks.manifest.json
54
+ * @param {string|undefined} declaredHooksDir the manifest's "hooksDir" field
55
+ * @returns {string} absolute path to the hooks directory
56
+ */
57
+ function resolveHooksDir(manifestPath, declaredHooksDir) {
58
+ const manifestDir = path.dirname(path.resolve(manifestPath));
59
+ if (!declaredHooksDir) return manifestDir;
60
+ if (path.isAbsolute(declaredHooksDir)) return path.resolve(declaredHooksDir);
61
+ // Relative hooksDir is interpreted relative to the manifest's directory's parent only
62
+ // when it does not simply re-point at the manifest dir; in practice the manifest sits
63
+ // inside the hooks dir, so the manifest directory is the source of truth.
64
+ return manifestDir;
65
+ }
66
+
67
+ /**
68
+ * Expand ${HOOKS_DIR} occurrences in a string with the absolute hooks dir.
69
+ * Defensive: returns the input unchanged for non-strings.
70
+ *
71
+ * @param {string} str
72
+ * @param {string} hooksDir absolute hooks directory
73
+ * @returns {string}
74
+ */
75
+ function expandHooksDir(str, hooksDir) {
76
+ if (typeof str !== 'string') return str;
77
+ // Replace every literal occurrence of ${HOOKS_DIR}. We avoid regex special-char issues
78
+ // by using split/join on the literal token.
79
+ return str.split('${HOOKS_DIR}').join(hooksDir);
80
+ }
81
+
82
+ class HookLoader {
83
+ /**
84
+ * @param {string} manifestPath absolute path to hooks.manifest.json
85
+ * @param {object} manifest the parsed + expanded manifest object
86
+ * @param {string} hooksDir absolute hooks directory
87
+ */
88
+ constructor(manifestPath, manifest, hooksDir) {
89
+ this.manifestPath = manifestPath;
90
+ this.manifest = manifest;
91
+ this.hooksDir = hooksDir;
92
+ // The scripts copy directory — the ONLY place writeScript may touch.
93
+ this.scriptsDir = path.join(hooksDir, 'scripts');
94
+ // v2: the persisted enabled-state overlay (keyed by hook id), kept separate
95
+ // from the manifest inventory. Lives next to the manifest in the hooks dir.
96
+ this.statePath = path.join(hooksDir, 'hooks.state.json');
97
+ }
98
+
99
+ /** @returns {number} manifest schema version */
100
+ get version() {
101
+ return this.manifest && this.manifest.version;
102
+ }
103
+
104
+ /** @returns {Array<object>} all hook specs (expanded) */
105
+ get hooks() {
106
+ return (this.manifest && Array.isArray(this.manifest.hooks)) ? this.manifest.hooks : [];
107
+ }
108
+
109
+ /**
110
+ * Enabled specs for a given lifecycle event, in manifest order.
111
+ * @param {string} event one of EVENTS
112
+ * @returns {Array<object>}
113
+ */
114
+ enabledHooksFor(event) {
115
+ return this.hooks.filter((h) => h && h.event === event && h.enabled === true);
116
+ }
117
+
118
+ /**
119
+ * Look up a single spec by id.
120
+ * @param {string} id
121
+ * @returns {object|undefined}
122
+ */
123
+ getSpec(id) {
124
+ return this.hooks.find((h) => h && h.id === id);
125
+ }
126
+
127
+ // ── v2: persisted enabled-state overlay ──────────────────────────────────
128
+
129
+ /**
130
+ * Read the enabled-state overlay from hooks.state.json. The overlay is a flat
131
+ * map of hook id → boolean. Missing/unreadable/malformed file → empty overlay
132
+ * (the manifest's own enabled flags stand). Never throws.
133
+ *
134
+ * @returns {Object<string, boolean>}
135
+ */
136
+ readState() {
137
+ let raw;
138
+ try {
139
+ raw = JSON.parse(fs.readFileSync(this.statePath, 'utf8'));
140
+ } catch (_) {
141
+ return {};
142
+ }
143
+ return normalizeState(raw);
144
+ }
145
+
146
+ /**
147
+ * Persist the enabled-state overlay atomically (temp + rename). Confined to the
148
+ * hooks dir. The overlay carries ONLY booleans keyed by hook id.
149
+ *
150
+ * @param {Object<string, boolean>} overlay
151
+ * @returns {void}
152
+ */
153
+ writeState(overlay) {
154
+ const clean = normalizeState(overlay);
155
+ atomicWriteJson(this.statePath, clean);
156
+ }
157
+
158
+ /**
159
+ * Apply the on-disk state overlay over the in-memory manifest specs. The overlay
160
+ * WINS over the manifest's own enabled flag (documented precedence). Called on
161
+ * load so enabledHooksFor reflects the persisted toggles. Idempotent.
162
+ *
163
+ * @returns {Object<string, boolean>} the overlay that was applied
164
+ */
165
+ applyStateOverlay() {
166
+ const overlay = this.readState();
167
+ for (const h of this.hooks) {
168
+ if (!h || typeof h.id !== 'string') continue;
169
+ if (Object.prototype.hasOwnProperty.call(overlay, h.id)) {
170
+ h.enabled = overlay[h.id] === true;
171
+ }
172
+ }
173
+ return overlay;
174
+ }
175
+
176
+ // ── v2: auto-detect / reconcile inventory ────────────────────────────────
177
+
178
+ /**
179
+ * Scan <hooksDir>/scripts for hook scripts and reconcile with the manifest.
180
+ *
181
+ * The FOLDER is the source of truth for what EXISTS; the manifest is CONFIG.
182
+ * - Manifest entries are returned with `present` reflecting whether their
183
+ * `script` file exists on disk (manifest entries with no script — inline
184
+ * commands — are treated as present:true, since there is no file to miss).
185
+ * - Scripts present on disk but absent from the manifest are surfaced as
186
+ * `detected` entries: enabled:false, detected:true, event/matcher unknown.
187
+ *
188
+ * Returns a normalised inventory. `all` is the union (configured first, in
189
+ * manifest order, then detected). Pure read — does not mutate the manifest or
190
+ * touch disk beyond reading the scripts dir.
191
+ *
192
+ * @returns {{ all: object[], configured: object[], detected: object[], orphans: object[] }}
193
+ */
194
+ autodetect() {
195
+ const scriptFiles = this._scanScripts(); // Set of basenames present on disk
196
+ const referenced = new Set();
197
+
198
+ const configured = [];
199
+ const orphans = [];
200
+
201
+ for (const h of this.hooks) {
202
+ if (!h || typeof h !== 'object') continue;
203
+ const base = scriptBasename(h.script);
204
+ if (base) referenced.add(base);
205
+
206
+ // Inline-command hooks (script:null) have no file to verify → present.
207
+ const present = base ? scriptFiles.has(base) : true;
208
+ const entry = Object.assign({}, h, {
209
+ detected: false,
210
+ configured: true,
211
+ present,
212
+ });
213
+ if (!present) {
214
+ // Orphan: manifest references a script that does not exist on disk.
215
+ entry.missing = true;
216
+ entry.scriptMissing = true;
217
+ entry.exists = false;
218
+ entry.status = 'missing';
219
+ orphans.push(entry);
220
+ }
221
+ configured.push(entry);
222
+ }
223
+
224
+ // Scripts on disk with no manifest entry → detected, unconfigured.
225
+ const detected = [];
226
+ for (const base of scriptFiles) {
227
+ if (referenced.has(base)) continue;
228
+ detected.push({
229
+ id: `detected/${base.replace(/\.[^.]+$/, '')}`,
230
+ event: null, // unknown — not yet configured
231
+ matcher: null,
232
+ type: 'command',
233
+ script: path.posix.join('scripts', base),
234
+ command: null,
235
+ timeout: null,
236
+ enabled: false, // detected entries are never auto-enabled
237
+ detected: true,
238
+ configured: false,
239
+ unconfigured: true,
240
+ present: true,
241
+ source: 'disk-scan',
242
+ description: `Detected on disk; not yet configured in the manifest (${base}).`,
243
+ });
244
+ }
245
+
246
+ return {
247
+ all: configured.concat(detected),
248
+ configured,
249
+ detected,
250
+ orphans,
251
+ };
252
+ }
253
+
254
+ /** @returns {object} alias of {@link HookLoader#autodetect} (contract §4 naming). */
255
+ reconcile() { return this.autodetect(); }
256
+ /** @returns {object} alias of {@link HookLoader#autodetect}. */
257
+ reconcileScripts() { return this.autodetect(); }
258
+ /** @returns {object} alias of {@link HookLoader#autodetect}. */
259
+ inventory() { return this.autodetect(); }
260
+ /** @returns {object} alias of {@link HookLoader#autodetect}. */
261
+ detect() { return this.autodetect(); }
262
+ /** @returns {object} alias of {@link HookLoader#autodetect}. */
263
+ scan() { return this.autodetect(); }
264
+
265
+ /**
266
+ * Scan the scripts dir for candidate hook script files. Hook scripts are
267
+ * executable shell/node scripts; we treat .sh/.js/.cmd/.bat/.ps1/.py as hook
268
+ * scripts and ignore everything else (e.g. a *.conf file is config, not a
269
+ * hook). Missing dir → empty set. Never throws.
270
+ *
271
+ * @returns {Set<string>} basenames of script files present on disk
272
+ * @private
273
+ */
274
+ _scanScripts() {
275
+ const out = new Set();
276
+ let entries;
277
+ try {
278
+ entries = fs.readdirSync(this.scriptsDir, { withFileTypes: true });
279
+ } catch (_) {
280
+ return out; // no scripts dir yet
281
+ }
282
+ for (const ent of entries) {
283
+ if (!ent.isFile || !ent.isFile()) continue;
284
+ if (HOOK_SCRIPT_EXTS.test(ent.name)) out.add(ent.name);
285
+ }
286
+ return out;
287
+ }
288
+
289
+ /**
290
+ * Toggle a hook's enabled flag and persist the manifest atomically.
291
+ *
292
+ * v2 precedence: the authoritative persisted toggle lives in the hooks.state.json
293
+ * OVERLAY (keyed by hook id), which WINS over the manifest's own enabled flag at
294
+ * load time. setEnabled writes that overlay atomically so the toggle survives a
295
+ * restart and stays separate from the auto-detected inventory. For backward
296
+ * compatibility (and so the manifest stays a faithful default), it ALSO mirrors
297
+ * the flag into the manifest — writing the ORIGINAL (unexpanded) command strings
298
+ * so the absolute ${HOOKS_DIR} expansion never leaks into the portable on-disk
299
+ * manifest. We re-read the raw manifest from disk to do this.
300
+ *
301
+ * If the hook is a v2 DETECTED entry (on disk, not in the manifest), there is no
302
+ * manifest row to mirror into — the overlay alone carries its state.
303
+ *
304
+ * @param {string} id hook id
305
+ * @param {boolean} bool desired enabled state
306
+ * @returns {boolean} true if the toggle was applied (manifest hook OR detected hook)
307
+ */
308
+ setEnabled(id, bool) {
309
+ const desired = !!bool;
310
+
311
+ // Update the in-memory (expanded) spec so the live engine sees the change
312
+ // immediately — if this id corresponds to a configured manifest hook.
313
+ const liveSpec = this.getSpec(id);
314
+ if (liveSpec) liveSpec.enabled = desired;
315
+
316
+ // (1) Persist the overlay — the authoritative, precedence-winning store.
317
+ const overlay = this.readState();
318
+ overlay[id] = desired;
319
+ this.writeState(overlay);
320
+
321
+ // (2) Mirror into the manifest when a matching row exists (keeps the manifest a
322
+ // faithful default and preserves the v1 setEnabled contract). Skipped for
323
+ // detected-only ids that have no manifest row.
324
+ let raw;
325
+ try {
326
+ raw = JSON.parse(fs.readFileSync(this.manifestPath, 'utf8'));
327
+ } catch (err) {
328
+ // If the on-disk file is unreadable, fall back to persisting a re-collapsed copy
329
+ // of the in-memory manifest (best effort — still atomic).
330
+ raw = collapseManifest(this.manifest, this.hooksDir);
331
+ }
332
+
333
+ if (raw && Array.isArray(raw.hooks)) {
334
+ const rawSpec = raw.hooks.find((h) => h && h.id === id);
335
+ if (rawSpec) {
336
+ rawSpec.enabled = desired;
337
+ atomicWriteJson(this.manifestPath, raw);
338
+ }
339
+ }
340
+
341
+ // True if we toggled a known manifest hook; also true if we recorded the overlay
342
+ // for a detected (manifest-less) hook so the manager can persist its state.
343
+ return !!liveSpec || true;
344
+ }
345
+
346
+ /**
347
+ * Add a new hook entry to the manifest AND the live in-memory engine view.
348
+ *
349
+ * Mirrors setEnabled's persistence pattern: the RAW manifest JSON is re-read from
350
+ * disk (so the portable ${HOOKS_DIR} token inside command strings stays UNCOLLAPSED
351
+ * on disk), the validated spec is appended VERBATIM, and the file is rewritten
352
+ * atomically (temp + rename). In ADDITION an EXPANDED copy — ${HOOKS_DIR} resolved in
353
+ * the command — is pushed onto this.manifest.hooks so enabledHooksFor / the live
354
+ * engine observe the new hook WITHOUT a reload.
355
+ *
356
+ * @param {object} spec
357
+ * - id: non-empty string; must not already exist in the manifest (rejected if it does)
358
+ * - event: one of the lifecycle EVENTS (EVENT_NAMES)
359
+ * - command: non-empty string
360
+ * - matcher/script/timeout/description: optional, stored verbatim
361
+ * - enabled: defaults to (spec.enabled === true)
362
+ * @returns {object} the stored spec (exactly as written to the manifest)
363
+ * @throws TypeError/Error if validation fails or the id already exists
364
+ */
365
+ addEntry(spec) {
366
+ if (!spec || typeof spec !== 'object') {
367
+ throw new TypeError('addEntry: spec must be an object');
368
+ }
369
+ if (typeof spec.id !== 'string' || spec.id.length === 0) {
370
+ throw new Error('addEntry: spec.id must be a non-empty string');
371
+ }
372
+ if (!EVENT_NAMES.includes(spec.event)) {
373
+ throw new Error(
374
+ `addEntry: spec.event must be one of ${EVENT_NAMES.join(', ')} (got ${JSON.stringify(spec.event)})`
375
+ );
376
+ }
377
+ if (typeof spec.command !== 'string' || spec.command.length === 0) {
378
+ throw new Error('addEntry: spec.command must be a non-empty string');
379
+ }
380
+
381
+ // The stored spec: every supplied field VERBATIM, with enabled normalized to a
382
+ // strict boolean. The command is left untouched so a portable ${HOOKS_DIR} token
383
+ // (if the caller used one) stays UNCOLLAPSED on disk.
384
+ const stored = Object.assign({}, spec, { enabled: spec.enabled === true });
385
+
386
+ // Re-read the RAW manifest from disk (commands uncollapsed). Fall back to a
387
+ // re-collapsed copy of the in-memory manifest if the file is unreadable — same
388
+ // resilience as setEnabled, and the collapse keeps ${HOOKS_DIR} uncollapsed too.
389
+ let raw;
390
+ try {
391
+ raw = JSON.parse(fs.readFileSync(this.manifestPath, 'utf8'));
392
+ } catch (_) {
393
+ raw = collapseManifest(this.manifest, this.hooksDir);
394
+ }
395
+ if (!raw || typeof raw !== 'object') raw = { version: this.version, hooks: [] };
396
+ if (!Array.isArray(raw.hooks)) raw.hooks = [];
397
+
398
+ if (raw.hooks.some((h) => h && h.id === spec.id)) {
399
+ throw new Error(`addEntry: a hook with id "${spec.id}" already exists`);
400
+ }
401
+
402
+ // Persist the spec VERBATIM, atomically.
403
+ raw.hooks.push(stored);
404
+ atomicWriteJson(this.manifestPath, raw);
405
+
406
+ // Mirror an EXPANDED copy into the live manifest so the engine sees it NOW.
407
+ if (!Array.isArray(this.manifest.hooks)) this.manifest.hooks = [];
408
+ const expanded = Object.assign({}, stored, {
409
+ command: expandHooksDir(stored.command, this.hooksDir),
410
+ });
411
+ this.manifest.hooks.push(expanded);
412
+
413
+ return stored;
414
+ }
415
+
416
+ /**
417
+ * Remove a hook entry by id from the manifest, the live engine view, and the
418
+ * persisted enabled-state overlay. Does NOT delete the script file on disk —
419
+ * scripts are inventory (auto-detect's source of truth); unconfiguring a hook
420
+ * only drops its manifest row, leaving any file for re-detection.
421
+ *
422
+ * Mirrors setEnabled's persistence pattern: re-read the RAW manifest (commands
423
+ * stay UNCOLLAPSED), filter out the id, and rewrite atomically ONLY if something
424
+ * changed. The in-memory expanded spec is spliced out so the live engine stops
425
+ * firing it WITHOUT a reload, and the overlay key (when present) is purged.
426
+ *
427
+ * @param {string} id hook id to remove
428
+ * @returns {boolean} true if a manifest row OR a live spec was removed
429
+ * @throws Error if id is not a non-empty string
430
+ */
431
+ removeEntry(id) {
432
+ if (typeof id !== 'string' || id.length === 0) {
433
+ throw new Error('removeEntry: id must be a non-empty string');
434
+ }
435
+
436
+ // (1) Re-read the RAW manifest and drop the matching row (commands uncollapsed).
437
+ let raw;
438
+ try {
439
+ raw = JSON.parse(fs.readFileSync(this.manifestPath, 'utf8'));
440
+ } catch (_) {
441
+ raw = collapseManifest(this.manifest, this.hooksDir);
442
+ }
443
+ let removedFromManifest = false;
444
+ if (raw && Array.isArray(raw.hooks)) {
445
+ const before = raw.hooks.length;
446
+ raw.hooks = raw.hooks.filter((h) => !(h && h.id === id));
447
+ removedFromManifest = raw.hooks.length !== before;
448
+ if (removedFromManifest) atomicWriteJson(this.manifestPath, raw); // write only if changed
449
+ }
450
+
451
+ // (2) Splice the live expanded spec(s) so the engine stops seeing it immediately.
452
+ let removedFromMemory = false;
453
+ if (Array.isArray(this.manifest.hooks)) {
454
+ for (let i = this.manifest.hooks.length - 1; i >= 0; i--) {
455
+ const h = this.manifest.hooks[i];
456
+ if (h && h.id === id) {
457
+ this.manifest.hooks.splice(i, 1);
458
+ removedFromMemory = true;
459
+ }
460
+ }
461
+ }
462
+
463
+ // (3) Purge the overlay key. Only rewrite the overlay when it actually carried
464
+ // this id, so we never create a stray state file for a never-toggled hook.
465
+ const overlay = this.readState();
466
+ if (Object.prototype.hasOwnProperty.call(overlay, id)) {
467
+ delete overlay[id];
468
+ this.writeState(overlay);
469
+ }
470
+
471
+ return removedFromManifest || removedFromMemory;
472
+ }
473
+
474
+ /**
475
+ * Read a hook's script source. Resolves the spec's "script" (relative to hooksDir) and
476
+ * reads it. Falls back to deriving the path from the script basename under scripts/.
477
+ *
478
+ * @param {string} id hook id
479
+ * @returns {string} file contents
480
+ * @throws if the hook id is unknown or the file cannot be read
481
+ */
482
+ readScript(id) {
483
+ const spec = this.getSpec(id);
484
+ if (!spec) throw new Error(`readScript: unknown hook id "${id}"`);
485
+ const scriptPath = this._scriptPathForSpec(spec);
486
+ return fs.readFileSync(scriptPath, 'utf8');
487
+ }
488
+
489
+ /**
490
+ * Write a hook's script source to the COPY under src/hooks/scripts. Refuses any path that
491
+ * resolves outside src/hooks/scripts (defense-in-depth for the isolation rule).
492
+ *
493
+ * @param {string} id hook id
494
+ * @param {string} text new source
495
+ * @returns {void}
496
+ * @throws if the id is unknown, or the resolved path escapes scripts/
497
+ */
498
+ writeScript(id, text) {
499
+ const spec = this.getSpec(id);
500
+ if (!spec) throw new Error(`writeScript: unknown hook id "${id}"`);
501
+ if (typeof text !== 'string') {
502
+ throw new TypeError('writeScript: text must be a string');
503
+ }
504
+
505
+ const scriptPath = this._scriptPathForSpec(spec);
506
+ this._assertInsideScriptsDir(scriptPath);
507
+
508
+ // Atomic write within the scripts dir (temp + rename), preserving any line endings
509
+ // the caller supplied (do not normalize — the shell scripts may be CRLF-sensitive).
510
+ atomicWriteText(scriptPath, text, this.scriptsDir);
511
+ }
512
+
513
+ /**
514
+ * Resolve the absolute filesystem path for a spec's script, anchored to hooksDir, and
515
+ * guard it against escaping scripts/.
516
+ *
517
+ * @param {object} spec
518
+ * @returns {string} absolute path inside scripts/
519
+ * @private
520
+ */
521
+ _scriptPathForSpec(spec) {
522
+ let rel = spec.script;
523
+ if (typeof rel !== 'string' || rel.length === 0) {
524
+ // Fall back to deriving from the id's basename if "script" is missing.
525
+ rel = path.join('scripts', `${path.basename(spec.id)}`);
526
+ }
527
+ // spec.script is documented relative to hooksDir (e.g. "scripts/on-prompt.sh").
528
+ const resolved = path.resolve(this.hooksDir, rel);
529
+ this._assertInsideScriptsDir(resolved);
530
+ return resolved;
531
+ }
532
+
533
+ /**
534
+ * Throw unless `target` resolves strictly inside this.scriptsDir.
535
+ * @param {string} target absolute candidate path
536
+ * @private
537
+ */
538
+ _assertInsideScriptsDir(target) {
539
+ const resolved = path.resolve(target);
540
+ const base = path.resolve(this.scriptsDir);
541
+ // Compare with a trailing separator so "<base>extra" cannot pass as "<base>".
542
+ const baseWithSep = base.endsWith(path.sep) ? base : base + path.sep;
543
+ const isInside = resolved === base || resolved.startsWith(baseWithSep);
544
+ if (!isInside) {
545
+ throw new Error(
546
+ `path-traversal blocked: "${resolved}" is outside the scripts dir "${base}"`
547
+ );
548
+ }
549
+ }
550
+ }
551
+
552
+ /**
553
+ * Coerce an arbitrary parsed value into a clean { id: boolean } overlay map. Accepts
554
+ * either a flat map ({ "<id>": bool }) or a wrapped form ({ enabled: { ... } }) for
555
+ * forward-tolerance; non-boolean values are coerced to strict booleans; non-object
556
+ * input yields an empty overlay. Never throws.
557
+ *
558
+ * @param {*} raw
559
+ * @returns {Object<string, boolean>}
560
+ */
561
+ function normalizeState(raw) {
562
+ const src =
563
+ raw && typeof raw === 'object' && raw.enabled && typeof raw.enabled === 'object'
564
+ ? raw.enabled
565
+ : raw;
566
+ const out = {};
567
+ if (!src || typeof src !== 'object' || Array.isArray(src)) return out;
568
+ for (const key of Object.keys(src)) {
569
+ out[key] = src[key] === true;
570
+ }
571
+ return out;
572
+ }
573
+
574
+ /**
575
+ * The basename of a spec's script field (e.g. "scripts/on-prompt.sh" → "on-prompt.sh").
576
+ * Returns null for inline-command hooks (script null/empty). Tolerates either path
577
+ * separator since manifests use forward slashes.
578
+ *
579
+ * @param {*} script the spec.script field
580
+ * @returns {string|null}
581
+ */
582
+ function scriptBasename(script) {
583
+ if (typeof script !== 'string' || script.length === 0) return null;
584
+ const norm = script.split('\\').join('/');
585
+ const base = norm.slice(norm.lastIndexOf('/') + 1);
586
+ return base.length ? base : null;
587
+ }
588
+
589
+ /**
590
+ * Produce a manifest object with command strings collapsed back to ${HOOKS_DIR} so the
591
+ * on-disk form stays machine-portable. Used only as a fallback when the raw file cannot
592
+ * be re-read during persistence.
593
+ *
594
+ * @param {object} manifest expanded manifest
595
+ * @param {string} hooksDir absolute hooks dir to collapse out of command strings
596
+ * @returns {object} a deep-enough copy with collapsed commands
597
+ */
598
+ function collapseManifest(manifest, hooksDir) {
599
+ const copy = JSON.parse(JSON.stringify(manifest));
600
+ if (Array.isArray(copy.hooks)) {
601
+ for (const h of copy.hooks) {
602
+ if (h && typeof h.command === 'string' && hooksDir) {
603
+ h.command = h.command.split(hooksDir).join('${HOOKS_DIR}');
604
+ }
605
+ }
606
+ }
607
+ return copy;
608
+ }
609
+
610
+ /**
611
+ * Atomically write a JS object as pretty JSON: write to a temp file in the SAME directory
612
+ * as the target (so rename is atomic on the same filesystem), then rename over the target.
613
+ *
614
+ * @param {string} targetPath absolute destination
615
+ * @param {object} obj
616
+ */
617
+ function atomicWriteJson(targetPath, obj) {
618
+ const data = JSON.stringify(obj, null, 2) + '\n';
619
+ atomicWriteText(targetPath, data, path.dirname(targetPath));
620
+ }
621
+
622
+ /**
623
+ * Atomically write text: temp file in `tmpDir` (must be on the same volume as the target),
624
+ * fsync, then rename over the target. Cleans up the temp file on failure.
625
+ *
626
+ * @param {string} targetPath absolute destination
627
+ * @param {string} data
628
+ * @param {string} tmpDir directory to place the temp file (same FS as target)
629
+ */
630
+ function atomicWriteText(targetPath, data, tmpDir) {
631
+ const dir = tmpDir || path.dirname(targetPath);
632
+ const tmpName = `.${path.basename(targetPath)}.${process.pid}.${Date.now()}.${Math.random()
633
+ .toString(36)
634
+ .slice(2)}.tmp`;
635
+ const tmpPath = path.join(dir, tmpName);
636
+
637
+ let fd;
638
+ try {
639
+ fd = fs.openSync(tmpPath, 'w');
640
+ fs.writeFileSync(fd, data);
641
+ try {
642
+ fs.fsyncSync(fd);
643
+ } catch (_) {
644
+ // fsync may fail on some platforms/filesystems; the rename is still ordered.
645
+ }
646
+ } finally {
647
+ if (fd !== undefined) {
648
+ try {
649
+ fs.closeSync(fd);
650
+ } catch (_) {
651
+ /* ignore */
652
+ }
653
+ }
654
+ }
655
+
656
+ try {
657
+ // rename is atomic on the same filesystem and overwrites the destination on POSIX;
658
+ // on Windows it overwrites too as long as the target isn't locked.
659
+ fs.renameSync(tmpPath, targetPath);
660
+ } catch (err) {
661
+ // Best-effort cleanup so we don't leave temp files behind.
662
+ try {
663
+ fs.unlinkSync(tmpPath);
664
+ } catch (_) {
665
+ /* ignore */
666
+ }
667
+ throw err;
668
+ }
669
+ }
670
+
671
+ /**
672
+ * Load a manifest from disk, expand ${HOOKS_DIR} in every command string, and return a
673
+ * HookLoader holding it.
674
+ *
675
+ * @param {string} manifestPath absolute path to hooks.manifest.json
676
+ * @returns {HookLoader}
677
+ */
678
+ function loadManifest(manifestPath) {
679
+ if (typeof manifestPath !== 'string' || manifestPath.length === 0) {
680
+ throw new TypeError('loadManifest: manifestPath must be a non-empty string');
681
+ }
682
+ const absManifest = path.resolve(manifestPath);
683
+ const raw = JSON.parse(fs.readFileSync(absManifest, 'utf8'));
684
+
685
+ const hooksDir = resolveHooksDir(absManifest, raw && raw.hooksDir);
686
+
687
+ // Build the expanded manifest (do not mutate the raw parse beyond this copy).
688
+ const expanded = {
689
+ version: raw && raw.version,
690
+ hooksDir, // store the resolved ABSOLUTE hooks dir for downstream consumers
691
+ hooks: [],
692
+ };
693
+
694
+ const srcHooks = (raw && Array.isArray(raw.hooks)) ? raw.hooks : [];
695
+ for (const h of srcHooks) {
696
+ if (!h || typeof h !== 'object') continue;
697
+ const spec = Object.assign({}, h);
698
+ // Expand ${HOOKS_DIR} inside the command (and, defensively, any string field that
699
+ // might reference it — but command is the documented carrier).
700
+ if (typeof spec.command === 'string') {
701
+ spec.command = expandHooksDir(spec.command, hooksDir);
702
+ }
703
+ // Normalize enabled to a strict boolean (manifest authors may omit it).
704
+ spec.enabled = spec.enabled === true;
705
+ expanded.hooks.push(spec);
706
+ }
707
+
708
+ const loader = new HookLoader(absManifest, expanded, hooksDir);
709
+ // v2: apply the persisted enabled-state overlay OVER the manifest flags so the
710
+ // returned loader (and enabledHooksFor) reflects the durable toggles. The overlay
711
+ // wins; absent/unreadable overlay leaves the manifest defaults intact.
712
+ loader.applyStateOverlay();
713
+ return loader;
714
+ }
715
+
716
+ /**
717
+ * Tiny inline self-check: add then remove a temp entry and confirm it round-trips
718
+ * with the manifest byte-for-byte RESTORED. Runs only under `node hook-loader.js`
719
+ * (never on import) and uses a THROWAWAY manifest in the OS temp dir, so the live
720
+ * project manifest is never touched. Exits non-zero on any failure.
721
+ */
722
+ function _selfCheck() {
723
+ const os = require('node:os');
724
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hookloader-selfcheck-'));
725
+ const manifestPath = path.join(tmpRoot, 'hooks.manifest.json');
726
+ const assert = (cond, msg) => { if (!cond) throw new Error('self-check FAILED: ' + msg); };
727
+ try {
728
+ // A manifest whose command uses the ${HOOKS_DIR} token, so we can prove it stays
729
+ // UNCOLLAPSED on disk while being EXPANDED in the live view.
730
+ const original =
731
+ JSON.stringify(
732
+ { version: 1, hooks: [{ id: 'existing', event: 'PreToolUse', command: '${HOOKS_DIR}/scripts/x.sh', enabled: true }] },
733
+ null,
734
+ 2
735
+ ) + '\n';
736
+ fs.writeFileSync(manifestPath, original);
737
+
738
+ const loader = loadManifest(manifestPath);
739
+ const tmpSpec = { id: 'selfcheck/tmp', event: 'PostToolUse', command: '${HOOKS_DIR}/scripts/tmp.sh' };
740
+
741
+ const stored = loader.addEntry(tmpSpec);
742
+ assert(stored.enabled === false, 'enabled should default to false');
743
+ // On disk: VERBATIM, token uncollapsed.
744
+ const afterAdd = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
745
+ const diskRow = afterAdd.hooks.find((h) => h.id === 'selfcheck/tmp');
746
+ assert(!!diskRow, 'temp entry must be persisted to the manifest');
747
+ assert(diskRow.command === '${HOOKS_DIR}/scripts/tmp.sh', 'on-disk command must keep ${HOOKS_DIR} uncollapsed');
748
+ // Live: EXPANDED so the engine sees it without reload.
749
+ const liveRow = loader.getSpec('selfcheck/tmp');
750
+ assert(!!liveRow, 'temp entry must be live in this.manifest.hooks');
751
+ assert(liveRow.command === path.join(loader.hooksDir, 'scripts/tmp.sh') ||
752
+ liveRow.command === loader.hooksDir + '/scripts/tmp.sh',
753
+ 'live command must be expanded: ' + liveRow.command);
754
+
755
+ // Duplicate id must be rejected.
756
+ let threw = false;
757
+ try { loader.addEntry(tmpSpec); } catch (_) { threw = true; }
758
+ assert(threw, 'adding a duplicate id must throw');
759
+
760
+ const removed = loader.removeEntry('selfcheck/tmp');
761
+ assert(removed === true, 'removeEntry should report a removal');
762
+ assert(!loader.getSpec('selfcheck/tmp'), 'temp entry must be gone from the live view');
763
+
764
+ // Manifest must be RESTORED byte-for-byte (the only other row is "existing").
765
+ const afterRemove = fs.readFileSync(manifestPath, 'utf8');
766
+ assert(afterRemove === original, 'manifest must be restored byte-for-byte after add+remove');
767
+
768
+ process.stdout.write('hook-loader self-check OK (add+remove round-trip; manifest restored)\n');
769
+ } finally {
770
+ try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch (_) { /* ignore */ }
771
+ }
772
+ }
773
+
774
+ if (require.main === module) {
775
+ try {
776
+ _selfCheck();
777
+ } catch (err) {
778
+ process.stderr.write(((err && err.stack) || String(err)) + '\n');
779
+ process.exit(1);
780
+ }
781
+ }
782
+
783
+ module.exports = {
784
+ loadManifest,
785
+ HookLoader,
786
+ // Exported for unit tests / reuse:
787
+ expandHooksDir,
788
+ resolveHooksDir,
789
+ normalizeState,
790
+ scriptBasename,
791
+ };