toolfunnel 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +102 -213
- package/bin/toolfunnel.js +372 -147
- package/docs/KNOWN_BUGS.md +99 -225
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +70 -0
- package/docs/packaging.md +115 -115
- package/hooks/hooks.manifest.json +27 -27
- package/hooks/scripts/example-audit.js +21 -21
- package/hooks/scripts/example-deny-dangerous.js +31 -31
- package/mcp/expose.example.json +30 -30
- package/mcp/servers/mock-modern/server.js +147 -0
- package/mcp/servers/mock-upstream/server.js +192 -12
- package/package.json +1 -1
- package/src/auth/config.js +174 -174
- package/src/auth/install.js +181 -181
- package/src/auth/resource-server.js +344 -344
- package/src/core/config-home.js +134 -134
- package/src/core/events.js +105 -105
- package/src/core/hook-engine.js +275 -275
- package/src/core/hook-loader.js +791 -791
- package/src/core/hook-runner.js +346 -346
- package/src/core/logger.js +171 -171
- package/src/core/matcher.js +51 -51
- package/src/core/metrics.js +75 -75
- package/src/core/requires.js +138 -138
- package/src/core/server-config.js +98 -77
- package/src/core/tool-registry.js +180 -180
- package/src/extend/add-hook.md +152 -142
- package/src/extend/add-mcp.md +122 -121
- package/src/extend/configure.md +87 -0
- package/src/extend/create-tool.md +168 -152
- package/src/extend/howto.js +102 -98
- package/src/extend/package.md +187 -187
- package/src/extend/wrap.md +99 -0
- package/src/mcp/aggregator.js +1246 -807
- package/src/mcp/expose-store.js +460 -430
- package/src/mcp/gated-run.js +201 -194
- package/src/mcp/http-transport.js +1311 -981
- package/src/mcp/mcp-client.js +907 -530
- package/src/mcp/modern.js +372 -0
- package/src/mcp/protocol.js +468 -466
- package/src/mcp/server.js +2688 -1322
- package/src/packages/packager.js +715 -715
- package/src/tools/drift.js +138 -138
- package/src/tools/registry.js +513 -511
- package/src/tools/tool-state.js +170 -104
- package/src/ui/public/app.js +1174 -1055
- package/src/ui/public/index.html +286 -249
- package/src/ui/public/styles.css +535 -535
- package/src/ui/server.js +1348 -1190
- package/tools/scripts/base64.js +139 -139
- package/tools/scripts/danger.js +61 -61
- package/tools/scripts/echo.js +39 -39
- package/tools/scripts/hash.js +101 -101
- package/tools/scripts/json.js +109 -109
- package/tools/scripts/text-stats.js +131 -131
- package/tools/scripts/tf-env.js +43 -43
- package/tools/scripts/tf-hook-add.js +151 -151
- package/tools/scripts/tf-hook-set.js +112 -112
- package/tools/scripts/tf-list.js +192 -192
- package/tools/scripts/tf-log.js +86 -86
- package/tools/scripts/tf-mcp-add.js +117 -117
- package/tools/scripts/tf-mcp-set.js +121 -102
- package/tools/scripts/tf-pack.js +237 -237
- package/tools/scripts/tf-tool-add.js +134 -129
- package/tools/scripts/tf-tool-set.js +142 -142
- package/tools/scripts/tf-wrap.js +103 -0
- package/tools/scripts/uuid.js +102 -102
- package/tools/tools.register.json +227 -216
- 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/releasing.md +0 -31
- package/docs/setup.md +0 -352
- package/docs/tools-and-upstreams.md +0 -469
package/docs/hooks-and-gating.md
DELETED
|
@@ -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.
|