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.
- package/README.md +11 -7
- package/SECURITY.md +84 -0
- package/bin/toolfunnel.js +97 -18
- package/docs/KNOWN_BUGS.md +71 -90
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +24 -10
- package/hooks/hooks.manifest.json +1 -1
- package/hooks/scripts/example-deny-dangerous.js +12 -4
- package/mcp/servers/mock-upstream/server.js +36 -6
- package/package.json +67 -65
- package/src/auth/config.js +2 -2
- package/src/auth/resource-server.js +12 -3
- package/src/core/child-reaper.js +61 -0
- package/src/core/events.js +7 -7
- package/src/core/hook-engine.js +85 -31
- package/src/core/hook-loader.js +46 -13
- package/src/core/hook-runner.js +258 -38
- package/src/core/logger.js +138 -12
- package/src/core/matcher.js +59 -10
- package/src/core/rename-migration.js +163 -0
- package/src/core/requires.js +1 -1
- package/src/extend/add-hook.md +2 -2
- package/src/extend/add-mcp.md +3 -6
- package/src/extend/configure.md +2 -1
- package/src/extend/create-tool.md +1 -1
- package/src/extend/howto.js +3 -3
- package/src/extend/package.md +11 -8
- package/src/mcp/aggregator.js +480 -52
- package/src/mcp/expose-store.js +130 -13
- package/src/mcp/gated-run.js +26 -17
- package/src/mcp/http-transport.js +274 -22
- package/src/mcp/mcp-client.js +194 -31
- package/src/mcp/modern.js +44 -10
- package/src/mcp/server.js +513 -89
- package/src/tools/registry.js +172 -24
- package/src/ui/public/app.js +17 -4
- package/src/ui/server.js +297 -29
- package/tools/scripts/tf-hook-set.js +11 -3
- package/tools/tools.register.json +227 -227
- package/src/core/tool-registry.js +0 -180
- package/src/packages/packager.js +0 -715
- package/src/tools/drift.js +0 -138
package/src/mcp/expose-store.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* expose-store.js - the persisted MCP config store (the
|
|
5
|
-
*
|
|
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
|
|
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.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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:
|
|
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
|
|
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 });
|
package/src/mcp/gated-run.js
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* gated-run.js - the SAFETY CRUX of the gateway.
|
|
5
5
|
*
|
|
6
|
-
* Contract: the
|
|
7
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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).
|
|
153
|
-
//
|
|
154
|
-
logger
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
// (
|
|
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,
|