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.
- package/LICENSE +21 -0
- package/README.md +324 -0
- package/bin/toolfunnel.js +112 -0
- package/docs/admin-ui.md +404 -0
- package/docs/configuration.md +402 -0
- package/docs/hooks-and-gating.md +428 -0
- package/docs/logging.md +223 -0
- package/docs/management.md +429 -0
- package/docs/oauth.md +103 -0
- package/docs/setup.md +352 -0
- package/docs/tools-and-upstreams.md +469 -0
- package/hooks/hooks.manifest.json +27 -0
- package/hooks/scripts/.gitkeep +3 -0
- package/hooks/scripts/example-audit.js +21 -0
- package/hooks/scripts/example-deny-dangerous.js +31 -0
- package/mcp/expose.example.json +30 -0
- package/mcp/servers/mock-upstream/server.js +170 -0
- package/package.json +63 -0
- package/src/auth/config.js +172 -0
- package/src/auth/install.js +181 -0
- package/src/auth/resource-server.js +319 -0
- package/src/core/events.js +105 -0
- package/src/core/hook-engine.js +275 -0
- package/src/core/hook-loader.js +791 -0
- package/src/core/hook-runner.js +346 -0
- package/src/core/logger.js +169 -0
- package/src/core/matcher.js +51 -0
- package/src/core/metrics.js +75 -0
- package/src/core/tool-registry.js +180 -0
- package/src/extend/add-hook.md +142 -0
- package/src/extend/add-mcp.md +121 -0
- package/src/extend/create-tool.md +132 -0
- package/src/extend/howto.js +98 -0
- package/src/extend/package.md +154 -0
- package/src/mcp/aggregator.js +802 -0
- package/src/mcp/expose-store.js +430 -0
- package/src/mcp/gated-run.js +194 -0
- package/src/mcp/http-transport.js +962 -0
- package/src/mcp/mcp-client.js +524 -0
- package/src/mcp/protocol.js +466 -0
- package/src/mcp/server.js +1240 -0
- package/src/packages/packager.js +713 -0
- package/src/tools/drift.js +138 -0
- package/src/tools/registry.js +468 -0
- package/src/tools/tool-state.js +104 -0
- package/src/ui/public/app.js +1055 -0
- package/src/ui/public/index.html +249 -0
- package/src/ui/public/logo.jpg +0 -0
- package/src/ui/public/styles.css +535 -0
- package/src/ui/server.js +1144 -0
- package/tools/scripts/base64.js +139 -0
- package/tools/scripts/danger.js +61 -0
- package/tools/scripts/echo.js +39 -0
- package/tools/scripts/hash.js +101 -0
- package/tools/scripts/json.js +109 -0
- package/tools/scripts/text-stats.js +131 -0
- package/tools/scripts/tf-hook-add.js +152 -0
- package/tools/scripts/tf-hook-set.js +113 -0
- package/tools/scripts/tf-list.js +195 -0
- package/tools/scripts/tf-log.js +83 -0
- package/tools/scripts/tf-mcp-add.js +119 -0
- package/tools/scripts/tf-mcp-set.js +104 -0
- package/tools/scripts/tf-tool-add.js +129 -0
- package/tools/scripts/tf-tool-set.js +148 -0
- package/tools/scripts/uuid.js +102 -0
- package/tools/tools.register.json +171 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
# Configuration reference
|
|
2
|
+
|
|
3
|
+
ToolFunnel is driven entirely by plain JSON files on disk — no database, no environment-variable
|
|
4
|
+
configuration of the surface. Everything the gateway exposes, curates, and gates is read from a
|
|
5
|
+
handful of config files. This document is the authoritative, field-by-field reference; it is
|
|
6
|
+
derived from the loaders that actually read each file (`src/tools/registry.js`,
|
|
7
|
+
`src/tools/tool-state.js`, `src/mcp/expose-store.js`, `src/core/hook-loader.js`), not from intent.
|
|
8
|
+
|
|
9
|
+
## The clean split: `src/` is the engine, the top level is yours
|
|
10
|
+
|
|
11
|
+
ToolFunnel keeps a deliberate separation between **the engine** and **your configuration**:
|
|
12
|
+
|
|
13
|
+
- **`src/`** is ToolFunnel's own engine code. You never edit it. It holds the server, the protocol,
|
|
14
|
+
the transports, the aggregator, the hook engine, and the tool *system* (`src/tools/registry.js`,
|
|
15
|
+
`src/tools/tool-state.js`, `src/tools/drift.js`). These modules *read* your config — they are not
|
|
16
|
+
your config.
|
|
17
|
+
- **`tools/`, `mcp/`, and `hooks/`** at the repo root are **your** stuff — plain JSON plus the
|
|
18
|
+
scripts they reference. This is the only place you author anything:
|
|
19
|
+
|
|
20
|
+
| Directory | What you put there | Read by (engine) |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| `tools/` | `tools.register.json`, `tools.state.json`, `scripts/*.js` — your first-party tools | `src/tools/registry.js`, `src/tools/tool-state.js` |
|
|
23
|
+
| `mcp/` | `expose.json` — your upstream MCP servers + the curated tools drawn from them | `src/mcp/expose-store.js` |
|
|
24
|
+
| `hooks/` | `hooks.manifest.json`, `hooks.state.json`, `scripts/*` — your policy gate | `src/core/hook-loader.js` |
|
|
25
|
+
|
|
26
|
+
Rule of thumb: if you are editing something under `src/`, stop — that is the machinery. Your
|
|
27
|
+
register, your upstreams, and your gate all live at the top level.
|
|
28
|
+
|
|
29
|
+
## The files at a glance
|
|
30
|
+
|
|
31
|
+
| File | Loader | Purpose |
|
|
32
|
+
|---|---|---|
|
|
33
|
+
| `tools/tools.register.json` | `src/tools/registry.js` | The tool register — every first-party tool's metadata + how to invoke it. |
|
|
34
|
+
| `tools/tools.state.json` | `src/tools/tool-state.js` | Per-tool enable/disable + hidden overlay over the register. |
|
|
35
|
+
| `mcp/expose.json` | `src/mcp/expose-store.js` | Upstream MCP connections + the curated-direct tools drawn from them. |
|
|
36
|
+
| `hooks/hooks.manifest.json` | `src/core/hook-loader.js` | The hook manifest — the gate. Each entry is one lifecycle hook. |
|
|
37
|
+
| `hooks/hooks.state.json` | `src/core/hook-loader.js` | Per-hook enabled overlay over the manifest (sibling of the manifest). |
|
|
38
|
+
| `logs/log.config.json` | `src/core/logger.js` | Toggle + path for the optional JSONL audit log (tool runs + gate allow/deny decisions). **Default OFF**; the file is not created until logging is enabled (via the `tf_log` management function or the admin UI Logs tab). Full reference: [`logging.md`](logging.md). |
|
|
39
|
+
| `auth/auth.config.json` | `src/auth/config.js` | Toggle + settings for the optional **OAuth 2.1 resource-server** validation. **Default OFF** (loopback-only, unauthenticated). When enabled, the HTTP transport validates a bearer token on every request. Full reference: [`oauth.md`](oauth.md). |
|
|
40
|
+
|
|
41
|
+
> **All paths resolve under the repo root.** The server (`src/mcp/server.js`) anchors
|
|
42
|
+
> `ROOT = <repo>` from its own location (`path.resolve(__dirname, '..', '..')`) and joins every
|
|
43
|
+
> config path off it: `tools/tools.register.json`, `tools/tools.state.json`,
|
|
44
|
+
> `hooks/hooks.manifest.json`, `mcp/expose.json`, and the tool scripts root `tools/scripts`.
|
|
45
|
+
> Nothing is read from outside the tree, and script/command targets are path-guarded to stay
|
|
46
|
+
> inside it (the isolation rule).
|
|
47
|
+
|
|
48
|
+
## Conventions shared by every file
|
|
49
|
+
|
|
50
|
+
- **JSON only**, parsed once at load. A malformed register, expose store, or manifest throws a
|
|
51
|
+
clear error at load time — misconfiguration is caught at startup, not at model-call time. The
|
|
52
|
+
two overlay files (`tools.state.json`, `hooks.state.json`) are the exception: a
|
|
53
|
+
missing/unreadable/malformed overlay degrades to "no overrides" and never throws.
|
|
54
|
+
- **Atomic writes.** Every write goes to a temp file in the same directory, is fsync'd, then
|
|
55
|
+
renamed over the target. A crash leaves either the old file or the new file, never a
|
|
56
|
+
half-written config. Prefer the loader's write methods over hand-editing a live file.
|
|
57
|
+
- **No restart for register/state edits.** The server reads `tools.register.json` and
|
|
58
|
+
`tools.state.json` fresh on every `toolfunnel_list_tools` call, so a tool added or toggled is
|
|
59
|
+
visible with no reconnect. (Curated-direct tools from `expose.json` are the exception — see that
|
|
60
|
+
section.)
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## `tools/tools.register.json` — the tool register
|
|
65
|
+
|
|
66
|
+
Top-level shape:
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"version": 1,
|
|
71
|
+
"description": "Tool register for the gateway — built-in demo scripts.",
|
|
72
|
+
"tools": [ /* entries */ ]
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
- `version` — number (defaults to `1` if absent). Schema version of the register.
|
|
77
|
+
- `description` — string (defaults to `""`). Free-text label, preserved across writes.
|
|
78
|
+
- `tools` — array of entries (defaults to `[]`).
|
|
79
|
+
|
|
80
|
+
### Entry shape
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
{
|
|
84
|
+
"id": "echo",
|
|
85
|
+
"name": "Echo",
|
|
86
|
+
"summary": "Return the provided arguments back unchanged.",
|
|
87
|
+
"category": "demo",
|
|
88
|
+
"instructions": "Echoes its structured args back in the result. Pass any JSON value as args and the same value is returned.",
|
|
89
|
+
"invoke": { "type": "script", "path": "scripts/echo.js" }
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
| Field | Type | Required | Meaning |
|
|
94
|
+
|---|---|---|---|
|
|
95
|
+
| `id` | string | **yes** | Stable unique key. `toolfunnel_run_tool({ name })` / `toolfunnel_tool_instructions({ name })` look up by this (id **or** display name). Duplicate ids are rejected **at load**. |
|
|
96
|
+
| `name` | string | **yes** | Display name shown in `toolfunnel_list_tools` briefs and the manager UI. |
|
|
97
|
+
| `summary` | string | no (→ `""`) | One line. This is *all* a brief carries for the tool — keep it tight so the model can pick from the brief alone. |
|
|
98
|
+
| `category` | string | no (→ `""`) | Grouping label (`demo`, `crypto`, `data`, …). `toolfunnel_list_tools({ category })` filters on an exact match. |
|
|
99
|
+
| `instructions` | string | no (→ `""`) | Full usage doc returned by `toolfunnel_tool_instructions`. The long-tail payload — be complete here, terse in `summary`. |
|
|
100
|
+
| `mode` | string | no (→ inferred) | Execution mode — `gateway` or `reference`. See below. |
|
|
101
|
+
| `invoke` | object | **yes** (unless `mode: "reference"`) | How the tool runs. Two forms below. |
|
|
102
|
+
|
|
103
|
+
Validation (from `registry.js::validateEntry`): `id` and `name` must be non-empty strings, and
|
|
104
|
+
`mode`, if present, must be `reference` or `gateway`. `invoke` must be present with `invoke.type`
|
|
105
|
+
one of `script` | `shell` — **except** an explicit `mode: "reference"` may omit `invoke` entirely
|
|
106
|
+
(a reference tool runs nothing server-side, so there is nothing to invoke). `summary`, `category`,
|
|
107
|
+
and `instructions` are optional and default to empty strings.
|
|
108
|
+
|
|
109
|
+
### `mode` — gateway vs reference execution
|
|
110
|
+
|
|
111
|
+
The optional `mode` field decides **who runs the tool** (from `registry.js::resolveMode` /
|
|
112
|
+
`VALID_MODES`):
|
|
113
|
+
|
|
114
|
+
- **`gateway`** — ToolFunnel executes the `invoke` **server-side**, through the gated run path
|
|
115
|
+
(PreToolUse → execute → PostToolUse). This is the original behaviour of every `script`/`shell`
|
|
116
|
+
tool.
|
|
117
|
+
- **`reference`** — ToolFunnel executes **nothing**. `toolfunnel_run_tool` returns the tool's
|
|
118
|
+
`instructions` so the **connected AI performs the action in its own environment**. A reference
|
|
119
|
+
tool needs no `invoke`.
|
|
120
|
+
|
|
121
|
+
When `mode` is absent it is **inferred backward-compatibly**: a `script`/`shell` invoke ⇒
|
|
122
|
+
`gateway` (so every existing register entry keeps running exactly as before), anything else ⇒
|
|
123
|
+
`reference`. An explicit, valid `mode` always wins over the inference.
|
|
124
|
+
|
|
125
|
+
### `invoke` — two forms
|
|
126
|
+
|
|
127
|
+
**Script invoke (the implemented, tested path):**
|
|
128
|
+
|
|
129
|
+
```json
|
|
130
|
+
{ "type": "script", "path": "scripts/echo.js" }
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
- `path` points at a file under **`tools/scripts/`**. The runner takes `path.basename(path)` and
|
|
134
|
+
resolves it under the scripts root (`tools/scripts`), then refuses any result that escapes that
|
|
135
|
+
directory (defense-in-depth path guard). A `script` invoke needs a non-empty `path` or load
|
|
136
|
+
fails. In practice `path` is always written as `scripts/<id>.js`.
|
|
137
|
+
- At run, the gateway spawns `node <resolved script>` with the structured args delivered in the
|
|
138
|
+
environment variable `TOOLFUNNEL_TOOL_ARGS` (JSON-encoded; `null` if no args). The script prints
|
|
139
|
+
exactly **one JSON line** to stdout and exits `0`. A non-zero exit is surfaced as the tool's
|
|
140
|
+
failure (the runner never rejects on exit code — only on a spawn failure).
|
|
141
|
+
|
|
142
|
+
**Shell invoke:**
|
|
143
|
+
|
|
144
|
+
```json
|
|
145
|
+
{ "type": "shell", "command": "node tools/scripts/echo-upper.js" }
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
- A `shell` invoke needs a non-empty `command` or load fails.
|
|
149
|
+
- `registry.js` deliberately does **not** spawn a shell. `resolveExecution` returns a deferred
|
|
150
|
+
descriptor (`{ deferred:true, type:"shell", command, args }`) and hands it to the gated runner,
|
|
151
|
+
which owns the actual shell spawn — so arbitrary shell execution stays behind the PreToolUse gate
|
|
152
|
+
and out of the un-gated register module.
|
|
153
|
+
- When the gate allows it, the server runs the `command` **verbatim through the OS shell**
|
|
154
|
+
(`spawnSync(command, { shell:true })`) with **`cwd` set to the repo root** and
|
|
155
|
+
`TOOLFUNNEL_TOOL_ARGS` (the JSON args) injected into the environment. There is **no token
|
|
156
|
+
substitution** — the string is passed to the shell as written, so a relative path such as
|
|
157
|
+
`tools/scripts/echo-upper.js` resolves against the repo root. Prefer `script` for anything
|
|
158
|
+
reusable; reach for `shell` only for thin one-liners.
|
|
159
|
+
|
|
160
|
+
> **Isolation rule.** An `invoke` must only reference scripts/commands that live **inside the
|
|
161
|
+
> gateway's own tree** (`tools/scripts/`). Never point `invoke` at a tool living outside it — copy
|
|
162
|
+
> the logic into `tools/scripts/` first, then point at the clone. The script path-guard enforces
|
|
163
|
+
> this for `script` invokes; for `shell` you are responsible for keeping the command inside the
|
|
164
|
+
> tree.
|
|
165
|
+
|
|
166
|
+
### Live example (the shipped register)
|
|
167
|
+
|
|
168
|
+
The default register ships **seven** demo `script` tools — `echo`, `base64`, `hash`, `uuid`,
|
|
169
|
+
`json`, `text-stats`, `danger` — each
|
|
170
|
+
`{ "type": "script", "path": "scripts/<id>.js" }`, with the matching file in `tools/scripts/`.
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## `tools/tools.state.json` — the enable/disable + hidden overlay
|
|
175
|
+
|
|
176
|
+
A small overlay keyed by tool `id`. The register (`tools.register.json`) holds **all** tools; this
|
|
177
|
+
file decides which are surfaced to clients and which are decluttered from the manager list. The
|
|
178
|
+
shipped default is just `{}` (no overrides — everything on, nothing hidden).
|
|
179
|
+
|
|
180
|
+
```json
|
|
181
|
+
{
|
|
182
|
+
"danger": { "enabled": false },
|
|
183
|
+
"text-stats": { "hidden": true }
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
| Per-id field | Default | Axis | Effect |
|
|
188
|
+
|---|---|---|---|
|
|
189
|
+
| `enabled` | **ON** | MCP visibility | A tool is active unless the overlay explicitly says `enabled: false`. Disabled tools are filtered out of `toolfunnel_list_tools` so the client never sees them. |
|
|
190
|
+
| `hidden` | not hidden | manager-list only | `hidden: true` declutters the tool from the **manager** listing only. It is **never** consulted by the MCP server and does not change the client's view. |
|
|
191
|
+
|
|
192
|
+
Key behaviours (from `tool-state.js`):
|
|
193
|
+
|
|
194
|
+
- **Default ON.** A tool absent from the overlay — or present without `enabled: false` — is active.
|
|
195
|
+
Newly-added register tools are live until you untick them, and the file only ever lists the tools
|
|
196
|
+
you have toggled.
|
|
197
|
+
- **The two axes are independent.** A tool can be active-but-hidden or disabled-but-visible.
|
|
198
|
+
`enabled` controls what the client sees; `hidden` only declutters the manager view.
|
|
199
|
+
- **Toggles merge.** `setToolEnabled` / `setToolHidden` merge into the existing entry, so flipping
|
|
200
|
+
one flag preserves the other, then persist atomically.
|
|
201
|
+
- **Tolerant load.** A missing or malformed file loads as `{}` (no overrides) and never throws.
|
|
202
|
+
- **Read fresh.** The server re-reads this overlay on every `toolfunnel_list_tools` call, so UI
|
|
203
|
+
toggles take effect with no restart.
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
## `mcp/expose.json` — upstream MCPs + curated-direct selection
|
|
208
|
+
|
|
209
|
+
Two blocks: the upstream MCP servers to connect to, and which of their tools to surface downstream
|
|
210
|
+
as *curated-direct* tools. The shipped default is empty
|
|
211
|
+
(`{ "version": 1, "upstreams": [], "expose": [] }`), so the gateway connects to nothing and the
|
|
212
|
+
curated-direct surface is empty until you author entries. An annotated sample lives alongside it at
|
|
213
|
+
`mcp/expose.example.json`.
|
|
214
|
+
|
|
215
|
+
```json
|
|
216
|
+
{
|
|
217
|
+
"version": 1,
|
|
218
|
+
"upstreams": [
|
|
219
|
+
{
|
|
220
|
+
"id": "mock",
|
|
221
|
+
"transport": "stdio",
|
|
222
|
+
"command": "node",
|
|
223
|
+
"args": ["mcp/servers/mock-upstream/server.js"],
|
|
224
|
+
"env": {},
|
|
225
|
+
"enabled": true,
|
|
226
|
+
"description": "Bundled demo upstream MCP (zero-dependency stdio): ping, add, echo."
|
|
227
|
+
}
|
|
228
|
+
],
|
|
229
|
+
"expose": [
|
|
230
|
+
{
|
|
231
|
+
"upstream": "mock",
|
|
232
|
+
"tool": "ping",
|
|
233
|
+
"as": "mock_ping",
|
|
234
|
+
"category": "demo",
|
|
235
|
+
"enabled": true
|
|
236
|
+
}
|
|
237
|
+
]
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
> The demo upstream that ships with ToolFunnel lives at `mcp/servers/mock-upstream/server.js` and
|
|
242
|
+
> offers `ping`, `add`, and `echo`. A ready-to-copy version of the sample above is at
|
|
243
|
+
> `mcp/expose.example.json`.
|
|
244
|
+
|
|
245
|
+
### `upstreams[]` — one entry per upstream MCP
|
|
246
|
+
|
|
247
|
+
| Field | Type | Default | Meaning |
|
|
248
|
+
|---|---|---|---|
|
|
249
|
+
| `id` | string | — (required) | Stable unique key, referenced by `expose[].upstream`. Duplicate ids are rejected at load. |
|
|
250
|
+
| `transport` | string | `"stdio"` | How the aggregator connects. `"stdio"` (spawn `command` + `args`) is the only valid transport today. |
|
|
251
|
+
| `command` | string | `""` | The process to spawn for a `stdio` upstream. **Required and non-empty** for `stdio`. The interpreter slot (`node`, `python`, …) is not path-guarded; the `args` path is. |
|
|
252
|
+
| `args` | string[] | `[]` | Command arguments. A script path here must resolve **inside the gateway root** (the aggregator's isolation guard rejects an arg path that escapes the tree — a relative path like `mcp/servers/mock-upstream/server.js` is resolved against the repo root and stays inside it). |
|
|
253
|
+
| `env` | object | `{}` | Extra environment for the spawned upstream. |
|
|
254
|
+
| `enabled` | boolean | `true` | Whether the aggregator connects to this upstream at startup. |
|
|
255
|
+
| `description` | string | `""` | Human-facing note for the MCP Manager UI. |
|
|
256
|
+
|
|
257
|
+
### `expose[]` — which upstream tools become curated-direct
|
|
258
|
+
|
|
259
|
+
| Field | Type | Default | Meaning |
|
|
260
|
+
|---|---|---|---|
|
|
261
|
+
| `upstream` | string | — (required) | Must match an existing `upstreams[].id`. Referential integrity is enforced on write — you cannot expose a tool from an unknown upstream (`addExpose` throws). |
|
|
262
|
+
| `tool` | string | — (required) | The upstream tool's own name, as its `tools/list` reports it. |
|
|
263
|
+
| `as` | string | `<upstream>_<tool>` | The downstream name the client sees — the **rename**. Optional; defaults to the namespaced `<upstream>_<tool>` so two upstreams can't collide. On write the default is materialised into the stored entry, so the saved entry is self-describing. |
|
|
264
|
+
| `category` | string | `""` | Grouping label, same convention as register entries. |
|
|
265
|
+
| `enabled` | boolean | `true` | Whether the tool is actually exposed downstream right now. |
|
|
266
|
+
|
|
267
|
+
Key behaviours (from `expose-store.js`):
|
|
268
|
+
|
|
269
|
+
- **Natural key.** `(upstream, tool)` is the key; duplicate pairs are rejected at load.
|
|
270
|
+
- **Cascade delete.** Removing an upstream also removes every `expose[]` entry that references it,
|
|
271
|
+
so no expose entry can dangle against a missing upstream.
|
|
272
|
+
- **Lenient missing file.** A missing `expose.json` loads as an empty store bound to the path; the
|
|
273
|
+
file is **not created until the first write** (the server starts clean with no live upstreams). A
|
|
274
|
+
malformed file, a duplicate upstream id, or a duplicate expose key throws at load.
|
|
275
|
+
- **Reconnect cost.** Unlike register tools, curated-direct tools are negotiated with the client at
|
|
276
|
+
connect time — changing the exposed set needs a reconnect (or a `tools/list_changed`
|
|
277
|
+
notification if the host honours it). Register tools reached via the meta-tools are always live.
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
281
|
+
## `hooks/hooks.manifest.json` — the hook manifest (the gate)
|
|
282
|
+
|
|
283
|
+
Every tool execution funnels through the hook engine: a `PreToolUse` hook can block a call before
|
|
284
|
+
it runs (fails closed), and a `PostToolUse` hook observes the result (advisory — it cannot un-run
|
|
285
|
+
the tool). The manifest is the list of those hooks. The shipped default is
|
|
286
|
+
`{ "version": 1, "hooks": [] }` — **an empty `hooks` array is allow-all** (no gate fires).
|
|
287
|
+
|
|
288
|
+
Top-level shape:
|
|
289
|
+
|
|
290
|
+
```json
|
|
291
|
+
{
|
|
292
|
+
"version": 1,
|
|
293
|
+
"hooksDir": "optional absolute override",
|
|
294
|
+
"hooks": [ /* entries */ ]
|
|
295
|
+
}
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
- `version` — manifest schema version.
|
|
299
|
+
- `hooksDir` — optional. The absolute hooks directory. If omitted (the normal case) it is resolved
|
|
300
|
+
from the manifest's own location — the manifest sits inside `hooks/`, so **that directory is the
|
|
301
|
+
source of truth** and `${HOOKS_DIR}` expands to the absolute path of `hooks/`.
|
|
302
|
+
- `hooks` — array of hook entries.
|
|
303
|
+
|
|
304
|
+
### Hook entry shape
|
|
305
|
+
|
|
306
|
+
```json
|
|
307
|
+
{
|
|
308
|
+
"id": "pre-tool-use/deny-dangerous",
|
|
309
|
+
"event": "PreToolUse",
|
|
310
|
+
"matcher": "Bash|toolfunnel_run_tool",
|
|
311
|
+
"type": "command",
|
|
312
|
+
"command": "bash \"${HOOKS_DIR}/scripts/deny-dangerous.sh\"",
|
|
313
|
+
"script": "scripts/deny-dangerous.sh",
|
|
314
|
+
"timeout": 5,
|
|
315
|
+
"enabled": true,
|
|
316
|
+
"description": "Denies any tool call whose args contain a destructive pattern."
|
|
317
|
+
}
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
| Field | Type | Meaning |
|
|
321
|
+
|---|---|---|
|
|
322
|
+
| `id` | string | Unique key, convention `"<kebab-event>/<script-basename>"`. The loader and `hooks.state.json` key on this. |
|
|
323
|
+
| `event` | string | One of the six lifecycle events (see below). |
|
|
324
|
+
| `matcher` | string | Regex, anchored as a **full match** against `tool_name` (wrapped `^(?:…)$`). `""`, `undefined`, or `"*"` = always fire. Tool-less events ignore it. A malformed regex fails closed (never matches), never throws. Example: `"Bash\|Write\|Edit"`. |
|
|
325
|
+
| `type` | string | `"command"` — the only type today. |
|
|
326
|
+
| `command` | string | The command line the runner spawns (with `shell: true`). `${HOOKS_DIR}` expands to the absolute `hooks/` path **at load time**. The event payload is written to the command's **stdin** as JSON. |
|
|
327
|
+
| `script` | string | The script path relative to `hooksDir` (e.g. `scripts/deny-dangerous.sh`). Used by the manager's open/edit; `writeScript` refuses any path that resolves outside `hooks/scripts/`. Inline-command hooks (no `script`) are allowed — there is just no file to edit. |
|
|
328
|
+
| `timeout` | number | **Seconds** (the runner multiplies by 1000). Defaults to **60s** if absent or non-positive. The child is killed past this. |
|
|
329
|
+
| `enabled` | boolean | The manifest's **seed** state. The live value is the overlay in `hooks.state.json` — see below. Normalised to a strict boolean at load (an omitted `enabled` is `false`). |
|
|
330
|
+
| `description` | string | What the hook does, for the manager UI. |
|
|
331
|
+
|
|
332
|
+
One entry **per hook command** — a single settings-style event with N commands becomes N entries.
|
|
333
|
+
|
|
334
|
+
### The six lifecycle events
|
|
335
|
+
|
|
336
|
+
`SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `PreCompact` (from
|
|
337
|
+
`src/core/events.js`). `PreToolUse` is the only event that can block a tool *before* it runs. The
|
|
338
|
+
matcher applies to the tool-bearing events (`PreToolUse` / `PostToolUse`) and is ignored for the
|
|
339
|
+
tool-less ones (`SessionStart`, `UserPromptSubmit`, `Stop`, `PreCompact`), which always fire.
|
|
340
|
+
|
|
341
|
+
### How a hook blocks (the two protocols)
|
|
342
|
+
|
|
343
|
+
The runner (`src/core/hook-runner.js`) spawns the command, pipes the event JSON to stdin, then
|
|
344
|
+
reads the result:
|
|
345
|
+
|
|
346
|
+
- **Exit-code protocol:** `exit 2` blocks; **stderr is the reason**. `exit 0` succeeds (for
|
|
347
|
+
`SessionStart` / `UserPromptSubmit`, stdout becomes injected context; otherwise advisory). Any
|
|
348
|
+
other non-zero code is a non-blocking error.
|
|
349
|
+
- **JSON protocol** (honoured only on `exit 0`, when stdout parses as a JSON object): a `PreToolUse`
|
|
350
|
+
hook blocks via `hookSpecificOutput.permissionDecision: "deny"` (`"allow"` passes; `"ask"` is
|
|
351
|
+
non-blocking in the autonomous host). The other blockable events block via top-level
|
|
352
|
+
`decision: "block"`. `continue: false` stops the whole loop;
|
|
353
|
+
`hookSpecificOutput.additionalContext` is injected text.
|
|
354
|
+
|
|
355
|
+
### Activation overlay — `hooks/hooks.state.json`
|
|
356
|
+
|
|
357
|
+
What *exists* (the `hooks/scripts/` folder + manifest config) is split from what's *on*. The live
|
|
358
|
+
enabled/disabled value lives in a sibling overlay keyed by hook `id`, and **the overlay wins over
|
|
359
|
+
the manifest's seed `enabled`** at load.
|
|
360
|
+
|
|
361
|
+
`setEnabled(id, bool)` writes the overlay atomically as a flat map:
|
|
362
|
+
|
|
363
|
+
```json
|
|
364
|
+
{
|
|
365
|
+
"pre-tool-use/deny-dangerous": true,
|
|
366
|
+
"user-prompt-submit/inject-context": false
|
|
367
|
+
}
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
The loader also tolerates a wrapped form on read (forward-compatibility):
|
|
371
|
+
|
|
372
|
+
```json
|
|
373
|
+
{ "version": 1, "enabled": { "pre-tool-use/deny-dangerous": true } }
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
Both read the same way; the flat map is what `setEnabled` writes. The overlay is kept separate from
|
|
377
|
+
the auto-detected inventory, so re-scanning `hooks/scripts/` never clobbers your toggles, and
|
|
378
|
+
toggles survive restarts. (`setEnabled` also mirrors the flag back into the manifest — re-collapsing
|
|
379
|
+
`${HOOKS_DIR}` so the on-disk manifest stays portable — but the overlay is the authoritative,
|
|
380
|
+
precedence-winning store.)
|
|
381
|
+
|
|
382
|
+
---
|
|
383
|
+
|
|
384
|
+
## Path resolution summary
|
|
385
|
+
|
|
386
|
+
Every config and execution target resolves under the repo root. The server anchors the top-level
|
|
387
|
+
config paths; the hook loader anchors the hooks paths to the manifest's own directory.
|
|
388
|
+
|
|
389
|
+
| Anchor | Path |
|
|
390
|
+
|---|---|
|
|
391
|
+
| Tool register | `<repo>/tools/tools.register.json` |
|
|
392
|
+
| Tool state overlay | `<repo>/tools/tools.state.json` |
|
|
393
|
+
| Tool scripts root | `<repo>/tools/scripts/` |
|
|
394
|
+
| Expose store | `<repo>/mcp/expose.json` |
|
|
395
|
+
| Hook manifest | `<repo>/hooks/hooks.manifest.json` |
|
|
396
|
+
| Hook state overlay | `<repo>/hooks/hooks.state.json` |
|
|
397
|
+
| Hook scripts root | `<repo>/hooks/scripts/` |
|
|
398
|
+
| Log config | `<repo>/logs/log.config.json` |
|
|
399
|
+
|
|
400
|
+
Script and command targets are additionally path-guarded to stay inside their respective
|
|
401
|
+
`scripts/` directories — a config entry can never become a path-escape out of the tree. Engine code
|
|
402
|
+
under `src/` reads all of the above; you never edit `src/` to configure the gateway.
|