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,138 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * drift.js — tool register drift detector.
5
+ *
6
+ * The register can go stale when entries are not maintained alongside the files
7
+ * they describe (a config file left unmaintained over time, tools missing). A
8
+ * behavioural "update it immediately" rule is fragile. This is the STRUCTURAL
9
+ * guard (fix-the-tool, not the discipline): scan the real scripts directory
10
+ * against the register and surface drift so it can never silently rot again.
11
+ *
12
+ * Mirrors the host's auto-detect (the folder is the source of truth for what
13
+ * EXISTS; the register carries config). Read-only — it never edits anything,
14
+ * inside or outside the gateway (honours the isolation rule: MAY READ the live
15
+ * scripts directory, MUST NOT write it).
16
+ *
17
+ * detectDrift({ registerPath, toolsDir }) -> {
18
+ * ok, registerPath, toolsDir,
19
+ * registeredCount, // distinct .js basenames referenced by the register
20
+ * onDiskCount, // tool files found under toolsDir
21
+ * missingFromRegister: [str], // on disk under toolsDir but NOT referenced by any entry
22
+ * deadEntries: [{id, path}], // entries whose referenced script no longer exists
23
+ * note,
24
+ * }
25
+ *
26
+ * Matching is by FILENAME (basename), case-insensitive: an entry "covers" a file
27
+ * if the file's basename appears anywhere in the entry's `path` or invoke.command
28
+ * (so grouped family entries that list their variants in `path` cover them).
29
+ *
30
+ * CommonJS, Node built-ins only.
31
+ */
32
+
33
+ const fs = require('node:fs');
34
+ const path = require('node:path');
35
+
36
+ // dirs/files that are not tools — never count as drift
37
+ const SKIP_DIRS = new Set(['.archive', 'node_modules', 'scripts', 'docs', 'setup']);
38
+ const TOOL_EXTS = new Set(['.js']);
39
+ // obvious non-tool scripts (probes/tests) — surfaced separately, not as "missing"
40
+ const NONTOOL_RE = /^(test_|_)/i;
41
+
42
+ /** Every .js basename (lowercased) referenced by the register's path + invoke fields. */
43
+ function registeredBasenames(data) {
44
+ const set = new Set();
45
+ const tools = Array.isArray(data && data.tools) ? data.tools : [];
46
+ const grab = (s) => {
47
+ if (typeof s !== 'string') return;
48
+ // filename run ending in .js — NO spaces in the class (so "wraps helper.js"
49
+ // yields "helper.js", not the whole phrase). path/dir prefixes are excluded too.
50
+ const m = s.match(/[A-Za-z0-9_.\-]+\.js/g);
51
+ if (m) for (const f of m) set.add(path.basename(f).toLowerCase());
52
+ };
53
+ for (const t of tools) {
54
+ grab(t.path);
55
+ if (t.invoke && typeof t.invoke.command === 'string') grab(t.invoke.command);
56
+ // a family entry may explicitly name the member files it covers
57
+ if (Array.isArray(t.covers)) for (const f of t.covers) {
58
+ if (typeof f === 'string') set.add(path.basename(f).toLowerCase());
59
+ }
60
+ }
61
+ return set;
62
+ }
63
+
64
+ /** Recursively collect .js files under dir, skipping SKIP_DIRS and dotdirs. */
65
+ function scanTools(dir) {
66
+ const out = [];
67
+ let entries;
68
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_e) { return out; }
69
+ for (const e of entries) {
70
+ if (e.isDirectory()) {
71
+ if (SKIP_DIRS.has(e.name) || e.name.startsWith('.')) continue;
72
+ out.push(...scanTools(path.join(dir, e.name)));
73
+ } else if (e.isFile() && TOOL_EXTS.has(path.extname(e.name).toLowerCase())) {
74
+ out.push(path.join(dir, e.name));
75
+ }
76
+ }
77
+ return out;
78
+ }
79
+
80
+ function detectDrift({ registerPath, toolsDir } = {}) {
81
+ if (!registerPath || !toolsDir) {
82
+ return { ok: false, error: 'detectDrift: registerPath and toolsDir are required' };
83
+ }
84
+ let data;
85
+ try {
86
+ data = JSON.parse(fs.readFileSync(registerPath, 'utf8'));
87
+ } catch (err) {
88
+ return { ok: false, error: `cannot read/parse register: ${err.message}` };
89
+ }
90
+
91
+ const registered = registeredBasenames(data);
92
+ const onDiskFiles = scanTools(toolsDir);
93
+ const onDiskBasenames = new Set(onDiskFiles.map((f) => path.basename(f).toLowerCase()));
94
+
95
+ // on disk but not referenced by any register entry (skip obvious probes/tests)
96
+ const missingFromRegister = [];
97
+ const nonToolScripts = [];
98
+ for (const f of onDiskFiles) {
99
+ const base = path.basename(f);
100
+ const lower = base.toLowerCase();
101
+ if (registered.has(lower)) continue;
102
+ if (NONTOOL_RE.test(base)) { nonToolScripts.push(base); continue; }
103
+ missingFromRegister.push(base);
104
+ }
105
+
106
+ // entries whose script-invoke path references a file no longer present under scriptsRoot
107
+ const deadEntries = [];
108
+ const tools = Array.isArray(data.tools) ? data.tools : [];
109
+ for (const t of tools) {
110
+ const p = typeof t.path === 'string' ? t.path : '';
111
+ const cmd = t.invoke && typeof t.invoke.command === 'string' ? t.invoke.command : '';
112
+ const m = p.match(/[A-Za-z0-9_\-]+\.js/) || cmd.match(/[A-Za-z0-9_\-]+\.js/);
113
+ if (m && !onDiskBasenames.has(m[0].toLowerCase())) {
114
+ deadEntries.push({ id: t.id, path: p });
115
+ }
116
+ }
117
+
118
+ missingFromRegister.sort();
119
+ nonToolScripts.sort();
120
+
121
+ return {
122
+ ok: true,
123
+ registerPath,
124
+ toolsDir,
125
+ registeredCount: registered.size,
126
+ onDiskCount: onDiskFiles.length,
127
+ missingFromRegister,
128
+ deadEntries,
129
+ nonToolScripts,
130
+ note:
131
+ 'Scope: scans toolsDir (the scripts root) only. Scripts that live outside it ' +
132
+ 'may be referenced by the register but are not drift-checked here. Matching is ' +
133
+ 'by filename. nonToolScripts (test_*/_*) are listed separately, not counted as ' +
134
+ 'missing.',
135
+ };
136
+ }
137
+
138
+ module.exports = { detectDrift, registeredBasenames, scanTools };
@@ -0,0 +1,468 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * registry.js — the dynamic, persisted Tool Register (see the architecture doc §5).
5
+ *
6
+ * This is distinct from src/core/tool-registry.js: that one is the in-memory
7
+ * catalogue of tools handed to a backend at connect time. THIS one is the
8
+ * structured, on-disk register the lean meta-tools (toolfunnel_list_tools /
9
+ * toolfunnel_tool_instructions / toolfunnel_run_tool) read every call — so register edits
10
+ * are visible with no reconnect (§1, "no restart for register changes").
11
+ *
12
+ * Register entry shape (tools.register.json -> { version, description, tools:[] }):
13
+ * { id, name, summary, category, instructions, invoke, mode? }
14
+ * invoke is one of:
15
+ * { type: "script", path: "scripts/<file>" } // run a host-local script
16
+ * { type: "shell", command: "..." } // run a shell command
17
+ * mode (optional) is one of:
18
+ * "gateway" — execute the invoke server-side via the gated run path (default for
19
+ * a script/shell invoke; the original behaviour).
20
+ * "reference" — do NOT execute; toolfunnel_run_tool returns the instructions so the
21
+ * connected AI runs the action itself. invoke may be omitted.
22
+ * When mode is absent it is inferred: script/shell invoke -> "gateway", else "reference".
23
+ *
24
+ * Responsibilities:
25
+ * list({filter?, category?}) -> briefs only [{id,name,summary,category}]
26
+ * instructions(id) -> the full instructions string (throws on unknown)
27
+ * getEntry(id) -> the full entry (clone; throws on unknown)
28
+ * add(entry) / update(id,patch) / remove(id) -> mutate + ATOMIC persist
29
+ * resolveExecution(id, args) -> { type, run: () => Promise<result> }
30
+ * describes HOW to run the invoke. It does NOT
31
+ * gate — gating is gated-run.js's job (§9).
32
+ *
33
+ * Pure-ish: the registry only touches its own JSON file + (for script invokes)
34
+ * spawns node on a path resolved under this src/tools dir. Dependencies that a
35
+ * test may want to stub (the executor) are injectable.
36
+ *
37
+ * CommonJS, Node built-ins only, atomic writes (temp + rename).
38
+ */
39
+
40
+ const fs = require('node:fs');
41
+ const path = require('node:path');
42
+ const { spawn } = require('node:child_process');
43
+
44
+ const VALID_INVOKE_TYPES = new Set(['script', 'shell']);
45
+
46
+ /**
47
+ * Execution modes (optional per-entry "mode" field):
48
+ * "gateway" — ToolFunnel EXECUTES the invoke server-side through the gated run path
49
+ * (the original behaviour for every script/shell tool).
50
+ * "reference" — ToolFunnel does NOT execute. toolfunnel_run_tool returns the tool's
51
+ * instructions so the connected AI performs the action in ITS OWN
52
+ * environment. A reference tool needs no invoke (nothing runs here).
53
+ */
54
+ const VALID_MODES = new Set(['reference', 'gateway']);
55
+
56
+ /**
57
+ * Resolve an entry's execution mode. An explicit, valid `mode` wins. When absent it is
58
+ * INFERRED backward-compatibly: a script/shell invoke means "gateway" (run it here, as
59
+ * every existing tool does); anything else means "reference" (there is nothing to run).
60
+ * @param {object} entry a register entry
61
+ * @returns {"gateway"|"reference"}
62
+ */
63
+ function resolveMode(entry) {
64
+ if (entry && (entry.mode === 'reference' || entry.mode === 'gateway')) return entry.mode;
65
+ const t = entry && entry.invoke && entry.invoke.type;
66
+ return t === 'script' || t === 'shell' ? 'gateway' : 'reference';
67
+ }
68
+
69
+ /** Deep-ish clone for plain JSON entries (structuredClone may be absent on old node). */
70
+ function cloneEntry(entry) {
71
+ return JSON.parse(JSON.stringify(entry));
72
+ }
73
+
74
+ /**
75
+ * Atomic write: serialise to a temp file in the SAME directory as the target
76
+ * (so rename is atomic on the same filesystem/drive), fsync, then rename over
77
+ * the target. A crash leaves either the old file or the new file, never a
78
+ * half-written register.
79
+ */
80
+ function atomicWriteJson(targetPath, obj) {
81
+ const dir = path.dirname(targetPath);
82
+ const base = path.basename(targetPath);
83
+ const tmp = path.join(dir, `.${base}.${process.pid}.${Date.now()}.tmp`);
84
+ const data = JSON.stringify(obj, null, 2) + '\n';
85
+ const fd = fs.openSync(tmp, 'w');
86
+ try {
87
+ fs.writeSync(fd, data, 0, 'utf8');
88
+ fs.fsyncSync(fd);
89
+ } finally {
90
+ fs.closeSync(fd);
91
+ }
92
+ fs.renameSync(tmp, targetPath);
93
+ }
94
+
95
+ /** Validate a register entry shape; throws with a clear message on a bad shape. */
96
+ function validateEntry(entry, { requireId = true } = {}) {
97
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
98
+ throw new Error('Registry: entry must be an object');
99
+ }
100
+ if (requireId && (typeof entry.id !== 'string' || entry.id.length === 0)) {
101
+ throw new Error('Registry: entry.id must be a non-empty string');
102
+ }
103
+ if (typeof entry.name !== 'string' || entry.name.length === 0) {
104
+ throw new Error(`Registry: entry.name must be a non-empty string (id=${entry.id})`);
105
+ }
106
+ // Optional execution mode. When present it must be one of the valid modes.
107
+ if (entry.mode !== undefined && !VALID_MODES.has(entry.mode)) {
108
+ throw new Error(
109
+ `Registry: entry.mode must be one of ${[...VALID_MODES].join('|')} (id=${entry.id})`
110
+ );
111
+ }
112
+ // A reference tool executes nothing server-side, so its invoke may be omitted. Only an
113
+ // EXPLICIT mode:"reference" relaxes this — with mode absent the invoke is still required
114
+ // (backward-compatible: every existing tool keeps its mandatory invoke).
115
+ if (entry.mode === 'reference' && entry.invoke === undefined) {
116
+ return entry;
117
+ }
118
+ const inv = entry.invoke;
119
+ if (!inv || typeof inv !== 'object') {
120
+ throw new Error(`Registry: entry.invoke is required (id=${entry.id})`);
121
+ }
122
+ if (!VALID_INVOKE_TYPES.has(inv.type)) {
123
+ throw new Error(
124
+ `Registry: entry.invoke.type must be one of ${[...VALID_INVOKE_TYPES].join('|')} (id=${entry.id})`
125
+ );
126
+ }
127
+ if (inv.type === 'script' && (typeof inv.path !== 'string' || inv.path.length === 0)) {
128
+ throw new Error(`Registry: script invoke needs a non-empty "path" (id=${entry.id})`);
129
+ }
130
+ if (inv.type === 'shell' && (typeof inv.command !== 'string' || inv.command.length === 0)) {
131
+ throw new Error(`Registry: shell invoke needs a non-empty "command" (id=${entry.id})`);
132
+ }
133
+ return entry;
134
+ }
135
+
136
+ /**
137
+ * Default script executor: spawn `node <resolved script>` with the structured
138
+ * args passed in env TOOLFUNNEL_TOOL_ARGS. Resolves with { ok, code, stdout, stderr }.
139
+ * Never rejects on a non-zero exit — the caller (the gated runner / meta-tool)
140
+ * decides how to surface a tool failure. It DOES reject only on spawn failure.
141
+ *
142
+ * Path safety: the script path must resolve INSIDE scriptsRoot (defense-in-depth
143
+ * for the isolation rule — a register entry must not become a path-escape).
144
+ */
145
+ function defaultRunScript(scriptsRoot, invoke, args) {
146
+ return new Promise((resolve, reject) => {
147
+ const resolved = path.resolve(scriptsRoot, path.basename(invoke.path));
148
+ // path.basename strips any directory traversal; confirm anyway.
149
+ const rel = path.relative(scriptsRoot, resolved);
150
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
151
+ reject(new Error(`Registry: script path escapes scripts root ("${invoke.path}")`));
152
+ return;
153
+ }
154
+ if (!fs.existsSync(resolved)) {
155
+ reject(new Error(`Registry: script not found ("${resolved}")`));
156
+ return;
157
+ }
158
+ const child = spawn(process.execPath, [resolved], {
159
+ env: { ...process.env, TOOLFUNNEL_TOOL_ARGS: JSON.stringify(args ?? null) },
160
+ stdio: ['ignore', 'pipe', 'pipe'],
161
+ });
162
+ let stdout = '';
163
+ let stderr = '';
164
+ child.stdout.on('data', (d) => { stdout += d.toString(); });
165
+ child.stderr.on('data', (d) => { stderr += d.toString(); });
166
+ child.on('error', reject);
167
+ child.on('close', (code) => {
168
+ resolve({ ok: code === 0, code, stdout, stderr });
169
+ });
170
+ });
171
+ }
172
+
173
+ class Registry {
174
+ /**
175
+ * @param {object} opts
176
+ * @param {string} opts.filePath path to tools.register.json (source of truth on disk)
177
+ * @param {object} opts.data parsed register data { version, description, tools }
178
+ * @param {string} [opts.scriptsRoot] dir holding script-invoke targets (default: <fileDir>/scripts)
179
+ * @param {Function} [opts.runScript] injectable (scriptsRoot, invoke, args) => Promise<result>
180
+ */
181
+ constructor({ filePath, data, scriptsRoot, runScript } = {}) {
182
+ if (!filePath) throw new Error('Registry: filePath is required');
183
+ this._filePath = filePath;
184
+ this._scriptsRoot = scriptsRoot || path.join(path.dirname(filePath), 'scripts');
185
+ this._runScript = runScript || defaultRunScript;
186
+
187
+ const d = data && typeof data === 'object' ? data : {};
188
+ this._version = typeof d.version === 'number' ? d.version : 1;
189
+ this._description = typeof d.description === 'string' ? d.description : '';
190
+ const tools = Array.isArray(d.tools) ? d.tools : [];
191
+
192
+ // Index by id; reject duplicate ids at load (a duplicate id makes
193
+ // instructions(id)/run(id) ambiguous).
194
+ this._byId = new Map();
195
+ for (const t of tools) {
196
+ validateEntry(t);
197
+ if (this._byId.has(t.id)) {
198
+ throw new Error(`Registry: duplicate tool id "${t.id}" in ${filePath}`);
199
+ }
200
+ this._byId.set(t.id, cloneEntry(t));
201
+ }
202
+ }
203
+
204
+ // ---- read API -----------------------------------------------------------
205
+
206
+ /**
207
+ * Briefs only — the surface toolfunnel_list_tools returns. Optional case-insensitive
208
+ * substring `filter` (matched against id/name/summary) and exact `category`.
209
+ * @returns {Array<{id,name,summary,category}>} in insertion order.
210
+ */
211
+ list({ filter, category } = {}) {
212
+ const needle = typeof filter === 'string' && filter.length ? filter.toLowerCase() : null;
213
+ const out = [];
214
+ for (const t of this._byId.values()) {
215
+ if (category && t.category !== category) continue;
216
+ if (needle) {
217
+ const hay = `${t.id} ${t.name} ${t.summary || ''}`.toLowerCase();
218
+ if (!hay.includes(needle)) continue;
219
+ }
220
+ out.push({
221
+ id: t.id,
222
+ name: t.name,
223
+ summary: t.summary || '',
224
+ category: t.category || '',
225
+ });
226
+ }
227
+ return out;
228
+ }
229
+
230
+ /** Full instructions string for one tool. Throws on unknown id (per §5 contract). */
231
+ instructions(id) {
232
+ const t = this._byId.get(id);
233
+ if (!t) throw new Error(`Registry.instructions: unknown tool id "${id}"`);
234
+ return typeof t.instructions === 'string' ? t.instructions : '';
235
+ }
236
+
237
+ /** Full entry (cloned so callers can't mutate internal state). Throws on unknown id. */
238
+ getEntry(id) {
239
+ const t = this._byId.get(id);
240
+ if (!t) throw new Error(`Registry.getEntry: unknown tool id "${id}"`);
241
+ return cloneEntry(t);
242
+ }
243
+
244
+ /** @returns {boolean} */
245
+ has(id) {
246
+ return this._byId.has(id);
247
+ }
248
+
249
+ // ---- write API (atomic persist) ----------------------------------------
250
+
251
+ /** Add a new entry. Throws if id missing/duplicate or shape invalid. Persists. */
252
+ add(entry) {
253
+ validateEntry(entry);
254
+ if (this._byId.has(entry.id)) {
255
+ throw new Error(`Registry.add: tool id "${entry.id}" already exists`);
256
+ }
257
+ this._byId.set(entry.id, cloneEntry(entry));
258
+ this._persist();
259
+ return this.getEntry(entry.id);
260
+ }
261
+
262
+ /**
263
+ * Patch an existing entry. `id` cannot be changed via patch (it's the key).
264
+ * Merges shallow over the existing entry, re-validates, persists.
265
+ */
266
+ update(id, patch) {
267
+ const existing = this._byId.get(id);
268
+ if (!existing) throw new Error(`Registry.update: unknown tool id "${id}"`);
269
+ if (!patch || typeof patch !== 'object') {
270
+ throw new Error('Registry.update: patch must be an object');
271
+ }
272
+ const merged = { ...existing, ...patch, id };
273
+ validateEntry(merged);
274
+ this._byId.set(id, cloneEntry(merged));
275
+ this._persist();
276
+ return this.getEntry(id);
277
+ }
278
+
279
+ /** Remove an entry. Throws on unknown id. Persists. */
280
+ remove(id) {
281
+ if (!this._byId.has(id)) throw new Error(`Registry.remove: unknown tool id "${id}"`);
282
+ this._byId.delete(id);
283
+ this._persist();
284
+ return true;
285
+ }
286
+
287
+ /**
288
+ * Write a host-local script's text under this scriptsRoot, atomically.
289
+ *
290
+ * Resolves path.basename(invokePath) under scriptsRoot (basename strips any
291
+ * directory traversal) and GUARDS that the resolved path stays inside
292
+ * scriptsRoot — the same path-escape defence as defaultRunScript, so a
293
+ * tf_tool_add(scriptText) can never write outside the isolation root. Writes
294
+ * `text` via a temp file in the same dir + fsync + rename (mirror of
295
+ * atomicWriteJson): a crash leaves either no file or the complete file.
296
+ *
297
+ * @param {string} invokePath e.g. "scripts/foo.js" — only the basename is honoured
298
+ * @param {string} text full file contents to write
299
+ * @returns {string} the resolved absolute path that was written
300
+ */
301
+ writeScript(invokePath, text) {
302
+ if (typeof invokePath !== 'string' || invokePath.length === 0) {
303
+ throw new Error('Registry.writeScript: invokePath (non-empty string) is required');
304
+ }
305
+ if (typeof text !== 'string') {
306
+ throw new Error('Registry.writeScript: text must be a string');
307
+ }
308
+ const scriptsRoot = this._scriptsRoot;
309
+ const resolved = path.resolve(scriptsRoot, path.basename(invokePath));
310
+ // path.basename strips any directory traversal; confirm anyway.
311
+ const rel = path.relative(scriptsRoot, resolved);
312
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
313
+ throw new Error(`Registry.writeScript: script path escapes scripts root ("${invokePath}")`);
314
+ }
315
+ fs.mkdirSync(scriptsRoot, { recursive: true });
316
+ const dir = path.dirname(resolved);
317
+ const base = path.basename(resolved);
318
+ const tmp = path.join(dir, `.${base}.${process.pid}.${Date.now()}.tmp`);
319
+ const fd = fs.openSync(tmp, 'w');
320
+ try {
321
+ fs.writeSync(fd, text, 0, 'utf8');
322
+ fs.fsyncSync(fd);
323
+ } finally {
324
+ fs.closeSync(fd);
325
+ }
326
+ fs.renameSync(tmp, resolved);
327
+ return resolved;
328
+ }
329
+
330
+ // ---- execution description (does NOT gate) ------------------------------
331
+
332
+ /**
333
+ * Describe HOW to run a tool's invoke. Returns { type, run } where `run` is a
334
+ * zero-arg function returning a Promise of the tool result. resolveExecution
335
+ * performs NO gating — toolfunnel_run_tool / gated-run.js fires the PreToolUse hook
336
+ * and only then calls `run()` (the architecture doc §2/§9). Keeping the gate
337
+ * out of here keeps the safety case in one auditable place.
338
+ *
339
+ * - script invoke: run() spawns node on the host-local script (via the injected
340
+ * runScript), passing args in TOOLFUNNEL_TOOL_ARGS.
341
+ * - shell invoke: run() does NOT execute here. It returns a descriptor
342
+ * { type:'shell', command, args } so the gated runner owns the actual shell
343
+ * spawn (the broadest power) behind the gate. This keeps arbitrary shell
344
+ * execution out of the un-gated register module by design.
345
+ *
346
+ * @param {string} id
347
+ * @param {any} args structured args forwarded to the tool
348
+ * @returns {{ type: string, run: () => Promise<any> }}
349
+ */
350
+ resolveExecution(id, args) {
351
+ const entry = this._byId.get(id);
352
+ if (!entry) throw new Error(`Registry.resolveExecution: unknown tool id "${id}"`);
353
+ const mode = resolveMode(entry);
354
+
355
+ // ---- reference mode: nothing runs here. ----------------------------------
356
+ // The connected AI performs the action in its own environment. We hand back the
357
+ // tool's instructions (no `run`) so the run-path can short-circuit BEFORE the gate
358
+ // — a reference tool never spawns and never needs gating (nothing executes).
359
+ if (mode === 'reference') {
360
+ return {
361
+ type: 'reference',
362
+ mode: 'reference',
363
+ instructions: typeof entry.instructions === 'string' ? entry.instructions : '',
364
+ };
365
+ }
366
+
367
+ const invoke = entry.invoke;
368
+
369
+ if (invoke.type === 'script') {
370
+ const scriptsRoot = this._scriptsRoot;
371
+ const runScript = this._runScript;
372
+ return {
373
+ type: 'script',
374
+ mode: 'gateway',
375
+ run: () => runScript(scriptsRoot, invoke, args),
376
+ };
377
+ }
378
+
379
+ if (invoke.type === 'shell') {
380
+ // Deliberately do NOT spawn a shell here. Hand the descriptor to the
381
+ // gated runner, which executes shell only after PreToolUse allows it.
382
+ return {
383
+ type: 'shell',
384
+ mode: 'gateway',
385
+ run: async () => ({
386
+ deferred: true,
387
+ type: 'shell',
388
+ command: invoke.command,
389
+ args: args ?? null,
390
+ }),
391
+ };
392
+ }
393
+
394
+ // validateEntry guards the load path, but guard here too for late mutation.
395
+ throw new Error(`Registry.resolveExecution: unsupported invoke.type for "${id}"`);
396
+ }
397
+
398
+ /**
399
+ * The resolved execution mode for a tool ("gateway" | "reference"). Exposed so callers
400
+ * (UI, tests) can ask without resolving a full executor. Throws on unknown id.
401
+ * @param {string} id
402
+ * @returns {"gateway"|"reference"}
403
+ */
404
+ mode(id) {
405
+ const entry = this._byId.get(id);
406
+ if (!entry) throw new Error(`Registry.mode: unknown tool id "${id}"`);
407
+ return resolveMode(entry);
408
+ }
409
+
410
+ // ---- internals ----------------------------------------------------------
411
+
412
+ /** Serialise current state back to the register file atomically. */
413
+ _persist() {
414
+ const obj = {
415
+ version: this._version,
416
+ description: this._description,
417
+ tools: [...this._byId.values()],
418
+ };
419
+ atomicWriteJson(this._filePath, obj);
420
+ }
421
+
422
+ /** @returns {number} */
423
+ get size() {
424
+ return this._byId.size;
425
+ }
426
+ }
427
+
428
+ /**
429
+ * loadRegistry(path) -> Registry
430
+ *
431
+ * Reads + parses the register JSON at `filePath` and returns a Registry bound to
432
+ * it. Throws a clear error on a missing/malformed file or a bad entry shape so
433
+ * misconfiguration is caught at load time, not at model-call time.
434
+ *
435
+ * @param {string} filePath
436
+ * @param {object} [opts] forwarded to the Registry ctor (scriptsRoot, runScript)
437
+ * @returns {Registry}
438
+ */
439
+ function loadRegistry(filePath, opts = {}) {
440
+ if (typeof filePath !== 'string' || filePath.length === 0) {
441
+ throw new Error('loadRegistry: filePath (non-empty string) is required');
442
+ }
443
+ let raw;
444
+ try {
445
+ raw = fs.readFileSync(filePath, 'utf8');
446
+ } catch (err) {
447
+ throw new Error(`loadRegistry: cannot read register at "${filePath}": ${err.message}`);
448
+ }
449
+ let data;
450
+ try {
451
+ data = JSON.parse(raw);
452
+ } catch (err) {
453
+ throw new Error(`loadRegistry: register at "${filePath}" is not valid JSON: ${err.message}`);
454
+ }
455
+ return new Registry({ filePath, data, ...opts });
456
+ }
457
+
458
+ module.exports = {
459
+ Registry,
460
+ loadRegistry,
461
+ // exported for unit tests / the gated runner
462
+ atomicWriteJson,
463
+ validateEntry,
464
+ defaultRunScript,
465
+ resolveMode,
466
+ VALID_INVOKE_TYPES,
467
+ VALID_MODES,
468
+ };