toolfunnel 0.6.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 +11 -7
- package/SECURITY.md +84 -0
- package/bin/toolfunnel.js +97 -18
- package/docs/KNOWN_BUGS.md +71 -90
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +24 -10
- package/hooks/hooks.manifest.json +1 -1
- package/hooks/scripts/example-deny-dangerous.js +12 -4
- package/mcp/servers/mock-upstream/server.js +36 -6
- package/package.json +67 -65
- package/src/auth/config.js +2 -2
- package/src/auth/resource-server.js +12 -3
- package/src/core/child-reaper.js +61 -0
- package/src/core/events.js +7 -7
- package/src/core/hook-engine.js +85 -31
- package/src/core/hook-loader.js +46 -13
- package/src/core/hook-runner.js +258 -38
- package/src/core/logger.js +138 -12
- package/src/core/matcher.js +59 -10
- package/src/core/rename-migration.js +163 -0
- package/src/core/requires.js +1 -1
- package/src/extend/add-hook.md +2 -2
- package/src/extend/add-mcp.md +3 -6
- package/src/extend/configure.md +2 -1
- package/src/extend/create-tool.md +1 -1
- package/src/extend/howto.js +3 -3
- package/src/extend/package.md +11 -8
- package/src/mcp/aggregator.js +480 -52
- package/src/mcp/expose-store.js +130 -13
- package/src/mcp/gated-run.js +26 -17
- package/src/mcp/http-transport.js +274 -22
- package/src/mcp/mcp-client.js +194 -31
- package/src/mcp/modern.js +44 -10
- package/src/mcp/server.js +513 -89
- package/src/tools/registry.js +172 -24
- package/src/ui/public/app.js +17 -4
- package/src/ui/server.js +297 -29
- package/tools/scripts/tf-hook-set.js +11 -3
- package/tools/tools.register.json +227 -227
- package/src/core/tool-registry.js +0 -180
- package/src/packages/packager.js +0 -715
- package/src/tools/drift.js +0 -138
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
|
|
34
34
|
const PROTOCOL_VERSION = '2024-11-05';
|
|
35
35
|
// `title` is a fidelity fixture: the 2025-06-18 spec added serverInfo.title, and the wrap must
|
|
36
|
-
// deliver it VERBATIM (a whitelist in wrappedIdentity used to eat it - wrap
|
|
36
|
+
// deliver it VERBATIM (a whitelist in wrappedIdentity used to eat it - found wire-testing the wrap, 2026-07-17).
|
|
37
37
|
const SERVER_INFO = { name: 'mock-upstream', version: '1.0.0', title: 'Mock Upstream Fixture' };
|
|
38
38
|
|
|
39
39
|
const TOOLS = [
|
|
@@ -88,12 +88,12 @@ const TOOLS = [
|
|
|
88
88
|
},
|
|
89
89
|
{
|
|
90
90
|
name: 'elicit',
|
|
91
|
-
description: 'Elicitation fixture
|
|
91
|
+
description: 'Elicitation fixture: sends a server-initiated elicitation/create mid-call, holds the call open, completes from the answer.',
|
|
92
92
|
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
93
93
|
},
|
|
94
94
|
];
|
|
95
95
|
|
|
96
|
-
// Outstanding SERVER->CLIENT requests this mock has issued (
|
|
96
|
+
// Outstanding SERVER->CLIENT requests this mock has issued (elicitation-bridge fixture): id -> callback fed
|
|
97
97
|
// with the client's response ({ result } or { error }).
|
|
98
98
|
const outstandingServerRequests = new Map();
|
|
99
99
|
let nextServerRequestId = 9001;
|
|
@@ -115,6 +115,15 @@ function handleMessage(msg) {
|
|
|
115
115
|
const method = msg.method;
|
|
116
116
|
const params = msg.params || {};
|
|
117
117
|
|
|
118
|
+
// Era-negotiation fixture: when TF_MOCK_METHOD_LOG is set, RECORD every incoming method in
|
|
119
|
+
// arrival order. A reconnect test reads the log to prove WHICH negotiation a fresh connection
|
|
120
|
+
// ran (server/discover probe vs straight initialize) - the wire is the only honest witness.
|
|
121
|
+
if (typeof method === 'string' && process.env.TF_MOCK_METHOD_LOG) {
|
|
122
|
+
try {
|
|
123
|
+
require('node:fs').appendFileSync(process.env.TF_MOCK_METHOD_LOG, method + '\n');
|
|
124
|
+
} catch (_e) { /* fixture logging must never crash the mock */ }
|
|
125
|
+
}
|
|
126
|
+
|
|
118
127
|
// Notifications carry no id and must never be answered. Cancel-fidelity fixture: when
|
|
119
128
|
// TF_MOCK_CANCEL_LOG is set, RECORD each received notifications/cancelled requestId - a
|
|
120
129
|
// gateway test can then prove which id (translated or raw) actually reached this server.
|
|
@@ -128,7 +137,7 @@ function handleMessage(msg) {
|
|
|
128
137
|
}
|
|
129
138
|
|
|
130
139
|
// A message with an id and NO method is a client RESPONSE - route it to the outstanding
|
|
131
|
-
// server-initiated request it answers (
|
|
140
|
+
// server-initiated request it answers (elicitation-bridge fixture). Unknown ids are dropped.
|
|
132
141
|
if (method === undefined) {
|
|
133
142
|
const cb = outstandingServerRequests.get(id);
|
|
134
143
|
if (cb) {
|
|
@@ -140,6 +149,17 @@ function handleMessage(msg) {
|
|
|
140
149
|
|
|
141
150
|
switch (method) {
|
|
142
151
|
case 'initialize':
|
|
152
|
+
// Era-memo self-heal fixture: while the file named by TF_MOCK_INIT_REFUSE_FILE EXISTS,
|
|
153
|
+
// REFUSE the legacy handshake - the shape of an upstream upgraded to modern-only across a
|
|
154
|
+
// restart. File-gated (not env-gated) so a test can flip the behaviour BETWEEN respawns of
|
|
155
|
+
// this same fixture without touching the upstream's configured env.
|
|
156
|
+
if (process.env.TF_MOCK_INIT_REFUSE_FILE &&
|
|
157
|
+
require('node:fs').existsSync(process.env.TF_MOCK_INIT_REFUSE_FILE)) {
|
|
158
|
+
return writeMessage({
|
|
159
|
+
jsonrpc: '2.0', id,
|
|
160
|
+
error: { code: -32000, message: 'initialize refused (TF_MOCK_INIT_REFUSE_FILE fixture)' },
|
|
161
|
+
});
|
|
162
|
+
}
|
|
143
163
|
// Identity-mirroring fixture: RECORD the clientInfo each connect presents when
|
|
144
164
|
// TF_MOCK_CLIENTINFO_LOG is set. A wire test reads the log across a reconnect - first
|
|
145
165
|
// entry = the gateway's boot identity, last entry = the mirrored downstream client.
|
|
@@ -163,6 +183,16 @@ function handleMessage(msg) {
|
|
|
163
183
|
});
|
|
164
184
|
|
|
165
185
|
case 'tools/list':
|
|
186
|
+
// Era-memo invariant fixture: while the file named by TF_MOCK_LIST_FAIL_FILE EXISTS,
|
|
187
|
+
// tools/list errors AFTER a successful handshake - the shape of an upstream that half-boots.
|
|
188
|
+
// File-gated so a test can flip the behaviour between respawns of this same fixture.
|
|
189
|
+
if (process.env.TF_MOCK_LIST_FAIL_FILE &&
|
|
190
|
+
require('node:fs').existsSync(process.env.TF_MOCK_LIST_FAIL_FILE)) {
|
|
191
|
+
return writeMessage({
|
|
192
|
+
jsonrpc: '2.0', id,
|
|
193
|
+
error: { code: -32000, message: 'tools/list unavailable (TF_MOCK_LIST_FAIL_FILE fixture)' },
|
|
194
|
+
});
|
|
195
|
+
}
|
|
166
196
|
return writeMessage({ jsonrpc: '2.0', id, result: { tools: TOOLS } });
|
|
167
197
|
|
|
168
198
|
case 'server/discover':
|
|
@@ -203,7 +233,7 @@ function handleMessage(msg) {
|
|
|
203
233
|
const uri = params.uri;
|
|
204
234
|
if (uri === 'mock://upstream/slow') {
|
|
205
235
|
// In-flight fixture: reply after 2s so a gateway test has a WIDE window to fire a
|
|
206
|
-
// cancel at a genuinely outstanding forwarded request (400ms made
|
|
236
|
+
// cancel at a genuinely outstanding forwarded request (400ms made the wire cancel test a flake candidate
|
|
207
237
|
// on a loaded runner).
|
|
208
238
|
setTimeout(() => writeMessage({
|
|
209
239
|
jsonrpc: '2.0', id,
|
|
@@ -267,7 +297,7 @@ function handleMessage(msg) {
|
|
|
267
297
|
return;
|
|
268
298
|
}
|
|
269
299
|
if (name === 'elicit') {
|
|
270
|
-
//
|
|
300
|
+
// elicitation-bridge fixture: ask the CLIENT a question mid-call, hold the tools/call open, and
|
|
271
301
|
// complete it from the answer - exactly what a real eliciting legacy server does.
|
|
272
302
|
const reqId = nextServerRequestId++;
|
|
273
303
|
outstandingServerRequests.set(reqId, (resp) => {
|
package/package.json
CHANGED
|
@@ -1,65 +1,67 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "toolfunnel",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "A zero-dependency MCP gateway: host your own tools, forward and curate tools from other MCP servers, expose them leanly to cut agent token cost, and gate every call through your own policy hooks before it runs.",
|
|
5
|
-
"keywords": [
|
|
6
|
-
"mcp",
|
|
7
|
-
"model-context-protocol",
|
|
8
|
-
"mcp-gateway",
|
|
9
|
-
"mcp-proxy",
|
|
10
|
-
"mcp-server",
|
|
11
|
-
"ai",
|
|
12
|
-
"agents",
|
|
13
|
-
"llm",
|
|
14
|
-
"tools",
|
|
15
|
-
"claude",
|
|
16
|
-
"anthropic",
|
|
17
|
-
"aggregator",
|
|
18
|
-
"token-efficiency"
|
|
19
|
-
],
|
|
20
|
-
"license": "MIT",
|
|
21
|
-
"type": "commonjs",
|
|
22
|
-
"main": "src/mcp/server.js",
|
|
23
|
-
"bin": {
|
|
24
|
-
"toolfunnel": "bin/toolfunnel.js"
|
|
25
|
-
},
|
|
26
|
-
"engines": {
|
|
27
|
-
"node": ">=18"
|
|
28
|
-
},
|
|
29
|
-
"scripts": {
|
|
30
|
-
"start": "node bin/toolfunnel.js",
|
|
31
|
-
"build": "echo toolfunnel has no build step - zero-dependency, runs from source",
|
|
32
|
-
"http": "node bin/toolfunnel.js --http",
|
|
33
|
-
"install-oauth": "node bin/toolfunnel.js install-oauth",
|
|
34
|
-
"test": "node test/run-all.js",
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
|
|
42
|
-
"
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
"
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"tools/
|
|
53
|
-
"
|
|
54
|
-
"mcp/
|
|
55
|
-
"
|
|
56
|
-
"hooks/
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "toolfunnel",
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "A zero-dependency MCP gateway: host your own tools, forward and curate tools from other MCP servers, expose them leanly to cut agent token cost, and gate every call through your own policy hooks before it runs.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"mcp",
|
|
7
|
+
"model-context-protocol",
|
|
8
|
+
"mcp-gateway",
|
|
9
|
+
"mcp-proxy",
|
|
10
|
+
"mcp-server",
|
|
11
|
+
"ai",
|
|
12
|
+
"agents",
|
|
13
|
+
"llm",
|
|
14
|
+
"tools",
|
|
15
|
+
"claude",
|
|
16
|
+
"anthropic",
|
|
17
|
+
"aggregator",
|
|
18
|
+
"token-efficiency"
|
|
19
|
+
],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"type": "commonjs",
|
|
22
|
+
"main": "src/mcp/server.js",
|
|
23
|
+
"bin": {
|
|
24
|
+
"toolfunnel": "bin/toolfunnel.js"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"start": "node bin/toolfunnel.js",
|
|
31
|
+
"build": "echo toolfunnel has no build step - zero-dependency, runs from source",
|
|
32
|
+
"http": "node bin/toolfunnel.js --http",
|
|
33
|
+
"install-oauth": "node bin/toolfunnel.js install-oauth",
|
|
34
|
+
"test": "node test/run-all.js",
|
|
35
|
+
"prepublishOnly": "node test/run-all.js",
|
|
36
|
+
"release": "node scripts/release.js",
|
|
37
|
+
"test:integration": "node test/integration-real-mcp.test.js && node test/integration-http-client.test.js"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {},
|
|
40
|
+
"author": "rendeverance",
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "git+https://github.com/Rendeverance/toolfunnel.git"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://github.com/Rendeverance/toolfunnel#readme",
|
|
46
|
+
"bugs": {
|
|
47
|
+
"url": "https://github.com/Rendeverance/toolfunnel/issues"
|
|
48
|
+
},
|
|
49
|
+
"files": [
|
|
50
|
+
"bin/",
|
|
51
|
+
"src/",
|
|
52
|
+
"tools/tools.register.json",
|
|
53
|
+
"tools/scripts/",
|
|
54
|
+
"mcp/expose.example.json",
|
|
55
|
+
"mcp/servers/",
|
|
56
|
+
"hooks/hooks.manifest.json",
|
|
57
|
+
"hooks/scripts/",
|
|
58
|
+
"docs/",
|
|
59
|
+
"README.md",
|
|
60
|
+
"SECURITY.md",
|
|
61
|
+
"LICENSE"
|
|
62
|
+
],
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
65
|
+
"jose": "^5.10.0"
|
|
66
|
+
}
|
|
67
|
+
}
|
package/src/auth/config.js
CHANGED
|
@@ -99,7 +99,7 @@ function getConfig() {
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
/** A fresh, mutation-safe copy of the defaults (arrays cloned so a caller can't
|
|
102
|
+
/** A fresh, mutation-safe copy of the defaults (arrays cloned so a caller can't corrupt the frozen one). */
|
|
103
103
|
function cloneDefaults() {
|
|
104
104
|
return {
|
|
105
105
|
enabled: DEFAULTS.enabled,
|
|
@@ -118,7 +118,7 @@ function cloneDefaults() {
|
|
|
118
118
|
* rather than silently mis-validating. Only meaningful when `enabled` is true.
|
|
119
119
|
*
|
|
120
120
|
* Enforcement requires: an issuer, an audience (the RFC 8707 confused-deputy defence - a gateway
|
|
121
|
-
* that does not bind tokens to its own resource URI is
|
|
121
|
+
* that does not bind tokens to its own resource URI is unsafe), at least one pinned algorithm,
|
|
122
122
|
* and a key source (explicit jwksUri OR an issuer to derive one from via OIDC discovery).
|
|
123
123
|
*
|
|
124
124
|
* @param {object} [cfg] defaults to getConfig()
|
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Why a library and not hand-rolled node:crypto: validating a JWT securely is not the signature
|
|
13
13
|
* math (node:crypto has that) - it is the protocol discipline around it. The dangerous, CVE-prone
|
|
14
|
-
* decisions are (1) PINNING the accepted algorithm so an
|
|
14
|
+
* decisions are (1) PINNING the accepted algorithm so an untrusted token can't downgrade RS256->HS256 and
|
|
15
15
|
* forge a token by signing with the public key as an HMAC secret, (2) rejecting `alg:none`, (3)
|
|
16
|
-
* ignoring
|
|
16
|
+
* ignoring caller-controlled header params (jku/jwk/x5u), (4) validating iss/aud/exp/nbf, and (5)
|
|
17
17
|
* JWKS caching + key rotation. `jose` encodes all of these into its API; we MUST drive that API
|
|
18
18
|
* correctly - above all, ALWAYS pass the `algorithms` allowlist and the `audience` (the RFC 8707
|
|
19
19
|
* confused-deputy defence). `jose` v5's `require` build uses Node's native crypto (Node ≥18, no
|
|
@@ -245,6 +245,13 @@ function createValidator(cfg, opts) {
|
|
|
245
245
|
// refuses to start in this state, but a request must still never slip through.
|
|
246
246
|
return unauthorized(500, 'server_error', 'authentication misconfigured', 'no audience configured');
|
|
247
247
|
}
|
|
248
|
+
if (!cfg.issuer) {
|
|
249
|
+
// Symmetric with the audience guard: a blank issuer would make jwtVerify's `issuer` undefined,
|
|
250
|
+
// silently DISABLING issuer binding so a token from any authorization server would validate.
|
|
251
|
+
// configError refuses this at start-up, but the UI can persist an enabled+blank-issuer config
|
|
252
|
+
// that takes effect on the next request with no restart - so fail closed here too.
|
|
253
|
+
return unauthorized(500, 'server_error', 'authentication misconfigured', 'no issuer configured');
|
|
254
|
+
}
|
|
248
255
|
|
|
249
256
|
const token = extractBearer(authorizationHeader);
|
|
250
257
|
if (!token) {
|
|
@@ -266,7 +273,9 @@ function createValidator(cfg, opts) {
|
|
|
266
273
|
// issuer + audience are ENFORCED by jose; clockTolerance bounds exp/nbf skew. We never read the
|
|
267
274
|
// token header to choose the algorithm - the allowlist is fixed from config.
|
|
268
275
|
result = await jose.jwtVerify(token, resolver, {
|
|
269
|
-
issuer
|
|
276
|
+
// The guard above guarantees a non-empty issuer here; pass it straight through. The old
|
|
277
|
+
// `cfg.issuer || undefined` was the silent-skip footgun - undefined tells jose NOT to check.
|
|
278
|
+
issuer: cfg.issuer,
|
|
270
279
|
audience: cfg.audience,
|
|
271
280
|
algorithms: cfg.algorithms,
|
|
272
281
|
clockTolerance: cfg.clockToleranceSec,
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* child-reaper.js - last-resort cleanup for spawned hook/tool children.
|
|
5
|
+
*
|
|
6
|
+
* The hook runner and both local executors spawn their children `detached` on POSIX so the
|
|
7
|
+
* process GROUP can be killed as a tree. setsid() has a consequence: the children leave the
|
|
8
|
+
* gateway's session, so a terminal Ctrl-C no longer reaches them, and a gateway that dies
|
|
9
|
+
* without executing its per-child kill path leaves them reparented to init running to their
|
|
10
|
+
* natural end - with their supervising timeout timers dead alongside the gateway.
|
|
11
|
+
*
|
|
12
|
+
* This module is the counterpart: every spawn site registers its child here and unregisters on
|
|
13
|
+
* settle; process exit / SIGINT / SIGTERM sweeps whatever is still tracked. Synchronous kills
|
|
14
|
+
* only - an 'exit' handler cannot await. Windows gets child.kill() (the shell only; taskkill is
|
|
15
|
+
* async and cannot run here) - best effort, matching the platform's weaker group semantics.
|
|
16
|
+
* A SIGKILLed gateway sweeps nothing; that limit is inherent to SIGKILL.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const live = new Set();
|
|
20
|
+
let installed = false;
|
|
21
|
+
|
|
22
|
+
function sweep() {
|
|
23
|
+
for (const child of live) {
|
|
24
|
+
try {
|
|
25
|
+
if (child.pid != null && process.platform !== 'win32') {
|
|
26
|
+
try { process.kill(-child.pid, 'SIGKILL'); } catch (_eg) { child.kill('SIGKILL'); }
|
|
27
|
+
} else {
|
|
28
|
+
child.kill();
|
|
29
|
+
}
|
|
30
|
+
} catch (_e) { /* already gone */ }
|
|
31
|
+
}
|
|
32
|
+
live.clear();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function install() {
|
|
36
|
+
if (installed) return;
|
|
37
|
+
installed = true;
|
|
38
|
+
process.on('exit', sweep);
|
|
39
|
+
// On a signal, sweep and re-raise the default behaviour by exiting: only do so if nobody
|
|
40
|
+
// else handles the signal (respect an embedding host's own handlers).
|
|
41
|
+
for (const sig of ['SIGINT', 'SIGTERM']) {
|
|
42
|
+
process.on(sig, () => {
|
|
43
|
+
sweep();
|
|
44
|
+
if (process.listenerCount(sig) === 1) process.exit(sig === 'SIGINT' ? 130 : 143);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Register a spawned child for the last-resort sweep. Call untrack() when it settles. */
|
|
50
|
+
function track(child) {
|
|
51
|
+
if (!child || typeof child.kill !== 'function') return;
|
|
52
|
+
install();
|
|
53
|
+
live.add(child);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Remove a settled child from the sweep set. Safe to call twice. */
|
|
57
|
+
function untrack(child) {
|
|
58
|
+
live.delete(child);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { track, untrack };
|
package/src/core/events.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* events.js - lifecycle event names + per-event stdin payload builder.
|
|
5
5
|
*
|
|
6
|
-
* This is the contract surface
|
|
7
|
-
* (the exact stdin JSON shape). Field names are FROZEN
|
|
6
|
+
* This is the hook contract surface: the six lifecycle events and
|
|
7
|
+
* (the exact stdin JSON shape). Field names are FROZEN - they are the hook wire contract and must
|
|
8
8
|
* match Claude Code's documented hook input byte-for-byte so the real hook
|
|
9
9
|
* scripts run unchanged. Do not rename or add fields.
|
|
10
10
|
*
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* The six lifecycle events v3 supports
|
|
15
|
+
* The six lifecycle events v3 supports.
|
|
16
16
|
* Values equal the `hook_event_name` written into the stdin payload.
|
|
17
17
|
* Frozen so callers can rely on identity comparison and can't mutate the set.
|
|
18
18
|
*/
|
|
@@ -31,7 +31,7 @@ const EVENT_NAMES = Object.freeze(Object.values(EVENTS));
|
|
|
31
31
|
/**
|
|
32
32
|
* Build the stdin JSON object the runner pipes to a hook command.
|
|
33
33
|
*
|
|
34
|
-
* Every event carries the four common fields
|
|
34
|
+
* Every event carries the same four common fields:
|
|
35
35
|
* session_id, transcript_path, cwd, hook_event_name
|
|
36
36
|
* supplied by `ctx`. Each event then adds its own fields from `extra`:
|
|
37
37
|
*
|
|
@@ -45,7 +45,7 @@ const EVENT_NAMES = Object.freeze(Object.values(EVENTS));
|
|
|
45
45
|
* @param {string} event one of EVENTS (the hook_event_name).
|
|
46
46
|
* @param {object} ctx common fields: { session_id, transcript_path, cwd }.
|
|
47
47
|
* @param {object} [extra] event-specific fields (see table above).
|
|
48
|
-
* @returns {object} the stdin payload, with frozen field names
|
|
48
|
+
* @returns {object} the stdin payload, with the contract's frozen field names.
|
|
49
49
|
* @throws {Error} if `event` is not one of the six known events.
|
|
50
50
|
*/
|
|
51
51
|
function buildPayload(event, ctx, extra) {
|
|
@@ -56,7 +56,7 @@ function buildPayload(event, ctx, extra) {
|
|
|
56
56
|
const c = ctx || {};
|
|
57
57
|
const e = extra || {};
|
|
58
58
|
|
|
59
|
-
// Common fields - present on EVERY event, in
|
|
59
|
+
// Common fields - present on EVERY event, in their contract order.
|
|
60
60
|
// hook_event_name is always the canonical event string (not whatever ctx held).
|
|
61
61
|
const payload = {
|
|
62
62
|
session_id: c.session_id,
|
|
@@ -65,7 +65,7 @@ function buildPayload(event, ctx, extra) {
|
|
|
65
65
|
hook_event_name: event,
|
|
66
66
|
};
|
|
67
67
|
|
|
68
|
-
// Event-specific additions. Each branch sets EXACTLY the fields
|
|
68
|
+
// Event-specific additions. Each branch sets EXACTLY the fields the contract names -
|
|
69
69
|
// no more, no fewer - so the payload matches Claude Code's shape precisely.
|
|
70
70
|
switch (event) {
|
|
71
71
|
case EVENTS.SessionStart:
|
package/src/core/hook-engine.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* hook-engine.js - fire a lifecycle event through its matching, enabled hooks.
|
|
5
5
|
*
|
|
6
|
-
* Contract:
|
|
6
|
+
* Contract: fire() never rejects - see the class doc below.
|
|
7
7
|
*
|
|
8
8
|
* class HookEngine {
|
|
9
9
|
* constructor(loader) // loader provides enabled hook specs
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* blocked: boolean, // any hook blocked
|
|
13
13
|
* reason: string|null, // first block reason (in manifest order)
|
|
14
14
|
* stopLoop: boolean, // any hook said continue:false
|
|
15
|
-
* results: HookResult[] //
|
|
15
|
+
* results: HookResult[] // runner-result shapes, in stable (manifest) order
|
|
16
16
|
* }
|
|
17
17
|
* }
|
|
18
18
|
*
|
|
@@ -21,6 +21,11 @@
|
|
|
21
21
|
* Tool-less events (SessionStart, UserPromptSubmit, Stop, PreCompact) ignore the matcher -
|
|
22
22
|
* matcher.js handles that, but we also derive toolName only from the built payload so the
|
|
23
23
|
* matcher gets exactly what the contract says it should see.
|
|
24
|
+
* - A matcher that does not COMPILE is an unreadable gate: on a tool-bearing event the hook
|
|
25
|
+
* contributes a synthetic BLOCKING result (its command never runs) - an unreadable gate is
|
|
26
|
+
* never permission, mirroring the runner's unreadable-decision rule. Authoring surfaces
|
|
27
|
+
* refuse such a matcher up front (hook-loader.addEntry), so this only fires for a
|
|
28
|
+
* hand-edited manifest. (0.7.0; matcher-compile.test.js is the acceptance.)
|
|
24
29
|
* - Build the stdin payload once via events.buildPayload(event, ctx, extra).
|
|
25
30
|
* - Run selected hooks CONCURRENTLY (they are independent) with a global concurrency cap
|
|
26
31
|
* (default 8) so a big event cannot fork-bomb, but return results in MANIFEST ORDER.
|
|
@@ -28,7 +33,7 @@
|
|
|
28
33
|
* - blocked/reason = the FIRST blocker in order.
|
|
29
34
|
* - stopLoop = true if ANY hook returned continue:false.
|
|
30
35
|
*
|
|
31
|
-
* The runner never throws (
|
|
36
|
+
* The runner never throws (its hard guarantee), and this engine defends against a misbehaving runner too:
|
|
32
37
|
* any rejection is converted into a synthetic error result so fire() never rejects.
|
|
33
38
|
*
|
|
34
39
|
* CommonJS only. Zero host imports (must run headless under `node --test`).
|
|
@@ -55,6 +60,16 @@ function getMatchesFn() {
|
|
|
55
60
|
return () => true;
|
|
56
61
|
}
|
|
57
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Resolve matcherError from matcher.js (named export). Falls back to "no problem" so an older
|
|
65
|
+
* matcher module degrades to the previous behaviour rather than crashing the engine.
|
|
66
|
+
* @returns {(m: string|undefined) => (string|null)}
|
|
67
|
+
*/
|
|
68
|
+
function getMatcherErrorFn() {
|
|
69
|
+
if (matcher && typeof matcher.matcherError === 'function') return matcher.matcherError;
|
|
70
|
+
return () => null;
|
|
71
|
+
}
|
|
72
|
+
|
|
58
73
|
/**
|
|
59
74
|
* Resolve buildPayload from events.js (named export per contract).
|
|
60
75
|
* @returns {(event: string, ctx: object, extra: object) => object}
|
|
@@ -85,7 +100,7 @@ function getRunHookFn() {
|
|
|
85
100
|
}
|
|
86
101
|
|
|
87
102
|
/**
|
|
88
|
-
* Build a synthetic "failed" result matching the
|
|
103
|
+
* Build a synthetic "failed" result matching the runner's result shape, used when the runner rejects
|
|
89
104
|
* unexpectedly (it is contractually not supposed to, but we never let the loop throw).
|
|
90
105
|
* @param {object} spec
|
|
91
106
|
* @param {string} event
|
|
@@ -166,6 +181,7 @@ class HookEngine {
|
|
|
166
181
|
|
|
167
182
|
// Resolve contract functions once.
|
|
168
183
|
this._matches = getMatchesFn();
|
|
184
|
+
this._matcherError = getMatcherErrorFn();
|
|
169
185
|
this._buildPayload = getBuildPayloadFn();
|
|
170
186
|
this._runHook = getRunHookFn();
|
|
171
187
|
}
|
|
@@ -183,7 +199,7 @@ class HookEngine {
|
|
|
183
199
|
const context = ctx || {};
|
|
184
200
|
const extraFields = extra || {};
|
|
185
201
|
|
|
186
|
-
// Build the stdin payload once (frozen field names
|
|
202
|
+
// Build the stdin payload once (the contract's frozen field names).
|
|
187
203
|
let payload;
|
|
188
204
|
try {
|
|
189
205
|
payload = this._buildPayload(event, context, extraFields);
|
|
@@ -204,43 +220,81 @@ class HookEngine {
|
|
|
204
220
|
// For tool-less events this is undefined and matcher.js treats it as always-fire.
|
|
205
221
|
const toolName = payload && payload.tool_name;
|
|
206
222
|
|
|
207
|
-
// Select enabled hooks for this event whose matcher matches, in MANIFEST ORDER.
|
|
208
|
-
const candidates = this.loader.enabledHooksFor(event) || [];
|
|
209
|
-
const selected = candidates.filter((spec) => {
|
|
210
|
-
try {
|
|
211
|
-
return this._matches(spec && spec.matcher, toolName);
|
|
212
|
-
} catch (_) {
|
|
213
|
-
// A broken matcher string should not crash the engine; skip that hook.
|
|
214
|
-
return false;
|
|
215
|
-
}
|
|
216
|
-
});
|
|
217
|
-
|
|
218
223
|
// Resolve the child cwd/env once for the runner.
|
|
219
224
|
const runCwd = (payload && payload.cwd) || this.cwd;
|
|
220
225
|
const runOpts = { cwd: runCwd };
|
|
221
226
|
if (this.env) runOpts.env = this.env;
|
|
222
227
|
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
228
|
+
// Select enabled hooks for this event and build task factories, in MANIFEST ORDER.
|
|
229
|
+
//
|
|
230
|
+
// A matcher that does not COMPILE cannot be evaluated, and an unreadable gate is never
|
|
231
|
+
// permission - the same rule the runner applies to an unreadable decision (gate-semantics /
|
|
232
|
+
// matcher-compile tests). On a TOOL-BEARING event such a hook contributes a synthetic
|
|
233
|
+
// BLOCKING result in its manifest position (its command is never run); authoring surfaces
|
|
234
|
+
// refuse the pattern up front (hook-loader.addEntry), so this branch only fires for a
|
|
235
|
+
// hand-edited manifest. Tool-less events ignore the matcher entirely (existing contract).
|
|
236
|
+
const candidates = this.loader.enabledHooksFor(event) || [];
|
|
237
|
+
const factories = [];
|
|
238
|
+
for (const spec of candidates) {
|
|
239
|
+
if (toolName !== undefined && toolName !== null) {
|
|
240
|
+
let compileProblem = null;
|
|
241
|
+
try {
|
|
242
|
+
compileProblem = this._matcherError(spec && spec.matcher);
|
|
243
|
+
} catch (_) {
|
|
244
|
+
compileProblem = null; // discriminator itself failing must not take the engine down
|
|
245
|
+
}
|
|
246
|
+
if (compileProblem) {
|
|
247
|
+
const unreadable = {
|
|
248
|
+
id: spec && spec.id,
|
|
249
|
+
event,
|
|
250
|
+
exitCode: -1,
|
|
251
|
+
timedOut: false,
|
|
252
|
+
stdout: '',
|
|
253
|
+
stderr: '',
|
|
254
|
+
blocked: true,
|
|
255
|
+
stopLoop: false,
|
|
256
|
+
reason:
|
|
257
|
+
`hook-engine: hook "${(spec && spec.id) || '(unnamed)'}" has a matcher that does ` +
|
|
258
|
+
`not compile (${compileProblem}); its gate could not be read, so the call is ` +
|
|
259
|
+
'denied (fail closed). Fix or disable the hook.',
|
|
260
|
+
inject: null,
|
|
261
|
+
durationMs: 0,
|
|
262
|
+
};
|
|
263
|
+
factories.push(async () => unreadable);
|
|
264
|
+
continue;
|
|
233
265
|
}
|
|
234
|
-
return syntheticFailure(spec, event, new Error('runner returned no result'), Date.now() - started);
|
|
235
|
-
} catch (err) {
|
|
236
|
-
return syntheticFailure(spec, event, err, Date.now() - started);
|
|
237
266
|
}
|
|
238
|
-
|
|
267
|
+
|
|
268
|
+
let fires = false;
|
|
269
|
+
try {
|
|
270
|
+
fires = this._matches(spec && spec.matcher, toolName);
|
|
271
|
+
} catch (_) {
|
|
272
|
+
// A matcher evaluation crash should not crash the engine; skip that hook.
|
|
273
|
+
fires = false;
|
|
274
|
+
}
|
|
275
|
+
if (!fires) continue;
|
|
276
|
+
|
|
277
|
+
factories.push(async () => {
|
|
278
|
+
const started = Date.now();
|
|
279
|
+
try {
|
|
280
|
+
const res = await this._runHook(spec, payload, runOpts);
|
|
281
|
+
// Defensive: ensure the result carries id/event even if the runner omitted them.
|
|
282
|
+
if (res && typeof res === 'object') {
|
|
283
|
+
if (res.id === undefined) res.id = spec.id;
|
|
284
|
+
if (res.event === undefined) res.event = event;
|
|
285
|
+
return res;
|
|
286
|
+
}
|
|
287
|
+
return syntheticFailure(spec, event, new Error('runner returned no result'), Date.now() - started);
|
|
288
|
+
} catch (err) {
|
|
289
|
+
return syntheticFailure(spec, event, err, Date.now() - started);
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
}
|
|
239
293
|
|
|
240
294
|
// Run concurrently with the cap; results come back in manifest order.
|
|
241
295
|
const results = await runWithConcurrency(factories, this.concurrency);
|
|
242
296
|
|
|
243
|
-
// Aggregate per
|
|
297
|
+
// Aggregate the per-hook results into the engine outcome.
|
|
244
298
|
const injectFragments = [];
|
|
245
299
|
let blocked = false;
|
|
246
300
|
let reason = null;
|