toolfunnel 0.3.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 (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +324 -0
  3. package/bin/toolfunnel.js +112 -0
  4. package/docs/admin-ui.md +404 -0
  5. package/docs/configuration.md +402 -0
  6. package/docs/hooks-and-gating.md +428 -0
  7. package/docs/logging.md +223 -0
  8. package/docs/management.md +429 -0
  9. package/docs/oauth.md +103 -0
  10. package/docs/setup.md +352 -0
  11. package/docs/tools-and-upstreams.md +469 -0
  12. package/hooks/hooks.manifest.json +27 -0
  13. package/hooks/scripts/.gitkeep +3 -0
  14. package/hooks/scripts/example-audit.js +21 -0
  15. package/hooks/scripts/example-deny-dangerous.js +31 -0
  16. package/mcp/expose.example.json +30 -0
  17. package/mcp/servers/mock-upstream/server.js +170 -0
  18. package/package.json +63 -0
  19. package/src/auth/config.js +172 -0
  20. package/src/auth/install.js +181 -0
  21. package/src/auth/resource-server.js +319 -0
  22. package/src/core/events.js +105 -0
  23. package/src/core/hook-engine.js +275 -0
  24. package/src/core/hook-loader.js +791 -0
  25. package/src/core/hook-runner.js +346 -0
  26. package/src/core/logger.js +169 -0
  27. package/src/core/matcher.js +51 -0
  28. package/src/core/metrics.js +75 -0
  29. package/src/core/tool-registry.js +180 -0
  30. package/src/extend/add-hook.md +142 -0
  31. package/src/extend/add-mcp.md +121 -0
  32. package/src/extend/create-tool.md +132 -0
  33. package/src/extend/howto.js +98 -0
  34. package/src/extend/package.md +154 -0
  35. package/src/mcp/aggregator.js +802 -0
  36. package/src/mcp/expose-store.js +430 -0
  37. package/src/mcp/gated-run.js +194 -0
  38. package/src/mcp/http-transport.js +962 -0
  39. package/src/mcp/mcp-client.js +524 -0
  40. package/src/mcp/protocol.js +466 -0
  41. package/src/mcp/server.js +1240 -0
  42. package/src/packages/packager.js +713 -0
  43. package/src/tools/drift.js +138 -0
  44. package/src/tools/registry.js +468 -0
  45. package/src/tools/tool-state.js +104 -0
  46. package/src/ui/public/app.js +1055 -0
  47. package/src/ui/public/index.html +249 -0
  48. package/src/ui/public/logo.jpg +0 -0
  49. package/src/ui/public/styles.css +535 -0
  50. package/src/ui/server.js +1144 -0
  51. package/tools/scripts/base64.js +139 -0
  52. package/tools/scripts/danger.js +61 -0
  53. package/tools/scripts/echo.js +39 -0
  54. package/tools/scripts/hash.js +101 -0
  55. package/tools/scripts/json.js +109 -0
  56. package/tools/scripts/text-stats.js +131 -0
  57. package/tools/scripts/tf-hook-add.js +152 -0
  58. package/tools/scripts/tf-hook-set.js +113 -0
  59. package/tools/scripts/tf-list.js +195 -0
  60. package/tools/scripts/tf-log.js +83 -0
  61. package/tools/scripts/tf-mcp-add.js +119 -0
  62. package/tools/scripts/tf-mcp-set.js +104 -0
  63. package/tools/scripts/tf-tool-add.js +129 -0
  64. package/tools/scripts/tf-tool-set.js +148 -0
  65. package/tools/scripts/uuid.js +102 -0
  66. package/tools/tools.register.json +171 -0
@@ -0,0 +1,429 @@
1
+ # Management & execution modes
2
+
3
+ ToolFunnel can manage *itself* through the same lean meta-tool surface it uses for everything
4
+ else. Eight first-party **management functions** — all in the register `category: "management"` —
5
+ let an agent (or a human, via the admin UI) add and toggle tools, register and curate upstream
6
+ MCPs, configure the hook gate, list everything that is configured, and switch the activity log on
7
+ or off. There is no separate admin API and no extra advertised tool: management runs through
8
+ `toolfunnel_run_tool`, behind the **same `PreToolUse` gate** as any other tool. That is deliberate —
9
+ the functions that *configure* the gateway are themselves gateable, which is the recursive-safety
10
+ story this document ends on.
11
+
12
+ This document is derived from the scripts that implement the functions
13
+ (`tools/scripts/tf-*.js`), their register entries (`tools/tools.register.json`), and the register
14
+ engine (`src/tools/registry.js`) — not from intent. For the hook gate itself see
15
+ [`hooks-and-gating.md`](hooks-and-gating.md); for the register/upstream model see
16
+ [`tools-and-upstreams.md`](tools-and-upstreams.md); for the manifest/overlay field reference see
17
+ [`configuration.md`](configuration.md).
18
+
19
+ ---
20
+
21
+ ## The management surface at a glance
22
+
23
+ | Function | What it changes | Read/Write | Liveness |
24
+ |---|---|---|---|
25
+ | `tf_tool_add` | Adds a register entry (optionally writes its backing script). | write | live — meta-tools re-read the register every call |
26
+ | `tf_tool_set` | Enables / disables / removes a register tool. | write | live |
27
+ | `tf_mcp_add` | Registers an upstream MCP (optionally curates some of its tools). | write | curated-direct surface needs an aggregator reconnect |
28
+ | `tf_mcp_set` | Enables / disables / removes an upstream MCP. | write | reconnect for the curated-direct surface |
29
+ | `tf_hook_add` | Adds a hook manifest entry (e.g. a `PreToolUse` gate). | write | live for the gate (loaded per gated run) |
30
+ | `tf_hook_set` | Enables / disables / removes a hook. | write | live |
31
+ | `tf_list` | Lists tools, MCPs, or hooks with their live state. | read-only | n/a |
32
+ | `tf_log` | Enables / disables / checks the JSONL audit log. | write | live |
33
+
34
+ All eight are ordinary register entries whose `invoke` is `{ type:"script", path:"scripts/tf-*.js" }`,
35
+ so they resolve to **gateway-run** mode (they execute server-side, on the host where ToolFunnel
36
+ lives — see *Execution modes*, below) and they touch **only** files inside the ToolFunnel root
37
+ (each script resolves its paths from its own `__dirname`, never from a caller-supplied path).
38
+
39
+ ---
40
+
41
+ ## How an AI invokes a management function
42
+
43
+ There is no special calling convention. A management function is reached exactly like every other
44
+ register tool — through the run meta-tool:
45
+
46
+ ```jsonc
47
+ toolfunnel_run_tool({ name: "tf_list", args: { kind: "tools" } })
48
+ ```
49
+
50
+ - **`name`** is the exact tool name as returned by `toolfunnel_list_tools` (e.g. `"tf_tool_add"`).
51
+ For management functions the register `id` and display `name` are identical, so the distinction
52
+ never matters here.
53
+ - **`args`** is the structured argument object for that function (the tables below). Omitted/empty
54
+ args arrive at the script as `null`/`{}`; each script reports bad input as a clean
55
+ `{ ok:false, error }` rather than crashing.
56
+ - Discover them first with `toolfunnel_list_tools({ category: "management" })`; pull a single
57
+ function's argument doc with `toolfunnel_tool_instructions({ name: "tf_hook_add" })`.
58
+
59
+ ### The result envelope
60
+
61
+ `toolfunnel_run_tool` returns the canonical gateway result, **not** the script's JSON directly.
62
+ For a successful gateway-run call:
63
+
64
+ ```jsonc
65
+ {
66
+ "ok": true,
67
+ "blocked": false,
68
+ "output": {
69
+ "ok": true, // ← from the script (defaultRunScript: ok = exit code 0)
70
+ "code": 0,
71
+ "stdout": "{\"ok\":true,\"id\":\"echo-upper\",\"name\":\"Echo Upper\", ...}\n",
72
+ "stderr": ""
73
+ }
74
+ }
75
+ ```
76
+
77
+ The management function's own JSON payload (e.g. `{ ok:true, id, action, enabled, state }`) is the
78
+ **single JSON line in `output.stdout`** — parse that to read what the function reported. A logical
79
+ failure inside the script is still `output.stdout` with `{ ok:false, error }` and exit 0 (the script
80
+ contract); the run itself succeeded.
81
+
82
+ If a `PreToolUse` hook **denies** the call, you instead get the block envelope and the script never
83
+ runs:
84
+
85
+ ```jsonc
86
+ { "ok": false, "blocked": true, "reason": "<hook's deny reason>", "output": null }
87
+ ```
88
+
89
+ ---
90
+
91
+ ## The eight management functions
92
+
93
+ Every script follows the same contract (`src/tools/registry.js::defaultRunScript`): args arrive as
94
+ JSON in `TOOLFUNNEL_TOOL_ARGS`, the script prints **exactly one JSON object** to stdout and exits 0.
95
+ Writes are atomic (temp file + fsync + rename). The shapes below are exactly what each script reads.
96
+
97
+ ### `tf_tool_add` — register a new tool
98
+
99
+ Adds a register entry, and (optionally) writes the backing script in the same call so the entry
100
+ never references a missing file. Change is **live** — the meta-tools re-read the register on the
101
+ next call, no reconnect.
102
+
103
+ | Arg | Type | Meaning |
104
+ |---|---|---|
105
+ | `id` | string (required) | Stable, unique key. Fails if it already exists. |
106
+ | `name` | string (required) | Display name. |
107
+ | `summary` | string | The one line `toolfunnel_list_tools` shows — keep it tight. |
108
+ | `category` | string | Grouping label (`text`, `crypto`, …). |
109
+ | `instructions` | string | Full usage doc returned by `toolfunnel_tool_instructions`. |
110
+ | `invoke` | object (required) | `{ type:"script", path:"scripts/<file>" }` or `{ type:"shell", command:"…" }`. |
111
+ | `scriptText` | string | If given **and** `invoke.type === "script"`, the body is written to `tools/scripts/<basename>` first (path-guarded), then the entry is added. |
112
+
113
+ Output: `{ ok:true, ...<the added entry>, scriptPath?: "<abs path>" }` (the `scriptPath` only when
114
+ `scriptText` was written), or `{ ok:false, error }`.
115
+
116
+ > **Mode note:** `tf_tool_add` does **not** accept a `mode` field — it always carries an `invoke`,
117
+ > so the new tool resolves to **gateway-run** (the mode is inferred from the invoke). To make a tool
118
+ > **reference**-mode, set its `mode` via the admin UI's per-tool mode control or by editing the
119
+ > register entry directly (see *Execution modes*).
120
+
121
+ ```jsonc
122
+ // Add a script tool AND author its script in one call:
123
+ toolfunnel_run_tool({
124
+ name: "tf_tool_add",
125
+ args: {
126
+ id: "echo-upper",
127
+ name: "Echo Upper",
128
+ summary: "Uppercase the supplied text and echo it back.",
129
+ category: "text",
130
+ instructions: "Args: { text: string }. Returns { ok:true, upper }.",
131
+ invoke: { type: "script", path: "scripts/echo-upper.js" },
132
+ scriptText: "#!/usr/bin/env node\n'use strict';\nconst a=JSON.parse(process.env.TOOLFUNNEL_TOOL_ARGS||'null');\nprocess.stdout.write(JSON.stringify({ok:true,upper:String(a&&a.text||'').toUpperCase()})+'\\n');\n"
133
+ }
134
+ })
135
+ ```
136
+
137
+ ### `tf_tool_set` — enable / disable / remove a tool
138
+
139
+ Flips the active overlay or removes a tool entirely.
140
+
141
+ | Arg | Type | Meaning |
142
+ |---|---|---|
143
+ | `id` | string (required) | The register id. |
144
+ | `action` | `"enable"` \| `"disable"` \| `"remove"` (required) | `enable`/`disable` flip the `tools/tools.state.json` active overlay (default ON); `remove` deletes the register entry **and** purges its state key. |
145
+
146
+ Disabled tools are hidden from `toolfunnel_list_tools` (the entry stays in the register).
147
+ Output: `{ ok:true, id, action, enabled, state }` for enable/disable, or
148
+ `{ ok:true, id, action, removed:true, state }` for remove (`state` is the resulting overlay map).
149
+
150
+ ```jsonc
151
+ toolfunnel_run_tool({ name: "tf_tool_set", args: { id: "echo-upper", action: "disable" } })
152
+ ```
153
+
154
+ ### `tf_mcp_add` — register an upstream MCP (and optionally curate it)
155
+
156
+ Adds an `upstreams[]` entry to `mcp/expose.json` and, for each `expose[]` item, a curated-direct
157
+ selection. The curated-direct surface is advertised at connect time, so **a new expose needs an
158
+ aggregator reconnect to appear**; the upstream record itself is persisted immediately.
159
+
160
+ | Arg | Type | Meaning |
161
+ |---|---|---|
162
+ | `id` | string (required) | Unique upstream key referenced by exposes. |
163
+ | `command` | string (required) | The interpreter/executable to spawn (`node`, `npx`, …). |
164
+ | `args` | string[] | argv. Any **path-shaped** token must resolve inside the ToolFunnel root (isolation guard). |
165
+ | `env` | object | Extra environment for the spawned process. |
166
+ | `transport` | string (default `"stdio"`) | Connection transport. |
167
+ | `enabled` | boolean (default `true`) | Whether the aggregator connects to it. |
168
+ | `description` | string | Human-facing note for the UI. |
169
+ | `expose` | array | `[{ tool, as?, category?, enabled? }]` — each item curates one upstream tool. `tool` is the upstream's real tool name; `as` is the downstream name (defaults to `<id>_<tool>`). |
170
+
171
+ Output: `{ ok:true, upstream:{…}, exposed:[{…}] }`, or `{ ok:false, error }`. There is **no
172
+ cross-step transaction**: if the upstream is added but a later `expose[]` item fails validation, the
173
+ upstream and earlier exposes remain persisted and the failure is reported.
174
+
175
+ ```jsonc
176
+ toolfunnel_run_tool({
177
+ name: "tf_mcp_add",
178
+ args: {
179
+ id: "mock",
180
+ command: "node",
181
+ args: ["mcp/servers/mock-upstream/server.js"],
182
+ description: "Bundled demo upstream (ping, add, echo).",
183
+ expose: [
184
+ { tool: "ping", as: "mock_ping", category: "demo" }
185
+ ]
186
+ }
187
+ })
188
+ ```
189
+
190
+ ### `tf_mcp_set` — enable / disable / remove an upstream MCP
191
+
192
+ | Arg | Type | Meaning |
193
+ |---|---|---|
194
+ | `id` | string (required) | The upstream id. |
195
+ | `action` | `"enable"` \| `"disable"` \| `"remove"` (required) | `enable`/`disable` toggle the upstream; `remove` deletes it and **cascade-removes** its `expose[]` entries. |
196
+
197
+ A reconnect is needed for the curated-direct surface to reflect the change. All three actions throw
198
+ on an unknown id (surfaced as `{ ok:false, error }`). Output: `{ ok:true, action, id, upstream:{…} }`
199
+ for enable/disable, or `{ ok:true, action, id, removed:true }` for remove.
200
+
201
+ ```jsonc
202
+ toolfunnel_run_tool({ name: "tf_mcp_set", args: { id: "example", action: "disable" } })
203
+ ```
204
+
205
+ ### `tf_hook_add` — add a hook (e.g. a `PreToolUse` gate)
206
+
207
+ Registers a hook in `hooks/hooks.manifest.json` and, optionally, writes its inline script under
208
+ `hooks/scripts/` (path-guarded). The manifest row is registered first; if the inline write fails the
209
+ row is **rolled back** so a failed add leaves no orphan entry.
210
+
211
+ | Arg | Type | Meaning |
212
+ |---|---|---|
213
+ | `id` | string (required) | Format `"<kebab-event>/<name>"`, e.g. `"pre-tool-use/gate-danger"`. |
214
+ | `event` | string (required) | One of the six lifecycle events; `PreToolUse` is the enforcement gate. |
215
+ | `matcher` | string | Regex (full-match, anchored) against `tool_name`. `""`/`"*"` = always fire. |
216
+ | `command` | string (required) | What the runner spawns. Use the portable `${HOOKS_DIR}` token, e.g. `node "${HOOKS_DIR}/scripts/gate.js"`. |
217
+ | `script` | string | Path (relative to `hooks/`) used by the manager's open/edit. |
218
+ | `timeout` | number | Seconds (default 60). |
219
+ | `enabled` | boolean (default **`true`**) | The manifest seed; live toggles live in `hooks.state.json`. |
220
+ | `description` | string | Human-facing note. |
221
+ | `scriptText` | string | If given, the script body is authored under `hooks/scripts/`. |
222
+
223
+ Output: `{ ok:true, id, hook:{…}, scriptWritten:bool }`, or `{ ok:false, error }`.
224
+
225
+ > **Match the display name.** The `tool_name` a `PreToolUse` matcher sees for a first-party tool is
226
+ > the register **display name** (it equals the id for management functions). Spaces are literal:
227
+ > gate the `danger` demo (name `"Danger Demo"`) with `matcher: "Danger Demo"`, not `"danger"`.
228
+
229
+ ```jsonc
230
+ // Gate the danger demo unless { confirm: true } is passed:
231
+ toolfunnel_run_tool({
232
+ name: "tf_hook_add",
233
+ args: {
234
+ id: "pre-tool-use/gate-danger",
235
+ event: "PreToolUse",
236
+ matcher: "Danger Demo",
237
+ command: "node \"${HOOKS_DIR}/scripts/gate-danger.js\"",
238
+ script: "scripts/gate-danger.js",
239
+ timeout: 5,
240
+ description: "Deny Danger Demo unless args.confirm === true.",
241
+ scriptText: "#!/usr/bin/env node\nlet r='';process.stdin.on('data',c=>r+=c);process.stdin.on('end',()=>{let e={};try{e=JSON.parse(r||'{}')}catch(_){process.exit(2)}if(e.tool_input&&e.tool_input.confirm===true)process.exit(0);process.stderr.write('confirm required');process.exit(2)});\n"
242
+ }
243
+ })
244
+ ```
245
+
246
+ ### `tf_hook_set` — enable / disable / remove a hook
247
+
248
+ | Arg | Type | Meaning |
249
+ |---|---|---|
250
+ | `id` | string (required) | The hook id. |
251
+ | `action` | `"enable"` \| `"disable"` \| `"remove"` (required) | `enable`/`disable` write the `hooks.state.json` overlay (which **wins** over the manifest seed) and mirror the manifest; `remove` deletes the manifest entry and purges the overlay key (the script file is left in place). |
252
+
253
+ Output: `{ ok:true, id, action, enabled }` for enable/disable, or `{ ok:true, id, action, removed }`
254
+ for remove (`removed` is `false` if the id was already absent — information, not a fault).
255
+
256
+ ```jsonc
257
+ toolfunnel_run_tool({ name: "tf_hook_set", args: { id: "pre-tool-use/gate-danger", action: "disable" } })
258
+ ```
259
+
260
+ ### `tf_list` — inventory (read-only)
261
+
262
+ The one read function: "what is configured, and what is its live state?" across the three stores.
263
+
264
+ | Arg | Type | Meaning |
265
+ |---|---|---|
266
+ | `kind` | `"tools"` \| `"mcps"` \| `"hooks"` (required) | Which store to enumerate. |
267
+
268
+ - `tools` → register briefs `{ id, name, summary, category }` annotated with `{ enabled, hidden }`.
269
+ - `mcps` → one item per upstream (its full config incl. `enabled`) with its curated-direct
270
+ `exposed` entries nested (each carrying its resolved downstream `name` and `enabled`).
271
+ - `hooks` → one item per manifest hook (full spec) with `enabled` resolved from the live overlay.
272
+
273
+ Output: `{ ok:true, kind, items:[…] }`. No writes, no network, no process mutation.
274
+
275
+ ```jsonc
276
+ toolfunnel_run_tool({ name: "tf_list", args: { kind: "hooks" } })
277
+ ```
278
+
279
+ ### `tf_log` — the activity / audit log
280
+
281
+ Toggles ToolFunnel's JSONL audit log (tool runs + gate allow/deny decisions). **Default OFF.**
282
+
283
+ | Arg | Type | Meaning |
284
+ |---|---|---|
285
+ | `action` | `"enable"` \| `"disable"` \| `"status"` (required) | Turn logging on/off, or report current state. |
286
+ | `path` | string | Optional log path, honoured only on `enable`. |
287
+
288
+ Output: `{ ok:true, action, enabled, path }` for enable/disable, or
289
+ `{ ok:true, action, enabled, path, count }` for status (`count` = recent log entries). The same
290
+ log is controllable from the UI **Logs** tab and via `logs/log.config.json`.
291
+
292
+ ```jsonc
293
+ toolfunnel_run_tool({ name: "tf_log", args: { action: "enable" } })
294
+ toolfunnel_run_tool({ name: "tf_log", args: { action: "status" } })
295
+ ```
296
+
297
+ ---
298
+
299
+ ## Management runs gated — the recursive-safety story
300
+
301
+ The management functions are not privileged. They are gateway-run tools, so every call funnels
302
+ through the **same `gatedRun` `PreToolUse` gate** as `danger` or any curated upstream tool
303
+ (`src/mcp/gated-run.js`). That means **a `PreToolUse` hook can restrict or deny a management call**,
304
+ and the gateway can govern the tools that govern *it*.
305
+
306
+ **Default is allow.** The shipped `hooks/hooks.manifest.json` has an empty `hooks` array, which is
307
+ allow-all — no hook fires, so management functions run unrestricted out of the box. Safety here is
308
+ **opt-in**: you add an enabled `PreToolUse` hook whose matcher matches the management tool name(s).
309
+ Because the `tool_name` the gate sees is the register name (which equals the id for these
310
+ functions), the matcher is the bare id or an alternation of ids — for example `tf_.*` to cover the
311
+ whole family, or `tf_hook_set|tf_log` to guard just the two that could blind the gateway:
312
+
313
+ ```jsonc
314
+ // Add a gate over the riskiest management functions: require { confirm: true }.
315
+ toolfunnel_run_tool({
316
+ name: "tf_hook_add",
317
+ args: {
318
+ id: "pre-tool-use/gate-management",
319
+ event: "PreToolUse",
320
+ matcher: "tf_hook_set|tf_hook_add|tf_log|tf_tool_set",
321
+ command: "node \"${HOOKS_DIR}/scripts/gate-management.js\"",
322
+ script: "scripts/gate-management.js",
323
+ description: "Require confirm:true before changing hooks/logging/tools.",
324
+ scriptText: "#!/usr/bin/env node\nlet r='';process.stdin.on('data',c=>r+=c);process.stdin.on('end',()=>{let e={};try{e=JSON.parse(r||'{}')}catch(_){process.exit(2)}const a=e.tool_input&&e.tool_input.args;if(a&&a.confirm===true)process.exit(0);process.stderr.write('management change requires args.confirm:true');process.exit(2)});\n"
325
+ }
326
+ })
327
+ ```
328
+
329
+ Why this matters:
330
+
331
+ - **The gate is uniform and unbypassable.** There is no back door that runs a management function
332
+ outside `gatedRun`, so a deny on `tf_*` is as enforced as a deny on any other tool.
333
+ - **`tf_log` and the hook functions can themselves be gated.** As the `tf_log` instructions note,
334
+ "like any tool, `tf_log` can itself be disabled to revoke logging control." Gating `tf_hook_set`
335
+ / `tf_hook_add` likewise prevents an agent from quietly disabling the very gate that constrains
336
+ it. This is the recursive-safety property: the controls *and* the controls-on-the-controls live
337
+ in the same gated surface.
338
+ - **Fail closed still holds.** If the gating hook errors or returns junk, the `PreToolUse` is
339
+ treated as a deny — a gate you can't trust to answer "allowed?" is never read as "allowed". A
340
+ management change can never slip through on a broken hook.
341
+
342
+ One bootstrapping caveat to keep in mind: if you gate `tf_hook_set` behind a confirmation you can no
343
+ longer toggle hooks *without* satisfying that confirmation — including the gate you just added. Keep
344
+ the admin UI (`node bin/toolfunnel.js --ui`) or a direct manifest edit as your out-of-band escape
345
+ hatch when you lock the hook functions down.
346
+
347
+ ---
348
+
349
+ ## Execution modes: reference vs gateway-run
350
+
351
+ Every register entry resolves to one of two **execution modes** (`src/tools/registry.js`,
352
+ `resolveMode`). The mode decides *where the work happens* when `toolfunnel_run_tool` is called.
353
+
354
+ ### `gateway-run` — ToolFunnel executes it, server-side, behind the gate
355
+
356
+ `mode: "gateway"`. ToolFunnel runs the tool's `invoke` itself: a `script` invoke spawns `node` on
357
+ the host-local script; a `shell` invoke is handed to the gated runner which spawns the shell **only
358
+ after `PreToolUse` allows**. The result comes back in the run envelope's `output`. This is the
359
+ original behaviour and the mode of **every shipped tool**, including all eight management functions.
360
+
361
+ Use gateway-run when:
362
+
363
+ - the action must run **on the host where ToolFunnel lives** (its filesystem, its config, its
364
+ credentials) — the management functions are the canonical case;
365
+ - you want the call **centrally gated, logged, and audited** through `gatedRun`;
366
+ - the work is a deterministic host-local script or one-line command.
367
+
368
+ ### `reference` — ToolFunnel only *describes* it; the AI runs it
369
+
370
+ `mode: "reference"`. ToolFunnel executes **nothing**. `toolfunnel_run_tool` short-circuits **before**
371
+ the gate and returns the tool's instructions so the **connected AI performs the action in its own
372
+ environment**:
373
+
374
+ ```jsonc
375
+ {
376
+ "ok": true,
377
+ "mode": "reference",
378
+ "name": "deploy-notes",
379
+ "instructions": "<the entry's instructions string>",
380
+ "message": "reference tool — perform this in your own environment per the instructions"
381
+ }
382
+ ```
383
+
384
+ Because nothing runs server-side, **no `PreToolUse` hook fires for a reference tool** — there is no
385
+ host-side side effect to gate; governance of the action is the AI's own environment's responsibility.
386
+ A reference entry needs no `invoke` (there is nothing to execute), so it may omit it.
387
+
388
+ Use reference when:
389
+
390
+ - the AI is better placed to act — it has the right filesystem, tokens, or working directory;
391
+ - you only want to hand the model **authoritative instructions** rather than run code centrally;
392
+ - you explicitly do **not** want ToolFunnel to execute the action on the host.
393
+
394
+ ### The backward-compatible default (how the mode is inferred)
395
+
396
+ An explicit, valid `mode` field always wins. When `mode` is **absent**, it is inferred so that every
397
+ pre-existing tool keeps its original behaviour:
398
+
399
+ | Entry | Inferred mode |
400
+ |---|---|
401
+ | `invoke.type === "script"` | `gateway` |
402
+ | `invoke.type === "shell"` | `gateway` |
403
+ | no executable invoke (and `mode` absent) | `reference` |
404
+
405
+ Validation backs this up: with `mode` **absent** an `invoke` is still **required** (so an existing
406
+ tool without a mode can never silently become a do-nothing reference). Only an **explicit**
407
+ `mode: "reference"` relaxes the invoke requirement. An invalid `mode` value is rejected at load.
408
+
409
+ ### Setting a tool's mode
410
+
411
+ - **New tools via `tf_tool_add`** always supply an `invoke`, so they come out **gateway-run** (the
412
+ inferred default). `tf_tool_add` does not take a `mode` argument.
413
+ - **To switch a tool to `reference` (or back)**, use the admin UI's per-tool mode control
414
+ (`POST /api/tools/mode {id, mode}` → `registry.update(id,{mode})`), or edit the register entry's
415
+ `mode` field directly. Switching a tool that *has* an `invoke` to `reference` simply stops
416
+ ToolFunnel from executing it — the `invoke` is ignored while the mode is `reference`.
417
+
418
+ ---
419
+
420
+ ## Quick reference
421
+
422
+ ```text
423
+ Discover: toolfunnel_list_tools({ category: "management" })
424
+ Learn args: toolfunnel_tool_instructions({ name: "tf_hook_add" })
425
+ Invoke: toolfunnel_run_tool({ name: "<tf_*>", args: { … } })
426
+ Read back: parse output.stdout (the script's own { ok, … } JSON)
427
+ Gate them: a PreToolUse hook matching "tf_.*" (default = allow-all)
428
+ Modes: gateway-run = ToolFunnel executes (gated) · reference = AI executes (no gate)
429
+ ```
package/docs/oauth.md ADDED
@@ -0,0 +1,103 @@
1
+ # OAuth 2.1 — optional resource-server validation
2
+
3
+ ToolFunnel is **loopback-only and unauthenticated by default** — the right posture for a single
4
+ operator on `localhost`. For a networked or multi-user deployment it can act as an **OAuth 2.1
5
+ resource server**: it validates the bearer token on every HTTP request *before any tool runs*.
6
+
7
+ This is **opt-in** and the only feature that adds a dependency.
8
+
9
+ ## The one dependency
10
+
11
+ The core gateway has `"dependencies": {}`. Enabling OAuth adds exactly one library —
12
+ [`jose`](https://www.npmjs.com/package/jose) — which is **itself zero-dependency**, audited, and the
13
+ same library the official MCP SDK uses. It is **not** a runtime dependency of the package: it lives
14
+ in `devDependencies` (for the test suite) and is **installed on demand**:
15
+
16
+ ```bash
17
+ toolfunnel install-oauth # runs: npm install jose@<pinned>
18
+ ```
19
+
20
+ …or click **Install** in the UI's **Auth** tab. Everyone who does not enable OAuth pulls nothing.
21
+
22
+ > **Why a library, not hand-rolled `node:crypto`?** Verifying a JWT securely is not the signature
23
+ > math (`node:crypto` has that) — it is the protocol discipline around it: pinning the algorithm so
24
+ > an attacker can't downgrade RS256→HS256, rejecting `alg:none`, ignoring attacker-controlled header
25
+ > params, validating `iss`/`aud`/`exp`/`nbf`, and JWKS caching + rotation. Each is a known CVE class.
26
+ > For the most security-sensitive code in the project, delegating to an audited library is the
27
+ > deliberate, defensible call — and `jose` is itself zero-dependency, so the audit surface barely
28
+ > moves. `jose` v5 (the CommonJS build, Node ≥ 18) is the pinned major.
29
+
30
+ ### Installing on demand — and the guaranteed manual fallback
31
+
32
+ `toolfunnel install-oauth` and the UI **Install** button both call the same installer
33
+ (`src/auth/install.js`). It is built to work where a naïve `npm install` breaks:
34
+
35
+ - **It runs npm's own JS entry directly with `node`** — `node <…>/npm/bin/npm-cli.js install jose@<pin>
36
+ --no-save` with `shell:false`. It does **not** spawn `npm.cmd`. Since the CVE-2024-27980 mitigation,
37
+ spawning a `.cmd` without a shell throws `EINVAL`, and spawning it *through* a shell misresolves the
38
+ shim's own path on portable/Windows Node. Running `npm-cli.js` with the current node binary sidesteps
39
+ all of it — no shell, no PATH lookup, no cwd sensitivity. (`npm-cli.js` is located beside the running
40
+ `node`; if it genuinely can't be found, the installer falls back to a shell-spawned `npm`, with the
41
+ version spec validated against a strict allowlist so the fallback can never be injected.)
42
+ - **It installs into the gateway's own `node_modules`** (resolved from the engine's location, not your
43
+ cwd) with `--no-save`, so `require('jose')` resolves wherever ToolFunnel is installed and the
44
+ package's `"dependencies": {}` stays untouched.
45
+ - **It never dead-ends.** Auto-install can't be universal — `npm-cli.js` sits in different places across
46
+ Homebrew / Volta / Docker / odd nvm layouts. So on *any* failure the result carries a copyable
47
+ `manualCommand` (`npm install jose@<pin>`) and the directory to run it in, and the CLI/UI surface that
48
+ message. You always have a one-line command that works in your own shell. You can also just run that
49
+ command yourself up front — the gateway only needs `require('jose')` to resolve.
50
+
51
+ ## What it does
52
+
53
+ When enabled, the HTTP transport:
54
+
55
+ - **Validates** the `Authorization: Bearer <token>` on every request to `/mcp` (POST and the SSE GET)
56
+ and `/health`, via `jose.jwtVerify` with a **pinned algorithm allowlist**, an **enforced issuer**,
57
+ and an **enforced audience** bound to this gateway's resource URI (the RFC 8707 confused-deputy
58
+ defence). A failure returns `401` (or `403` for insufficient scope) with a
59
+ `WWW-Authenticate: Bearer resource_metadata="…"` challenge.
60
+ - **Serves discovery** (unauthenticated) at `GET /.well-known/oauth-protected-resource` — the
61
+ RFC 9728 Protected Resource Metadata document naming the authorization server(s).
62
+ - **Unlocks a safe non-loopback bind.** With auth **off**, the host refuses to bind a non-loopback
63
+ address (it would expose an unauthenticated gateway). With auth **on**, the token is the boundary,
64
+ so off-localhost binds are allowed. The host also **refuses to start** if auth is on but `jose` is
65
+ missing or the config is incoherent (fail-fast, with an actionable message).
66
+
67
+ ## Configuration — `auth/auth.config.json`
68
+
69
+ Edit it in the UI's **Auth** tab, or directly. **Default OFF.** Read fresh per request (a toggle is
70
+ live; no restart).
71
+
72
+ | Field | Type | Meaning |
73
+ |---|---|---|
74
+ | `enabled` | boolean | Master switch. Default `false`. |
75
+ | `issuer` | string | The OAuth 2.1 / OIDC authorization server (the `iss` tokens must carry). **Required when enabled.** |
76
+ | `audience` | string | **This gateway's** resource URI. Tokens must be issued for it (`aud`). **Required when enabled** — without it tokens are unbound (the confused-deputy hole). |
77
+ | `jwksUri` | string | Explicit JWKS endpoint. Optional — if blank it is discovered from the issuer via OIDC / RFC 8414 discovery. |
78
+ | `algorithms` | string[] | The **pinned** signature-algorithm allowlist. Default `["RS256","ES256"]`. Pinning is the algorithm-confusion / `alg:none` defence. |
79
+ | `requiredScopes` | string[] | Optional. Every listed scope must be present (`scope` or `scp` claim), else `403 insufficient_scope`. |
80
+ | `clockToleranceSec` | number | `exp`/`nbf` skew tolerance, seconds. Default `30`, clamped to `[0, 300]`. |
81
+
82
+ ### Example (enabled)
83
+
84
+ ```json
85
+ {
86
+ "enabled": true,
87
+ "issuer": "https://auth.example.com",
88
+ "audience": "https://gateway.example.com",
89
+ "jwksUri": "",
90
+ "algorithms": ["RS256", "ES256"],
91
+ "requiredScopes": [],
92
+ "clockToleranceSec": 30
93
+ }
94
+ ```
95
+
96
+ ## Roles, and what is *not* yet implemented
97
+
98
+ ToolFunnel implements the **resource-server** role (validating inbound tokens). The OAuth **client**
99
+ leg — Dynamic Client Registration (RFC 7591), authorization-server-metadata discovery for *outbound*
100
+ calls, step-up authorization — and the 2026 spec hardening (RFC 9207 issuer validation, credential
101
+ binding) are **roadmap**, not shipped. So today ToolFunnel authenticates clients *to it*; it does not
102
+ yet perform the full client-side flow *to upstreams that require OAuth*. See the
103
+ [roadmap](../README.md#roadmap).