toolfunnel 0.7.0 → 0.8.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 +238 -235
- package/SECURITY.md +84 -84
- package/bin/toolfunnel.js +530 -451
- package/docs/KNOWN_BUGS.md +100 -80
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +84 -84
- package/docs/packaging.md +116 -115
- package/hooks/hooks.manifest.json +27 -27
- package/hooks/scripts/example-audit.js +21 -21
- package/hooks/scripts/example-deny-dangerous.js +39 -39
- package/mcp/expose.example.json +30 -30
- package/mcp/servers/mock-modern/server.js +147 -147
- package/mcp/servers/mock-upstream/server.js +379 -380
- package/package.json +67 -67
- package/src/auth/config.js +172 -174
- package/src/auth/install.js +181 -181
- package/src/auth/resource-server.js +358 -353
- package/src/core/atomic-write.js +97 -0
- package/src/core/child-reaper.js +61 -61
- package/src/core/config-home.js +226 -134
- package/src/core/events.js +105 -105
- package/src/core/hook-engine.js +304 -329
- package/src/core/hook-loader.js +766 -824
- package/src/core/hook-runner.js +615 -566
- package/src/core/logger.js +295 -297
- package/src/core/matcher.js +100 -100
- package/src/core/metrics.js +90 -75
- package/src/core/output-cap.js +31 -0
- package/src/core/rename-migration.js +178 -163
- package/src/core/requires.js +138 -138
- package/src/core/server-config.js +98 -98
- package/src/extend/add-hook.md +155 -152
- package/src/extend/add-mcp.md +119 -119
- package/src/extend/configure.md +88 -88
- package/src/extend/create-tool.md +168 -168
- package/src/extend/howto.js +102 -102
- package/src/extend/package.md +190 -190
- package/src/extend/wrap.md +99 -99
- package/src/mcp/aggregator.js +2248 -1674
- package/src/mcp/expose-store.js +755 -577
- package/src/mcp/gated-run.js +210 -210
- package/src/mcp/http-transport.js +1657 -1563
- package/src/mcp/mcp-client.js +1174 -1070
- package/src/mcp/modern.js +409 -406
- package/src/mcp/protocol.js +473 -468
- package/src/mcp/server.js +3317 -3112
- package/src/mcp/tool-approvals.js +396 -0
- package/src/tools/registry.js +607 -661
- package/src/tools/tool-state.js +170 -170
- package/src/ui/public/app.js +1246 -1187
- package/src/ui/public/index.html +286 -286
- package/src/ui/public/styles.css +535 -535
- package/src/ui/server.js +1742 -1616
- package/tools/scripts/base64.js +139 -139
- package/tools/scripts/danger.js +61 -61
- package/tools/scripts/echo.js +39 -39
- package/tools/scripts/hash.js +101 -101
- package/tools/scripts/json.js +109 -109
- package/tools/scripts/text-stats.js +131 -131
- package/tools/scripts/tf-accept-change.js +67 -0
- package/tools/scripts/tf-env.js +43 -43
- package/tools/scripts/tf-hook-add.js +151 -151
- package/tools/scripts/tf-hook-set.js +120 -120
- package/tools/scripts/tf-list.js +192 -192
- package/tools/scripts/tf-log.js +86 -86
- package/tools/scripts/tf-mcp-add.js +130 -117
- package/tools/scripts/tf-mcp-set.js +121 -121
- package/tools/scripts/tf-pack.js +237 -237
- package/tools/scripts/tf-tool-add.js +134 -134
- package/tools/scripts/tf-tool-set.js +142 -142
- package/tools/scripts/tf-wrap.js +103 -103
- package/tools/scripts/uuid.js +102 -102
- package/tools/tools.register.json +16 -5
|
@@ -1,163 +1,178 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* rename-migration.js - keep gates and per-tool state attached across a SURFACED-NAME change
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
if (
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if (hasOldState
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
*
|
|
136
|
-
* (
|
|
137
|
-
*
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* rename-migration.js - keep gates and per-tool state attached across a SURFACED-NAME change.
|
|
5
|
+
*
|
|
6
|
+
* A tool's SURFACED name is the string the PreToolUse gate matches and the tools.state.json
|
|
7
|
+
* key is written against. When that name changes (an expose `as` edit, an enabled flip, a
|
|
8
|
+
* local display-name edit) and the gate matcher and the state key keep the OLD string, a deny
|
|
9
|
+
* gate stops firing and a disabled tool comes back. The policy here,
|
|
10
|
+
* MATCHER-MIGRATE-ON-RENAME:
|
|
11
|
+
*
|
|
12
|
+
* - A WILDCARD matcher ('', '*', unset) fires for every tool name - it already covers the
|
|
13
|
+
* new name; untouched.
|
|
14
|
+
* - An EXACT-LITERAL matcher - the `escapeRegex(oldName)` shape every ToolFunnel surface
|
|
15
|
+
* (tf_* tools, the UI) writes - is REWRITTEN to `escapeRegex(newName)`.
|
|
16
|
+
* - Any OTHER matcher that would fire for the old name (same anchored-regex semantics as
|
|
17
|
+
* core/matcher.js, the gate's own matching) is one WE DID NOT AUTHOR and cannot safely
|
|
18
|
+
* rewrite: the rename is REFUSED with the hook named. Update the hook first, then rename.
|
|
19
|
+
* - tools.state.json: the old key's entry (enabled/hidden/hot) MOVES to the new key. A
|
|
20
|
+
* collision (both keys present with different content) refuses - never clobber curation.
|
|
21
|
+
*
|
|
22
|
+
* Validation runs COMPLETELY before the first write, and the caller invokes this BEFORE
|
|
23
|
+
* persisting its own rename - a refusal leaves every file untouched.
|
|
24
|
+
*
|
|
25
|
+
* CommonJS only. Node built-ins + core/matcher only (core/ stays headless).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const fs = require('node:fs');
|
|
29
|
+
const path = require('node:path');
|
|
30
|
+
const { matches, matcherError } = require('./matcher');
|
|
31
|
+
|
|
32
|
+
/** Regex-escape a literal so a matcher built from it FULL-matches the string verbatim.
|
|
33
|
+
* (Same shape as ui/server.js escapeRegex - the shape ToolFunnel-authored matchers use.) */
|
|
34
|
+
function escapeRegex(str) {
|
|
35
|
+
return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// THE atomic writer (temp + fsync + rename, failure-path unlink, stale sweep) - shared by every store.
|
|
39
|
+
// This file's own copy had neither the unlink nor the sweep.
|
|
40
|
+
const { atomicWriteJson, atomicWriteText } = require('./atomic-write');
|
|
41
|
+
|
|
42
|
+
function readJsonIfPresent(p) {
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
45
|
+
} catch (_e) {
|
|
46
|
+
return null; // absent or unreadable -> nothing to migrate there
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Migrate every gate matcher and state key from oldName to newName, or throw a refusal.
|
|
52
|
+
* @param {object} o
|
|
53
|
+
* @param {string} o.hooksPath hooks.manifest.json path (may be absent on disk)
|
|
54
|
+
* @param {string} o.statePath tools.state.json path (may be absent on disk)
|
|
55
|
+
* @param {string} o.oldName the surfaced name being retired
|
|
56
|
+
* @param {string} o.newName the surfaced name taking over
|
|
57
|
+
*/
|
|
58
|
+
function migrateSurfacedName(o) {
|
|
59
|
+
const { hooksPath, statePath, oldName, newName } = o || {};
|
|
60
|
+
if (typeof oldName !== 'string' || typeof newName !== 'string' || oldName === newName) return;
|
|
61
|
+
|
|
62
|
+
// ── Phase A: VALIDATE everything, write nothing ─────────────────────────────────────────────
|
|
63
|
+
const manifest = hooksPath ? readJsonIfPresent(hooksPath) : null;
|
|
64
|
+
const hooks = manifest && Array.isArray(manifest.hooks) ? manifest.hooks : [];
|
|
65
|
+
const literal = escapeRegex(oldName);
|
|
66
|
+
const rewrites = [];
|
|
67
|
+
for (const h of hooks) {
|
|
68
|
+
if (!h || typeof h !== 'object') continue;
|
|
69
|
+
const m = h.matcher;
|
|
70
|
+
if (m === undefined || m === null || m === '' || m === '*') continue; // wildcard covers both names
|
|
71
|
+
if (m === literal || m === oldName) {
|
|
72
|
+
// The exact-literal shape ToolFunnel authors (escaped, or the bare name when it contains
|
|
73
|
+
// no regex specials - the two are identical then, but hand-written manifests may carry
|
|
74
|
+
// the bare form for a name that DOES contain specials, e.g. "my.tool").
|
|
75
|
+
rewrites.push(h);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
// A matcher that does not COMPILE cannot be evaluated against oldName at all - matches()
|
|
79
|
+
// would answer false, silently carrying the rename past a hook this code cannot read.
|
|
80
|
+
// Same policy as a matching non-literal: refuse, name the hook, touch nothing.
|
|
81
|
+
// (addEntry refuses these at authoring, so only a hand-edited manifest reaches here.)
|
|
82
|
+
const compileProblem = matcherError(m);
|
|
83
|
+
if (compileProblem) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`rename refused: hook "${h.id || '(unnamed)'}" matcher ${JSON.stringify(m)} does not ` +
|
|
86
|
+
`compile (${compileProblem}) - it cannot be checked against "${oldName}", so the rename ` +
|
|
87
|
+
'cannot proceed safely. Fix the hook first, then rename'
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
if (matches(m, oldName)) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`rename refused: hook "${h.id || '(unnamed)'}" matcher "${m}" matches "${oldName}" but is ` +
|
|
93
|
+
'not an exact-literal matcher ToolFunnel can rewrite - update the hook to cover ' +
|
|
94
|
+
`"${newName}" first, then rename`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const state = statePath ? readJsonIfPresent(statePath) : null;
|
|
100
|
+
const hasOldState = !!(state && Object.prototype.hasOwnProperty.call(state, oldName));
|
|
101
|
+
if (hasOldState && Object.prototype.hasOwnProperty.call(state, newName) &&
|
|
102
|
+
JSON.stringify(state[oldName]) !== JSON.stringify(state[newName])) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`rename refused: tools.state.json already has an entry for "${newName}" that differs from ` +
|
|
105
|
+
`"${oldName}"'s - resolve the curation collision first, then rename`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Phase B: WRITE (all validation passed) ──────────────────────────────────────────────────
|
|
110
|
+
if (rewrites.length) {
|
|
111
|
+
for (const h of rewrites) h.matcher = escapeRegex(newName);
|
|
112
|
+
atomicWriteJson(hooksPath, manifest);
|
|
113
|
+
}
|
|
114
|
+
if (hasOldState) {
|
|
115
|
+
state[newName] = state[oldName];
|
|
116
|
+
delete state[oldName];
|
|
117
|
+
atomicWriteJson(statePath, state);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Convenience for the two stores: derive the sibling config paths from the CONFIG-HOME layout
|
|
123
|
+
* (<root>/mcp/expose.json, <root>/tools/tools.register.json, <root>/tools/tools.state.json,
|
|
124
|
+
* <root>/hooks/hooks.manifest.json) and migrate. `storeFilePath` is the calling store's own
|
|
125
|
+
* file; its grandparent is the home root.
|
|
126
|
+
*
|
|
127
|
+
* `opts.migrateState` (default TRUE) selects whether the tools.state.json key moves with the
|
|
128
|
+
* name. The gate matcher ALWAYS moves - it genuinely fires on the surfaced name for both stores.
|
|
129
|
+
* The state key does NOT, because the overlay has TWO key namespaces:
|
|
130
|
+
* - UPSTREAM tools are keyed by their SURFACED name (server.js:638,733,924; ui:1273) - an
|
|
131
|
+
* expose `as` edit changes that key, so ExposeStore migrates state (the default).
|
|
132
|
+
* - LOCAL register tools are keyed by their register ID (server.js:290,337,814-815,952;
|
|
133
|
+
* ui:326-328; tf-tool-set.js) - an id is immutable and a display-name edit does not touch
|
|
134
|
+
* it, so Registry passes migrateState:false. Moving the key there would ORPHAN the entry:
|
|
135
|
+
* the readers still look up the id, find nothing, and fall back to the defaults - which are
|
|
136
|
+
* enabled - so a tool deliberately switched off would come back on (rename-safety S5).
|
|
137
|
+
* Bites whenever name === id, i.e. every tf_* management tool.
|
|
138
|
+
*/
|
|
139
|
+
function migrateForStoreFile(storeFilePath, oldName, newName, opts) {
|
|
140
|
+
const root = path.dirname(path.dirname(path.resolve(storeFilePath)));
|
|
141
|
+
const migrateState = !(opts && opts.migrateState === false);
|
|
142
|
+
migrateSurfacedName({
|
|
143
|
+
hooksPath: path.join(root, 'hooks', 'hooks.manifest.json'),
|
|
144
|
+
statePath: migrateState ? path.join(root, 'tools', 'tools.state.json') : null,
|
|
145
|
+
oldName,
|
|
146
|
+
newName,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The sibling config files a migration may write, derived as migrateForStoreFile does (the store's
|
|
152
|
+
* grandparent is the config-home root). Exported so a caller can snapshot them BEFORE a migration and
|
|
153
|
+
* restore them if the write that follows fails - re-running a migration in reverse is not an unwind:
|
|
154
|
+
* it re-validates, and a pattern hook that matches the NEW name refuses it, leaving the manifest
|
|
155
|
+
* naming a surface the store does not (rename-rollback.test.js, case A2).
|
|
156
|
+
*/
|
|
157
|
+
function snapshotForStoreFile(storeFilePath) {
|
|
158
|
+
const root = path.dirname(path.dirname(storeFilePath));
|
|
159
|
+
const files = [path.join(root, 'hooks', 'hooks.manifest.json'), path.join(root, 'tools', 'tools.state.json')];
|
|
160
|
+
const snap = [];
|
|
161
|
+
for (const f of files) {
|
|
162
|
+
let bytes = null;
|
|
163
|
+
try { bytes = fs.readFileSync(f); } catch (_e) { bytes = null; } // absent stays absent
|
|
164
|
+
snap.push({ path: f, bytes });
|
|
165
|
+
}
|
|
166
|
+
return snap;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Put the snapshotted bytes back exactly (absent files are removed again). Best-effort, never throws. */
|
|
170
|
+
function restoreSnapshot(snap) {
|
|
171
|
+
for (const { path: f, bytes } of snap || []) {
|
|
172
|
+
try {
|
|
173
|
+
if (bytes == null) { if (fs.existsSync(f)) fs.unlinkSync(f); } else { atomicWriteText(f, bytes); }
|
|
174
|
+
} catch (_e) { /* best-effort - the caller's own error is the story */ }
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
module.exports = { migrateSurfacedName, migrateForStoreFile, escapeRegex, snapshotForStoreFile, restoreSnapshot };
|