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.
Files changed (42) hide show
  1. package/README.md +11 -7
  2. package/SECURITY.md +84 -0
  3. package/bin/toolfunnel.js +97 -18
  4. package/docs/KNOWN_BUGS.md +71 -90
  5. package/docs/MANUAL.pdf +0 -0
  6. package/docs/design.md +24 -10
  7. package/hooks/hooks.manifest.json +1 -1
  8. package/hooks/scripts/example-deny-dangerous.js +12 -4
  9. package/mcp/servers/mock-upstream/server.js +36 -6
  10. package/package.json +67 -65
  11. package/src/auth/config.js +2 -2
  12. package/src/auth/resource-server.js +12 -3
  13. package/src/core/child-reaper.js +61 -0
  14. package/src/core/events.js +7 -7
  15. package/src/core/hook-engine.js +85 -31
  16. package/src/core/hook-loader.js +46 -13
  17. package/src/core/hook-runner.js +258 -38
  18. package/src/core/logger.js +138 -12
  19. package/src/core/matcher.js +59 -10
  20. package/src/core/rename-migration.js +163 -0
  21. package/src/core/requires.js +1 -1
  22. package/src/extend/add-hook.md +2 -2
  23. package/src/extend/add-mcp.md +3 -6
  24. package/src/extend/configure.md +2 -1
  25. package/src/extend/create-tool.md +1 -1
  26. package/src/extend/howto.js +3 -3
  27. package/src/extend/package.md +11 -8
  28. package/src/mcp/aggregator.js +480 -52
  29. package/src/mcp/expose-store.js +130 -13
  30. package/src/mcp/gated-run.js +26 -17
  31. package/src/mcp/http-transport.js +274 -22
  32. package/src/mcp/mcp-client.js +194 -31
  33. package/src/mcp/modern.js +44 -10
  34. package/src/mcp/server.js +513 -89
  35. package/src/tools/registry.js +172 -24
  36. package/src/ui/public/app.js +17 -4
  37. package/src/ui/server.js +297 -29
  38. package/tools/scripts/tf-hook-set.js +11 -3
  39. package/tools/tools.register.json +227 -227
  40. package/src/core/tool-registry.js +0 -180
  41. package/src/packages/packager.js +0 -715
  42. 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-lab, 2026-07-17).
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 (Bridge B): sends a server-initiated elicitation/create mid-call, holds the call open, completes from the answer.',
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 (Bridge B fixture): id -> callback fed
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 (Bridge B fixture). Unknown ids are dropped.
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 W1 a flake candidate
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
- // Bridge B fixture: ask the CLIENT a question mid-call, hold the tools/call open, and
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.6.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
- "release": "node scripts/release.js",
36
- "test:integration": "node test/integration-real-mcp.test.js && node test/integration-http-client.test.js"
37
- },
38
- "dependencies": {},
39
- "author": "rendeverance",
40
- "repository": {
41
- "type": "git",
42
- "url": "git+https://github.com/Rendeverance/toolfunnel.git"
43
- },
44
- "homepage": "https://github.com/Rendeverance/toolfunnel#readme",
45
- "bugs": {
46
- "url": "https://github.com/Rendeverance/toolfunnel/issues"
47
- },
48
- "files": [
49
- "bin/",
50
- "src/",
51
- "tools/tools.register.json",
52
- "tools/scripts/",
53
- "mcp/expose.example.json",
54
- "mcp/servers/",
55
- "hooks/hooks.manifest.json",
56
- "hooks/scripts/",
57
- "docs/",
58
- "README.md",
59
- "LICENSE"
60
- ],
61
- "devDependencies": {
62
- "@modelcontextprotocol/sdk": "^1.0.0",
63
- "jose": "^5.10.0"
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
+ }
@@ -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 poison the frozen one). */
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 exploitable), at least one pinned algorithm,
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 attacker can't downgrade RS256->HS256 and
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 attacker-controlled header params (jku/jwk/x5u), (4) validating iss/aud/exp/nbf, and (5)
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: cfg.issuer || undefined,
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 };
@@ -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 from HOOK_ENGINE.md §1 (the six events) and §2
7
- * (the exact stdin JSON shape). Field names are FROZEN by §2's table - they must
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 (HOOK_ENGINE.md §1).
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 (HOOK_ENGINE.md §2):
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 per §2.
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 the order §2 lists them.
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 §2 names -
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:
@@ -3,7 +3,7 @@
3
3
  /**
4
4
  * hook-engine.js - fire a lifecycle event through its matching, enabled hooks.
5
5
  *
6
- * Contract: HOOK_ENGINE.md §6.
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[] // per §4, in stable (manifest) order
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 (§4), and this engine defends against a misbehaving runner too:
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 §4 shape, used when the runner rejects
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 per §2).
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
- // Build task factories (one per selected hook) - never reject.
224
- const factories = selected.map((spec) => async () => {
225
- const started = Date.now();
226
- try {
227
- const res = await this._runHook(spec, payload, runOpts);
228
- // Defensive: ensure the result carries id/event even if the runner omitted them.
229
- if (res && typeof res === 'object') {
230
- if (res.id === undefined) res.id = spec.id;
231
- if (res.event === undefined) res.event = event;
232
- return res;
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 §6.
297
+ // Aggregate the per-hook results into the engine outcome.
244
298
  const injectFragments = [];
245
299
  let blocked = false;
246
300
  let reason = null;