toolfunnel 0.6.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 (42) hide show
  1. package/README.md +11 -7
  2. package/SECURITY.md +84 -0
  3. package/bin/toolfunnel.js +97 -18
  4. package/docs/KNOWN_BUGS.md +71 -90
  5. package/docs/MANUAL.pdf +0 -0
  6. package/docs/design.md +24 -10
  7. package/hooks/hooks.manifest.json +1 -1
  8. package/hooks/scripts/example-deny-dangerous.js +12 -4
  9. package/mcp/servers/mock-upstream/server.js +36 -6
  10. package/package.json +67 -65
  11. package/src/auth/config.js +2 -2
  12. package/src/auth/resource-server.js +12 -3
  13. package/src/core/child-reaper.js +61 -0
  14. package/src/core/events.js +7 -7
  15. package/src/core/hook-engine.js +85 -31
  16. package/src/core/hook-loader.js +46 -13
  17. package/src/core/hook-runner.js +258 -38
  18. package/src/core/logger.js +138 -12
  19. package/src/core/matcher.js +59 -10
  20. package/src/core/rename-migration.js +163 -0
  21. package/src/core/requires.js +1 -1
  22. package/src/extend/add-hook.md +2 -2
  23. package/src/extend/add-mcp.md +3 -6
  24. package/src/extend/configure.md +2 -1
  25. package/src/extend/create-tool.md +1 -1
  26. package/src/extend/howto.js +3 -3
  27. package/src/extend/package.md +11 -8
  28. package/src/mcp/aggregator.js +480 -52
  29. package/src/mcp/expose-store.js +130 -13
  30. package/src/mcp/gated-run.js +26 -17
  31. package/src/mcp/http-transport.js +274 -22
  32. package/src/mcp/mcp-client.js +194 -31
  33. package/src/mcp/modern.js +44 -10
  34. package/src/mcp/server.js +513 -89
  35. package/src/tools/registry.js +172 -24
  36. package/src/ui/public/app.js +17 -4
  37. package/src/ui/server.js +297 -29
  38. package/tools/scripts/tf-hook-set.js +11 -3
  39. package/tools/tools.register.json +227 -227
  40. package/src/core/tool-registry.js +0 -180
  41. package/src/packages/packager.js +0 -715
  42. package/src/tools/drift.js +0 -138
@@ -1,11 +1,11 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * expose-store.js - the persisted MCP config store (the architecture notes and
5
- * the extension guide define the authoritative shape).
4
+ * expose-store.js - the persisted MCP config store (the extension guide defines the
5
+ * authoritative shape).
6
6
  *
7
7
  * This is a PURE, config-only module: no process spawning, no network, no MCP
8
- * client. It is the on-disk source of truth that the (Phase 2) aggregator reads
8
+ * client. It is the on-disk source of truth that the aggregator reads
9
9
  * to know WHICH upstream MCPs to connect to and WHICH of their tools to surface
10
10
  * downstream as curated-direct tools. Editing this store is how a human/model
11
11
  * authors the MCP surface; the aggregator just consumes the result.
@@ -53,25 +53,52 @@ function clone(value) {
53
53
  return JSON.parse(JSON.stringify(value));
54
54
  }
55
55
 
56
+ /** Sibling temps older than this are strandings, not in-flight writes (see registry.js). */
57
+ const STALE_TMP_MS = 60 * 60 * 1000;
58
+
59
+ /** Remove STALE stranded temps for `base` in `dir` - copied from registry.js, same rules:
60
+ * scoped to the target's own temp shape, entirely best-effort. */
61
+ function sweepStaleTmp(dir, base) {
62
+ let names;
63
+ try { names = fs.readdirSync(dir); } catch (_e) { return; }
64
+ const prefix = `.${base}.`;
65
+ const now = Date.now();
66
+ for (const name of names) {
67
+ if (!name.startsWith(prefix) || !name.endsWith('.tmp')) continue;
68
+ const p = path.join(dir, name);
69
+ try {
70
+ if (now - fs.statSync(p).mtimeMs > STALE_TMP_MS) fs.unlinkSync(p);
71
+ } catch (_e) { /* a concurrent writer renamed/removed it - fine */ }
72
+ }
73
+ }
74
+
56
75
  /**
57
76
  * Atomic write: serialise to a temp file in the SAME directory as the target (so
58
77
  * the rename is atomic on the same filesystem/drive), fsync, then rename over the
59
78
  * target. Copied verbatim from src/tools/registry.js so the two stores share one
60
- * proven persistence pattern.
79
+ * proven persistence pattern - including its cleanup contract: a failed write
80
+ * unlinks its own temp before rethrowing, and stale strandings are swept on the
81
+ * next successful write.
61
82
  */
