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.
Files changed (40) hide show
  1. package/README.md +167 -324
  2. package/bin/toolfunnel.js +39 -4
  3. package/docs/KNOWN_BUGS.md +33 -0
  4. package/docs/MANUAL.pdf +0 -0
  5. package/docs/packaging.md +115 -0
  6. package/package.json +3 -1
  7. package/src/auth/config.js +4 -2
  8. package/src/auth/resource-server.js +28 -3
  9. package/src/core/config-home.js +134 -0
  10. package/src/core/logger.js +4 -2
  11. package/src/core/requires.js +138 -0
  12. package/src/core/server-config.js +77 -0
  13. package/src/extend/create-tool.md +24 -4
  14. package/src/extend/package.md +33 -0
  15. package/src/mcp/aggregator.js +12 -7
  16. package/src/mcp/http-transport.js +71 -21
  17. package/src/mcp/mcp-client.js +9 -3
  18. package/src/mcp/server.js +181 -16
  19. package/src/packages/packager.js +5 -3
  20. package/src/tools/registry.js +47 -2
  21. package/src/ui/server.js +49 -3
  22. package/tools/scripts/tf-env.js +43 -0
  23. package/tools/scripts/tf-hook-add.js +4 -5
  24. package/tools/scripts/tf-hook-set.js +4 -5
  25. package/tools/scripts/tf-list.js +9 -12
  26. package/tools/scripts/tf-log.js +4 -1
  27. package/tools/scripts/tf-mcp-add.js +4 -6
  28. package/tools/scripts/tf-mcp-set.js +4 -6
  29. package/tools/scripts/tf-pack.js +237 -0
  30. package/tools/scripts/tf-tool-add.js +16 -16
  31. package/tools/scripts/tf-tool-set.js +5 -11
  32. package/tools/tools.register.json +46 -1
  33. package/docs/admin-ui.md +0 -404
  34. package/docs/configuration.md +0 -402
  35. package/docs/hooks-and-gating.md +0 -428
  36. package/docs/logging.md +0 -223
  37. package/docs/management.md +0 -429
  38. package/docs/oauth.md +0 -103
  39. package/docs/setup.md +0 -352
  40. package/docs/tools-and-upstreams.md +0 -469
