toolfunnel 0.3.0 → 0.5.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 +167 -324
- package/bin/toolfunnel.js +39 -4
- package/docs/KNOWN_BUGS.md +33 -0
- package/docs/MANUAL.pdf +0 -0
- package/docs/packaging.md +115 -0
- package/package.json +3 -1
- package/src/auth/config.js +4 -2
- package/src/auth/resource-server.js +28 -3
- package/src/core/config-home.js +134 -0
- package/src/core/logger.js +4 -2
- package/src/core/requires.js +138 -0
- package/src/core/server-config.js +77 -0
- package/src/extend/create-tool.md +24 -4
- package/src/extend/package.md +33 -0
- package/src/mcp/aggregator.js +12 -7
- package/src/mcp/http-transport.js +71 -21
- package/src/mcp/mcp-client.js +9 -3
- package/src/mcp/server.js +181 -16
- package/src/packages/packager.js +5 -3
- package/src/tools/registry.js +47 -2
- package/src/ui/server.js +49 -3
- package/tools/scripts/tf-env.js +43 -0
- package/tools/scripts/tf-hook-add.js +4 -5
- package/tools/scripts/tf-hook-set.js +4 -5
- package/tools/scripts/tf-list.js +9 -12
- package/tools/scripts/tf-log.js +4 -1
- package/tools/scripts/tf-mcp-add.js +4 -6
- package/tools/scripts/tf-mcp-set.js +4 -6
- package/tools/scripts/tf-pack.js +237 -0
- package/tools/scripts/tf-tool-add.js +16 -16
- package/tools/scripts/tf-tool-set.js +5 -11
- package/tools/tools.register.json +46 -1
- package/docs/admin-ui.md +0 -404
- package/docs/configuration.md +0 -402
- package/docs/hooks-and-gating.md +0 -428
- package/docs/logging.md +0 -223
- package/docs/management.md +0 -429
- package/docs/oauth.md +0 -103
- package/docs/setup.md +0 -352
- package/docs/tools-and-upstreams.md +0 -469
package/docs/management.md
DELETED
|
@@ -1,429 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,103 +0,0 @@
|
|
|
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).
|