toolfunnel 0.7.0 → 0.7.1

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 (67) hide show
  1. package/README.md +228 -235
  2. package/SECURITY.md +84 -84
  3. package/bin/toolfunnel.js +451 -451
  4. package/docs/KNOWN_BUGS.md +80 -80
  5. package/docs/design.md +84 -84
  6. package/docs/packaging.md +115 -115
  7. package/hooks/hooks.manifest.json +27 -27
  8. package/hooks/scripts/example-audit.js +21 -21
  9. package/hooks/scripts/example-deny-dangerous.js +39 -39
  10. package/mcp/expose.example.json +30 -30
  11. package/mcp/servers/mock-modern/server.js +147 -147
  12. package/mcp/servers/mock-upstream/server.js +380 -380
  13. package/package.json +67 -67
  14. package/src/auth/config.js +174 -174
  15. package/src/auth/install.js +181 -181
  16. package/src/auth/resource-server.js +353 -353
  17. package/src/core/child-reaper.js +61 -61
  18. package/src/core/config-home.js +134 -134
  19. package/src/core/events.js +105 -105
  20. package/src/core/hook-engine.js +329 -329
  21. package/src/core/hook-loader.js +824 -824
  22. package/src/core/hook-runner.js +566 -566
  23. package/src/core/logger.js +297 -297
  24. package/src/core/matcher.js +100 -100
  25. package/src/core/metrics.js +75 -75
  26. package/src/core/rename-migration.js +163 -163
  27. package/src/core/requires.js +138 -138
  28. package/src/core/server-config.js +98 -98
  29. package/src/extend/add-hook.md +152 -152
  30. package/src/extend/add-mcp.md +119 -119
  31. package/src/extend/configure.md +88 -88
  32. package/src/extend/create-tool.md +168 -168
  33. package/src/extend/howto.js +102 -102
  34. package/src/extend/package.md +190 -190
  35. package/src/extend/wrap.md +99 -99
  36. package/src/mcp/aggregator.js +1719 -1674
  37. package/src/mcp/expose-store.js +577 -577
  38. package/src/mcp/gated-run.js +210 -210
  39. package/src/mcp/http-transport.js +1563 -1563
  40. package/src/mcp/mcp-client.js +1070 -1070
  41. package/src/mcp/modern.js +406 -406
  42. package/src/mcp/protocol.js +468 -468
  43. package/src/mcp/server.js +3112 -3112
  44. package/src/tools/registry.js +661 -661
  45. package/src/tools/tool-state.js +170 -170
  46. package/src/ui/public/app.js +1187 -1187
  47. package/src/ui/public/index.html +286 -286
  48. package/src/ui/public/styles.css +535 -535
  49. package/src/ui/server.js +1616 -1616
  50. package/tools/scripts/base64.js +139 -139
  51. package/tools/scripts/danger.js +61 -61
  52. package/tools/scripts/echo.js +39 -39
  53. package/tools/scripts/hash.js +101 -101
  54. package/tools/scripts/json.js +109 -109
  55. package/tools/scripts/text-stats.js +131 -131
  56. package/tools/scripts/tf-env.js +43 -43
  57. package/tools/scripts/tf-hook-add.js +151 -151
  58. package/tools/scripts/tf-hook-set.js +120 -120
  59. package/tools/scripts/tf-list.js +192 -192
  60. package/tools/scripts/tf-log.js +86 -86
  61. package/tools/scripts/tf-mcp-add.js +117 -117
  62. package/tools/scripts/tf-mcp-set.js +121 -121
  63. package/tools/scripts/tf-pack.js +237 -237
  64. package/tools/scripts/tf-tool-add.js +134 -134
  65. package/tools/scripts/tf-tool-set.js +142 -142
  66. package/tools/scripts/tf-wrap.js +103 -103
  67. package/tools/scripts/uuid.js +102 -102
