toolfunnel 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +228 -231
  2. package/SECURITY.md +84 -0
  3. package/bin/toolfunnel.js +451 -372
  4. package/docs/KNOWN_BUGS.md +80 -99
  5. package/docs/MANUAL.pdf +0 -0
  6. package/docs/design.md +84 -70
  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 -147
  13. package/mcp/servers/mock-upstream/server.js +380 -350
  14. package/package.json +3 -1
  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 -98
  30. package/src/extend/add-hook.md +152 -152
  31. package/src/extend/add-mcp.md +119 -122
  32. package/src/extend/configure.md +88 -87
  33. package/src/extend/create-tool.md +168 -168
  34. package/src/extend/howto.js +102 -102
  35. package/src/extend/package.md +190 -187
  36. package/src/extend/wrap.md +99 -99
  37. package/src/mcp/aggregator.js +1719 -1246
  38. package/src/mcp/expose-store.js +577 -460
  39. package/src/mcp/gated-run.js +210 -201
  40. package/src/mcp/http-transport.js +1563 -1311
  41. package/src/mcp/mcp-client.js +1070 -907
  42. package/src/mcp/modern.js +406 -372
  43. package/src/mcp/protocol.js +468 -468
  44. package/src/mcp/server.js +3112 -2688
  45. package/src/tools/registry.js +661 -513
  46. package/src/tools/tool-state.js +170 -170
  47. package/src/ui/public/app.js +1187 -1174
  48. package/src/ui/public/index.html +286 -286
  49. package/src/ui/public/styles.css +535 -535
  50. package/src/ui/server.js +1616 -1348
  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 -121
  64. package/tools/scripts/tf-pack.js +237 -237
  65. package/tools/scripts/tf-tool-add.js +134 -134
  66. package/tools/scripts/tf-tool-set.js +142 -142
  67. package/tools/scripts/tf-wrap.js +103 -103
  68. package/tools/scripts/uuid.js +102 -102
  69. package/tools/tools.register.json +227 -227
  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,181 +1,181 @@
