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
@@ -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 repo root (two dirs up from src/core/). The configured
19
- * log `path` may be relative (resolved against root) or absolute.
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
- * Resolve a (possibly relative) log path against the repo root.
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
- return path.isAbsolute(p) ? p : path.join(ROOT, p);
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
- const raw = fs.readFileSync(logPath, 'utf8');
149
- const lines = raw.split('\n').filter(function (l) {
150
- return l.length > 0;
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 = [];
@@ -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? (HOOK_ENGINE.md §5)
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 (§5). This is checked AFTER the wildcard
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
- let re;
40
- try {
41
- re = new RegExp(`^(?:${matcher})$`);
42
- } catch (err) {
43
- // A malformed matcher must never throw out of the engine. Treat an
44
- // uncompilable pattern as "does not match" - fail closed, stay defensive.
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 };
@@ -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&evil") would CHAIN commands - a probe must never be able to run anything
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)) {
@@ -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`. The full contract is HOOK_ENGINE.md - this is
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 (HOOK_ENGINE.md §3):
82
+ Two protocols:
83
83
 
84
84
  **A) Exit-code (simple):**
85
85
  - `exit 0` → success. On `SessionStart`/`UserPromptSubmit`, stdout becomes **injected context**;
@@ -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 (see the architecture overview, §1): many upstream MCPs each expose many tools; the
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. Path-shaped **args** must stay **inside the config home** - the isolation guard refuses to connect an upstream whose args escape it. (The `command` itself, an executable, is not guarded; the currently-WRAPPED upstream is exempt in transparent-wrapper mode, with a warning.) |
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 (see the architecture overview, §1):
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`
@@ -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
- The 10 s handshake/list window is fixed: it detects dead servers); `"modernOnly": true`
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` - see HOOK_ENGINE.md §3.) When that fires,
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.
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * howto.js - the loader behind the `toolfunnel_howto` meta-tool (architecture notes §2 + §6).
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 from §2, plus the 0.6.0 pair - the transparent wrap and the no-code
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 a malicious
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
  *
@@ -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
- > Phase note: packages are Phase 2 (`packages/` directory). The structure below is what the importer
42
- > and exporter target; defining it now means the units authored today are already package-ready.
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 under `packages/` containing a manifest and the units' files in a mirrored
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": ["${PACKAGE_DIR}/mcp/vendor/notify-mcp/server.js"],
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
- `${PACKAGE_DIR}` expands to the installed package's absolute path (mirrors `${HOOKS_DIR}` /
150
- `${TOOLS_DIR}`), so a package's MCP/tool commands resolve wherever it lands.
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 (path-rewritten to `${PACKAGE_DIR}`).
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