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