1
- 'use strict';
2
-
3
- /**
4
- * auth/install.js - install the OPTIONAL OAuth dependency (`jose`) on demand.
5
- *
6
- * The whole point of ToolFunnel's zero-dependency stance is that a default install pulls NOTHING.
7
- * OAuth is opt-in, so its one library is installed only when a user decides they need it - via the
8
- * CLI (`toolfunnel install-oauth`) or the admin-UI button, both of which call installJose() here.
9
- *
10
- * It shells out to the user's own `npm` to install the PINNED jose version into the gateway
11
- * package's own node_modules (resolved from this file's location, NOT process.cwd(), so it lands
12
- * beside the engine wherever ToolFunnel is installed). It uses `--no-save` - the goal is simply to
13
- * make `require('jose')` resolvable; there is no manifest to update, and this keeps ToolFunnel's
14
- * own `dependencies: {}` untouched in every install shape.
15
- *
16
- * WINDOWS NOTE: do NOT spawn `npm.cmd`. Since the CVE-2024-27980 mitigation, spawning a `.cmd`
17
- * without a shell throws `EINVAL`; and spawning it WITH a shell is unreliable on portable Node
18
- * installs - the `npm.cmd` shim misresolves its own path under cmd.exe ("Cannot find module
19
- * ...\node_modules\npm\bin\npm-cli.js"). The robust, cross-platform approach is to run npm's JS entry
20
- * (`npm-cli.js`, located beside the running node binary) with `process.execPath` directly -
21
- * `shell:false`, no shim, no PATH lookup, no cwd sensitivity. We fall back to a shell-spawned `npm`
22
- * only if `npm-cli.js` can't be located; the spec is allowlist-validated so that fallback can't inject.
23
- *
24
- * NEVER throws - it resolves to a structured { ok, code, stdout, stderr, message } so the CLI/UI can
25
- * report the outcome cleanly even when npm is missing or the network is down.
26
- *
27
- * CommonJS only; Node built-ins (child_process, path, fs) + the pin from resource-server.js.
28
- */
29
-
30
- const path = require('node:path');
31
- const fs = require('node:fs');
32
- const { spawn } = require('node:child_process');
33
-
34
- const { JOSE_PIN, isJoseInstalled, _resetJoseCache } = require('./resource-server');
35
-
36
- /** The gateway package root: <root>/src/auth/install.js -> two dirs up. npm runs here so jose lands
37
- * in the engine's own node_modules regardless of the caller's cwd. */
38
- const PKG_ROOT = path.resolve(__dirname, '..', '..');
39
-
40
- /** npm is `npm.cmd` on Windows, `npm` elsewhere. We spawn it as a child rather than require()-ing. */
41
- const IS_WIN = process.platform === 'win32';
42
- const NPM_BIN = IS_WIN ? 'npm.cmd' : 'npm';
43
-
44
- /** A strict allowlist for an npm version spec. Covers what a single-package install actually uses -
45
- * `name@<exact|caret|tilde|x-range|dist-tag>` and scoped names - and NOTHING a shell could act on, so
46
- * the fallback shell path can never inject. Comparator/whitespace ranges (`>=1 <2`, `1 || 2`) and the
47
- * `*` wildcard are deliberately OUT: `> < = | *` and spaces are shell-meaningful. This is fine - the
48
- * gateway only ever installs the caret-pinned `jose@^5.10.0`. Anything outside the set is refused
49
- * before npm is ever invoked. */
50
- const SPEC_RE = /^[A-Za-z0-9@._/^~+-]+$/;
51
-
52
- /**
53
- * Locate npm's JS entry (`npm-cli.js`) so it can be run with `node` directly, bypassing the
54
- * `npm.cmd`/`npm` shim. Checks the layouts beside the running node binary (Windows/portable, then
55
- * POSIX), then the Windows per-user global. Returns the absolute path, or null if not found.
56
- * @returns {string|null}
57
- */
58
- function findNpmCli() {
59
- const dir = path.dirname(process.execPath);
60
- const candidates = [
61
- path.join(dir, 'node_modules', 'npm', 'bin', 'npm-cli.js'), // Windows / portable
62
- path.join(dir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'), // POSIX (/usr/bin -> /usr/lib)
63
- ];
64
- if (process.env.APPDATA) {
65
- candidates.push(path.join(process.env.APPDATA, 'npm', 'node_modules', 'npm', 'bin', 'npm-cli.js'));
66
- }
67
- for (const c of candidates) {
68
- try { if (fs.existsSync(c)) return c; } catch (_e) { /* ignore */ }
69
- }
70
- return null;
71
- }
72
-
73
- /**
74
- * installJose - run `npm install jose@<PIN>` in the gateway package root. Resolves a structured
75
- * result; never rejects. A 5-minute hard timeout guards against a hung registry.
76
- *
77
- * @param {object} [opts]
78
- * @param {number} [opts.timeoutMs] default 300000 (5 min).
79
- * @param {string} [opts.spec] override the install spec (tests). Default `jose@<JOSE_PIN>`.
80
- * @returns {Promise<{ ok:boolean, code:(number|null), stdout:string, stderr:string, message:string }>}
81
- */
82
- function installJose(opts) {
83
- const o = opts || {};
84
- const spec = typeof o.spec === 'string' && o.spec.length > 0 ? o.spec : `jose@${JOSE_PIN}`;
85
- const timeoutMs = Number.isFinite(o.timeoutMs) && o.timeoutMs > 0 ? o.timeoutMs : 300000;
86
-
87
- if (isJoseInstalled()) {
88
- return Promise.resolve({ ok: true, code: 0, stdout: '', stderr: '', message: `jose already installed (${spec})` });
89
- }
90
-
91
- // Refuse a spec carrying anything outside the strict allowlist BEFORE any shell is involved.
92
- if (!SPEC_RE.test(spec)) {
93
- return Promise.resolve({ ok: false, code: null, stdout: '', stderr: '', message: `refusing to install: invalid package spec "${spec}"` });
94
- }
95
-
96
- return new Promise((resolve) => {
97
- let child;
98
- let stdout = '';
99
- let stderr = '';
100
- let settled = false;
101
- const done = (result) => {
102
- if (settled) return;
103
- settled = true;
104
- clearTimeout(timer);
105
- // On ANY failure, attach the guaranteed-correct manual fallback. Auto-install can't be made
106
- // universal - npm-cli.js sits in different places relative to `node` across install methods
107
- // (Homebrew/Volta/Docker/odd nvm). But the user definitely has a working npm (they used it to
108
- // install/run the gateway), so a copyable command they run in their OWN shell always works.
109
- // The UI + CLI surface result.message, so a failed auto-install becomes an actionable next
110
- // step rather than a dead end.
111
- if (result && result.ok !== true) {
112
- result.manualCommand = 'npm install jose@' + JOSE_PIN;
113
- result.cwd = PKG_ROOT;
114
- if (typeof result.message === 'string' && result.message.indexOf('manually') === -1) {
115
- result.message += ` - or install it manually: run \`${result.manualCommand}\` in ${PKG_ROOT}`;
116
- }
117
- }
118
- resolve(result);
119
- };
120
-
121
- const timer = setTimeout(() => {
122
- try { if (child) child.kill('SIGKILL'); } catch (_e) { /* ignore */ }
123
- done({ ok: false, code: null, stdout, stderr, message: `npm install timed out after ${timeoutMs}ms` });
124
- }, timeoutMs);
125
- if (typeof timer.unref === 'function') timer.unref();
126
-
127
- try {
128
- const npmCli = findNpmCli();
129
- if (npmCli) {
130
- // PREFERRED: run npm's JS entry with the current node binary - no shell, no .cmd shim, no
131
- // PATH/cwd sensitivity. This is what actually works on a portable/Windows Node install.
132
- child = spawn(process.execPath, [npmCli, 'install', spec, '--no-save'], {
133
- cwd: PKG_ROOT,
134
- env: process.env,
135
- windowsHide: true,
136
- shell: false,
137
- });
138
- } else if (IS_WIN) {
139
- // Fallback (npm-cli.js not found): shell out to npm.cmd. The spec is allowlist-validated
140
- // above and double-quoted so cmd.exe can't act on the `^` in a caret range or inject.
141
- child = spawn(`"${NPM_BIN}" install "${spec}" --no-save`, {
142
- cwd: PKG_ROOT,
143
- env: process.env,
144
- windowsHide: true,
145
- shell: true,
146
- });
147
- } else {
148
- child = spawn(NPM_BIN, ['install', spec, '--no-save'], {
149
- cwd: PKG_ROOT,
150
- env: process.env,
151
- windowsHide: true,
152
- shell: false,
153
- });
154
- }
155
- } catch (e) {
156
- return done({ ok: false, code: null, stdout, stderr, message: `failed to spawn npm: ${(e && e.message) || e}` });
157
- }
158
-
159
- if (child.stdout) child.stdout.on('data', (d) => { stdout += d.toString(); });
160
- if (child.stderr) child.stderr.on('data', (d) => { stderr += d.toString(); });
161
- child.on('error', (e) => {
162
- done({ ok: false, code: null, stdout, stderr, message: `npm not runnable: ${(e && e.message) || e}` });
163
- });
164
- child.on('close', (code) => {
165
- // Clear the lazy cache so a freshly-installed jose is picked up WITHOUT a process restart.
166
- try { _resetJoseCache(); } catch (_e) { /* ignore */ }
167
- const ok = code === 0 && isJoseInstalled();
168
- done({
169
- ok,
170
- code,
171
- stdout,
172
- stderr,
173
- message: ok
174
- ? `installed ${spec}`
175
- : `npm install exited ${code}${isJoseInstalled() ? '' : ' (jose still not loadable)'}`,
176
- });
177
- });
178
- });
179
- }
180
-
181
- module.exports = { installJose, findNpmCli, SPEC_RE, PKG_ROOT, NPM_BIN };
1
+ 'use strict';
2
+
3
+ /**
4
+ * auth/install.js - install the OPTIONAL OAuth dependency (`jose`) on demand.
5
+ *
6
+ * The whole point of ToolFunnel's zero-dependency stance is that a default install pulls NOTHING.
7
+ * OAuth is opt-in, so its one library is installed only when a user decides they need it - via the
8
+ * CLI (`toolfunnel install-oauth`) or the admin-UI button, both of which call installJose() here.
9
+ *
10
+ * It shells out to the user's own `npm` to install the PINNED jose version into the gateway
11
+ * package's own node_modules (resolved from this file's location, NOT process.cwd(), so it lands
12
+ * beside the engine wherever ToolFunnel is installed). It uses `--no-save` - the goal is simply to
13
+ * make `require('jose')` resolvable; there is no manifest to update, and this keeps ToolFunnel's
14
+ * own `dependencies: {}` untouched in every install shape.
15
+ *
16
+ * WINDOWS NOTE: do NOT spawn `npm.cmd`. Since the CVE-2024-27980 mitigation, spawning a `.cmd`
17
+ * without a shell throws `EINVAL`; and spawning it WITH a shell is unreliable on portable Node
18
+ * installs - the `npm.cmd` shim misresolves its own path under cmd.exe ("Cannot find module
19
+ * ...\node_modules\npm\bin\npm-cli.js"). The robust, cross-platform approach is to run npm's JS entry
20
+ * (`npm-cli.js`, located beside the running node binary) with `process.execPath` directly -
21
+ * `shell:false`, no shim, no PATH lookup, no cwd sensitivity. We fall back to a shell-spawned `npm`
22
+ * only if `npm-cli.js` can't be located; the spec is allowlist-validated so that fallback can't inject.
23
+ *
24
+ * NEVER throws - it resolves to a structured { ok, code, stdout, stderr, message } so the CLI/UI can
25
+ * report the outcome cleanly even when npm is missing or the network is down.
26
+ *
27
+ * CommonJS only; Node built-ins (child_process, path, fs) + the pin from resource-server.js.
28
+ */
29
+
30
+ const path = require('node:path');
31
+ const fs = require('node:fs');
32
+ const { spawn } = require('node:child_process');
33
+
34
+ const { JOSE_PIN, isJoseInstalled, _resetJoseCache } = require('./resource-server');
35
+
36
+ /** The gateway package root: <root>/src/auth/install.js -> two dirs up. npm runs here so jose lands
37
+ * in the engine's own node_modules regardless of the caller's cwd. */
38
+ const PKG_ROOT = path.resolve(__dirname, '..', '..');
39
+
40
+ /** npm is `npm.cmd` on Windows, `npm` elsewhere. We spawn it as a child rather than require()-ing. */
41
+ const IS_WIN = process.platform === 'win32';
42
+ const NPM_BIN = IS_WIN ? 'npm.cmd' : 'npm';
43
+
44
+ /** A strict allowlist for an npm version spec. Covers what a single-package install actually uses -
45
+ * `name@<exact|caret|tilde|x-range|dist-tag>` and scoped names - and NOTHING a shell could act on, so
46
+ * the fallback shell path can never inject. Comparator/whitespace ranges (`>=1 <2`, `1 || 2`) and the
47
+ * `*` wildcard are deliberately OUT: `> < = | *` and spaces are shell-meaningful. This is fine - the
48
+ * gateway only ever installs the caret-pinned `jose@^5.10.0`. Anything outside the set is refused
49
+ * before npm is ever invoked. */
50
+ const SPEC_RE = /^[A-Za-z0-9@._/^~+-]+$/;
51
+
52
+ /**
53
+ * Locate npm's JS entry (`npm-cli.js`) so it can be run with `node` directly, bypassing the
54
+ * `npm.cmd`/`npm` shim. Checks the layouts beside the running node binary (Windows/portable, then
55
+ * POSIX), then the Windows per-user global. Returns the absolute path, or null if not found.
56
+ * @returns {string|null}
57
+ */
58
+ function findNpmCli() {
59
+ const dir = path.dirname(process.execPath);
60
+ const candidates = [
61
+ path.join(dir, 'node_modules', 'npm', 'bin', 'npm-cli.js'), // Windows / portable
62
+ path.join(dir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'), // POSIX (/usr/bin -> /usr/lib)
63
+ ];
64
+ if (process.env.APPDATA) {
65
+ candidates.push(path.join(process.env.APPDATA, 'npm', 'node_modules', 'npm', 'bin', 'npm-cli.js'));
66
+ }
67
+ for (const c of candidates) {
68
+ try { if (fs.existsSync(c)) return c; } catch (_e) { /* ignore */ }
69
+ }
70
+ return null;
71
+ }
72
+
73
+ /**
74
+ * installJose - run `npm install jose@<PIN>` in the gateway package root. Resolves a structured
75
+ * result; never rejects. A 5-minute hard timeout guards against a hung registry.
76
+ *
77
+ * @param {object} [opts]
78
+ * @param {number} [opts.timeoutMs] default 300000 (5 min).
79
+ * @param {string} [opts.spec] override the install spec (tests). Default `jose@<JOSE_PIN>`.
80
+ * @returns {Promise<{ ok:boolean, code:(number|null), stdout:string, stderr:string, message:string }>}
81
+ */
82
+ function installJose(opts) {
83
+ const o = opts || {};
84
+ const spec = typeof o.spec === 'string' && o.spec.length > 0 ? o.spec : `jose@${JOSE_PIN}`;
85
+ const timeoutMs = Number.isFinite(o.timeoutMs) && o.timeoutMs > 0 ? o.timeoutMs : 300000;
86
+
87
+ if (isJoseInstalled()) {
88
+ return Promise.resolve({ ok: true, code: 0, stdout: '', stderr: '', message: `jose already installed (${spec})` });
89
+ }
90
+
91
+ // Refuse a spec carrying anything outside the strict allowlist BEFORE any shell is involved.
92
+ if (!SPEC_RE.test(spec)) {
93
+ return Promise.resolve({ ok: false, code: null, stdout: '', stderr: '', message: `refusing to install: invalid package spec "${spec}"` });
94
+ }
95
+
96
+ return new Promise((resolve) => {
97
+ let child;
98
+ let stdout = '';
99
+ let stderr = '';
100
+ let settled = false;
101
+ const done = (result) => {
102
+ if (settled) return;
103
+ settled = true;
104
+ clearTimeout(timer);
105
+ // On ANY failure, attach the guaranteed-correct manual fallback. Auto-install can't be made
106
+ // universal - npm-cli.js sits in different places relative to `node` across install methods
107
+ // (Homebrew/Volta/Docker/odd nvm). But the user definitely has a working npm (they used it to
108
+ // install/run the gateway), so a copyable command they run in their OWN shell always works.
109
+ // The UI + CLI surface result.message, so a failed auto-install becomes an actionable next
110
+ // step rather than a dead end.
111
+ if (result && result.ok !== true) {
112
+ result.manualCommand = 'npm install jose@' + JOSE_PIN;
113
+ result.cwd = PKG_ROOT;
114
+ if (typeof result.message === 'string' && result.message.indexOf('manually') === -1) {
115
+ result.message += ` - or install it manually: run \`${result.manualCommand}\` in ${PKG_ROOT}`;
116
+ }
117
+ }
118
+ resolve(result);
119
+ };
120
+
121
+ const timer = setTimeout(() => {
122
+ try { if (child) child.kill('SIGKILL'); } catch (_e) { /* ignore */ }
123
+ done({ ok: false, code: null, stdout, stderr, message: `npm install timed out after ${timeoutMs}ms` });
124
+ }, timeoutMs);
125
+ if (typeof timer.unref === 'function') timer.unref();
126
+
127
+ try {
128
+ const npmCli = findNpmCli();
129
+ if (npmCli) {
130
+ // PREFERRED: run npm's JS entry with the current node binary - no shell, no .cmd shim, no
131
+ // PATH/cwd sensitivity. This is what actually works on a portable/Windows Node install.
132
+ child = spawn(process.execPath, [npmCli, 'install', spec, '--no-save'], {
133
+ cwd: PKG_ROOT,
134
+ env: process.env,
135
+ windowsHide: true,
136
+ shell: false,
137
+ });
138
+ } else if (IS_WIN) {
139
+ // Fallback (npm-cli.js not found): shell out to npm.cmd. The spec is allowlist-validated
140
+ // above and double-quoted so cmd.exe can't act on the `^` in a caret range or inject.
141
+ child = spawn(`"${NPM_BIN}" install "${spec}" --no-save`, {
142
+ cwd: PKG_ROOT,
143
+ env: process.env,
144
+ windowsHide: true,
145
+ shell: true,
146
+ });
147
+ } else {
148
+ child = spawn(NPM_BIN, ['install', spec, '--no-save'], {
149
+ cwd: PKG_ROOT,
150
+ env: process.env,
151
+ windowsHide: true,
152
+ shell: false,
153
+ });
154
+ }
155
+ } catch (e) {
156
+ return done({ ok: false, code: null, stdout, stderr, message: `failed to spawn npm: ${(e && e.message) || e}` });
157
+ }
158
+
159
+ if (child.stdout) child.stdout.on('data', (d) => { stdout += d.toString(); });
160
+ if (child.stderr) child.stderr.on('data', (d) => { stderr += d.toString(); });
161
+ child.on('error', (e) => {
162
+ done({ ok: false, code: null, stdout, stderr, message: `npm not runnable: ${(e && e.message) || e}` });
163
+ });
164
+ child.on('close', (code) => {
165
+ // Clear the lazy cache so a freshly-installed jose is picked up WITHOUT a process restart.
166
+ try { _resetJoseCache(); } catch (_e) { /* ignore */ }
167
+ const ok = code === 0 && isJoseInstalled();
168
+ done({
169
+ ok,
170
+ code,
171
+ stdout,
172
+ stderr,
173
+ message: ok
174
+ ? `installed ${spec}`
175
+ : `npm install exited ${code}${isJoseInstalled() ? '' : ' (jose still not loadable)'}`,
176
+ });
177
+ });
178
+ });
179
+ }
180
+
181
+ module.exports = { installJose, findNpmCli, SPEC_RE, PKG_ROOT, NPM_BIN };