toolfunnel 0.5.0 → 0.7.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 (72) hide show
  1. package/README.md +235 -167
  2. package/SECURITY.md +84 -0
  3. package/bin/toolfunnel.js +451 -147
  4. package/docs/KNOWN_BUGS.md +80 -33
  5. package/docs/MANUAL.pdf +0 -0
  6. package/docs/design.md +84 -0
  7. package/docs/packaging.md +115 -115
  8. package/hooks/hooks.manifest.json +27 -27
  9. package/hooks/scripts/example-audit.js +21 -21
  10. package/hooks/scripts/example-deny-dangerous.js +39 -31
  11. package/mcp/expose.example.json +30 -30
  12. package/mcp/servers/mock-modern/server.js +147 -0
  13. package/mcp/servers/mock-upstream/server.js +222 -12
  14. package/package.json +67 -65
  15. package/src/auth/config.js +174 -174
  16. package/src/auth/install.js +181 -181
  17. package/src/auth/resource-server.js +353 -344
  18. package/src/core/child-reaper.js +61 -0
  19. package/src/core/config-home.js +134 -134
  20. package/src/core/events.js +105 -105
  21. package/src/core/hook-engine.js +329 -275
  22. package/src/core/hook-loader.js +824 -791
  23. package/src/core/hook-runner.js +566 -346
  24. package/src/core/logger.js +297 -171
  25. package/src/core/matcher.js +100 -51
  26. package/src/core/metrics.js +75 -75
  27. package/src/core/rename-migration.js +163 -0
  28. package/src/core/requires.js +138 -138
  29. package/src/core/server-config.js +98 -77
  30. package/src/extend/add-hook.md +152 -142
  31. package/src/extend/add-mcp.md +119 -121
  32. package/src/extend/configure.md +88 -0
  33. package/src/extend/create-tool.md +168 -152
  34. package/src/extend/howto.js +102 -98
  35. package/src/extend/package.md +190 -187
  36. package/src/extend/wrap.md +99 -0
  37. package/src/mcp/aggregator.js +1674 -807
  38. package/src/mcp/expose-store.js +577 -430
  39. package/src/mcp/gated-run.js +210 -194
  40. package/src/mcp/http-transport.js +1563 -1012
  41. package/src/mcp/mcp-client.js +1070 -530
  42. package/src/mcp/modern.js +406 -0
  43. package/src/mcp/protocol.js +468 -466
  44. package/src/mcp/server.js +3112 -1405
  45. package/src/tools/registry.js +661 -513
  46. package/src/tools/tool-state.js +170 -104
  47. package/src/ui/public/app.js +1187 -1055
  48. package/src/ui/public/index.html +286 -249
  49. package/src/ui/public/styles.css +535 -535
  50. package/src/ui/server.js +1616 -1190
  51. package/tools/scripts/base64.js +139 -139
  52. package/tools/scripts/danger.js +61 -61
  53. package/tools/scripts/echo.js +39 -39
  54. package/tools/scripts/hash.js +101 -101
  55. package/tools/scripts/json.js +109 -109
  56. package/tools/scripts/text-stats.js +131 -131
  57. package/tools/scripts/tf-env.js +43 -43
  58. package/tools/scripts/tf-hook-add.js +151 -151
  59. package/tools/scripts/tf-hook-set.js +120 -112
  60. package/tools/scripts/tf-list.js +192 -192
  61. package/tools/scripts/tf-log.js +86 -86
  62. package/tools/scripts/tf-mcp-add.js +117 -117
  63. package/tools/scripts/tf-mcp-set.js +121 -102
  64. package/tools/scripts/tf-pack.js +237 -237
  65. package/tools/scripts/tf-tool-add.js +134 -129
  66. package/tools/scripts/tf-tool-set.js +142 -142
  67. package/tools/scripts/tf-wrap.js +103 -0
  68. package/tools/scripts/uuid.js +102 -102
  69. package/tools/tools.register.json +14 -3
  70. package/src/core/tool-registry.js +0 -180
  71. package/src/packages/packager.js +0 -715
  72. package/src/tools/drift.js +0 -138
