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/core/logger.js
CHANGED
|
@@ -15,12 +15,23 @@
|
|
|
15
15
|
* must never break a tool call or the gate. Everything is wrapped; failures are
|
|
16
16
|
* swallowed silently.
|
|
17
17
|
*
|
|
18
|
-
* Paths are resolved against the
|
|
19
|
-
*
|
|
18
|
+
* Paths are resolved against the config home, and the resolved path MUST stay inside
|
|
19
|
+
* <home>/logs/ (0.7.0; logging.test.js step 5 is the acceptance). The log path is
|
|
20
|
+
* settable from the tf_log tool surface and the UI's POST /api/logs/config, and every
|
|
21
|
+
* other file-writing path in the repo is confined to its root (hook-loader's scripts-dir
|
|
22
|
+
* assert, registry.writeScript, sendStatic) - this one now matches. setConfig REFUSES an
|
|
23
|
+
* out-of-home path loudly; getConfig treats one already in the file as unset (default),
|
|
24
|
+
* so log() and tail() stay in-home even against a hand-edited config. An operator who
|
|
25
|
+
* wants the log on another disk can make <home>/logs a symlink/junction - containment is
|
|
26
|
+
* checked on the unfollowed path.
|
|
20
27
|
*
|
|
21
28
|
* Config file: <root>/logs/log.config.json
|
|
22
29
|
* { "enabled": false, "path": "logs/toolfunnel.log.jsonl" }
|
|
23
30
|
*
|
|
31
|
+
* Both paths are BOUNDED (0.7.0, log-rotation.test.js): the live file rotates to `<path>.1`
|
|
32
|
+
* at MAX_LOG_BYTES (one older generation kept), and tail(n) reads a trailing slice rather
|
|
33
|
+
* than the whole file - with a whole-file fallback so bounding never changes an answer.
|
|
34
|
+
*
|
|
24
35
|
* CommonJS only.
|
|
25
36
|
*/
|
|
26
37
|
|
|
@@ -40,12 +51,37 @@ const DEFAULT_ENABLED = false;
|
|
|
40
51
|
const DEFAULT_PATH = 'logs/toolfunnel.log.jsonl';
|
|
41
52
|
|
|
42
53
|
/**
|
|
43
|
-
*
|
|
54
|
+
* The live file's size cap. An append that finds the file at/over it first renames the
|
|
55
|
+
* file to `<path>.1` (ONE older generation, overwritten by the next rotation), so disk
|
|
56
|
+
* use is bounded at ~2x the cap and the no-argument tail() (the documented all-lines
|
|
57
|
+
* mode, tf_log status's count) can never be asked to parse an unbounded file again.
|
|
58
|
+
* Rotation is best-effort: if the rename is refused (e.g. a reader holds `.1` open on
|
|
59
|
+
* win32) the append proceeds on the oversized live file and rotation retries next call -
|
|
60
|
+
* a record is never dropped for the sake of the cap.
|
|
61
|
+
*/
|
|
62
|
+
const MAX_LOG_BYTES = 5 * 1024 * 1024;
|
|
63
|
+
|
|
64
|
+
/** tail(n)'s bounded read: bytes budgeted per requested line (generous - records run
|
|
65
|
+
* ~100-200 bytes), with a floor so tiny n still reads one decent chunk. */
|
|
66
|
+
const TAIL_BYTES_PER_LINE = 1024;
|
|
67
|
+
const TAIL_READ_MIN_BYTES = 64 * 1024;
|
|
68
|
+
|
|
69
|
+
/** The directory every log path must resolve inside. */
|
|
70
|
+
const LOG_DIR = path.join(ROOT, 'logs');
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolve a (possibly relative) log path against the config home, REQUIRING the result
|
|
74
|
+
* to stay inside <home>/logs/. Returns the resolved absolute path, or null when it
|
|
75
|
+
* resolves outside (same refusal shape as registry.js defaultRunScript: '' would be the
|
|
76
|
+
* dir itself, '..'-prefixed left it, absolute rel = another drive on win32).
|
|
44
77
|
* @param {string} p
|
|
45
|
-
* @returns {string}
|
|
78
|
+
* @returns {string|null}
|
|
46
79
|
*/
|
|
47
80
|
function resolveLogPath(p) {
|
|
48
|
-
|
|
81
|
+
const resolved = path.resolve(ROOT, String(p));
|
|
82
|
+
const rel = path.relative(LOG_DIR, resolved);
|
|
83
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
|
84
|
+
return resolved;
|
|
49
85
|
}
|
|
50
86
|
|
|
51
87
|
/**
|
|
@@ -65,8 +101,11 @@ function getConfig() {
|
|
|
65
101
|
}
|
|
66
102
|
return {
|
|
67
103
|
enabled: parsed.enabled === true,
|
|
104
|
+
// A path that resolves outside <home>/logs/ is treated as UNSET, not honoured: the
|
|
105
|
+
// config file can be edited directly, and the readers (log/tail) must stay in-home
|
|
106
|
+
// regardless of how the value got there. setConfig additionally refuses one loudly.
|
|
68
107
|
path:
|
|
69
|
-
typeof parsed.path === 'string' && parsed.path.length > 0
|
|
108
|
+
typeof parsed.path === 'string' && parsed.path.length > 0 && resolveLogPath(parsed.path) !== null
|
|
70
109
|
? parsed.path
|
|
71
110
|
: DEFAULT_PATH,
|
|
72
111
|
};
|
|
@@ -96,7 +135,20 @@ function log(event) {
|
|
|
96
135
|
);
|
|
97
136
|
|
|
98
137
|
const logPath = resolveLogPath(cfg.path);
|
|
138
|
+
if (logPath === null) return; // cannot happen via getConfig (it sanitises); belt-and-braces
|
|
99
139
|
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
140
|
+
|
|
141
|
+
// Rotate BEFORE the append when the live file has reached the cap. Its own try/catch:
|
|
142
|
+
// a refused stat/rename must never cost the record below. `logPath + '.1'` inherits
|
|
143
|
+
// logPath's containment, and rename replaces an existing `.1` (one older generation).
|
|
144
|
+
try {
|
|
145
|
+
if (fs.statSync(logPath).size >= MAX_LOG_BYTES) {
|
|
146
|
+
fs.renameSync(logPath, logPath + '.1');
|
|
147
|
+
}
|
|
148
|
+
} catch (_e) {
|
|
149
|
+
// Missing file (nothing to rotate) or a refused rename - append regardless.
|
|
150
|
+
}
|
|
151
|
+
|
|
100
152
|
fs.appendFileSync(logPath, JSON.stringify(record) + '\n');
|
|
101
153
|
} catch (_err) {
|
|
102
154
|
// Logging must NEVER break the caller. Swallow everything.
|
|
@@ -117,6 +169,20 @@ function setConfig(patch) {
|
|
|
117
169
|
const current = getConfig();
|
|
118
170
|
const p = patch && typeof patch === 'object' && !Array.isArray(patch) ? patch : {};
|
|
119
171
|
|
|
172
|
+
// Refuse an out-of-home path LOUDLY before anything is written. Reaches every setter
|
|
173
|
+
// surface (tf_log's { ok:false }, the UI's error response) with nothing persisted.
|
|
174
|
+
// `current.path` needs no re-check - getConfig only ever returns a contained path.
|
|
175
|
+
// The code lets callers tell "you sent a bad path" (client input -> the UI's 400) apart
|
|
176
|
+
// from a genuine write failure (disk, perms -> 500) without matching on message prose.
|
|
177
|
+
if (typeof p.path === 'string' && p.path.length > 0 && resolveLogPath(p.path) === null) {
|
|
178
|
+
const err = new Error(
|
|
179
|
+
`log path refused: "${p.path}" resolves outside the config home's logs directory - ` +
|
|
180
|
+
'the activity log lives inside <home>/logs'
|
|
181
|
+
);
|
|
182
|
+
err.code = 'TF_LOG_PATH_REFUSED';
|
|
183
|
+
throw err;
|
|
184
|
+
}
|
|
185
|
+
|
|
120
186
|
const next = {
|
|
121
187
|
enabled: typeof p.enabled === 'boolean' ? p.enabled : current.enabled,
|
|
122
188
|
path:
|
|
@@ -132,12 +198,55 @@ function setConfig(patch) {
|
|
|
132
198
|
return next;
|
|
133
199
|
}
|
|
134
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Read the trailing slice of a file that is expected to contain the last `wanted` lines:
|
|
203
|
+
* the final min(size, max(64 KB, wanted KB)) bytes. When the read starts mid-file the
|
|
204
|
+
* first segment may be a PARTIAL line, so it is dropped (mid-character utf8 starts land
|
|
205
|
+
* in that dropped segment too). Returns the complete lines it can vouch for, plus whether
|
|
206
|
+
* the whole file was covered - the caller falls back to a whole-file read on a shortfall
|
|
207
|
+
* (e.g. one line longer than the window), so the bounded path can never change an answer,
|
|
208
|
+
* only the bytes it took to produce it.
|
|
209
|
+
* @param {string} logPath
|
|
210
|
+
* @param {number} wanted
|
|
211
|
+
* @returns {{ lines: string[], wholeFile: boolean }}
|
|
212
|
+
*/
|
|
213
|
+
function readTrailingLines(logPath, wanted) {
|
|
214
|
+
const fd = fs.openSync(logPath, 'r');
|
|
215
|
+
try {
|
|
216
|
+
const size = fs.fstatSync(fd).size;
|
|
217
|
+
const budget = Math.max(TAIL_READ_MIN_BYTES, wanted * TAIL_BYTES_PER_LINE);
|
|
218
|
+
const readLen = Math.min(size, budget);
|
|
219
|
+
const position = size - readLen;
|
|
220
|
+
const buf = Buffer.alloc(readLen);
|
|
221
|
+
let got = 0;
|
|
222
|
+
while (got < readLen) {
|
|
223
|
+
const r = fs.readSync(fd, buf, got, readLen - got, position + got);
|
|
224
|
+
if (r <= 0) break; // shrank underneath us (rotation) - use what we have
|
|
225
|
+
got += r;
|
|
226
|
+
}
|
|
227
|
+
let text = buf.toString('utf8', 0, got);
|
|
228
|
+
if (position > 0) {
|
|
229
|
+
const firstNl = text.indexOf('\n');
|
|
230
|
+
text = firstNl === -1 ? '' : text.slice(firstNl + 1);
|
|
231
|
+
}
|
|
232
|
+
const lines = text.split('\n').filter(function (l) { return l.length > 0; });
|
|
233
|
+
return { lines, wholeFile: position === 0 };
|
|
234
|
+
} finally {
|
|
235
|
+
try { fs.closeSync(fd); } catch (_e) { /* ignore */ }
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
135
239
|
/**
|
|
136
240
|
* tail - return the last n parsed JSON lines from the configured log.
|
|
137
241
|
*
|
|
138
242
|
* Returns [] if the log is missing or unreadable. Unparseable lines are skipped. When
|
|
139
243
|
* n is not a positive finite number, all lines are returned. Never throws.
|
|
140
244
|
*
|
|
245
|
+
* A finite n reads a BOUNDED trailing slice (~1 KB budgeted per requested line), not the
|
|
246
|
+
* whole file - with a whole-file fallback when the slice holds fewer than n lines, so the
|
|
247
|
+
* answer is byte-identical to the unbounded engine's in every case. The no-argument mode
|
|
248
|
+
* still reads everything by design; rotation (MAX_LOG_BYTES) bounds that file for it.
|
|
249
|
+
*
|
|
141
250
|
* @param {number} [n] how many trailing lines to parse and return.
|
|
142
251
|
* @returns {object[]}
|
|
143
252
|
*/
|
|
@@ -145,13 +254,30 @@ function tail(n) {
|
|
|
145
254
|
try {
|
|
146
255
|
const cfg = getConfig();
|
|
147
256
|
const logPath = resolveLogPath(cfg.path);
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
257
|
+
if (logPath === null) return []; // cannot happen via getConfig (it sanitises); belt-and-braces
|
|
258
|
+
|
|
259
|
+
const bounded = typeof n === 'number' && isFinite(n) && n > 0;
|
|
260
|
+
let lines;
|
|
261
|
+
let wanted;
|
|
262
|
+
if (bounded) {
|
|
263
|
+
wanted = Math.floor(n);
|
|
264
|
+
const sliceRead = readTrailingLines(logPath, wanted);
|
|
265
|
+
if (sliceRead.lines.length >= wanted || sliceRead.wholeFile) {
|
|
266
|
+
lines = sliceRead.lines;
|
|
267
|
+
} else {
|
|
268
|
+
// Shortfall on a partial window (unusually long lines): take the exact answer
|
|
269
|
+
// over the byte bound.
|
|
270
|
+
lines = fs.readFileSync(logPath, 'utf8').split('\n').filter(function (l) {
|
|
271
|
+
return l.length > 0;
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
} else {
|
|
275
|
+
lines = fs.readFileSync(logPath, 'utf8').split('\n').filter(function (l) {
|
|
276
|
+
return l.length > 0;
|
|
277
|
+
});
|
|
278
|
+
wanted = lines.length;
|
|
279
|
+
}
|
|
152
280
|
|
|
153
|
-
const wanted =
|
|
154
|
-
typeof n === 'number' && isFinite(n) && n > 0 ? Math.floor(n) : lines.length;
|
|
155
281
|
const slice = lines.slice(-wanted);
|
|
156
282
|
|
|
157
283
|
const out = [];
|
package/src/core/matcher.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* matcher.js - does a hook's matcher string fire for a given tool?
|
|
4
|
+
* matcher.js - does a hook's matcher string fire for a given tool?
|
|
5
5
|
*
|
|
6
6
|
* Mirrors Claude Code's matcher semantics:
|
|
7
7
|
* - "" | undefined | "*" -> always fire (wildcard).
|
|
@@ -13,6 +13,38 @@
|
|
|
13
13
|
* CommonJS only. Zero host imports (core/ must run headless under node --test).
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
// One compile per distinct matcher string. matches() sits on the gate's hot path (every
|
|
17
|
+
// hook x every tool call), and rebuilding `^(?:Bash|Write|Edit)$` per call was pure waste:
|
|
18
|
+
// RegExp construction is a deterministic function of the string and ours carries no flags
|
|
19
|
+
// (no g/y -> no lastIndex state), so memoising by the string can never change an answer.
|
|
20
|
+
// Invalidation is by construction, not by event: a reloaded manifest's new matcher is a
|
|
21
|
+
// NEW string -> a new key; the old entry just goes unused. Failed compiles memoise too
|
|
22
|
+
// (a malformed matcher answers false without a fresh throw per call). The cap is a
|
|
23
|
+
// backstop against degenerate dynamically-generated matchers: past it the memo resets -
|
|
24
|
+
// worst case is exactly the old per-call compile, never unbounded growth or a wrong fire.
|
|
25
|
+
const COMPILE_CACHE_MAX = 256;
|
|
26
|
+
const compileCache = new Map(); // String(matcher) -> { re: RegExp|null, err: string|null }
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The memoised compile shared by matches() / matcherError(). Keyed on String(matcher) so a
|
|
30
|
+
* non-string matcher coerces exactly as the template literal always did.
|
|
31
|
+
* @param {*} matcher
|
|
32
|
+
* @returns {{ re: RegExp|null, err: string|null }}
|
|
33
|
+
*/
|
|
34
|
+
function compiled(matcher) {
|
|
35
|
+
const key = String(matcher);
|
|
36
|
+
let hit = compileCache.get(key);
|
|
37
|
+
if (hit) return hit;
|
|
38
|
+
try {
|
|
39
|
+
hit = { re: new RegExp(`^(?:${key})$`), err: null };
|
|
40
|
+
} catch (err) {
|
|
41
|
+
hit = { re: null, err: (err && err.message) || String(err) };
|
|
42
|
+
}
|
|
43
|
+
if (compileCache.size >= COMPILE_CACHE_MAX) compileCache.clear();
|
|
44
|
+
compileCache.set(key, hit);
|
|
45
|
+
return hit;
|
|
46
|
+
}
|
|
47
|
+
|
|
16
48
|
/**
|
|
17
49
|
* @param {string|undefined|null} matcher the hook's matcher string.
|
|
18
50
|
* @param {string|undefined|null} toolName the tool the model requested, or
|
|
@@ -26,7 +58,7 @@ function matches(matcher, toolName) {
|
|
|
26
58
|
}
|
|
27
59
|
|
|
28
60
|
// Tool-less events: there is nothing to match against, so the matcher is
|
|
29
|
-
// ignored and the hook always fires
|
|
61
|
+
// ignored and the hook always fires. This is checked AFTER the wildcard
|
|
30
62
|
// case so a "*" matcher is handled uniformly, and BEFORE we attempt a regex
|
|
31
63
|
// (which would otherwise need a string to test).
|
|
32
64
|
if (toolName === undefined || toolName === null) {
|
|
@@ -36,16 +68,33 @@ function matches(matcher, toolName) {
|
|
|
36
68
|
// Anything else is a regex, anchored as a full match against toolName.
|
|
37
69
|
// We wrap in ^(?:...)$ so alternations like "Bash|Write" mean "the whole tool
|
|
38
70
|
// name is one of these", not "contains one of these".
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
//
|
|
44
|
-
//
|
|
71
|
+
const c = compiled(matcher);
|
|
72
|
+
if (!c.re) {
|
|
73
|
+
// A malformed matcher must never throw out of the engine, and matches() cannot
|
|
74
|
+
// claim a fire it could not evaluate - so this answers false. That is NOT
|
|
75
|
+
// permission to proceed: callers that gate on the answer must distinguish
|
|
76
|
+
// "did not match" from "could not be read" via matcherError() (hook-engine
|
|
77
|
+
// denies the call; hook-loader.addEntry refuses to store the matcher at all).
|
|
45
78
|
return false;
|
|
46
79
|
}
|
|
47
80
|
|
|
48
|
-
return re.test(toolName);
|
|
81
|
+
return c.re.test(toolName);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Why does this matcher NOT compile? Returns null for a wildcard/unset matcher or one that
|
|
86
|
+
* compiles under the same ^(?:...)$ anchoring matches() applies; otherwise the compile error's
|
|
87
|
+
* message. This is the discriminator between "did not match" and "could not be read":
|
|
88
|
+
* authoring surfaces (hook-loader.addEntry, and through it the UI and tf_hook_add) use it to
|
|
89
|
+
* REFUSE a pattern that can never be evaluated, and hook-engine.fire treats a non-null answer
|
|
90
|
+
* on a tool-bearing event as an unreadable gate and denies the call (matcher-compile.test.js).
|
|
91
|
+
*
|
|
92
|
+
* @param {string|undefined|null} m the hook's matcher string.
|
|
93
|
+
* @returns {string|null} the compile problem, or null when the matcher is usable.
|
|
94
|
+
*/
|
|
95
|
+
function matcherError(m) {
|
|
96
|
+
if (m === undefined || m === null || m === '' || m === '*') return null;
|
|
97
|
+
return compiled(m).err;
|
|
49
98
|
}
|
|
50
99
|
|
|
51
|
-
module.exports = { matches };
|
|
100
|
+
module.exports = { matches, matcherError };
|
|
@@ -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 };
|
package/src/core/requires.js
CHANGED
|
@@ -98,7 +98,7 @@ function checkRequires(home) {
|
|
|
98
98
|
const command = entry.command.trim();
|
|
99
99
|
// A requires command is a bare PROGRAM NAME, never a shell string. This is load-bearing on
|
|
100
100
|
// Windows: the cmd.exe shim fallback below hands the token to cmd, where an unquoted
|
|
101
|
-
// metacharacter ("x&
|
|
101
|
+
// metacharacter ("x&denied") would CHAIN commands - a probe must never be able to run anything
|
|
102
102
|
// beyond `<program> <versionArg>`. Reject anything shell-shaped up front (also catches paths:
|
|
103
103
|
// declare tools by PATH name, which is the only portable claim a pack can make anyway).
|
|
104
104
|
if (!/^[A-Za-z0-9][A-Za-z0-9._+-]*$/.test(command)) {
|
package/src/extend/add-hook.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
This is the instruction served by `toolfunnel_howto({ topic: "add-hook" })`. It explains how to add a hook
|
|
4
4
|
to the Hook Engine: the manifest entry shape, where the script goes, and how the enabled/disabled
|
|
5
|
-
state is persisted separately in `hooks.state.json`.
|
|
5
|
+
state is persisted separately in `hooks.state.json`. This is
|
|
6
6
|
the authoring view.
|
|
7
7
|
|
|
8
8
|
A hook is exactly two things:
|
|
@@ -79,7 +79,7 @@ The payload a tool event receives on stdin:
|
|
|
79
79
|
(`PreToolUse` → `tool_name` + `tool_input`; `PostToolUse` adds `tool_response`. Tool-less
|
|
80
80
|
events carry their own fields - `prompt`, `source` - instead.)
|
|
81
81
|
|
|
82
|
-
Two protocols
|
|
82
|
+
Two protocols:
|
|
83
83
|
|
|
84
84
|
**A) Exit-code (simple):**
|
|
85
85
|
- `exit 0` → success. On `SessionStart`/`UserPromptSubmit`, stdout becomes **injected context**;
|
package/src/extend/add-mcp.md
CHANGED
|
@@ -5,15 +5,12 @@ This is the instruction served by `toolfunnel_howto({ topic: "add-mcp" })`. It e
|
|
|
5
5
|
tools to expose downstream** to the CLI. All of this is configured in one persisted file:
|
|
6
6
|
`mcp/expose.json`.
|
|
7
7
|
|
|
8
|
-
The architecture
|
|
8
|
+
The architecture: many upstream MCPs each expose many tools; the
|
|
9
9
|
aggregator connects to them and the **MCP Manager picks which tools to surface** to the CLI as
|
|
10
10
|
*curated-direct* tools - alongside the four lean meta-tools (`toolfunnel_list_tools`,
|
|
11
11
|
`toolfunnel_tool_instructions`, `toolfunnel_run_tool`, `toolfunnel_howto`). The CLI sees a small surface; everything
|
|
12
12
|
else stays reachable through the register.
|
|
13
13
|
|
|
14
|
-
> Phase note: the live aggregator (`src/mcp/aggregator.js`) is Phase 2. This doc defines the
|
|
15
|
-
> `expose.json` shape so the config is authorable now and the aggregator reads exactly these fields
|
|
16
|
-
> when it lands.
|
|
17
14
|
|
|
18
15
|
---
|
|
19
16
|
|
|
@@ -69,7 +66,7 @@ from them:
|
|
|
69
66
|
|---|---|---|
|
|
70
67
|
| `id` | string | Stable key for this upstream. Referenced by `expose[].upstream`. Unique. |
|
|
71
68
|
| `transport` | string | How the aggregator connects. `"stdio"` (spawn `command`+`args`) is the baseline. |
|
|
72
|
-
| `command` / `args` / `env` | string / string[] / object | The process the aggregator spawns for a `stdio` upstream.
|
|
69
|
+
| `command` / `args` / `env` | string / string[] / object | The process the aggregator spawns for a `stdio` upstream. The isolation guard refuses to connect an upstream whose path-shaped **args** (including `--flag=value` values), **cwd**, or code-loading **env** vars (`NODE_OPTIONS`, `NODE_PATH`, and their equivalents) reach outside the config home. (The `command` itself, an executable, is not guarded. Opt-outs, each warned loudly by name: the currently-WRAPPED upstream is exempt; `allowOutsidePaths: true` on the entry exempts its paths; `allowCodeLoadingEnv: true` exempts the env class.) |
|
|
73
70
|
| `enabled` | boolean | Whether the aggregator connects to this upstream at startup. |
|
|
74
71
|
| `description` | string | Human-facing note for the MCP Manager UI. |
|
|
75
72
|
|
|
@@ -105,7 +102,7 @@ an `expose` entry (no upstream reconnect required to *list*, though see §3 on t
|
|
|
105
102
|
|
|
106
103
|
## 3. Curated-direct vs. the register (update cost)
|
|
107
104
|
|
|
108
|
-
This matters because the two layers refresh differently
|
|
105
|
+
This matters because the two layers refresh differently:
|
|
109
106
|
|
|
110
107
|
- **Curated-direct tools** (what `expose[]` produces) are negotiated with the CLI **at connect time**.
|
|
111
108
|
Changing the exposed set therefore needs a **reconnect**, or a `notifications/tools/list_changed`
|
package/src/extend/configure.md
CHANGED
|
@@ -71,7 +71,8 @@ legacy MCP protocol permanently (opt-in, warns loudly); `"env"` passes environme
|
|
|
71
71
|
the spawned server; `"cwd"` sets its working directory; `"timeoutMs"` raises the payload
|
|
72
72
|
timeout for that upstream (default 120000 ms - applies to `tools/call`, `prompts/get` and
|
|
73
73
|
`resources/read`; under a wrap, a tool that reports progress keeps its call alive regardless.
|
|
74
|
-
|
|
74
|
+
the separate 10 s handshake/list window detects dead servers, and `"requestTimeoutMs"` raises
|
|
75
|
+
it for a server that needs longer to boot); `"modernOnly": true`
|
|
75
76
|
requires that upstream to speak the modern (2026-07-28) protocol - the connect fails with a
|
|
76
77
|
clear error instead of falling back to legacy (the mirror of `legacyPin`; setting both on one
|
|
77
78
|
upstream is refused). Server side, `"serveLegacy": false` in `toolfunnel.json` makes the
|
|
@@ -129,7 +129,7 @@ toolfunnel_run_tool(name, args)
|
|
|
129
129
|
- **The PreToolUse gate is the whole safety case.** A hook denies a call by returning, on exit 0,
|
|
130
130
|
`{ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny",
|
|
131
131
|
"permissionDecisionReason": "..." } }` - OR by `exit 2` with the reason on stderr. (PreToolUse uses
|
|
132
|
-
`permissionDecision`, NOT a top-level `decision`
|
|
132
|
+
`permissionDecision`, NOT a top-level `decision` . When that fires,
|
|
133
133
|
`run` returns an error result and the script is never spawned.
|
|
134
134
|
- `permissionDecision: "allow"` passes; `"ask"` is non-blocking in the autonomous host (no
|
|
135
135
|
interactive prompt) - the reason is captured but the tool proceeds.
|
package/src/extend/howto.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* howto.js - the loader behind the `toolfunnel_howto` meta-tool
|
|
4
|
+
* howto.js - the loader behind the `toolfunnel_howto` meta-tool.
|
|
5
5
|
*
|
|
6
6
|
* `toolfunnel_howto({ topic })` returns the self-extension instructions for one topic. This module
|
|
7
7
|
* resolves a topic to its authored markdown file (the topic -> file map below) and reads it.
|
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
* howto(topic) -> string // the markdown content for that topic
|
|
10
10
|
*
|
|
11
11
|
* Topics: create-tool | add-mcp | add-hook | package | wrap | configure
|
|
12
|
-
* (the original four
|
|
12
|
+
* (the original four topics, plus the 0.6.0 pair - the transparent wrap and the no-code
|
|
13
13
|
* config map - so a primer-less agent can learn the headline feature from inside).
|
|
14
14
|
* Unknown topics THROW (the protocol layer turns that into a clean tool error, not a crash).
|
|
15
15
|
*
|
|
16
16
|
* Isolation: reads are confined to THIS directory (src/extend). The topic map is a fixed
|
|
17
|
-
* allow-list of basenames - there is no path interpolation from the caller, so
|
|
17
|
+
* allow-list of basenames - there is no path interpolation from the caller, so an untrusted
|
|
18
18
|
* `topic` cannot traverse out of src/extend. We additionally re-verify the resolved path is
|
|
19
19
|
* inside this directory (defense-in-depth, mirroring the hook loader's writeScript guard).
|
|
20
20
|
*
|
package/src/extend/package.md
CHANGED
|
@@ -38,8 +38,9 @@ all three yourself in anything you install.
|
|
|
38
38
|
The per-UNIT packaging below remains the right shape for sharing a single tool/hook/MCP selection
|
|
39
39
|
rather than a whole gateway.
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
The per-unit package is a CONVENTION, not machinery: export and import are the structured
|
|
42
|
+
hand-steps below (each one maps onto a management function), so a package needs no importer
|
|
43
|
+
binary - and the whole-setup `tf_pack` above covers the common case with one call.
|
|
43
44
|
|
|
44
45
|
---
|
|
45
46
|
|
|
@@ -59,8 +60,8 @@ A package gathers one or more of these, plus the files they reference.
|
|
|
59
60
|
|
|
60
61
|
## 2. Package layout
|
|
61
62
|
|
|
62
|
-
A package is a folder
|
|
63
|
-
tree so import is a structured copy, not guesswork:
|
|
63
|
+
A package is a folder (conventionally `packages/<name>/` inside the config home) containing a
|
|
64
|
+
manifest and the units' files in a mirrored tree so import is a structured copy, not guesswork:
|
|
64
65
|
|
|
65
66
|
```
|
|
66
67
|
packages/
|
|
@@ -124,7 +125,7 @@ entries):
|
|
|
124
125
|
"id": "notify",
|
|
125
126
|
"transport": "stdio",
|
|
126
127
|
"command": "node",
|
|
127
|
-
"args": ["
|
|
128
|
+
"args": ["mcp/vendor/notify-mcp/server.js"],
|
|
128
129
|
"enabled": true,
|
|
129
130
|
"description": "Messaging bridge MCP."
|
|
130
131
|
},
|
|
@@ -146,8 +147,9 @@ entries):
|
|
|
146
147
|
| `units.hooks[]` | Each has the full manifest `entry`, its seed `enabled` (for `hooks.state.json`), and `files`. |
|
|
147
148
|
| `units.mcp[]` | Each has an `upstream` block, its `expose[]` selections, and any vendored `files`. |
|
|
148
149
|
|
|
149
|
-
|
|
150
|
-
|
|
150
|
+
Write vendored-server paths RELATIVE and set the upstream's `cwd` to the installed package
|
|
151
|
+
folder (or write the absolute inside-home path at import time) - the isolation guard requires
|
|
152
|
+
them inside the config home either way.
|
|
151
153
|
|
|
152
154
|
---
|
|
153
155
|
|
|
@@ -178,7 +180,8 @@ Import is the reverse: **drop a package folder in `packages/` + register its uni
|
|
|
178
180
|
3. **Hooks:** copy each `units.hooks[].files` into `hooks/scripts/` and add each `entry` to
|
|
179
181
|
`hooks.manifest.json`; write its `enabled` into `hooks.state.json`.
|
|
180
182
|
4. **MCP:** copy vendored `files` into the package's `mcp/vendor/`, add each `upstream` to
|
|
181
|
-
`expose.json` `upstreams[]` and its `expose[]` selections
|
|
183
|
+
`expose.json` `upstreams[]` and its `expose[]` selections, rewriting the vendored paths for
|
|
184
|
+
their installed location (inside the config home - the isolation rule).
|
|
182
185
|
|
|
183
186
|
After import: tools are live immediately (the register is read fresh on every `toolfunnel_list_tools`).
|
|
184
187
|
Hooks take effect once the loader re-scans (next startup, or a reload). Curated-direct MCP tools need
|