62
83
  function atomicWriteJson(targetPath, obj) {
63
84
  const dir = path.dirname(targetPath);
64
85
  const base = path.basename(targetPath);
86
+ sweepStaleTmp(dir, base);
65
87
  const tmp = path.join(dir, `.${base}.${process.pid}.${Date.now()}.tmp`);
66
88
  const data = JSON.stringify(obj, null, 2) + '\n';
67
- const fd = fs.openSync(tmp, 'w');
68
89
  try {
69
- fs.writeSync(fd, data, 0, 'utf8');
70
- fs.fsyncSync(fd);
71
- } finally {
72
- fs.closeSync(fd);
90
+ const fd = fs.openSync(tmp, 'w');
91
+ try {
92
+ fs.writeSync(fd, data, 0, 'utf8');
93
+ fs.fsyncSync(fd);
94
+ } finally {
95
+ fs.closeSync(fd);
96
+ }
97
+ fs.renameSync(tmp, targetPath);
98
+ } catch (err) {
99
+ try { fs.unlinkSync(tmp); } catch (_e) { /* best-effort - the error below is the story */ }
100
+ throw err;
73
101
  }
74
- fs.renameSync(tmp, targetPath);
75
102
  }
76
103
 
77
104
  /** True for a non-empty string. */
@@ -79,6 +106,45 @@ function isNonEmptyString(v) {
79
106
  return typeof v === 'string' && v.length > 0;
80
107
  }
81
108
 
