toolfunnel 0.5.0 → 0.7.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 +235 -167
- package/SECURITY.md +84 -0
- package/bin/toolfunnel.js +451 -147
- package/docs/KNOWN_BUGS.md +80 -33
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +84 -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 +39 -31
- package/mcp/expose.example.json +30 -30
- package/mcp/servers/mock-modern/server.js +147 -0
- package/mcp/servers/mock-upstream/server.js +222 -12
- package/package.json +67 -65
- package/src/auth/config.js +174 -174
- package/src/auth/install.js +181 -181
- package/src/auth/resource-server.js +353 -344
- package/src/core/child-reaper.js +61 -0
- package/src/core/config-home.js +134 -134
- package/src/core/events.js +105 -105
- package/src/core/hook-engine.js +329 -275
- package/src/core/hook-loader.js +824 -791
- package/src/core/hook-runner.js +566 -346
- package/src/core/logger.js +297 -171
- package/src/core/matcher.js +100 -51
- package/src/core/metrics.js +75 -75
- package/src/core/rename-migration.js +163 -0
- package/src/core/requires.js +138 -138
- package/src/core/server-config.js +98 -77
- package/src/extend/add-hook.md +152 -142
- package/src/extend/add-mcp.md +119 -121
- package/src/extend/configure.md +88 -0
- package/src/extend/create-tool.md +168 -152
- package/src/extend/howto.js +102 -98
- package/src/extend/package.md +190 -187
- package/src/extend/wrap.md +99 -0
- package/src/mcp/aggregator.js +1674 -807
- package/src/mcp/expose-store.js +577 -430
- package/src/mcp/gated-run.js +210 -194
- package/src/mcp/http-transport.js +1563 -1012
- package/src/mcp/mcp-client.js +1070 -530
- package/src/mcp/modern.js +406 -0
- package/src/mcp/protocol.js +468 -466
- package/src/mcp/server.js +3112 -1405
- package/src/tools/registry.js +661 -513
- package/src/tools/tool-state.js +170 -104
- package/src/ui/public/app.js +1187 -1055
- package/src/ui/public/index.html +286 -249
- package/src/ui/public/styles.css +535 -535
- package/src/ui/server.js +1616 -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 +120 -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 +14 -3
- package/src/core/tool-registry.js +0 -180
- package/src/packages/packager.js +0 -715
- package/src/tools/drift.js +0 -138
|
@@ -1,77 +1,98 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* server-config.js
|
|
5
|
-
*
|
|
6
|
-
* Reads `toolfunnel.json` at the gateway root (absent by default). This is what lets a
|
|
7
|
-
* third party ship an MCP built on toolfunnel that introduces itself as ITSELF: the
|
|
8
|
-
* `initialize` handshake and `/health` report THEIR serverName/serverVersion, and their
|
|
9
|
-
* pack can carry its own default ports so `npx their-mcp` never collides with another
|
|
10
|
-
* gateway on the user's machine. CLI flags still win: precedence is flag > config > default.
|
|
11
|
-
*
|
|
12
|
-
* Shape (every field optional
|
|
13
|
-
* {
|
|
14
|
-
* "serverName": "my-mcp", // initialize serverInfo.name (default "toolfunnel")
|
|
15
|
-
* "serverVersion": "1.0.0", // initialize serverInfo.version (default: package.json)
|
|
16
|
-
* "
|
|
17
|
-
* "
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* server-config.js - OPTIONAL gateway-level configuration: identity + default ports.
|
|
5
|
+
*
|
|
6
|
+
* Reads `toolfunnel.json` at the gateway root (absent by default). This is what lets a
|
|
7
|
+
* third party ship an MCP built on toolfunnel that introduces itself as ITSELF: the
|
|
8
|
+
* `initialize` handshake and `/health` report THEIR serverName/serverVersion, and their
|
|
9
|
+
* pack can carry its own default ports so `npx their-mcp` never collides with another
|
|
10
|
+
* gateway on the user's machine. CLI flags still win: precedence is flag > config > default.
|
|
11
|
+
*
|
|
12
|
+
* Shape (every field optional - an empty/absent file IS the default identity):
|
|
13
|
+
* {
|
|
14
|
+
* "serverName": "my-mcp", // initialize serverInfo.name (default "toolfunnel")
|
|
15
|
+
* "serverVersion": "1.0.0", // initialize serverInfo.version (default: package.json)
|
|
16
|
+
* "clientName": "my-client", // identity presented TO upstreams (default "toolfunnel")
|
|
17
|
+
* "clientVersion": "1.0.0", // ...its version (default built-in)
|
|
18
|
+
* "httpPort": 9998, // --http default port (default 9998)
|
|
19
|
+
* "uiPort": 9777 // --ui default port (default 9777)
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* clientName/clientVersion are the OUTBOUND identity - what upstream MCP servers see in the
|
|
23
|
+
* gateway's own initialize handshake. Under a WRAP on stdio the downstream client's REAL
|
|
24
|
+
* clientInfo is mirrored upstream instead (two-way invisibility for legacy/stdio clients -
|
|
25
|
+
* modern clients send no initialize); these settings are the funnel-mode / HTTP identity and
|
|
26
|
+
* the pre-handshake boot identity.
|
|
27
|
+
*
|
|
28
|
+
* TOLERANT by design, field by field: a missing file, bad JSON, or an invalid FIELD falls
|
|
29
|
+
* back to that field's default - a broken identity file must never stop the gateway from
|
|
30
|
+
* starting as plain "toolfunnel" (the same fail-open-to-default posture as log.config.json;
|
|
31
|
+
* contrast auth config, which fails CLOSED because it guards a boundary). No new deps.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const fs = require('node:fs');
|
|
35
|
+
const path = require('node:path');
|
|
36
|
+
|
|
37
|
+
/** The compiled-in defaults. version comes from package.json (single source of truth). */
|
|
38
|
+
function defaults() {
|
|
39
|
+
let version = '0.0.0';
|
|
40
|
+
try {
|
|
41
|
+
version = require('../../package.json').version || version;
|
|
42
|
+
} catch (_e) {
|
|
43
|
+
/* unreadable package.json -> a placeholder beats a crash */
|
|
44
|
+
}
|
|
45
|
+
// clientName/clientVersion default null = "use the built-in client identity" (McpClient's
|
|
46
|
+
// CLIENT_INFO) - so absent config produces byte-identical wire behaviour to before.
|
|
47
|
+
// serveLegacy default TRUE: every existing client today speaks the legacy era - refusing it is
|
|
48
|
+
// an explicit hardening opt-in ("modern-only"), never a default.
|
|
49
|
+
return { serverName: 'toolfunnel', serverVersion: version, clientName: null, clientVersion: null, httpPort: 9998, uiPort: 9777, serveLegacy: true };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A valid TCP port for a bind default: an integer 1..65535 (0 = OS-assigned stays flag-only -
|
|
53
|
+
* an ephemeral DEFAULT would make the gateway un-findable across restarts). */
|
|
54
|
+
function isPort(v) {
|
|
55
|
+
return Number.isInteger(v) && v >= 1 && v <= 65535;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Load the gateway config, merged over the defaults.
|
|
60
|
+
* @param {string} rootDir the gateway root (the directory that holds toolfunnel.json)
|
|
61
|
+
* @returns {{serverName:string, serverVersion:string, httpPort:number, uiPort:number}}
|
|
62
|
+
*/
|
|
63
|
+
function loadServerConfig(rootDir) {
|
|
64
|
+
const out = defaults();
|
|
65
|
+
let raw;
|
|
66
|
+
try {
|
|
67
|
+
raw = fs.readFileSync(path.join(rootDir, 'toolfunnel.json'), 'utf8');
|
|
68
|
+
} catch (_e) {
|
|
69
|
+
return out; // absent - the normal case
|
|
70
|
+
}
|
|
71
|
+
let cfg;
|
|
72
|
+
try {
|
|
73
|
+
cfg = JSON.parse(raw);
|
|
74
|
+
} catch (_e) {
|
|
75
|
+
return out; // bad JSON - start as the default identity rather than not at all
|
|
76
|
+
}
|
|
77
|
+
if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) return out;
|
|
78
|
+
if (typeof cfg.serverName === 'string' && cfg.serverName.trim().length > 0) {
|
|
79
|
+
out.serverName = cfg.serverName.trim();
|
|
80
|
+
}
|
|
81
|
+
if (typeof cfg.serverVersion === 'string' && cfg.serverVersion.trim().length > 0) {
|
|
82
|
+
out.serverVersion = cfg.serverVersion.trim();
|
|
83
|
+
}
|
|
84
|
+
if (typeof cfg.clientName === 'string' && cfg.clientName.trim().length > 0) {
|
|
85
|
+
out.clientName = cfg.clientName.trim();
|
|
86
|
+
}
|
|
87
|
+
if (typeof cfg.clientVersion === 'string' && cfg.clientVersion.trim().length > 0) {
|
|
88
|
+
out.clientVersion = cfg.clientVersion.trim();
|
|
89
|
+
}
|
|
90
|
+
if (isPort(cfg.httpPort)) out.httpPort = cfg.httpPort;
|
|
91
|
+
if (isPort(cfg.uiPort)) out.uiPort = cfg.uiPort;
|
|
92
|
+
// Only an EXPLICIT false flips modern-only on; any other value (absent, truthy, garbage)
|
|
93
|
+
// keeps the serve-both default - misconfiguration must never lock real clients out.
|
|
94
|
+
if (cfg.serveLegacy === false) out.serveLegacy = false;
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { loadServerConfig };
|
package/src/extend/add-hook.md
CHANGED
|
@@ -1,142 +1,152 @@
|
|
|
1
|
-
# How to add a hook (self-extension)
|
|
2
|
-
|
|
3
|
-
This is the instruction served by `toolfunnel_howto({ topic: "add-hook" })`. It explains how to add a hook
|
|
4
|
-
to the Hook Engine: the manifest entry shape, where the script goes, and how the enabled/disabled
|
|
5
|
-
state is persisted separately in `hooks.state.json`.
|
|
6
|
-
the authoring view.
|
|
7
|
-
|
|
8
|
-
A hook is exactly two things:
|
|
9
|
-
1. **A manifest entry** in `hooks/hooks.manifest.json` (its config: event, matcher, command,
|
|
10
|
-
2. **A script** under `hooks/scripts/` that the entry's `command` runs.
|
|
11
|
-
|
|
12
|
-
Activation (on/off) is NOT in the manifest's source of truth at runtime
|
|
13
|
-
`hooks/hooks.state.json` (see §4).
|
|
14
|
-
|
|
15
|
-
---
|
|
16
|
-
|
|
17
|
-
## 1. The manifest entry shape
|
|
18
|
-
|
|
19
|
-
`hooks.manifest.json` has `version`, `hooksDir`, and a `hooks` array. Each entry:
|
|
20
|
-
|
|
21
|
-
```json
|
|
22
|
-
{
|
|
23
|
-
"id": "pre-tool-use/deny-dangerous",
|
|
24
|
-
"event": "PreToolUse",
|
|
25
|
-
"matcher": "Bash|toolfunnel_run_tool",
|
|
26
|
-
"type": "command",
|
|
27
|
-
"command": "bash \"${HOOKS_DIR}/scripts/deny-dangerous.sh\"",
|
|
28
|
-
"script": "scripts/deny-dangerous.sh",
|
|
29
|
-
"timeout": 5,
|
|
30
|
-
"enabled": true,
|
|
31
|
-
"description": "Denies any tool call whose args contain a destructive pattern (rm -rf, etc)."
|
|
32
|
-
}
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
| Field | Type | Meaning |
|
|
36
|
-
|---|---|---|
|
|
37
|
-
| `id` | string | Unique key, format `"<kebab-event>/<script-basename-without-ext>"` (e.g. `pre-tool-use/deny-dangerous`). The loader and `hooks.state.json` key on this. |
|
|
38
|
-
| `event` | string | One of the six lifecycle events: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `PreCompact`. |
|
|
39
|
-
| `matcher` | string | Regex anchored full-match against `tool_name`. `""`, `undefined`, or `"*"` = always fire. Tool-less events (SessionStart, UserPromptSubmit, Stop, PreCompact) ignore it. Example: `"Bash|Write|Edit"`. |
|
|
40
|
-
| `type` | string | `"command"` (the only type today). |
|
|
41
|
-
| `command` | string | The command line the runner spawns. `${HOOKS_DIR}` expands to the absolute `hooks` path at load time. |
|
|
42
|
-
| `script` | string | The script path relative to `hooksDir`, e.g. `scripts/deny-dangerous.sh`. Used by the manager's open/edit (`readScript`/`writeScript`). |
|
|
43
|
-
| `timeout` | number | **Seconds** (the runner multiplies by 1000). The runner kills the child past this. |
|
|
44
|
-
| `enabled` | boolean | The manifest's default/seed state. The live value is the overlay in `hooks.state.json`
|
|
45
|
-
| `description` | string | What the hook does, for the manager UI. |
|
|
46
|
-
|
|
47
|
-
One entry **per hook command**
|
|
48
|
-
|
|
49
|
-
---
|
|
50
|
-
|
|
51
|
-
## 2. Where the script goes
|
|
52
|
-
|
|
53
|
-
```
|
|
54
|
-
hooks/
|
|
55
|
-
hooks.manifest.json # config entries (above)
|
|
56
|
-
hooks.state.json # enabled/disabled overlay, keyed by hook id
|
|
57
|
-
scripts/ # your hook scripts live HERE
|
|
58
|
-
deny-dangerous.sh
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
(The loader itself
|
|
62
|
-
`src/core/`, not in `hooks/`.)
|
|
63
|
-
|
|
64
|
-
`writeScript` (the manager's save path) refuses any path that resolves outside
|
|
65
|
-
`hooks/scripts/`
|
|
66
|
-
never point a hook at a script in a live host hooks directory. Clone the behaviour in if you need it.
|
|
67
|
-
|
|
68
|
-
---
|
|
69
|
-
|
|
70
|
-
## 3. What the script returns (the two protocols)
|
|
71
|
-
|
|
72
|
-
The runner spawns the command, writes the event payload as JSON on stdin, then reads the result.
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
1
|
+
# How to add a hook (self-extension)
|
|
2
|
+
|
|
3
|
+
This is the instruction served by `toolfunnel_howto({ topic: "add-hook" })`. It explains how to add a hook
|
|
4
|
+
to the Hook Engine: the manifest entry shape, where the script goes, and how the enabled/disabled
|
|
5
|
+
state is persisted separately in `hooks.state.json`. This is
|
|
6
|
+
the authoring view.
|
|
7
|
+
|
|
8
|
+
A hook is exactly two things:
|
|
9
|
+
1. **A manifest entry** in `hooks/hooks.manifest.json` (its config: event, matcher, command, ...).
|
|
10
|
+
2. **A script** under `hooks/scripts/` that the entry's `command` runs.
|
|
11
|
+
|
|
12
|
+
Activation (on/off) is NOT in the manifest's source of truth at runtime - it lives in an overlay,
|
|
13
|
+
`hooks/hooks.state.json` (see §4).
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## 1. The manifest entry shape
|
|
18
|
+
|
|
19
|
+
`hooks.manifest.json` has `version`, `hooksDir`, and a `hooks` array. Each entry:
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"id": "pre-tool-use/deny-dangerous",
|
|
24
|
+
"event": "PreToolUse",
|
|
25
|
+
"matcher": "Bash|toolfunnel_run_tool",
|
|
26
|
+
"type": "command",
|
|
27
|
+
"command": "bash \"${HOOKS_DIR}/scripts/deny-dangerous.sh\"",
|
|
28
|
+
"script": "scripts/deny-dangerous.sh",
|
|
29
|
+
"timeout": 5,
|
|
30
|
+
"enabled": true,
|
|
31
|
+
"description": "Denies any tool call whose args contain a destructive pattern (rm -rf, etc)."
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
| Field | Type | Meaning |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| `id` | string | Unique key, format `"<kebab-event>/<script-basename-without-ext>"` (e.g. `pre-tool-use/deny-dangerous`). The loader and `hooks.state.json` key on this. |
|
|
38
|
+
| `event` | string | One of the six lifecycle events: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `PreCompact`. |
|
|
39
|
+
| `matcher` | string | Regex anchored full-match against `tool_name`. `""`, `undefined`, or `"*"` = always fire. Tool-less events (SessionStart, UserPromptSubmit, Stop, PreCompact) ignore it. Example: `"Bash|Write|Edit"`. |
|
|
40
|
+
| `type` | string | `"command"` (the only type today). |
|
|
41
|
+
| `command` | string | The command line the runner spawns. `${HOOKS_DIR}` expands to the absolute `hooks` path at load time. |
|
|
42
|
+
| `script` | string | The script path relative to `hooksDir`, e.g. `scripts/deny-dangerous.sh`. Used by the manager's open/edit (`readScript`/`writeScript`). |
|
|
43
|
+
| `timeout` | number | **Seconds** (the runner multiplies by 1000). The runner kills the child past this. |
|
|
44
|
+
| `enabled` | boolean | The manifest's default/seed state. The live value is the overlay in `hooks.state.json` - see §4. |
|
|
45
|
+
| `description` | string | What the hook does, for the manager UI. |
|
|
46
|
+
|
|
47
|
+
One entry **per hook command** - a settings.json event with N hooks becomes N entries.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## 2. Where the script goes
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
hooks/
|
|
55
|
+
hooks.manifest.json # config entries (above)
|
|
56
|
+
hooks.state.json # enabled/disabled overlay, keyed by hook id
|
|
57
|
+
scripts/ # your hook scripts live HERE
|
|
58
|
+
deny-dangerous.sh
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
(The loader itself - `hook-loader.js` - is engine code and is NOT user content; it lives in
|
|
62
|
+
`src/core/`, not in `hooks/`.)
|
|
63
|
+
|
|
64
|
+
`writeScript` (the manager's save path) refuses any path that resolves outside
|
|
65
|
+
`hooks/scripts/` - defense-in-depth for the isolation rule. Author your script there only;
|
|
66
|
+
never point a hook at a script in a live host hooks directory. Clone the behaviour in if you need it.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## 3. What the script returns (the two protocols)
|
|
71
|
+
|
|
72
|
+
The runner spawns the command, writes the event payload as JSON on stdin, then reads the result.
|
|
73
|
+
The payload a tool event receives on stdin:
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{ "tool_name": "note_slow", "tool_input": { "arg": "value" } }
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
(`PreToolUse` → `tool_name` + `tool_input`; `PostToolUse` adds `tool_response`. Tool-less
|
|
80
|
+
events carry their own fields - `prompt`, `source` - instead.)
|
|
81
|
+
|
|
82
|
+
Two protocols:
|
|
83
|
+
|
|
84
|
+
**A) Exit-code (simple):**
|
|
85
|
+
- `exit 0` → success. On `SessionStart`/`UserPromptSubmit`, stdout becomes **injected context**;
|
|
86
|
+
on other events stdout is advisory.
|
|
87
|
+
- `exit 2` → **blocking**. stderr is the reason - on a blocked tool call the client receives it
|
|
88
|
+
as the result text with `isError: true`.
|
|
89
|
+
- any other code → non-blocking error (stderr captured, loop continues; never throws).
|
|
90
|
+
|
|
91
|
+
**B) JSON (advanced)** - honored **only on exit 0**, when stdout parses as a JSON object:
|
|
92
|
+
```json
|
|
93
|
+
{
|
|
94
|
+
"continue": true,
|
|
95
|
+
"decision": "block",
|
|
96
|
+
"reason": "...",
|
|
97
|
+
"hookSpecificOutput": {
|
|
98
|
+
"hookEventName": "PreToolUse",
|
|
99
|
+
"permissionDecision": "allow" | "deny" | "ask",
|
|
100
|
+
"permissionDecisionReason": "...",
|
|
101
|
+
"additionalContext": "..."
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
- **PreToolUse blocks via `hookSpecificOutput.permissionDecision: "deny"`** - NOT the top-level
|
|
106
|
+
`decision` field. `"allow"` passes; `"ask"` is non-blocking in the autonomous host.
|
|
107
|
+
- **Top-level `decision: "block"`** is for the other blockable events (UserPromptSubmit, PostToolUse,
|
|
108
|
+
Stop, PreCompact). There is no `"approve"`.
|
|
109
|
+
- `continue: false` stops the whole loop. `hookSpecificOutput.additionalContext` → injected text.
|
|
110
|
+
|
|
111
|
+
This is the mechanism that makes a PreToolUse hook gate `toolfunnel_run_tool`: match its `tool_name`, and
|
|
112
|
+
return `permissionDecision: "deny"` to block the call before the tool runs.
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## 4. Activation state lives in `hooks.state.json`
|
|
117
|
+
|
|
118
|
+
The Hook Manager design splits **what exists** from **what's on**:
|
|
119
|
+
|
|
120
|
+
- **The `scripts/` folder is the source of truth for what exists.** On startup the loader scans it
|
|
121
|
+
and reconciles with the manifest: newly-found scripts are added as entries (event/matcher inferred
|
|
122
|
+
or flagged "unconfigured"); manifest entries whose script is missing are flagged.
|
|
123
|
+
- **The manifest carries config** (event, matcher, command, timeout, description, seed `enabled`).
|
|
124
|
+
- **`hooks.state.json` is the activation overlay** - an object keyed by hook `id` recording the live
|
|
125
|
+
enabled/disabled value:
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{
|
|
129
|
+
"version": 1,
|
|
130
|
+
"enabled": {
|
|
131
|
+
"pre-tool-use/deny-dangerous": true,
|
|
132
|
+
"user-prompt-submit/inject-context": false
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Toggling a hook writes `hooks.state.json` **atomically** (temp + rename). It survives restarts and is
|
|
138
|
+
kept separate from the auto-detected inventory, so re-scanning the folder never clobbers your toggles.
|
|
139
|
+
At load, the overlay wins over the manifest's seed `enabled` for any id it lists.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## 5. Steps to add a hook
|
|
144
|
+
|
|
145
|
+
1. Write the script into `hooks/scripts/<name>.sh` (or a node script). Read the stdin JSON
|
|
146
|
+
payload; emit one of the two protocols above.
|
|
147
|
+
2. Add the manifest entry: `id` = `"<kebab-event>/<name>"`, set `event`, `matcher`, `command`
|
|
148
|
+
(`bash "${HOOKS_DIR}/scripts/<name>.sh"`), `script`, `timeout` (seconds), `description`, and a
|
|
149
|
+
seed `enabled`.
|
|
150
|
+
3. Set its live state in `hooks.state.json` via the loader's `setEnabled(id, bool)` (atomic write).
|
|
151
|
+
4. Verify: the manager lists it under its event; a run-once test fires it with a sample payload and
|
|
152
|
+
shows the result. For a gate, confirm a matching `toolfunnel_run_tool` call is denied with your reason.
|