@@ -1,210 +1,210 @@
1
- 'use strict';
2
-
3
- /**
4
- * gated-run.js - the SAFETY CRUX of the gateway.
5
- *
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
9
- * (HookEngine.fire returns { injected, blocked, reason, stopLoop, results }; the engine has
10
- * ALREADY normalised exit-2 / hookSpecificOutput.permissionDecision:"deny" into { blocked, reason }).
11
- *
12
- * This is the single place where "run a tool" becomes "fire PreToolUse -> maybe execute ->
13
- * fire PostToolUse". Every powerful run-path (toolfunnel_run_tool, registry.run) funnels through here so
14
- * the gate is impossible to bypass. The LOAD-BEARING INVARIANT, proven by test:
15
- *
16
- * A PreToolUse deny MUST prevent execute() from ever being called.
17
- *
18
- * Design rules:
19
- * - PURE: `engine` and `execute` are INJECTED. No transport, no SDK, no host, no fs.
20
- * Unit-testable under `node --test` with a fake engine + a spy execute().
21
- * - NEVER throws out of gatedRun. A thrown execute() is captured as { ok:false, error }.
22
- * A misbehaving engine.fire() (rejection or junk return) is treated conservatively.
23
- * - PostToolUse is advisory : a PostToolUse block cannot un-run the tool),
24
- * so we fire it for feedback but never let it flip ok->false or throw the path.
25
- *
26
- * Return shape (always one of these, never an exception):
27
- * blocked by PreToolUse : { ok:false, blocked:true, reason, output:null }
28
- * execute threw : { ok:false, blocked:false, error, output:null }
29
- * success : { ok:true, blocked:false, output }
30
- *
31
- * CommonJS only. Node built-ins only. No new dependencies.
32
- */
33
-
34
- // Activity log. Self-gating (no-op unless explicitly enabled) and contracted never to
35
- // throw, so wiring it here cannot alter gate behaviour or break the run path.
36
- const logger = require('../core/logger');
37
-
38
- const EVENTS = {
39
- PRE: 'PreToolUse',
40
- POST: 'PostToolUse',
41
- };
42
-
43
- /**
44
- * Fire one lifecycle event through the injected engine, defensively.
45
- *
46
- * The HookEngine contract says fire() never rejects, but gatedRun is the
47
- * safety crux: we must not assume a perfectly-behaved engine. If fire() rejects or returns a
48
- * non-object, we fail CLOSED on the gate (PreToolUse) - an engine we cannot trust to answer
49
- * "allowed?" must not be read as "allowed". PostToolUse is advisory, so a failure there is benign.
50
- *
51
- * @param {object} engine an object exposing async fire(event, ctx, extra)
52
- * @param {string} event one of EVENTS
53
- * @param {object} ctx common hook context (session_id, transcript_path, cwd, ...)
54
- * @param {object} extra event-specific fields
55
- * @returns {Promise<{ injected:string, blocked:boolean, reason:(string|null),
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).
60
- */
61
- async function fireSafely(engine, event, ctx, extra) {
62
- try {
63
- const res = await engine.fire(event, ctx, extra);
64
- // The engine already normalises blocking to { blocked, reason }. Defensively coerce a
65
- // junk/undefined return into a well-shaped result so callers can read fields safely.
66
- if (res && typeof res === 'object') {
67
- return {
68
- injected: typeof res.injected === 'string' ? res.injected : '',
69
- blocked: res.blocked === true,
70
- reason: res.reason != null ? res.reason : null,
71
- stopLoop: res.stopLoop === true,
72
- results: Array.isArray(res.results) ? res.results : [],
73
- };
74
- }
75
- // Non-object return from fire(): per-event. PreToolUse fails CLOSED - a gate that
76
- // returns junk cannot be read as "allowed". PostToolUse is advisory -> benign.
77
- if (event === EVENTS.PRE) {
78
- return {
79
- injected: '',
80
- blocked: true,
81
- reason: 'hook engine returned a non-object for ' + event + ' - failing closed',
82
- internal: true, // wiring failure, NOT operator policy - wrap paths must neutralise it
83
- stopLoop: false,
84
- results: [],
85
- };
86
- }
87
- return { injected: '', blocked: false, reason: null, stopLoop: false, results: [] };
88
- } catch (err) {
89
- // fire() should never reject; if it does, fail closed on the GATE and benign on POST.
90
- const reason =
91
- 'hook engine error during ' + event + ': ' + ((err && err.message) || String(err));
92
- if (event === EVENTS.PRE) {
93
- // Fail CLOSED: an engine that cannot answer the gate denies the run.
94
- return { injected: '', blocked: true, reason, internal: true, stopLoop: false, results: [] };
95
- }
96
- // PostToolUse is advisory and cannot un-run the tool - a failure here is benign.
97
- return { injected: '', blocked: false, reason: null, stopLoop: false, results: [] };
98
- }
99
- }
100
-
101
- /**
102
- * gatedRun - route a single tool invocation through the host's hook engine.
103
- *
104
- * Sequence:
105
- * 1. fire PreToolUse with { tool_name, tool_input: args }.
106
- * 2. if blocked -> return { ok:false, blocked:true, reason, output:null } and DO NOT call execute.
107
- * 3. else await execute(); on throw, capture as error (output stays null).
108
- * 4. fire PostToolUse with { tool_name, tool_input: args, tool_response: output }.
109
- * (PostToolUse is advisory - fired for feedback even on the error path so PostToolUse hooks
110
- * observe what happened; it never flips ok or throws.)
111
- * 5. return { ok:true, blocked:false, output } | { ok:false, blocked:false, error, output:null }.
112
- *
113
- * @param {object} params
114
- * @param {object} params.engine the host's HookEngine - async fire(event, ctx, extra). INJECTED.
115
- * @param {object} [params.ctx] common hook context (session_id, transcript_path, cwd, ...).
116
- * @param {string} params.toolName the tool name (becomes tool_name in the payload).
117
- * @param {*} [params.args] the tool input (becomes tool_input in the payload).
118
- * @param {Function} params.execute thunk that performs the actual call (from
119
- * registry.resolveExecution). `() => any | Promise<any>`. INJECTED.
120
- * @returns {Promise<{ ok:boolean, blocked:boolean, reason?:(string|null),
121
- * output:(*|null), error?:Error }>}
122
- */
123
- async function gatedRun(params) {
124
- const p = params || {};
125
- const { engine, ctx, toolName, args, execute } = p;
126
-
127
- // ---- Validate injected dependencies, failing CLOSED (never execute on a bad wiring). ----
128
- if (!engine || typeof engine.fire !== 'function') {
129
- return {
130
- ok: false,
131
- blocked: true,
132
- reason: 'gatedRun: engine with async fire() is required',
133
- internal: true, // wiring failure, NOT operator policy - wrap paths must neutralise it
134
- output: null,
135
- };
136
- }
137
- if (typeof execute !== 'function') {
138
- return {
139
- ok: false,
140
- blocked: true,
141
- reason: 'gatedRun: execute thunk (function) is required',
142
- internal: true,
143
- output: null,
144
- };
145
- }
146
-
147
- const context = ctx || {};
148
-
149
- // ---- 1. PreToolUse gate. -------------------------------------------------------------
150
- // tool_input carries the args verbatim.
151
- const pre = await fireSafely(engine, EVENTS.PRE, context, {
152
- tool_name: toolName,
153
- tool_input: args,
154
- });
155
-
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 */ }
169
-
170
- // ---- 2. Blocked -> STOP. The load-bearing invariant: execute() is NOT called. ---------
171
- if (pre.blocked) {
172
- return {
173
- ok: false,
174
- blocked: true,
175
- reason: pre.reason,
176
- // Distinguish a genuine hook DENY (operator policy - its reason may be shown to a wrapped
177
- // client) from an internal fail-closed (engine wiring/crash - its reason is a gateway tell
178
- // that must be neutralised on the wrap path).
179
- internal: pre.internal === true,
180
- output: null,
181
- };
182
- }
183
-
184
- // ---- 3. Allowed -> execute the real call. Capture a throw as `error`. -----------------
185
- let output = null;
186
- let error = null;
187
- try {
188
- output = await execute();
189
- } catch (err) {
190
- // Normalise any thrown value into an Error so the caller always gets an Error instance.
191
- error = err instanceof Error ? err : new Error(String(err));
192
- }
193
-
194
- // ---- 4. PostToolUse (advisory). Fired even on the error path so hooks see the outcome. -
195
- // tool_response is the output (null when execute threw). PostToolUse cannot un-run the tool
196
- // (PostToolUse is advisory), so its result never flips ok and never throws this path.
197
- await fireSafely(engine, EVENTS.POST, context, {
198
- tool_name: toolName,
199
- tool_input: args,
200
- tool_response: output,
201
- });
202
-
203
- // ---- 5. Final result. ----------------------------------------------------------------
204
- if (error) {
205
- return { ok: false, blocked: false, error, output: null };
206
- }
207
- return { ok: true, blocked: false, output };
208
- }
209
-
210
- module.exports = { gatedRun, EVENTS };
1
+ 'use strict';
2
+
3
+ /**
4
+ * gated-run.js - the SAFETY CRUX of the gateway.
5
+ *
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
9
+ * (HookEngine.fire returns { injected, blocked, reason, stopLoop, results }; the engine has
10
+ * ALREADY normalised exit-2 / hookSpecificOutput.permissionDecision:"deny" into { blocked, reason }).
11
+ *
12
+ * This is the single place where "run a tool" becomes "fire PreToolUse -> maybe execute ->
13
+ * fire PostToolUse". Every powerful run-path (toolfunnel_run_tool, registry.run) funnels through here so
14
+ * the gate is impossible to bypass. The LOAD-BEARING INVARIANT, proven by test:
15
+ *
16
+ * A PreToolUse deny MUST prevent execute() from ever being called.
17
+ *
18
+ * Design rules:
19
+ * - PURE: `engine` and `execute` are INJECTED. No transport, no SDK, no host, no fs.
20
+ * Unit-testable under `node --test` with a fake engine + a spy execute().
21
+ * - NEVER throws out of gatedRun. A thrown execute() is captured as { ok:false, error }.
22
+ * A misbehaving engine.fire() (rejection or junk return) is treated conservatively.
23
+ * - PostToolUse is advisory : a PostToolUse block cannot un-run the tool),
24
+ * so we fire it for feedback but never let it flip ok->false or throw the path.
25
+ *
26
+ * Return shape (always one of these, never an exception):
27
+ * blocked by PreToolUse : { ok:false, blocked:true, reason, output:null }
28
+ * execute threw : { ok:false, blocked:false, error, output:null }
29
+ * success : { ok:true, blocked:false, output }
30
+ *
31
+ * CommonJS only. Node built-ins only. No new dependencies.
32
+ */
33
+
34
+ // Activity log. Self-gating (no-op unless explicitly enabled) and contracted never to
35
+ // throw, so wiring it here cannot alter gate behaviour or break the run path.
36
+ const logger = require('../core/logger');
37
+
38
+ const EVENTS = {
39
+ PRE: 'PreToolUse',
40
+ POST: 'PostToolUse',
41
+ };
42
+
43
+ /**
44
+ * Fire one lifecycle event through the injected engine, defensively.
45
+ *
46
+ * The HookEngine contract says fire() never rejects, but gatedRun is the
47
+ * safety crux: we must not assume a perfectly-behaved engine. If fire() rejects or returns a
48
+ * non-object, we fail CLOSED on the gate (PreToolUse) - an engine we cannot trust to answer
49
+ * "allowed?" must not be read as "allowed". PostToolUse is advisory, so a failure there is benign.
50
+ *
51
+ * @param {object} engine an object exposing async fire(event, ctx, extra)
52
+ * @param {string} event one of EVENTS
53
+ * @param {object} ctx common hook context (session_id, transcript_path, cwd, ...)
54
+ * @param {object} extra event-specific fields
55
+ * @returns {Promise<{ injected:string, blocked:boolean, reason:(string|null),
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).
60
+ */
61
+ async function fireSafely(engine, event, ctx, extra) {
62
+ try {
63
+ const res = await engine.fire(event, ctx, extra);
64
+ // The engine already normalises blocking to { blocked, reason }. Defensively coerce a
65
+ // junk/undefined return into a well-shaped result so callers can read fields safely.
66
+ if (res && typeof res === 'object') {
67
+ return {
68
+ injected: typeof res.injected === 'string' ? res.injected : '',
69
+ blocked: res.blocked === true,
70
+ reason: res.reason != null ? res.reason : null,
71
+ stopLoop: res.stopLoop === true,
72
+ results: Array.isArray(res.results) ? res.results : [],
73
+ };
74
+ }
75
+ // Non-object return from fire(): per-event. PreToolUse fails CLOSED - a gate that
76
+ // returns junk cannot be read as "allowed". PostToolUse is advisory -> benign.
77
+ if (event === EVENTS.PRE) {
78
+ return {
79
+ injected: '',
80
+ blocked: true,
81
+ reason: 'hook engine returned a non-object for ' + event + ' - failing closed',
82
+ internal: true, // wiring failure, NOT operator policy - wrap paths must neutralise it
83
+ stopLoop: false,
84
+ results: [],
85
+ };
86
+ }
87
+ return { injected: '', blocked: false, reason: null, stopLoop: false, results: [] };
88
+ } catch (err) {
89
+ // fire() should never reject; if it does, fail closed on the GATE and benign on POST.
90
+ const reason =
91
+ 'hook engine error during ' + event + ': ' + ((err && err.message) || String(err));
92
+ if (event === EVENTS.PRE) {
93
+ // Fail CLOSED: an engine that cannot answer the gate denies the run.
94
+ return { injected: '', blocked: true, reason, internal: true, stopLoop: false, results: [] };
95
+ }
96
+ // PostToolUse is advisory and cannot un-run the tool - a failure here is benign.
97
+ return { injected: '', blocked: false, reason: null, stopLoop: false, results: [] };
98
+ }
99
+ }
100
+
101
+ /**
102
+ * gatedRun - route a single tool invocation through the host's hook engine.
103
+ *
104
+ * Sequence:
105
+ * 1. fire PreToolUse with { tool_name, tool_input: args }.
106
+ * 2. if blocked -> return { ok:false, blocked:true, reason, output:null } and DO NOT call execute.
107
+ * 3. else await execute(); on throw, capture as error (output stays null).
108
+ * 4. fire PostToolUse with { tool_name, tool_input: args, tool_response: output }.
109
+ * (PostToolUse is advisory - fired for feedback even on the error path so PostToolUse hooks
110
+ * observe what happened; it never flips ok or throws.)
111
+ * 5. return { ok:true, blocked:false, output } | { ok:false, blocked:false, error, output:null }.
112
+ *
113
+ * @param {object} params
114
+ * @param {object} params.engine the host's HookEngine - async fire(event, ctx, extra). INJECTED.
115
+ * @param {object} [params.ctx] common hook context (session_id, transcript_path, cwd, ...).
116
+ * @param {string} params.toolName the tool name (becomes tool_name in the payload).
117
+ * @param {*} [params.args] the tool input (becomes tool_input in the payload).
118
+ * @param {Function} params.execute thunk that performs the actual call (from
119
+ * registry.resolveExecution). `() => any | Promise<any>`. INJECTED.
120
+ * @returns {Promise<{ ok:boolean, blocked:boolean, reason?:(string|null),
121
+ * output:(*|null), error?:Error }>}
122
+ */
123
+ async function gatedRun(params) {
124
+ const p = params || {};
125
+ const { engine, ctx, toolName, args, execute } = p;
126
+
127
+ // ---- Validate injected dependencies, failing CLOSED (never execute on a bad wiring). ----
128
+ if (!engine || typeof engine.fire !== 'function') {
129
+ return {
130
+ ok: false,
131
+ blocked: true,
132
+ reason: 'gatedRun: engine with async fire() is required',
133
+ internal: true, // wiring failure, NOT operator policy - wrap paths must neutralise it
134
+ output: null,
135
+ };
136
+ }
137
+ if (typeof execute !== 'function') {
138
+ return {
139
+ ok: false,
140
+ blocked: true,
141
+ reason: 'gatedRun: execute thunk (function) is required',
142
+ internal: true,
143
+ output: null,
144
+ };
145
+ }
146
+
147
+ const context = ctx || {};
148
+
149
+ // ---- 1. PreToolUse gate. -------------------------------------------------------------
150
+ // tool_input carries the args verbatim.
151
+ const pre = await fireSafely(engine, EVENTS.PRE, context, {
152
+ tool_name: toolName,
153
+ tool_input: args,
154
+ });
155
+
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 */ }
169
+
170
+ // ---- 2. Blocked -> STOP. The load-bearing invariant: execute() is NOT called. ---------
171
+ if (pre.blocked) {
172
+ return {
173
+ ok: false,
174
+ blocked: true,
175
+ reason: pre.reason,
176
+ // Distinguish a genuine hook DENY (operator policy - its reason may be shown to a wrapped
177
+ // client) from an internal fail-closed (engine wiring/crash - its reason is a gateway tell
178
+ // that must be neutralised on the wrap path).
179
+ internal: pre.internal === true,
180
+ output: null,
181
+ };
182
+ }
183
+
184
+ // ---- 3. Allowed -> execute the real call. Capture a throw as `error`. -----------------
185
+ let output = null;
186
+ let error = null;
187
+ try {
188
+ output = await execute();
189
+ } catch (err) {
190
+ // Normalise any thrown value into an Error so the caller always gets an Error instance.
191
+ error = err instanceof Error ? err : new Error(String(err));
192
+ }
193
+
194
+ // ---- 4. PostToolUse (advisory). Fired even on the error path so hooks see the outcome. -
195
+ // tool_response is the output (null when execute threw). PostToolUse cannot un-run the tool
196
+ // (PostToolUse is advisory), so its result never flips ok and never throws this path.
197
+ await fireSafely(engine, EVENTS.POST, context, {
198
+ tool_name: toolName,
199
+ tool_input: args,
200
+ tool_response: output,
201
+ });
202
+
203
+ // ---- 5. Final result. ----------------------------------------------------------------
204
+ if (error) {
205
+ return { ok: false, blocked: false, error, output: null };
206
+ }
207
+ return { ok: true, blocked: false, output };
208
+ }
209
+
210
+ module.exports = { gatedRun, EVENTS };