@@ -1,428 +0,0 @@
1
- # Hooks and gating
2
-
3
- Every tool execution in ToolFunnel funnels through one chokepoint: the **hook engine**. Before a
4
- tool runs, a `PreToolUse` hook can **block** it; after it runs, a `PostToolUse` hook can **observe**
5
- the result. Hooks are small command-line programs you drop into `hooks/scripts/` and register in
6
- `hooks/hooks.manifest.json`. This is the gateway's safety layer — the reason a tool call can be
7
- denied, audited, or annotated without touching the tool itself.
8
-
9
- This document is derived from the modules that actually implement the gate — `src/mcp/gated-run.js`,
10
- `src/core/hook-engine.js`, `src/core/hook-runner.js`, `src/core/matcher.js`, and
11
- `src/core/events.js` — not from intent. The protocol mirrors Claude Code's hook system so hook
12
- scripts written for Claude Code run here unchanged.
13
-
14
- For the manifest field-by-field reference and the activation overlay, see
15
- [`configuration.md`](configuration.md). For the tool register and upstreams, see
16
- [`tools-and-upstreams.md`](tools-and-upstreams.md).
17
-
18
- ---
19
-
20
- ## The gate at a glance
21
-
22
- Every powerful run-path — `toolfunnel_run_tool` and the curated-direct path — routes a single tool
23
- invocation through `gatedRun({ engine, ctx, toolName, args, execute })` (`src/mcp/gated-run.js`).
24
- The sequence is fixed:
25
-
26
- 1. **Fire `PreToolUse`** with `{ tool_name, tool_input: args }`.
27
- 2. **If any hook blocks → STOP.** Return `{ ok:false, blocked:true, reason, output:null }` and
28
- **`execute()` is never called.** This is the load-bearing invariant, proven by test:
29
- *a `PreToolUse` deny MUST prevent execution.*
30
- 3. **Otherwise `await execute()`** — the real tool call. A throw is captured as `error` (output
31
- stays `null`); `gatedRun` itself never throws.
32
- 4. **Fire `PostToolUse`** with `{ tool_name, tool_input: args, tool_response: output }` — fired even
33
- on the error path so observers see the outcome.
34
- 5. **Return** `{ ok:true, blocked:false, output }`, or `{ ok:false, blocked:false, error, output:null }`.
35
-
36
- ```
37
- toolfunnel_run_tool(name, args)
38
- │
39
- ▼
40
- ┌─────────────┐ blocked ┌──────────────────────────────┐
41
- │ PreToolUse │────────────▶│ { ok:false, blocked:true, │ execute() NEVER runs
42
- │ (the GATE) │ │ reason, output:null } │
43
- └─────────────┘ └──────────────────────────────┘
44
- │ allowed
45
- ▼
46
- ┌─────────────┐
47
- │ execute() │ ← the actual tool side effect happens HERE, and only here
48
- └─────────────┘
49
- │ output (or captured error)
50
- ▼
51
- ┌─────────────┐
52
- │ PostToolUse │ ← advisory: observes tool_response; CANNOT un-run the tool
53
- └─────────────┘
54
- │
55
- ▼
56
- { ok, blocked, output | error }
57
- ```
58
-
59
- ### Fail closed
60
-
61
- The gate is the safety crux, so it treats an *untrustworthy* engine as a *denying* engine.
62
- `fireSafely` (in `gated-run.js`) wraps every `engine.fire`:
63
-
64
- - A `PreToolUse` fire that **rejects**, or returns a **non-object**, is normalised to
65
- `blocked: true` — a gate that cannot answer "allowed?" is never read as "allowed".
66
- - A bad wiring (missing `engine.fire`, missing `execute` thunk) returns `blocked: true` without
67
- ever calling `execute`.
68
- - `PostToolUse` is advisory, so a failure there is benign — it never flips `ok` and never throws.
69
-
70
- The result is a hard guarantee: **the only way `execute()` runs is a clean `PreToolUse` allow.**
71
-
72
- ---
73
-
74
- ## The six lifecycle events
75
-
76
- The engine supports exactly six events (`src/core/events.js`, frozen). The value is written into
77
- the stdin payload as `hook_event_name`. Every event carries four common fields —
78
- `session_id`, `transcript_path`, `cwd`, `hook_event_name` — plus its own:
79
-
80
- | Event | Added payload fields | Can block? | Matcher applies? |
81
- |---|---|---|---|
82
- | `SessionStart` | `source` (`"startup"` \| `"resume"` \| `"compact"`) | no | no (tool-less) |
83
- | `UserPromptSubmit` | `prompt` (user text) | yes (`decision:"block"`) | no (tool-less) |
84
- | **`PreToolUse`** | `tool_name`, `tool_input` | **yes — ENFORCEMENT** | **yes** |
85
- | `PostToolUse` | `tool_name`, `tool_input`, `tool_response` | no (advisory) | yes |
86
- | `Stop` | `stop_hook_active` (bool) | yes (`decision:"block"`) | no (tool-less) |
87
- | `PreCompact` | `trigger` (`"manual"` \| `"auto"`), `custom_instructions` | no | no (tool-less) |
88
-
89
- `SessionStart` and `UserPromptSubmit` are special: on a clean `exit 0`, their stdout becomes
90
- **injected context** (see *Injection*, below). The other events' stdout is advisory unless it uses
91
- the JSON protocol.
92
-
93
- Only the tool-bearing events (`PreToolUse`, `PostToolUse`) consult the matcher. For the four
94
- tool-less events there is no `tool_name`, so the matcher is ignored and the hook always fires.
95
-
96
- > **The gated-run path uses only `PreToolUse` and `PostToolUse`.** The other four events are fired
97
- > by the host directly around the session/prompt lifecycle (where their injection and block
98
- > decisions are consumed); `gatedRun` itself fires nothing else.
99
-
100
- ---
101
-
102
- ## PreToolUse = enforcement · PostToolUse = observe / shape
103
-
104
- These two events look symmetric but are not. The asymmetry is the whole design.
105
-
106
- ### PreToolUse — the enforcement point
107
-
108
- `PreToolUse` runs **before** the tool. It is the only event in the run-path that can stop a tool
109
- from happening. A `PreToolUse` block means `execute()` is never invoked — no side effect, no
110
- output, just `{ ok:false, blocked:true, reason }`. Use it to:
111
-
112
- - **Deny** a dangerous call outright (destructive args, disallowed target, missing confirmation).
113
- - **Require a precondition** (a confirm flag, a dry-run first, a rate check).
114
- - **Inject context** for the model via `additionalContext` (see below) while still allowing.
115
-
116
- Because it gates real side effects, `PreToolUse` **fails closed**: a hook that errors or returns
117
- junk is treated as a denial by the engine wrapper.
118
-
119
- ### PostToolUse — observe and shape, never block
120
-
121
- `PostToolUse` runs **after** the tool, with the tool's output in `tool_response`. It **cannot
122
- un-run the tool** — the side effect has already happened. It is the place for:
123
-
124
- - **Audit** — append the call + result to a log, emit metrics.
125
- - **Alerting / triggers** — fire a webhook or kick off follow-on work when a result matches.
126
- - **Redaction / transform of *downstream* state** — scrub a secret from a log you control, rewrite
127
- an artifact the tool produced. (It shapes side effects, not the already-returned value.)
128
-
129
- In the gated-run path `gatedRun` fires `PostToolUse` purely for observation and **discards its
130
- result** — a `PostToolUse` `decision:"block"` or `additionalContext` does not flow back through
131
- `gatedRun`, because there is nothing left to block. (For the host-fired lifecycle events the engine
132
- *does* surface `blocked`/`injected`; that is a different path.) Treat `PostToolUse` as
133
- fire-and-observe: anything it needs to *do*, it does as its own side effect.
134
-
135
- ---
136
-
137
- ## Writing a hook
138
-
139
- A hook is a command. The runner (`src/core/hook-runner.js`) spawns it with `shell: true`, writes
140
- the **event JSON to its stdin**, captures stdout/stderr, and interprets the result. Your script:
141
-
142
- 1. **Reads the event JSON from stdin** (the payload for its event — see the table above).
143
- 2. **Decides**, then signals the decision with **one of two output protocols**.
144
- 3. **Exits.**
145
-
146
- The runner **never rejects**: a spawn failure, a timeout, or a malformed spec always resolves to a
147
- well-formed result (`exitCode:-1`, `blocked:false`). Timeout is the manifest `timeout` in
148
- **seconds** (default 60); past it the whole child tree is killed.
149
-
150
- ### Output protocol A — exit code (simplest)
151
-
152
- | Exit code | Meaning |
153
- |---|---|
154
- | `0` | **Success / allow.** For `SessionStart` & `UserPromptSubmit`, stdout becomes injected context; for other events stdout is advisory (captured, not acted on). |
155
- | `2` | **Block.** `stderr` is the block reason. This is the simplest way to deny a `PreToolUse` call. |
156
- | any other non-zero | **Non-blocking error.** stderr is captured; the call is **not** blocked. |
157
-
158
- `exit 2` always blocks, on any event, and stdout/JSON is ignored on any non-zero exit.
159
-
160
- ### Output protocol B — JSON on stdout (honoured only on `exit 0`)
161
-
162
- If the script exits `0` and stdout is a JSON object carrying a known protocol key
163
- (`continue`, `decision`, `reason`, or `hookSpecificOutput`), the runner reads it as structured
164
- output. An arbitrary JSON blob with none of those keys falls through to protocol A.
165
-
166
- | Field | Effect |
167
- |---|---|
168
- | `hookSpecificOutput.permissionDecision` | **`PreToolUse` gate.** `"deny"` blocks (reason from `hookSpecificOutput.permissionDecisionReason`); `"allow"` passes; `"ask"` is non-blocking in this autonomous host (no interactive prompt) — its reason is captured for context. |
169
- | `decision: "block"` | Block for the other blockable events (`UserPromptSubmit`, `Stop`, `PostToolUse`, `PreCompact`); reason from top-level `reason`. (There is no `"approve"`.) |
170
- | `continue: false` | Stop the whole hook loop (`stopLoop`). |
171
- | `hookSpecificOutput.additionalContext` | Text **injected** into context (any event). |
172
-
173
- > The real Claude Code mechanism for a `PreToolUse` deny is `hookSpecificOutput.permissionDecision`,
174
- > **not** a top-level `decision`. This gateway follows that exactly.
175
-
176
- ### Injection
177
-
178
- Injected text — `additionalContext` (protocol B, any event), or the raw stdout of a `SessionStart`/
179
- `UserPromptSubmit` hook on `exit 0` (protocol A) — is collected by the engine and returned as the
180
- joined `injected` string. The host consumes that for the session/prompt lifecycle events. Note that
181
- `gatedRun` does not surface injection back to the tool caller, so `additionalContext` from a
182
- `PreToolUse` hook is collected by the engine but not propagated through the tool-run result.
183
-
184
- ### Aggregation (when several hooks match one event)
185
-
186
- Matching hooks run **concurrently** (capped, default 8) but results are aggregated in **manifest
187
- order** (`src/core/hook-engine.js`):
188
-
189
- - **`blocked` / `reason`** — the **first** blocking hook in manifest order owns the reason.
190
- - **`injected`** — every non-null inject fragment, joined by `"\n"`, in order.
191
- - **`stopLoop`** — true if **any** hook returned `continue:false`.
192
-
193
- ---
194
-
195
- ## The `CLAUDE_PROJECT_DIR` / `HOOKS_DIR` environment (Claude-Code-compatible)
196
-
197
- Two mechanisms let a hook resolve its own files. They are intentionally Claude-Code-compatible so a
198
- hook authored for Claude Code resolves itself the same way here.
199
-
200
- 1. **`${HOOKS_DIR}` token in the manifest `command`** — expanded **at load time** by
201
- `src/core/hook-loader.js` to the absolute hooks directory, resolved from the manifest's own
202
- location (the manifest sits in `hooks/`, so `${HOOKS_DIR}` → `<repo>/hooks`). **This is the
203
- reliable way to point at your script**, e.g. `node "${HOOKS_DIR}/scripts/gate-danger.js"`. It is
204
- a literal string substitution done before the command ever runs.
205
-
206
- 2. **Child-process environment variables** set by the runner on every hook spawn:
207
- - **`CLAUDE_PROJECT_DIR`** — the repository root (`<repo>`). Reliable; derive any path from it.
208
- - **`HOOKS_DIR`** — the hooks directory the runner resolved (precedence: an explicit
209
- `opts.hooksDir`, then an `env.HOOKS_DIR`, then the runner's built-in default).
210
-
211
- Prefer the `${HOOKS_DIR}` **manifest token** (load-time expansion, always the directory containing
212
- the manifest) or `CLAUDE_PROJECT_DIR` (the repo root) to locate files. See the *Caveats* note at the
213
- end about reading the `HOOKS_DIR` **env var** directly.
214
-
215
- ---
216
-
217
- ## Matcher semantics
218
-
219
- The matcher (`src/core/matcher.js`) decides whether a hook fires for a given tool. It mirrors Claude
220
- Code:
221
-
222
- - `""`, `undefined`, or `"*"` → **always fire** (wildcard).
223
- - Tool-less events (`tool_name` is `null`/`undefined`) → the matcher is **ignored**; the hook always
224
- fires.
225
- - Otherwise the matcher is a **regex, anchored as a FULL match** against `tool_name`. It is wrapped
226
- as `^(?:<matcher>)$`, so `"Bash|Write|Edit"` means *the whole tool name is one of these*, not
227
- *contains one of these*.
228
- - A **malformed regex fails closed** — it matches nothing and never throws.
229
-
230
- ### What `tool_name` actually is — read this before writing a matcher
231
-
232
- For a first-party tool run via `toolfunnel_run_tool`, the `tool_name` the matcher sees is the
233
- register entry's **display `name`** (falling back to its `id`), **not** the lowercase id. The server
234
- adapter resolves the call and passes `toolName: entry.name || id` into the gate.
235
-
236
- So to gate the `danger` demo (id `danger`, **name `"Danger Demo"`**) the matcher must be
237
- `"Danger Demo"`, not `"danger"`. Spaces are literal in the regex; `^(?:Danger Demo)$` matches the
238
- exact name. To gate several tools, use an alternation: `"Danger Demo|Bash"`.
239
-
240
- ---
241
-
242
- ## Where hooks live
243
-
244
- ```
245
- hooks/
246
- ├── hooks.manifest.json # the gate — the ordered list of hooks (config)
247
- ├── hooks.state.json # per-hook enabled overlay (created on first toggle; overlay WINS)
248
- └── scripts/ # your hook programs (the source of truth for what EXISTS)
249
- └── gate-danger.js
250
- ```
251
-
252
- The default `hooks.manifest.json` is `{ "version": 1, "hooks": [] }` — **an empty `hooks` array is
253
- allow-all**: no hook fires, every tool runs. The gate only bites once you add an enabled hook.
254
-
255
- A manifest entry:
256
-
257
- ```json
258
- {
259
- "id": "pre-tool-use/gate-danger",
260
- "event": "PreToolUse",
261
- "matcher": "Danger Demo",
262
- "type": "command",
263
- "command": "node \"${HOOKS_DIR}/scripts/gate-danger.js\"",
264
- "script": "scripts/gate-danger.js",
265
- "timeout": 5,
266
- "enabled": true,
267
- "description": "Deny the Danger Demo tool unless args.confirm === true."
268
- }
269
- ```
270
-
271
- - `command` is what the runner spawns (`${HOOKS_DIR}` expands at load). The event payload arrives on
272
- its **stdin**.
273
- - `script` is the path relative to `hooks/` used by the manager's open/edit; writes are path-guarded
274
- to stay inside `hooks/scripts/`.
275
- - `timeout` is in **seconds** (default 60).
276
- - `enabled` here is the manifest **seed**; the live value is the `hooks.state.json` overlay, which
277
- **wins** at load. See [`configuration.md`](configuration.md#activation-overlay--srchookshooksstatejson)
278
- for the overlay rules.
279
-
280
- > Tool scripts live under `tools/scripts/`; **hook** scripts live under `hooks/scripts/`. They are
281
- > separate trees with separate path guards — don't cross them.
282
-
283
- ---
284
-
285
- ## Worked example — gating the `danger` tool
286
-
287
- The `danger` demo tool exists to prove the gate. Its only side effect is appending one line to the
288
- file named by the env var `TOOLFUNNEL_DANGER_LOG` (`tools/scripts/danger.js`). If the gate denies
289
- the call, `execute()` never runs, so **that line never appears** — the observable proof that the
290
- gate held. A runnable version of this example ships under
291
- [`examples/gate-danger/`](../examples/gate-danger).
292
-
293
- ### 1. The hook script — `hooks/scripts/gate-danger.js`
294
-
295
- Reads the `PreToolUse` event JSON from stdin and denies unless `tool_input.confirm === true`. It
296
- fails closed: an unparsable payload is a denial.
297
-
298
- ```js
299
- #!/usr/bin/env node
300
- 'use strict';
301
-
302
- // PreToolUse gate for the "Danger Demo" tool.
303
- // Contract: read the event JSON from stdin; deny by exiting 2 (stderr = reason),
304
- // allow by exiting 0. Fails closed — anything unexpected denies.
305
-
306
- let raw = '';
307
- process.stdin.setEncoding('utf8');
308
- process.stdin.on('data', (chunk) => { raw += chunk; });
309
- process.stdin.on('end', () => {
310
- let event;
311
- try {
312
- event = JSON.parse(raw || '{}');
313
- } catch (_err) {
314
- process.stderr.write('gate-danger: unparsable event payload — denying.\n');
315
- process.exit(2); // block (exit-code protocol)
316
- }
317
-
318
- const args = (event && event.tool_input) || {};
319
- if (args.confirm === true) {
320
- process.exit(0); // allow — execute() will run
321
- }
322
-
323
- process.stderr.write('Danger Demo is gated: pass { "confirm": true } to proceed.\n');
324
- process.exit(2); // deny — execute() is NEVER called
325
- });
326
- ```
327
-
328
- The same deny using the **JSON protocol** (protocol B) instead of `exit 2` — useful when you also
329
- want to surface a structured reason:
330
-
331
- ```js
332
- // ...inside the 'end' handler, the deny branch:
333
- process.stdout.write(JSON.stringify({
334
- hookSpecificOutput: {
335
- hookEventName: 'PreToolUse',
336
- permissionDecision: 'deny',
337
- permissionDecisionReason: 'Danger Demo requires { confirm: true }.'
338
- }
339
- }) + '\n');
340
- process.exit(0); // JSON protocol is honoured ONLY on exit 0
341
- ```
342
-
343
- ### 2. Register it — `hooks/hooks.manifest.json`
344
-
345
- ```json
346
- {
347
- "version": 1,
348
- "hooks": [
349
- {
350
- "id": "pre-tool-use/gate-danger",
351
- "event": "PreToolUse",
352
- "matcher": "Danger Demo",
353
- "type": "command",
354
- "command": "node \"${HOOKS_DIR}/scripts/gate-danger.js\"",
355
- "script": "scripts/gate-danger.js",
356
- "timeout": 5,
357
- "enabled": true,
358
- "description": "Deny the Danger Demo tool unless args.confirm === true."
359
- }
360
- ]
361
- }
362
- ```
363
-
364
- `matcher` is `"Danger Demo"` — the register **display name** of the `danger` tool, which is what the
365
- gate sees as `tool_name` (not the id `danger`).
366
-
367
- ### 3. What happens
368
-
369
- | Call | `PreToolUse` result | `danger.js` side effect |
370
- |---|---|---|
371
- | `toolfunnel_run_tool({ name: "danger" })` | **blocked**, reason from stderr | **none** — `execute()` never runs; no line in `TOOLFUNNEL_DANGER_LOG` |
372
- | `toolfunnel_run_tool({ name: "danger", args: { confirm: true } })` | allowed | the tool runs; one line appended to the log |
373
-
374
- The denied call returns `{ ok:false, blocked:true, reason:"Danger Demo is gated: ..." }`. Because
375
- the gate sits *before* `execute()`, the proof is the absence of the log line — exactly the invariant
376
- the gateway guarantees.
377
-
378
- To exercise it: enable the hook, set `TOOLFUNNEL_DANGER_LOG` to a scratch file in the host
379
- environment, and run `danger` with and without `{ confirm: true }`. The file gains a line only on
380
- the allowed call.
381
-
382
- ---
383
-
384
- ## Gating the management functions (recursive safety)
385
-
386
- The eight `tf_*` management functions — `tf_tool_add`, `tf_tool_set`, `tf_mcp_add`, `tf_mcp_set`,
387
- `tf_hook_add`, `tf_hook_set`, `tf_list`, `tf_log` (category `management` in the register; full
388
- reference in [`management.md`](management.md)) — are **ordinary register tools** reached through
389
- `toolfunnel_run_tool`. So every management call funnels through the **same `gatedRun`
390
- PreToolUse gate** as any other tool — the gate is uniform, with no privileged side door.
391
-
392
- Because `tool_name` for a first-party tool is the register **display name** (here each `name`
393
- equals its `id`: `tf_tool_add`, …), a single `PreToolUse` hook with matcher `tf_.*` (anchored as a
394
- full match → `^(?:tf_.*)$`) gates **every** management call at once. Use it to require a confirm
395
- flag, deny mutation of the register/upstreams/hooks, or block self-extension entirely.
396
-
397
- This is **recursive safety**: even the gate-management tools are themselves gated. A `tf_hook_set`
398
- that would disable your deny hook, or a `tf_tool_set` that would remove a tool, still hits
399
- `PreToolUse` first — so one `tf_.*` hook can hold the whole management surface closed, and `tf_log`
400
- can itself be disabled to revoke logging control.
401
-
402
- ---
403
-
404
- ## Safety summary
405
-
406
- - **One chokepoint.** Every powerful run-path goes through `gatedRun` — the gate cannot be bypassed.
407
- - **`PreToolUse` enforces, fails closed.** A block, an engine error, or a junk return all prevent
408
- `execute()` from running.
409
- - **`PostToolUse` observes.** It sees the result and can act on side effects, but it cannot un-run
410
- the tool, and in the gated-run path its result is discarded.
411
- - **Empty manifest = allow-all.** Safety is opt-in: add an enabled `PreToolUse` hook to bite.
412
- - **Match the display name.** `tool_name` for first-party tools is the register `name`, not the id.
413
- - **Management is gated too.** The `tf_*` self-extension functions run through the same gate; a
414
- single `PreToolUse` hook matching `tf_.*` restricts the whole management surface (recursive safety).
415
-
416
- ---
417
-
418
- ## Caveats
419
-
420
- - **Read files via `${HOOKS_DIR}` (manifest token) or `CLAUDE_PROJECT_DIR` (env), not the
421
- `HOOKS_DIR` env var.** The `${HOOKS_DIR}` token is expanded at load to the manifest's directory
422
- (`<repo>/hooks`) and is always correct; `CLAUDE_PROJECT_DIR` is the repo root. The `HOOKS_DIR`
423
- *environment variable* the runner exports falls back to its built-in default, which currently
424
- tracks the legacy `src/hooks` location rather than the top-level `hooks/` — so prefer the token or
425
- `CLAUDE_PROJECT_DIR` for resolving paths inside a hook script.
426
- - **`timeout` is seconds, not milliseconds.** A `timeout: 5` is a 5-second budget.
427
- - **Exit code `2` is the only blocking exit code.** Any other non-zero exit is a *non-blocking*
428
- error — your deny will silently pass through if you exit `1`.
package/docs/logging.md DELETED
@@ -1,223 +0,0 @@
1
- # Audit logging reference
2
-
3
- ToolFunnel ships a small, optional **activity/audit log**: an append-only JSONL record of the
4
- gateway's own decisions — which tools were run and how the gate ruled on them. It is
5
- **off by default**, writes nothing until you turn it on, and is contracted never to interfere with a
6
- tool call or the gate.
7
-
8
- This document is derived from the code that actually implements it, not from intent:
9
-
10
- - `src/core/logger.js` — the store: config read/write, the `log()` writer, and `tail()`.
11
- - `src/mcp/protocol.js` and `src/mcp/gated-run.js` — the two emit points (tool runs and gate decisions).
12
- - `tools/scripts/tf-log.js` — the `tf_log` management tool (the AI's control point).
13
- - `src/ui/server.js` + `src/ui/public/app.js` — the admin UI Logs tab.
14
-
15
- ## Default OFF — privacy and lean by design
16
-
17
- Nothing is logged, and **no files are created**, until logging is explicitly enabled.
18
-
19
- - The toggle lives in `logs/log.config.json`. **A missing config file means disabled** — the safe
20
- default — so a fresh checkout logs nothing and creates nothing under `logs/`.
21
- - `logger.log()` self-gates: on every call it re-reads the config and, if not enabled, returns
22
- immediately as a no-op (no file touched). Enabling is therefore the *only* thing that ever starts
23
- writing.
24
- - The config is read **fresh on every event** (no caching). A toggle takes effect for the very next
25
- event — no reconnect, no restart.
26
- - `log()` **never throws.** A bad config, an unwritable disk, a full filesystem — all are swallowed
27
- silently. A logging failure can never break a tool call or the gate. This is why the logger can be
28
- wired straight into the safety-critical run path without changing its behaviour.
29
-
30
- ## What gets logged
31
-
32
- The gateway emits four kinds of record — two on the **run path** (tool runs, gate decisions) and two
33
- **audit** records (config changes made through the admin UI, and auth denials at the transport gate).
34
- Every record additionally carries a logger-stamped `ts` field (ISO-8601, prepended by `log()`) as its
35
- first key.
36
-
37
- ### Tool runs — `type: 'tool'`
38
-
39
- Emitted from the `toolfunnel_run_tool` path in `src/mcp/protocol.js`, once per invocation:
40
-
41
- ```json
42
- { "type": "tool", "name": "hash", "mode": "gateway-run", "ok": true, "blocked": false, "durationMs": 12 }
43
- ```
44
-
45
- | Field | Meaning |
46
- |---|---|
47
- | `name` | The tool name the caller asked to run. |
48
- | `mode` | The execution mode used — `gateway` / `gateway-run` for server-side runs, or `reference`. |
49
- | `ok` | Whether the run succeeded (`normalized.ok === true`). |
50
- | `blocked` | `true` when the PreToolUse gate denied the run. |
51
- | `durationMs` | Wall-clock duration of the gated execution. |
52
-
53
- > **Reference-mode tools log a lighter record.** A `reference` tool executes nothing on the
54
- > server (the connected AI runs it in its own environment), so its record is
55
- > `{ type:'tool', name, mode:'reference', ok:true, blocked:false }` with **no `durationMs`** —
56
- > there was no server-side execution to time.
57
-
58
- ### Gate decisions — `type: 'gate'`
59
-
60
- Emitted from `src/mcp/gated-run.js`, the single choke-point every server-side run and forwarded
61
- upstream call passes through. The record is written for **both outcomes** (it sits before the
62
- blocked-return), so every gated run produces one gate record:
63
-
64
- ```json
65
- { "type": "gate", "tool": "danger", "decision": "deny", "reason": "blocked by policy: rm -rf" }
66
- ```
67
-
68
- | Field | Meaning |
69
- |---|---|
70
- | `tool` | The tool name the gate ruled on. |
71
- | `decision` | `"allow"` or `"deny"` — `deny` is a PreToolUse block. |
72
- | `reason` | The gate's reason string (e.g. a hook's deny message); `null` on an allow with no message. |
73
-
74
- Because gate records come from the gated run path, **reference-mode tools produce no gate record** —
75
- nothing is gated when nothing runs server-side.
76
-
77
- ### Config changes — `type: 'config'`, `via: 'ui'`
78
-
79
- Emitted from `src/ui/server.js` (`logConfigChange`) after **every config-MUTATING admin-UI POST that
80
- succeeds**. A management console changes the gateway's *security posture* — which tools are
81
- visible/promoted/hidden, which upstreams attach, which hooks gate, whether OAuth is on — and auditing
82
- those changes matters at least as much as logging individual tool runs. The record always carries
83
- `type:'config'` and `via:'ui'`, an `event` naming the change, and only the fields that actually
84
- changed (undefined fields are dropped):
85
-
86
- ```json
87
- { "type": "config", "via": "ui", "event": "tool_state", "id": "hash", "hot": true }
88
- ```
89
-
90
- | `event` | Raised by | Notable fields |
91
- |---|---|---|
92
- | `tool_state` | toggle a tool's enabled/hidden/hot axis | `id`, plus whichever of `enabled` / `hidden` / `hot` changed |
93
- | `tool_add` / `tool_remove` / `tool_update` / `tool_mode` | add / remove / edit / re-mode a tool | `id` (and `mode` for a mode change) |
94
- | `tool_hook` | toggle a tool's Pre/Post gate | `id`, `hook`, `on` |
95
- | `hook_add` / `hook_state` | add / enable-disable-remove a manifest hook | `id`, `action` |
96
- | `mcp_add` / `mcp_state` | attach / enable-disable-remove an upstream | `id`, `action` |
97
- | `auth_config` | change the OAuth config | `enabled`, `issuer` |
98
- | `log_config` | toggle the activity log itself | `enabled` |
99
- | `oauth_install` | install the optional `jose` dependency | — |
100
-
101
- Read-only and test-only endpoints (e.g. `GET /api/tools`, `POST /api/mcp/discover`) are deliberately
102
- **not** audited — they change nothing. Only a write that returns `ok:true` is logged. Like everything
103
- here it is self-gating: silent until the log is enabled.
104
-
105
- ### Auth denials — `type: 'auth'`, `event: 'deny'`
106
-
107
- Emitted from the OAuth gate in `src/mcp/http-transport.js` (`passesAuth`) when a request to a
108
- protected route is **rejected**. This is the security-relevant event — and it was previously
109
- invisible, because a denial happens at the transport gate *before* the protocol/logging layer ever
110
- runs. A run of failed auth attempts now leaves a trace:
111
-
112
- ```json
113
- { "type": "auth", "event": "deny", "error": "invalid_token", "status": 401, "path": "/mcp" }
114
- ```
115
-
116
- | Field | Meaning |
117
- |---|---|
118
- | `error` | The OAuth error code — `invalid_token` (bad/missing/expired) or `insufficient_scope`. |
119
- | `status` | The HTTP status sent back — `401` (authenticate) or `403` (scope). |
120
- | `path` | The request path (query string stripped). |
121
-
122
- Only **denials** are logged — a successful auth proceeds to the normal run-path records above. Auth
123
- records appear only on the HTTP transport (stdio is local-trust and ungated).
124
-
125
- ### Scope — honest about what this is *not*
126
-
127
- This is an audit log of **the gateway's own activity**, not a request/response capture. A record
128
- tells you *that* a tool ran (its name, mode, outcome, duration) and *how the gate ruled* (allow/deny
129
- and why) — it does **not** contain the tool's input arguments or its output, unless a specific event
130
- field happens to carry it (the gate's `reason` string is the only free-text field today). If you
131
- need full tool I/O capture, that is a PostToolUse hook's job, not this log's.
132
-
133
- ## The JSONL format
134
-
135
- The log is [JSON Lines](https://jsonlines.org/): **one JSON object per line**, newline-terminated,
136
- strictly appended (`fs.appendFileSync`). Each line is an independent record — no enclosing array, no
137
- commas between lines — so the file is safe to `tail -f`, grep, or stream, and a truncated final line
138
- never corrupts the rest.
139
-
140
- ```jsonl
141
- {"ts":"2026-06-29T13:02:11.004Z","type":"gate","tool":"hash","decision":"allow","reason":null}
142
- {"ts":"2026-06-29T13:02:11.016Z","type":"tool","name":"hash","mode":"gateway-run","ok":true,"blocked":false,"durationMs":12}
143
- {"ts":"2026-06-29T13:05:44.210Z","type":"gate","tool":"danger","decision":"deny","reason":"blocked by policy: rm -rf"}
144
- ```
145
-
146
- Readers tolerate damage: `tail()` (used by the UI and by `tf_log status`) splits on newlines, drops
147
- empty lines, and **skips any line that fails to parse** rather than failing the whole read.
148
-
149
- ## The three control points
150
-
151
- All three converge on the same store (`src/core/logger.js`) and the same toggle file
152
- (`logs/log.config.json`). Use whichever fits the context; they are interchangeable.
153
-
154
- ### 1. `tf_log` — the AI's control point
155
-
156
- `tf_log` is a first-party **management** tool (category `management`), discovered via
157
- `toolfunnel_list_tools` and executed via `toolfunnel_run_tool` through the PreToolUse gate. It is the
158
- connected model's hands-on switch for the log.
159
-
160
- Args: `{ action: 'enable' | 'disable' | 'status', path? }`
161
-
162
- | Action | Effect | Output (one JSON line) |
163
- |---|---|---|
164
- | `enable` | Turns logging on; optional `path` overrides the log file. | `{ ok:true, action, enabled, path }` |
165
- | `disable` | Turns logging off (the file is left in place). | `{ ok:true, action, enabled, path }` |
166
- | `status` | Reports current config plus how many entries are on disk. | `{ ok:true, action, enabled, path, count }` |
167
- | *(bad args)* | A logical failure is reported, never thrown. | `{ ok:false, error }` |
168
-
169
- > **Disabling the `tf_log` tool revokes the AI's logging control.** `tf_log` is an ordinary register
170
- > entry, so it can itself be turned off in `tools/tools.state.json` (or via the UI Tools tab). When
171
- > it is disabled, the model can no longer discover or run it — logging can then only be changed via
172
- > the UI Logs tab or by editing the config file directly. This is the intended way to take logging
173
- > control out of the AI's hands.
174
-
175
- ### 2. The UI Logs tab — the human's control point
176
-
177
- Launch the optional admin UI with `node bin/toolfunnel.js --ui` (binds `127.0.0.1:9777`) and open the
178
- **Logs** tab. It is read-mostly:
179
-
180
- - An **on/off switch** bound to the logger config (`POST /api/logs/config { enabled }`). A status
181
- word (`logging is on` / `logging is off`) and, when on, the resolved log **path** sit beside it.
182
- - A **newest-first list** of the most recent **100** records (`GET /api/logs` → `tail(100)`,
183
- reversed for display). Each row shows a kind badge (`gate` / `tool` / `config` / `auth`), the
184
- subject (tool name, the config `event`, or the auth `path`), a status badge (`allow`/`deny` for
185
- gate rows; `ok`/`fail`/`blocked` for tool rows; the event name for config rows; `deny` for auth
186
- rows), and the timestamp; a second line shows the gate `reason`, the tool's `mode` and
187
- `durationMs`, the changed config fields, or the auth `error`/`status`.
188
- - A **Refresh** button re-reads both the config and the recent entries.
189
-
190
- When logging is off the list shows "Logging is off — flip the switch above to start recording."
191
-
192
- ### 3. `logs/log.config.json` — the file
193
-
194
- The toggle is plain JSON at the repo root:
195
-
196
- ```json
197
- {
198
- "enabled": false,
199
- "path": "logs/toolfunnel.log.jsonl"
200
- }
201
- ```
202
-
203
- - `enabled` — boolean; anything other than literal `true` reads as off.
204
- - `path` — where the log is written; **relative paths resolve against the repo root**, absolute
205
- paths are honoured as-is. A missing/blank value falls back to the default below.
206
-
207
- Both `tf_log` and the UI write this file through `logger.setConfig()`, which **atomically merges** a
208
- partial patch (temp file + rename) so toggling `enabled` preserves your custom `path`, and vice
209
- versa. `setConfig()` is also the *only* function that creates the file and the `logs/` directory —
210
- consistent with default-off. You can hand-edit it, but prefer the tool or UI so the write stays
211
- atomic.
212
-
213
- ## Where things live
214
-
215
- | What | Path | Notes |
216
- |---|---|---|
217
- | Config (the toggle) | `<root>/logs/log.config.json` | Not created until logging is first enabled. Missing = disabled. |
218
- | Log file | `<root>/logs/toolfunnel.log.jsonl` | Default; overridable via the config `path`. Created on first write. |
219
-
220
- Both paths anchor on the repo root (`path.resolve(__dirname, '..', '..')` from `src/core/logger.js`),
221
- matching the rest of ToolFunnel's "everything under the tree" convention. Neither file is needed for
222
- the gateway to run — delete the log to clear history, delete the config to return to the default-off
223
- state.