109
+ const { migrateForStoreFile } = require('../core/rename-migration');
110
+
111
+ /** The name an expose entry surfaces its tool under RIGHT NOW: an ENABLED entry's `as`
112
+ * (self-describing since addExpose defaults it); a disabled entry does not alias - the tool
113
+ * surfaces under the namespaced default. Mirrors Aggregator._surfacedName / the UI's
114
+ * surfacedNameFor. */
115
+ function surfacedOf(e) {
116
+ return (e && e.enabled && isNonEmptyString(e.as)) ? e.as : `${e.upstream}_${e.tool}`;
117
+ }
118
+
119
+ /**
120
+ * Normalise an env map to a NULL-PROTOTYPE object of string -> string.
121
+ *
122
+ * Not cosmetic - it closes two proven isolation-guard bypasses at the boundary, by making the
123
+ * stored, guarded and spawned representations identical:
124
+ * - `JSON.parse('{"__proto__":{...}}')` yields an OWN `__proto__` data property. Spread keeps it,
125
+ * and the client's `Object.assign` env merge then uses [[Set]], firing the
126
+ * Object.prototype.__proto__ setter so the payload becomes the merged env's PROTOTYPE.
127
+ * child_process enumerates env with for..in ("prototype values are intentionally included"),
128
+ * so the child receives those keys while an own-key walk sees nothing. Copying own keys into
129
+ * a null-prototype object makes that carrier inert.
130
+ * - Non-string values reach the child regardless: node builds each pair as `${key}=${value}`, so
131
+ * `['--require=/outside-module.js']` stringifies to its element. Coerce here, once.
132
+ *
133
+ * @param {*} raw
134
+ * @returns {Object<string,string>} null-prototype, string-valued
135
+ */
136
+ function normaliseEnv(raw) {
137
+ const out = Object.create(null);
138
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
139
+ for (const key of Object.getOwnPropertyNames(raw)) {
140
+ if (key === '__proto__') continue; // never re-establish the carrier
141
+ const v = raw[key];
142
+ if (v === undefined || v === null) continue;
143
+ out[key] = typeof v === 'string' ? v : String(v);
144
+ }
145
+ return out;
146
+ }
147
+
82
148
  /**
83
149
  * Normalise a raw upstream object into the canonical shape, filling sane defaults
84
150
  * for the optional fields. Does NOT validate - used both on load (lenient) and as
@@ -90,12 +156,12 @@ function normaliseUpstream(raw) {
90
156
  // Unknown fields ride along (the same deliberate choice as /api/identity): every store
91
157
  // WRITE rewrites the file, so dropping unrecognised fields meant a 0.6.0 pin/unpin toggle
92
158
  // silently erased hand-authored extras. Known fields are normalised below.
93
- ...u,
159
+ ...(u && typeof u === 'object' ? JSON.parse(JSON.stringify(u)) : {}),
94
160
  id: typeof u.id === 'string' ? u.id : '',
95
161
  transport: typeof u.transport === 'string' ? u.transport : 'stdio',
96
162
  command: typeof u.command === 'string' ? u.command : '',
97
163
  args: Array.isArray(u.args) ? u.args.slice() : [],
98
- env: u.env && typeof u.env === 'object' && !Array.isArray(u.env) ? { ...u.env } : {},
164
+ env: normaliseEnv(u.env),
99
165
  enabled: typeof u.enabled === 'boolean' ? u.enabled : true,
100
166
  // 0.6.0 legacy shim: OPT-IN commitment flag. A pinned upstream is knowingly legacy-era -
101
167
  // the gateway keeps speaking MCP 2024-11-05 to it forever (never auto-upgrades), warns at
@@ -106,6 +172,38 @@ function normaliseUpstream(raw) {
106
172
  // refuses the legacy fallback at connect instead of negotiating down. Off by default
107
173
  // (default = speak whichever era the server understands). Era-policy switches, 2026-07-18.
108
174
  modernOnly: u.modernOnly === true,
175
+ // allowCodeLoadingEnv: OPT-OUT of the isolation guard's code-loading env refusal for THIS
176
+ // upstream (NODE_OPTIONS and friends). Same pattern as legacyPin: off by default, explicit
177
+ // in config where an auditor can see it, warned on connect when it excuses something.
178
+ allowCodeLoadingEnv: u.allowCodeLoadingEnv === true,
179
+ // allowOutsidePaths: *** USE WITH EXTREME CAUTION ***
180
+ //
181
+ // This is the big one. allowCodeLoadingEnv excuses ONE class of environment variable; this
182
+ // excuses the ENTIRE path guard for this upstream - args, the cwd they resolve against, and
183
+ // the path-list env vars - so the server can reach anywhere on disk the account can.
184
+ //
185
+ // It exists because refusing outright was wrong. A filesystem server pointed at a documents
186
+ // folder, a git server pointed at a repo, a database tool holding a socket somewhere else:
187
+ // these live outside the config home BY NATURE, and without this flag funnel mode could not
188
+ // run them at all. The honest answer is a switch the operator sets deliberately, not a wall
189
+ // with no door.
190
+ //
191
+ // What it costs: the guard is a TRIPWIRE for configs that quietly reach outside an auditable
192
+ // home. Setting this says "I know this one reaches out, and I meant it". You are then trusting
193
+ // that server the way you trust anything you installed - because that is what you have done.
194
+ // It buys you nothing to set it on an upstream that does not need it.
195
+ //
196
+ // What it does NOT excuse: the OPTION-STRING env class (NODE_OPTIONS, JAVA_TOOL_OPTIONS,
197
+ // PERL5OPT, RUBYOPT...). Those are mini command lines in the runtime's own grammar - they
198
+ // load code with no path to check - and they have their own flag, directly above. Setting
199
+ // this one to point a git server at a repo must not silently disarm that one too: two
200
+ // exemptions mean two deliberate decisions, each visible on its own line to an auditor.
201
+ //
202
+ // Scope is the safety property: it is read per upstream, from THIS entry, at every connect.
203
+ // One upstream opting out NEVER relaxes another's guard, and the gateway warns on every
204
+ // connect, ONCE PER CLASS, about everything it excused. Same pattern as legacyPin - off by
205
+ // default, explicit in config where an auditor can see it, loud when it applies.
206
+ allowOutsidePaths: u.allowOutsidePaths === true,
109
207
  // Child working directory - the wrap era-probe and the client spawn both read it; it was
110
208
  // dropped here, so a configured cwd never survived the store.
111
209
  cwd: typeof u.cwd === 'string' && u.cwd.length ? u.cwd : undefined,
@@ -343,6 +441,9 @@ class ExposeStore {
343
441
  // Persist an explicit `as` (default the namespaced name so the stored entry
344
442
  // is self-describing and the aggregator never has to recompute it).
345
443
  e.as = isNonEmptyString(e.as) ? e.as : `${e.upstream}_${e.tool}`;
444
+ // A new ENABLED alias renames the surface from the namespaced default it had while merely
445
+ // lean-discovered - migrate-or-refuse, exactly like updateExpose (no-op when as == default).
446
+ if (e.enabled) this._migrateSurfaced(`${e.upstream}_${e.tool}`, e.as);
346
447
  this._expose.push(e);
347
448
  this._persist();
348
449
  return clone(e);
@@ -361,7 +462,13 @@ class ExposeStore {
361
462
  if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
362
463
  throw new Error('ExposeStore.updateExpose: patch must be an object');
363
464
  }
364
- const merged = normaliseExpose({ ...this._expose[idx], ...patch, upstream, tool });
465
+ const prior = this._expose[idx];
466
+ const merged = normaliseExpose({ ...prior, ...patch, upstream, tool });
467
+ // Rename-safety: a change to the SURFACED name - an `as` edit OR an enabled flip
468
+ // (only an ENABLED entry's `as` surfaces; a disabled entry's tool falls back to the
469
+ // namespaced default) - must carry the gate matchers and tools.state.json keys with it, or
470
+ // the rename is REFUSED. Runs BEFORE persist so a refusal leaves this file untouched.
471
+ this._migrateSurfaced(surfacedOf(prior), surfacedOf(merged));
365
472
  this._expose[idx] = merged;
366
473
  this._persist();
367
474
  return clone(merged);
@@ -373,11 +480,21 @@ class ExposeStore {
373
480
  if (idx === -1) {
374
481
  throw new Error(`ExposeStore.removeExpose: unknown expose entry (upstream="${upstream}", tool="${tool}")`);
375
482
  }
483
+ // Removing an ENABLED aliased entry renames the surface back to the namespaced default -
484
+ // same migrate-or-refuse contract as updateExpose (rename-safety).
485
+ const prior = this._expose[idx];
486
+ this._migrateSurfaced(surfacedOf(prior), `${prior.upstream}_${prior.tool}`);
376
487
  this._expose.splice(idx, 1);
377
488
  this._persist();
378
489
  return true;
379
490
  }
380
491
 
492
+ /** Gate matchers + state keys follow the surfaced name (or the rename refuses; see rename-migration.js). */
493
+ _migrateSurfaced(oldName, newName) {
494
+ if (oldName === newName) return;
495
+ migrateForStoreFile(this._filePath, oldName, newName);
496
+ }
497
+
381
498
  /** Toggle an expose entry's `enabled` flag; persists. Throws on unknown (upstream,tool). */
