toolfunnel 0.7.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 (67) hide show
  1. package/README.md +228 -235
  2. package/SECURITY.md +84 -84
  3. package/bin/toolfunnel.js +451 -451
  4. package/docs/KNOWN_BUGS.md +80 -80
  5. package/docs/design.md +84 -84
  6. package/docs/packaging.md +115 -115
  7. package/hooks/hooks.manifest.json +27 -27
  8. package/hooks/scripts/example-audit.js +21 -21
  9. package/hooks/scripts/example-deny-dangerous.js +39 -39
  10. package/mcp/expose.example.json +30 -30
  11. package/mcp/servers/mock-modern/server.js +147 -147
  12. package/mcp/servers/mock-upstream/server.js +380 -380
  13. package/package.json +67 -67
  14. package/src/auth/config.js +174 -174
  15. package/src/auth/install.js +181 -181
  16. package/src/auth/resource-server.js +353 -353
  17. package/src/core/child-reaper.js +61 -61
  18. package/src/core/config-home.js +134 -134
  19. package/src/core/events.js +105 -105
  20. package/src/core/hook-engine.js +329 -329
  21. package/src/core/hook-loader.js +824 -824
  22. package/src/core/hook-runner.js +566 -566
  23. package/src/core/logger.js +297 -297
  24. package/src/core/matcher.js +100 -100
  25. package/src/core/metrics.js +75 -75
  26. package/src/core/rename-migration.js +163 -163
  27. package/src/core/requires.js +138 -138
  28. package/src/core/server-config.js +98 -98
  29. package/src/extend/add-hook.md +152 -152
  30. package/src/extend/add-mcp.md +119 -119
  31. package/src/extend/configure.md +88 -88
  32. package/src/extend/create-tool.md +168 -168
  33. package/src/extend/howto.js +102 -102
  34. package/src/extend/package.md +190 -190
  35. package/src/extend/wrap.md +99 -99
  36. package/src/mcp/aggregator.js +1719 -1674
  37. package/src/mcp/expose-store.js +577 -577
  38. package/src/mcp/gated-run.js +210 -210
  39. package/src/mcp/http-transport.js +1563 -1563
  40. package/src/mcp/mcp-client.js +1070 -1070
  41. package/src/mcp/modern.js +406 -406
  42. package/src/mcp/protocol.js +468 -468
  43. package/src/mcp/server.js +3112 -3112
  44. package/src/tools/registry.js +661 -661
  45. package/src/tools/tool-state.js +170 -170
  46. package/src/ui/public/app.js +1187 -1187
  47. package/src/ui/public/index.html +286 -286
  48. package/src/ui/public/styles.css +535 -535
  49. package/src/ui/server.js +1616 -1616
  50. package/tools/scripts/base64.js +139 -139
  51. package/tools/scripts/danger.js +61 -61
  52. package/tools/scripts/echo.js +39 -39
  53. package/tools/scripts/hash.js +101 -101
  54. package/tools/scripts/json.js +109 -109
  55. package/tools/scripts/text-stats.js +131 -131
  56. package/tools/scripts/tf-env.js +43 -43
  57. package/tools/scripts/tf-hook-add.js +151 -151
  58. package/tools/scripts/tf-hook-set.js +120 -120
  59. package/tools/scripts/tf-list.js +192 -192
  60. package/tools/scripts/tf-log.js +86 -86
  61. package/tools/scripts/tf-mcp-add.js +117 -117
  62. package/tools/scripts/tf-mcp-set.js +121 -121
  63. package/tools/scripts/tf-pack.js +237 -237
  64. package/tools/scripts/tf-tool-add.js +134 -134
  65. package/tools/scripts/tf-tool-set.js +142 -142
  66. package/tools/scripts/tf-wrap.js +103 -103
  67. package/tools/scripts/uuid.js +102 -102
@@ -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 };