@@ -1,75 +1,75 @@
1
- 'use strict';
2
-
3
- /**
4
- * metrics.js — in-memory, per-process tool-call counters (lightweight observability).
5
- *
6
- * A privileged gateway should be able to answer "what has run through me, and how often did it
7
- * fail?" without standing up a metrics stack. This is the zero-dependency MVP of that: process-
8
- * lifetime counters incremented at the single tools/call chokepoint and surfaced via the HTTP
9
- * `/health` endpoint and the admin UI. It is NOT persistent (counts reset when the host restarts)
10
- * and NOT a substitute for the JSONL audit log (src/core/logger.js, which records individual events
11
- * to disk when enabled) — the two are complementary: the log is the durable per-event record, this
12
- * is the cheap live aggregate.
13
- *
14
- * Design rules (same discipline as logger.js):
15
- * - Zero dependencies. Plain objects + integers.
16
- * - record() / snapshot() NEVER throw — instrumentation must never break a tool call.
17
- * - In-memory only; no files, no timers, no network.
18
- *
19
- * CommonJS only.
20
- */
21
-
22
- const state = {
23
- startedAt: new Date().toISOString(),
24
- calls: 0,
25
- errors: 0,
26
- byTool: Object.create(null), // name -> { calls, errors }
27
- };
28
-
29
- /**
30
- * record — count one tools/call outcome. `ok:false` (an isError result — a tool failure OR a
31
- * PreToolUse denial, which both surface as an error envelope) increments the error counters.
32
- * @param {{ tool?:string, ok?:boolean }} ev
33
- */
34
- function record(ev) {
35
- try {
36
- const tool = ev && typeof ev.tool === 'string' && ev.tool.length > 0 ? ev.tool : 'unknown';
37
- const ok = !(ev && ev.ok === false);
38
- state.calls += 1;
39
- if (!ok) state.errors += 1;
40
- let t = state.byTool[tool];
41
- if (!t) {
42
- t = { calls: 0, errors: 0 };
43
- state.byTool[tool] = t;
44
- }
45
- t.calls += 1;
46
- if (!ok) t.errors += 1;
47
- } catch (_e) {
48
- /* instrumentation must never break the caller */
49
- }
50
- }
51
-
52
- /**
53
- * snapshot — a fresh, mutation-safe copy of the counters for /health + the UI. NEVER throws.
54
- * @returns {{ startedAt:string, calls:number, errors:number, byTool:Object<string,{calls:number,errors:number}> }}
55
- */
56
- function snapshot() {
57
- try {
58
- const byTool = {};
59
- for (const k of Object.keys(state.byTool)) {
60
- byTool[k] = { calls: state.byTool[k].calls, errors: state.byTool[k].errors };
61
- }
62
- return { startedAt: state.startedAt, calls: state.calls, errors: state.errors, byTool };
63
- } catch (_e) {
64
- return { startedAt: state.startedAt, calls: 0, errors: 0, byTool: {} };
65
- }
66
- }
67
-
68
- /** reset — zero the counters (tests). The startedAt stamp is left as the process start. */
69
- function reset() {
70
- state.calls = 0;
71
- state.errors = 0;
72
- state.byTool = Object.create(null);
73
- }
74
-
75
- module.exports = { record, snapshot, reset };
1
+ 'use strict';
2
+
3
+ /**
4
+ * metrics.js - in-memory, per-process tool-call counters (lightweight observability).
5
+ *
6
+ * A privileged gateway should be able to answer "what has run through me, and how often did it
7
+ * fail?" without standing up a metrics stack. This is the zero-dependency MVP of that: process-
8
+ * lifetime counters incremented at the single tools/call chokepoint and surfaced via the HTTP
9
+ * `/health` endpoint and the admin UI. It is NOT persistent (counts reset when the host restarts)
10
+ * and NOT a substitute for the JSONL audit log (src/core/logger.js, which records individual events
11
+ * to disk when enabled) - the two are complementary: the log is the durable per-event record, this
12
+ * is the cheap live aggregate.
13
+ *
14
+ * Design rules (same discipline as logger.js):
15
+ * - Zero dependencies. Plain objects + integers.
16
+ * - record() / snapshot() NEVER throw - instrumentation must never break a tool call.
17
+ * - In-memory only; no files, no timers, no network.
18
+ *
19
+ * CommonJS only.
20
+ */
21
+
22
+ const state = {
23
+ startedAt: new Date().toISOString(),
24
+ calls: 0,
25
+ errors: 0,
26
+ byTool: Object.create(null), // name -> { calls, errors }
27
+ };
28
+
29
+ /**
30
+ * record - count one tools/call outcome. `ok:false` (an isError result - a tool failure OR a
31
+ * PreToolUse denial, which both surface as an error envelope) increments the error counters.
32
+ * @param {{ tool?:string, ok?:boolean }} ev
33
+ */
34
+ function record(ev) {
35
+ try {
36
+ const tool = ev && typeof ev.tool === 'string' && ev.tool.length > 0 ? ev.tool : 'unknown';
37
+ const ok = !(ev && ev.ok === false);
38
+ state.calls += 1;
39
+ if (!ok) state.errors += 1;
40
+ let t = state.byTool[tool];
41
+ if (!t) {
42
+ t = { calls: 0, errors: 0 };
43
+ state.byTool[tool] = t;
44
+ }
45
+ t.calls += 1;
46
+ if (!ok) t.errors += 1;
47
+ } catch (_e) {
48
+ /* instrumentation must never break the caller */
49
+ }
50
+ }
51
+
52
+ /**
53
+ * snapshot - a fresh, mutation-safe copy of the counters for /health + the UI. NEVER throws.
54
+ * @returns {{ startedAt:string, calls:number, errors:number, byTool:Object<string,{calls:number,errors:number}> }}
55
+ */
56
+ function snapshot() {
57
+ try {
58
+ const byTool = {};
59
+ for (const k of Object.keys(state.byTool)) {
60
+ byTool[k] = { calls: state.byTool[k].calls, errors: state.byTool[k].errors };
61
+ }
62
+ return { startedAt: state.startedAt, calls: state.calls, errors: state.errors, byTool };
63
+ } catch (_e) {
64
+ return { startedAt: state.startedAt, calls: 0, errors: 0, byTool: {} };
65
+ }
66
+ }
67
+
68
+ /** reset - zero the counters (tests). The startedAt stamp is left as the process start. */
69
+ function reset() {
70
+ state.calls = 0;
71
+ state.errors = 0;
72
+ state.byTool = Object.create(null);
73
+ }
74
+
75
+ module.exports = { record, snapshot, reset };
@@ -0,0 +1,163 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * rename-migration.js - keep gates and per-tool state attached across a SURFACED-NAME change
5
+ * (0.7.0; the rename-safety.test.js invariants are the acceptance).
6
+ *
7
+ * A tool's SURFACED name is the string the PreToolUse gate matches and the tools.state.json
8
+ * key is written against. 0.6.0 let that name change (an expose `as` edit, an enabled flip, a
9
+ * local display-name edit) while the gate matcher and the state key silently kept the OLD
10
+ * string - a deny gate stopped firing and a disabled tool resurrected. The policy here,
11
+ * MATCHER-MIGRATE-ON-RENAME:
12
+ *
13
+ * - A WILDCARD matcher ('', '*', unset) fires for every tool name - it already covers the
14
+ * new name; untouched.
15
+ * - An EXACT-LITERAL matcher - the `escapeRegex(oldName)` shape every ToolFunnel surface
16
+ * (tf_* tools, the UI) writes - is REWRITTEN to `escapeRegex(newName)`.
17
+ * - Any OTHER matcher that would fire for the old name (same anchored-regex semantics as
18
+ * core/matcher.js, the gate's own matching) is one WE DID NOT AUTHOR and cannot safely
19
+ * rewrite: the rename is REFUSED with the hook named. Update the hook first, then rename.
20
+ * - tools.state.json: the old key's entry (enabled/hidden/hot) MOVES to the new key. A
21
+ * collision (both keys present with different content) refuses - never clobber curation.
22
+ *
23
+ * Validation runs COMPLETELY before the first write, and the caller invokes this BEFORE
24
+ * persisting its own rename - a refusal leaves every file untouched.
25
+ *
26
+ * CommonJS only. Node built-ins + core/matcher only (core/ stays headless).
27
+ */
28
+
29
+ const fs = require('node:fs');
30
+ const path = require('node:path');
31
+ const { matches, matcherError } = require('./matcher');
32
+
33
+ /** Regex-escape a literal so a matcher built from it FULL-matches the string verbatim.
34
+ * (Same shape as ui/server.js escapeRegex - the shape ToolFunnel-authored matchers use.) */
35
+ function escapeRegex(str) {
36
+ return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
37
+ }
38
+
39
+ /** Copied exactly from registry.js::atomicWriteJson - crash leaves old file or new, never half. */
40
+ function atomicWriteJson(targetPath, obj) {
41
+ const dir = path.dirname(targetPath);
42
+ const base = path.basename(targetPath);
43
+ const tmp = path.join(dir, `.${base}.${process.pid}.${Date.now()}.tmp`);
44
+ const data = JSON.stringify(obj, null, 2) + '\n';
45
+ const fd = fs.openSync(tmp, 'w');
46
+ try {
47
+ fs.writeSync(fd, data, 0, 'utf8');
48
+ fs.fsyncSync(fd);
49
+ } finally {
50
+ fs.closeSync(fd);
51
+ }
52
+ fs.renameSync(tmp, targetPath);
53
+ }
54
+
55
+ function readJsonIfPresent(p) {
56
+ try {
57
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
58
+ } catch (_e) {
59
+ return null; // absent or unreadable -> nothing to migrate there
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Migrate every gate matcher and state key from oldName to newName, or throw a refusal.
65
+ * @param {object} o
66
+ * @param {string} o.hooksPath hooks.manifest.json path (may be absent on disk)
67
+ * @param {string} o.statePath tools.state.json path (may be absent on disk)
68
+ * @param {string} o.oldName the surfaced name being retired
69
+ * @param {string} o.newName the surfaced name taking over
70
+ */
71
+ function migrateSurfacedName(o) {
72
+ const { hooksPath, statePath, oldName, newName } = o || {};
73
+ if (typeof oldName !== 'string' || typeof newName !== 'string' || oldName === newName) return;
74
+
75
+ // ── Phase A: VALIDATE everything, write nothing ─────────────────────────────────────────────
76
+ const manifest = hooksPath ? readJsonIfPresent(hooksPath) : null;
77
+ const hooks = manifest && Array.isArray(manifest.hooks) ? manifest.hooks : [];
78
+ const literal = escapeRegex(oldName);
79
+ const rewrites = [];
80
+ for (const h of hooks) {
81
+ if (!h || typeof h !== 'object') continue;
82
+ const m = h.matcher;
83
+ if (m === undefined || m === null || m === '' || m === '*') continue; // wildcard covers both names
84
+ if (m === literal || m === oldName) {
85
+ // The exact-literal shape ToolFunnel authors (escaped, or the bare name when it contains
86
+ // no regex specials - the two are identical then, but hand-written manifests may carry
87
+ // the bare form for a name that DOES contain specials, e.g. "my.tool").
88
+ rewrites.push(h);
89
+ continue;
90
+ }
91
+ // A matcher that does not COMPILE cannot be evaluated against oldName at all - matches()
92
+ // would answer false, silently carrying the rename past a hook this code cannot read.
93
+ // Same policy as a matching non-literal: refuse, name the hook, touch nothing.
94
+ // (addEntry refuses these at authoring, so only a hand-edited manifest reaches here.)
95
+ const compileProblem = matcherError(m);
96
+ if (compileProblem) {
97
+ throw new Error(
98
+ `rename refused: hook "${h.id || '(unnamed)'}" matcher ${JSON.stringify(m)} does not ` +
99
+ `compile (${compileProblem}) - it cannot be checked against "${oldName}", so the rename ` +
100
+ 'cannot proceed safely. Fix the hook first, then rename'
101
+ );
102
+ }
103
+ if (matches(m, oldName)) {
104
+ throw new Error(
105
+ `rename refused: hook "${h.id || '(unnamed)'}" matcher "${m}" matches "${oldName}" but is ` +
106
+ 'not an exact-literal matcher ToolFunnel can rewrite - update the hook to cover ' +
107
+ `"${newName}" first, then rename`
108
+ );
109
+ }
110
+ }
111
+
112
+ const state = statePath ? readJsonIfPresent(statePath) : null;
113
+ const hasOldState = !!(state && Object.prototype.hasOwnProperty.call(state, oldName));
114
+ if (hasOldState && Object.prototype.hasOwnProperty.call(state, newName) &&
115
+ JSON.stringify(state[oldName]) !== JSON.stringify(state[newName])) {
116
+ throw new Error(
117
+ `rename refused: tools.state.json already has an entry for "${newName}" that differs from ` +
118
+ `"${oldName}"'s - resolve the curation collision first, then rename`
119
+ );
120
+ }
121
+
122
+ // ── Phase B: WRITE (all validation passed) ──────────────────────────────────────────────────
123
+ if (rewrites.length) {
124
+ for (const h of rewrites) h.matcher = escapeRegex(newName);
125
+ atomicWriteJson(hooksPath, manifest);
126
+ }
127
+ if (hasOldState) {
128
+ state[newName] = state[oldName];
129
+ delete state[oldName];
130
+ atomicWriteJson(statePath, state);
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Convenience for the two stores: derive the sibling config paths from the CONFIG-HOME layout
136
+ * (<root>/mcp/expose.json, <root>/tools/tools.register.json, <root>/tools/tools.state.json,
137
+ * <root>/hooks/hooks.manifest.json) and migrate. `storeFilePath` is the calling store's own
138
+ * file; its grandparent is the home root.
139
+ *
140
+ * `opts.migrateState` (default TRUE) selects whether the tools.state.json key moves with the
141
+ * name. The gate matcher ALWAYS moves - it genuinely fires on the surfaced name for both stores.
142
+ * The state key does NOT, because the overlay has TWO key namespaces:
143
+ * - UPSTREAM tools are keyed by their SURFACED name (server.js:638,733,924; ui:1273) - an
144
+ * expose `as` edit changes that key, so ExposeStore migrates state (the default).
145
+ * - LOCAL register tools are keyed by their register ID (server.js:290,337,814-815,952;
146
+ * ui:326-328; tf-tool-set.js) - an id is immutable and a display-name edit does not touch
147
+ * it, so Registry passes migrateState:false. Moving the key there would ORPHAN the entry:
148
+ * the readers still look up the id, find nothing, and fall back to the defaults - which are
149
+ * enabled - so a tool deliberately switched off would come back on (rename-safety S5).
150
+ * Bites whenever name === id, i.e. every tf_* management tool.
151
+ */
152
+ function migrateForStoreFile(storeFilePath, oldName, newName, opts) {
153
+ const root = path.dirname(path.dirname(path.resolve(storeFilePath)));
154
+ const migrateState = !(opts && opts.migrateState === false);
155
+ migrateSurfacedName({
156
+ hooksPath: path.join(root, 'hooks', 'hooks.manifest.json'),
157
+ statePath: migrateState ? path.join(root, 'tools', 'tools.state.json') : null,
158
+ oldName,
159
+ newName,
160
+ });
161
+ }
162
+
163
+ module.exports = { migrateSurfacedName, migrateForStoreFile, escapeRegex };
@@ -1,138 +1,138 @@
1
- 'use strict';
2
-
3
- /**
4
- * requires.js — runtime-requirement preflight (the packaging story's honesty layer).
5
- *
6
- * A shipped pack's tools may need runtimes the gateway itself does not (python for a PDF tool,
7
- * git for a repo tool). The pack DECLARES them in its toolfunnel.json — the identity file that
8
- * already travels with every config home — and the gateway probes them ONCE at startup:
9
- *
10
- * "requires": [
11
- * { "command": "python", "min": "3.10", "why": "the pdf-extract tools" },
12
- * { "command": "git" }
13
- * ]
14
- *
15
- * Probes are ADVISORY, never fatal: a missing runtime breaks only the tools that need it, so the
16
- * gateway starts and the operator gets a clear stderr line naming what is missing, the version
17
- * found vs needed, and the declared "why". Fields per entry (only `command` is required):
18
- * command the executable to probe (e.g. "python", "node", "git")
19
- * versionArg the flag that prints a version (default "--version")
20
- * min minimum version, dotted numerics (e.g. "3.10" / "18" / "3.10.2")
21
- * why shown in the warning so the operator knows which tools care
22
- *
23
- * Zero new dependencies: child_process probes + a hand-rolled dotted-numeric compare. NO semver
24
- * library — by the packaging hard constraint, and dotted numerics cover real runtime versions.
25
- */
26
-
27
- const fs = require('node:fs');
28
- const path = require('node:path');
29
- const { spawnSync } = require('node:child_process');
30
-
31
- /** Parse the FIRST dotted-numeric version out of a probe's output ("Python 3.12.1" → [3,12,1]).
32
- * Returns null when no version-shaped token is present. */
33
- function parseVersion(text) {
34
- const m = /(\d+)\.(\d+)(?:\.(\d+))?/.exec(String(text || ''));
35
- if (!m) {
36
- const single = /\b(\d+)\b/.exec(String(text || ''));
37
- return single ? [Number(single[1]), 0, 0] : null;
38
- }
39
- return [Number(m[1]), Number(m[2]), Number(m[3] || 0)];
40
- }
41
-
42
- /** Hand-rolled dotted-numeric compare: -1 | 0 | 1 for a<b | a==b | a>b. */
43
- function compareVersions(a, b) {
44
- for (let i = 0; i < 3; i += 1) {
45
- const x = a[i] || 0;
46
- const y = b[i] || 0;
47
- if (x < y) return -1;
48
- if (x > y) return 1;
49
- }
50
- return 0;
51
- }
52
-
53
- /**
54
- * Probe one command for its version string. On Windows a PATHEXT shim (a .cmd like npx) cannot be
55
- * spawned shell:false (ENOENT/EINVAL — the CVE-2024-27980 mitigation), so a failed direct probe is
56
- * retried through cmd.exe, mirroring mcp-client's winLaunch.
57
- * @param {string} command
58
- * @param {string} versionArg
59
- * @returns {{found: boolean, version: (number[]|null), raw: string}}
60
- */
61
- function probe(command, versionArg) {
62
- const runOnce = (cmd, args) => spawnSync(cmd, args, { encoding: 'utf8', windowsHide: true, timeout: 5000 });
63
- let res = runOnce(command, [versionArg]);
64
- if (res.error && process.platform === 'win32') {
65
- const comspec = process.env.ComSpec || 'cmd.exe';
66
- res = runOnce(comspec, ['/c', command, versionArg]);
67
- }
68
- if (res.error || res.status == null) return { found: false, version: null, raw: '' };
69
- const raw = `${res.stdout || ''} ${res.stderr || ''}`.trim();
70
- const version = parseVersion(raw);
71
- // "Spawned" is not "exists": the cmd.exe fallback launches fine for a NONEXISTENT command and
72
- // exits non-zero ("is not recognized…"). Existence = a clean exit OR a version-shaped answer.
73
- const found = res.status === 0 || version !== null;
74
- return { found, version: found ? version : null, raw };
75
- }
76
-
77
- /**
78
- * Check the `requires` declared in `<home>/toolfunnel.json`. Returns the problem list — the
79
- * caller decides how to surface it (the gateway logs each to stderr and starts anyway). NEVER
80
- * throws; a malformed requires entry is itself reported as a problem rather than crashing.
81
- * @param {string} home the resolved config home
82
- * @returns {Array<{command:string, problem:string}>}
83
- */
84
- function checkRequires(home) {
85
- let cfg;
86
- try {
87
- cfg = JSON.parse(fs.readFileSync(path.join(home, 'toolfunnel.json'), 'utf8'));
88
- } catch (_e) {
89
- return []; // absent/bad identity file → nothing declared
90
- }
91
- const list = cfg && Array.isArray(cfg.requires) ? cfg.requires : [];
92
- const problems = [];
93
- for (const entry of list) {
94
- if (!entry || typeof entry !== 'object' || typeof entry.command !== 'string' || entry.command.trim() === '') {
95
- problems.push({ command: String(entry && entry.command), problem: 'malformed requires entry (needs a "command" string)' });
96
- continue;
97
- }
98
- const command = entry.command.trim();
99
- // A requires command is a bare PROGRAM NAME, never a shell string. This is load-bearing on
100
- // Windows: the cmd.exe shim fallback below hands the token to cmd, where an unquoted
101
- // metacharacter ("x&evil") would CHAIN commands — a probe must never be able to run anything
102
- // beyond `<program> <versionArg>`. Reject anything shell-shaped up front (also catches paths:
103
- // declare tools by PATH name, which is the only portable claim a pack can make anyway).
104
- if (!/^[A-Za-z0-9][A-Za-z0-9._+-]*$/.test(command)) {
105
- problems.push({ command, problem: `requires.command "${command}" is not a bare program name (no paths, spaces, or shell characters)` });
106
- continue;
107
- }
108
- const why = typeof entry.why === 'string' && entry.why ? ` (needed for: ${entry.why})` : '';
109
- const versionArg = typeof entry.versionArg === 'string' && /^--?[A-Za-z][A-Za-z-]*$/.test(entry.versionArg)
110
- ? entry.versionArg
111
- : '--version'; // same rule: a version FLAG, never a shell fragment
112
- const p = probe(command, versionArg);
113
- if (!p.found) {
114
- problems.push({ command, problem: `"${command}" was not found on PATH${why}` });
115
- continue;
116
- }
117
- if (typeof entry.min === 'string' && entry.min.trim() !== '') {
118
- const min = parseVersion(entry.min);
119
- if (!min) {
120
- problems.push({ command, problem: `requires.min "${entry.min}" is not a dotted numeric version` });
121
- continue;
122
- }
123
- if (!p.version) {
124
- problems.push({ command, problem: `"${command}" answered but printed no recognisable version (wanted >= ${entry.min})${why}` });
125
- continue;
126
- }
127
- if (compareVersions(p.version, min) < 0) {
128
- problems.push({
129
- command,
130
- problem: `"${command}" is ${p.version.join('.')} but this setup declares >= ${entry.min}${why}`,
131
- });
132
- }
133
- }
134
- }
135
- return problems;
136
- }
137
-
138
- module.exports = { checkRequires, parseVersion, compareVersions, probe };
1
+ 'use strict';
2
+
3
+ /**
4
+ * requires.js - runtime-requirement preflight (the packaging story's honesty layer).
5
+ *
6
+ * A shipped pack's tools may need runtimes the gateway itself does not (python for a PDF tool,
7
+ * git for a repo tool). The pack DECLARES them in its toolfunnel.json - the identity file that
8
+ * already travels with every config home - and the gateway probes them ONCE at startup:
9
+ *
10
+ * "requires": [
11
+ * { "command": "python", "min": "3.10", "why": "the pdf-extract tools" },
12
+ * { "command": "git" }
13
+ * ]
14
+ *
15
+ * Probes are ADVISORY, never fatal: a missing runtime breaks only the tools that need it, so the
16
+ * gateway starts and the operator gets a clear stderr line naming what is missing, the version
17
+ * found vs needed, and the declared "why". Fields per entry (only `command` is required):
18
+ * command the executable to probe (e.g. "python", "node", "git")
19
+ * versionArg the flag that prints a version (default "--version")
20
+ * min minimum version, dotted numerics (e.g. "3.10" / "18" / "3.10.2")
21
+ * why shown in the warning so the operator knows which tools care
22
+ *
23
+ * Zero new dependencies: child_process probes + a hand-rolled dotted-numeric compare. NO semver
24
+ * library - by the packaging hard constraint, and dotted numerics cover real runtime versions.
25
+ */
26
+
27
+ const fs = require('node:fs');
28
+ const path = require('node:path');
29
+ const { spawnSync } = require('node:child_process');
30
+
31
+ /** Parse the FIRST dotted-numeric version out of a probe's output ("Python 3.12.1" -> [3,12,1]).
32
+ * Returns null when no version-shaped token is present. */
33
+ function parseVersion(text) {
34
+ const m = /(\d+)\.(\d+)(?:\.(\d+))?/.exec(String(text || ''));
35
+ if (!m) {
36
+ const single = /\b(\d+)\b/.exec(String(text || ''));
37
+ return single ? [Number(single[1]), 0, 0] : null;
38
+ }
39
+ return [Number(m[1]), Number(m[2]), Number(m[3] || 0)];
40
+ }
41
+
42
+ /** Hand-rolled dotted-numeric compare: -1 | 0 | 1 for a<b | a==b | a>b. */
43
+ function compareVersions(a, b) {
44
+ for (let i = 0; i < 3; i += 1) {
45
+ const x = a[i] || 0;
46
+ const y = b[i] || 0;
47
+ if (x < y) return -1;
48
+ if (x > y) return 1;
49
+ }
50
+ return 0;
51
+ }
52
+
53
+ /**
54
+ * Probe one command for its version string. On Windows a PATHEXT shim (a .cmd like npx) cannot be
55
+ * spawned shell:false (ENOENT/EINVAL - the CVE-2024-27980 mitigation), so a failed direct probe is
56
+ * retried through cmd.exe, mirroring mcp-client's winLaunch.
57
+ * @param {string} command
58
+ * @param {string} versionArg
59
+ * @returns {{found: boolean, version: (number[]|null), raw: string}}
60
+ */
61
+ function probe(command, versionArg) {
62
+ const runOnce = (cmd, args) => spawnSync(cmd, args, { encoding: 'utf8', windowsHide: true, timeout: 5000 });
63
+ let res = runOnce(command, [versionArg]);
64
+ if (res.error && process.platform === 'win32') {
65
+ const comspec = process.env.ComSpec || 'cmd.exe';
66
+ res = runOnce(comspec, ['/c', command, versionArg]);
67
+ }
68
+ if (res.error || res.status == null) return { found: false, version: null, raw: '' };
69
+ const raw = `${res.stdout || ''} ${res.stderr || ''}`.trim();
70
+ const version = parseVersion(raw);
71
+ // "Spawned" is not "exists": the cmd.exe fallback launches fine for a NONEXISTENT command and
72
+ // exits non-zero ("is not recognized..."). Existence = a clean exit OR a version-shaped answer.
73
+ const found = res.status === 0 || version !== null;
74
+ return { found, version: found ? version : null, raw };
75
+ }
76
+
77
+ /**
78
+ * Check the `requires` declared in `<home>/toolfunnel.json`. Returns the problem list - the
79
+ * caller decides how to surface it (the gateway logs each to stderr and starts anyway). NEVER
80
+ * throws; a malformed requires entry is itself reported as a problem rather than crashing.
81
+ * @param {string} home the resolved config home
82
+ * @returns {Array<{command:string, problem:string}>}
83
+ */
84
+ function checkRequires(home) {
85
+ let cfg;
86
+ try {
87
+ cfg = JSON.parse(fs.readFileSync(path.join(home, 'toolfunnel.json'), 'utf8'));
88
+ } catch (_e) {
89
+ return []; // absent/bad identity file -> nothing declared
90
+ }
91
+ const list = cfg && Array.isArray(cfg.requires) ? cfg.requires : [];
92
+ const problems = [];
93
+ for (const entry of list) {
94
+ if (!entry || typeof entry !== 'object' || typeof entry.command !== 'string' || entry.command.trim() === '') {
95
+ problems.push({ command: String(entry && entry.command), problem: 'malformed requires entry (needs a "command" string)' });
96
+ continue;
97
+ }
98
+ const command = entry.command.trim();
99
+ // A requires command is a bare PROGRAM NAME, never a shell string. This is load-bearing on
100
+ // Windows: the cmd.exe shim fallback below hands the token to cmd, where an unquoted
101
+ // metacharacter ("x&denied") would CHAIN commands - a probe must never be able to run anything
102
+ // beyond `<program> <versionArg>`. Reject anything shell-shaped up front (also catches paths:
103
+ // declare tools by PATH name, which is the only portable claim a pack can make anyway).
104
+ if (!/^[A-Za-z0-9][A-Za-z0-9._+-]*$/.test(command)) {
105
+ problems.push({ command, problem: `requires.command "${command}" is not a bare program name (no paths, spaces, or shell characters)` });
106
+ continue;
107
+ }
108
+ const why = typeof entry.why === 'string' && entry.why ? ` (needed for: ${entry.why})` : '';
109
+ const versionArg = typeof entry.versionArg === 'string' && /^--?[A-Za-z][A-Za-z-]*$/.test(entry.versionArg)
110
+ ? entry.versionArg
111
+ : '--version'; // same rule: a version FLAG, never a shell fragment
112
+ const p = probe(command, versionArg);
113
+ if (!p.found) {
114
+ problems.push({ command, problem: `"${command}" was not found on PATH${why}` });
115
+ continue;
116
+ }
117
+ if (typeof entry.min === 'string' && entry.min.trim() !== '') {
118
+ const min = parseVersion(entry.min);
119
+ if (!min) {
120
+ problems.push({ command, problem: `requires.min "${entry.min}" is not a dotted numeric version` });
121
+ continue;
122
+ }
123
+ if (!p.version) {
124
+ problems.push({ command, problem: `"${command}" answered but printed no recognisable version (wanted >= ${entry.min})${why}` });
125
+ continue;
126
+ }
127
+ if (compareVersions(p.version, min) < 0) {
128
+ problems.push({
129
+ command,
130
+ problem: `"${command}" is ${p.version.join('.')} but this setup declares >= ${entry.min}${why}`,
131
+ });
132
+ }
133
+ }
134
+ }
135
+ return problems;
136
+ }
137
+
138
+ module.exports = { checkRequires, parseVersion, compareVersions, probe };