382
499
  setExposeEnabled(upstream, tool, enabled) {
383
500
  return this.updateExpose(upstream, tool, { enabled: !!enabled });
@@ -3,8 +3,9 @@
3
3
  /**
4
4
  * gated-run.js - the SAFETY CRUX of the gateway.
5
5
  *
6
- * Contract: the architecture contract §2 (the `toolfunnel_run_tool` path) and §9 (the tested invariant).
7
- * Hook contract: HOOK_ENGINE.md §1 (PreToolUse can block, PostToolUse cannot un-run) and §6
6
+ * Contract: the gated tool-run path. The tested invariant (test/gate.test.js; docs/MANUAL.pdf
7
+ * section 10): a PreToolUse deny prevents execute() from ever being called.
8
+ * Hook contract: PreToolUse can block, PostToolUse cannot un-run, and
8
9
  * (HookEngine.fire returns { injected, blocked, reason, stopLoop, results }; the engine has
9
10
  * ALREADY normalised exit-2 / hookSpecificOutput.permissionDecision:"deny" into { blocked, reason }).
10
11
  *
@@ -19,7 +20,7 @@
19
20
  * Unit-testable under `node --test` with a fake engine + a spy execute().
20
21
  * - NEVER throws out of gatedRun. A thrown execute() is captured as { ok:false, error }.
21
22
  * A misbehaving engine.fire() (rejection or junk return) is treated conservatively.
22
- * - PostToolUse is advisory (HOOK_ENGINE.md §1: a PostToolUse block cannot un-run the tool),
23
+ * - PostToolUse is advisory : a PostToolUse block cannot un-run the tool),
23
24
  * so we fire it for feedback but never let it flip ok->false or throw the path.
24
25
  *
25
26
  * Return shape (always one of these, never an exception):
@@ -42,7 +43,7 @@ const EVENTS = {
42
43
  /**
43
44
  * Fire one lifecycle event through the injected engine, defensively.
44
45
  *
45
- * The HookEngine contract (HOOK_ENGINE.md §6) says fire() never rejects, but gatedRun is the
46
+ * The HookEngine contract says fire() never rejects, but gatedRun is the
46
47
  * safety crux: we must not assume a perfectly-behaved engine. If fire() rejects or returns a
47
48
  * non-object, we fail CLOSED on the gate (PreToolUse) - an engine we cannot trust to answer
48
49
  * "allowed?" must not be read as "allowed". PostToolUse is advisory, so a failure there is benign.
@@ -50,9 +51,12 @@ const EVENTS = {
50
51
  * @param {object} engine an object exposing async fire(event, ctx, extra)
51
52
  * @param {string} event one of EVENTS
52
53
  * @param {object} ctx common hook context (session_id, transcript_path, cwd, ...)
53
- * @param {object} extra event-specific fields (per HOOK_ENGINE.md §2 table)
54
+ * @param {object} extra event-specific fields
54
55
  * @returns {Promise<{ injected:string, blocked:boolean, reason:(string|null),
55
- * stopLoop:boolean, results:object[] }>}
56
+ * stopLoop:boolean, results:object[], internal?:boolean }>}
57
+ * `internal` is present (true) ONLY on the PreToolUse fail-closed paths (non-object
58
+ * return / fire() rejection): the block is a WIRING failure, not operator policy, and
59
+ * its reason is a gateway tell that wrap paths must neutralise (see gatedRun's return).
56
60
  */
57
61
  async function fireSafely(engine, event, ctx, extra) {
58
62
  try {
@@ -97,7 +101,7 @@ async function fireSafely(engine, event, ctx, extra) {
97
101
  /**
98
102
  * gatedRun - route a single tool invocation through the host's hook engine.
99
103
  *
100
- * Sequence (the architecture contract §2):
104
+ * Sequence:
101
105
  * 1. fire PreToolUse with { tool_name, tool_input: args }.
102
106
  * 2. if blocked -> return { ok:false, blocked:true, reason, output:null } and DO NOT call execute.
103
107
  * 3. else await execute(); on throw, capture as error (output stays null).
@@ -143,20 +147,25 @@ async function gatedRun(params) {
143
147
  const context = ctx || {};
144
148
 
145
149
  // ---- 1. PreToolUse gate. -------------------------------------------------------------
146
- // tool_input carries the args verbatim (per HOOK_ENGINE.md §2 PreToolUse row).
150
+ // tool_input carries the args verbatim.
147
151
  const pre = await fireSafely(engine, EVENTS.PRE, context, {
148
152
  tool_name: toolName,
149
153
  tool_input: args,
150
154
  });
151
155
 
152
- // ---- Activity log: record the gate decision (allow|deny). Self-gates on enabled;
153
- // never throws. Placed before the blocked-return so BOTH outcomes are logged. ---
154
- logger.log({
155
- type: 'gate',
156
- tool: toolName,
157
- decision: pre.blocked ? 'deny' : 'allow',
158
- reason: pre.reason,
159
- });
156
+ // ---- Activity log: record the gate decision (allow|deny). The logger CONTRACTS never to
157
+ // throw, but this is the safety crux and it sits between the gate decision and the
158
+ // blocked-return - the same paranoia applied to the engine applies here: a logger that
159
+ // breaks its contract must not break the gate. Placed before the blocked-return so BOTH
160
+ // outcomes are logged. ---
161
+ try {
162
+ logger.log({
163
+ type: 'gate',
164
+ tool: toolName,
165
+ decision: pre.blocked ? 'deny' : 'allow',
166
+ reason: pre.reason,
167
+ });
168
+ } catch (_e) { /* the activity log must never alter gate behaviour */ }
160
169
 
161
170
  // ---- 2. Blocked -> STOP. The load-bearing invariant: execute() is NOT called. ---------
162
171
  if (pre.blocked) {
@@ -184,7 +193,7 @@ async function gatedRun(params) {
184
193
 
185
194
  // ---- 4. PostToolUse (advisory). Fired even on the error path so hooks see the outcome. -
186
195
  // tool_response is the output (null when execute threw). PostToolUse cannot un-run the tool
187
- // (HOOK_ENGINE.md §1), so its result never flips ok and never throws this path.
196
+ // (PostToolUse is advisory), so its result never flips ok and never throws this path.
188
197
  await fireSafely(engine, EVENTS.POST, context, {
189
198
  tool_name: toolName,
190
199
  tool_input: args,