toolfunnel 0.3.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 (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +324 -0
  3. package/bin/toolfunnel.js +112 -0
  4. package/docs/admin-ui.md +404 -0
  5. package/docs/configuration.md +402 -0
  6. package/docs/hooks-and-gating.md +428 -0
  7. package/docs/logging.md +223 -0
  8. package/docs/management.md +429 -0
  9. package/docs/oauth.md +103 -0
  10. package/docs/setup.md +352 -0
  11. package/docs/tools-and-upstreams.md +469 -0
  12. package/hooks/hooks.manifest.json +27 -0
  13. package/hooks/scripts/.gitkeep +3 -0
  14. package/hooks/scripts/example-audit.js +21 -0
  15. package/hooks/scripts/example-deny-dangerous.js +31 -0
  16. package/mcp/expose.example.json +30 -0
  17. package/mcp/servers/mock-upstream/server.js +170 -0
  18. package/package.json +63 -0
  19. package/src/auth/config.js +172 -0
  20. package/src/auth/install.js +181 -0
  21. package/src/auth/resource-server.js +319 -0
  22. package/src/core/events.js +105 -0
  23. package/src/core/hook-engine.js +275 -0
  24. package/src/core/hook-loader.js +791 -0
  25. package/src/core/hook-runner.js +346 -0
  26. package/src/core/logger.js +169 -0
  27. package/src/core/matcher.js +51 -0
  28. package/src/core/metrics.js +75 -0
  29. package/src/core/tool-registry.js +180 -0
  30. package/src/extend/add-hook.md +142 -0
  31. package/src/extend/add-mcp.md +121 -0
  32. package/src/extend/create-tool.md +132 -0
  33. package/src/extend/howto.js +98 -0
  34. package/src/extend/package.md +154 -0
  35. package/src/mcp/aggregator.js +802 -0
  36. package/src/mcp/expose-store.js +430 -0
  37. package/src/mcp/gated-run.js +194 -0
  38. package/src/mcp/http-transport.js +962 -0
  39. package/src/mcp/mcp-client.js +524 -0
  40. package/src/mcp/protocol.js +466 -0
  41. package/src/mcp/server.js +1240 -0
  42. package/src/packages/packager.js +713 -0
  43. package/src/tools/drift.js +138 -0
  44. package/src/tools/registry.js +468 -0
  45. package/src/tools/tool-state.js +104 -0
  46. package/src/ui/public/app.js +1055 -0
  47. package/src/ui/public/index.html +249 -0
  48. package/src/ui/public/logo.jpg +0 -0
  49. package/src/ui/public/styles.css +535 -0
  50. package/src/ui/server.js +1144 -0
  51. package/tools/scripts/base64.js +139 -0
  52. package/tools/scripts/danger.js +61 -0
  53. package/tools/scripts/echo.js +39 -0
  54. package/tools/scripts/hash.js +101 -0
  55. package/tools/scripts/json.js +109 -0
  56. package/tools/scripts/text-stats.js +131 -0
  57. package/tools/scripts/tf-hook-add.js +152 -0
  58. package/tools/scripts/tf-hook-set.js +113 -0
  59. package/tools/scripts/tf-list.js +195 -0
  60. package/tools/scripts/tf-log.js +83 -0
  61. package/tools/scripts/tf-mcp-add.js +119 -0
  62. package/tools/scripts/tf-mcp-set.js +104 -0
  63. package/tools/scripts/tf-tool-add.js +129 -0
  64. package/tools/scripts/tf-tool-set.js +148 -0
  65. package/tools/scripts/uuid.js +102 -0
  66. package/tools/tools.register.json +171 -0
@@ -0,0 +1,319 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * auth/resource-server.js — OPTIONAL OAuth 2.1 resource-server token validation.
5
+ *
6
+ * This is the ONLY module that touches `jose`, and it does so with a LAZY require so the core
7
+ * gateway keeps `dependencies: {}` (zero runtime deps). `jose` is installed on demand — the CLI
8
+ * `toolfunnel install-oauth` or the admin-UI button (see src/auth/install.js) — and pulled in here
9
+ * only when auth is enabled. If it is not installed, every entry point fails CLOSED with a clear,
10
+ * actionable message; it never silently allows a request through.
11
+ *
12
+ * Why a library and not hand-rolled node:crypto: validating a JWT securely is not the signature
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
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)
17
+ * JWKS caching + key rotation. `jose` encodes all of these into its API; we MUST drive that API
18
+ * correctly — above all, ALWAYS pass the `algorithms` allowlist and the `audience` (the RFC 8707
19
+ * confused-deputy defence). `jose` v5's `require` build uses Node's native crypto (Node ≥18, no
20
+ * Web-Crypto-global dependency), which is why it is the pinned major for this CommonJS project.
21
+ *
22
+ * NOTHING here throws out of validate() — a verification failure is a structured unauthorized
23
+ * result, not an exception. CommonJS only; Node built-ins (node:url, global fetch) + lazy jose.
24
+ */
25
+
26
+ /**
27
+ * The PINNED jose version installed on demand. A caret range so patch/minor security fixes flow,
28
+ * but the major (v5 — the CommonJS, Node-native-crypto build; v6 is ESM-only and would force a
29
+ * Node ≥22 require-of-ESM floor incompatible with this project's `engines: >=18`) is fixed.
30
+ */
31
+ const JOSE_PIN = '^5.10.0';
32
+
33
+ /** Lazy jose handle: undefined = not yet attempted, null = attempted + absent, object = loaded. */
34
+ let _jose = undefined;
35
+
36
+ /**
37
+ * loadJose — lazily require('jose'), cached. Returns the module or null if it is not installed.
38
+ * NEVER throws (a missing/incompatible jose returns null; the caller fails closed with a message).
39
+ * @returns {object|null}
40
+ */
41
+ function loadJose() {
42
+ if (_jose !== undefined) return _jose;
43
+ try {
44
+ // eslint-disable-next-line global-require
45
+ _jose = require('jose');
46
+ } catch (_e) {
47
+ _jose = null;
48
+ }
49
+ return _jose;
50
+ }
51
+
52
+ /** Is jose importable in this runtime? (installed AND loadable as CommonJS). */
53
+ function isJoseInstalled() {
54
+ return loadJose() != null;
55
+ }
56
+
57
+ /** Reset the lazy cache — used by tests and by the UI right after an on-demand install. */
58
+ function _resetJoseCache() {
59
+ _jose = undefined;
60
+ }
61
+
62
+ /**
63
+ * Build the value of a WWW-Authenticate response header for the Bearer scheme. Per RFC 6750 +
64
+ * RFC 9728: a bare challenge (no credentials) omits the error code; an invalid/insufficient token
65
+ * carries error= and error_description=. `resource_metadata` points the client at the protected-
66
+ * resource-metadata document so it can discover the authorization server (RFC 9728).
67
+ *
68
+ * @param {{ resourceMetadataUrl?:string, error?:string, description?:string }} o
69
+ * @returns {string}
70
+ */
71
+ function buildWwwAuthenticate(o) {
72
+ const parts = [];
73
+ if (o && o.resourceMetadataUrl) parts.push(`resource_metadata="${o.resourceMetadataUrl}"`);
74
+ if (o && o.error) parts.push(`error="${o.error}"`);
75
+ if (o && o.description) parts.push(`error_description="${String(o.description).replace(/"/g, "'")}"`);
76
+ return parts.length ? `Bearer ${parts.join(', ')}` : 'Bearer';
77
+ }
78
+
79
+ /**
80
+ * Extract the bearer token from an Authorization header. Returns the token string, or null if the
81
+ * header is absent/malformed/not the Bearer scheme. Exactly one whitespace-delimited token after a
82
+ * case-insensitive "Bearer" is accepted.
83
+ * @param {string|undefined} authorizationHeader
84
+ * @returns {string|null}
85
+ */
86
+ function extractBearer(authorizationHeader) {
87
+ if (typeof authorizationHeader !== 'string') return null;
88
+ const m = /^Bearer[ \t]+([^\s]+)[ \t]*$/i.exec(authorizationHeader.trim());
89
+ return m ? m[1] : null;
90
+ }
91
+
92
+ /**
93
+ * Resolve the JWKS URI: prefer an explicit cfg.jwksUri, else discover it from the issuer via OIDC
94
+ * Discovery (RFC 8414 / OpenID Connect Discovery). Tries the OIDC well-known first, then the OAuth
95
+ * Authorization Server Metadata well-known. Bounded by a timeout. Returns the URL string or throws
96
+ * a plain Error (the caller turns a throw into a fail-closed unauthorized result).
97
+ *
98
+ * @param {object} cfg the resolved auth config
99
+ * @param {number} [timeoutMs]
100
+ * @returns {Promise<string>}
101
+ */
102
+ async function resolveJwksUri(cfg, timeoutMs) {
103
+ if (cfg.jwksUri) return cfg.jwksUri;
104
+ if (!cfg.issuer) throw new Error('no jwksUri and no issuer to discover one from');
105
+
106
+ const base = cfg.issuer.replace(/\/+$/, '');
107
+ const candidates = [
108
+ `${base}/.well-known/openid-configuration`,
109
+ `${base}/.well-known/oauth-authorization-server`,
110
+ ];
111
+ let lastErr = null;
112
+ for (const url of candidates) {
113
+ try {
114
+ const doc = await fetchJson(url, timeoutMs);
115
+ if (doc && typeof doc.jwks_uri === 'string' && doc.jwks_uri.length > 0) return doc.jwks_uri;
116
+ lastErr = new Error(`discovery doc at ${url} has no jwks_uri`);
117
+ } catch (e) {
118
+ lastErr = e;
119
+ }
120
+ }
121
+ throw lastErr || new Error('OIDC discovery failed');
122
+ }
123
+
124
+ /** GET + parse JSON with a hard timeout. Throws on non-2xx, network error, or bad JSON. */
125
+ async function fetchJson(url, timeoutMs) {
126
+ const ac = new AbortController();
127
+ const ms = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 10000;
128
+ const t = setTimeout(() => ac.abort(), ms);
129
+ try {
130
+ const res = await fetch(url, { signal: ac.signal, redirect: 'follow' });
131
+ if (!res || !res.ok) throw new Error(`GET ${url} -> HTTP ${res ? res.status : 'no-response'}`);
132
+ return await res.json();
133
+ } finally {
134
+ clearTimeout(t);
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Collect the scopes carried by a verified token: the space-delimited `scope` claim (RFC 8693) or
140
+ * an array `scp` claim (some issuers). Returns a Set of scope strings.
141
+ * @param {object} claims
142
+ * @returns {Set<string>}
143
+ */
144
+ function scopesOf(claims) {
145
+ const out = new Set();
146
+ if (claims && typeof claims.scope === 'string') {
147
+ for (const s of claims.scope.split(/\s+/)) if (s) out.add(s);
148
+ }
149
+ if (claims && Array.isArray(claims.scp)) {
150
+ for (const s of claims.scp) if (typeof s === 'string' && s) out.add(s);
151
+ }
152
+ return out;
153
+ }
154
+
155
+ /**
156
+ * createValidator — build a token validator from a resolved auth config.
157
+ *
158
+ * @param {object} cfg the resolved config (see auth/config.js getConfig()).
159
+ * @param {object} [opts]
160
+ * @param {string} [opts.resourceMetadataUrl] the absolute URL of this gateway's protected-
161
+ * resource-metadata document, embedded in 401
162
+ * WWW-Authenticate challenges (RFC 9728).
163
+ * @param {number} [opts.discoveryTimeoutMs] OIDC discovery + JWKS fetch timeout (default 10s).
164
+ * @param {Function} [opts.log] optional (msg)=>void for server-side diagnostics
165
+ * (the real reason is logged, NOT leaked to clients).
166
+ * @returns {{ validate:(authHeader:string|undefined)=>Promise<object>, unauthorized:Function,
167
+ * jwksReady:()=>boolean }}
168
+ */
169
+ function createValidator(cfg, opts) {
170
+ const o = opts || {};
171
+ const resourceMetadataUrl = typeof o.resourceMetadataUrl === 'string' ? o.resourceMetadataUrl : '';
172
+ const log = typeof o.log === 'function' ? o.log : () => {};
173
+ const discoveryTimeoutMs = Number.isFinite(o.discoveryTimeoutMs) ? o.discoveryTimeoutMs : 10000;
174
+
175
+ // The JWKS resolver is built ONCE, lazily, on first validate — memoised across calls. jose's
176
+ // createRemoteJWKSet owns the HTTP fetch, in-memory cache, cooldown, and refetch-on-unknown-kid
177
+ // (key rotation) — we do not hand-roll any of that. A build failure is recomputed next call so a
178
+ // transient discovery outage self-heals.
179
+ let jwks = null;
180
+ let jwksBuildPromise = null;
181
+
182
+ function unauthorized(status, error, description, logReason) {
183
+ if (logReason) log(`auth reject: ${logReason}`);
184
+ return {
185
+ ok: false,
186
+ status: status || 401,
187
+ error: error || 'invalid_token',
188
+ errorDescription: description || '',
189
+ wwwAuthenticate: buildWwwAuthenticate({ resourceMetadataUrl, error, description }),
190
+ };
191
+ }
192
+
193
+ async function ensureJwks() {
194
+ if (jwks) return jwks;
195
+ if (!jwksBuildPromise) {
196
+ jwksBuildPromise = (async () => {
197
+ const jose = loadJose();
198
+ if (!jose) throw new Error('jose is not installed');
199
+ const uri = await resolveJwksUri(cfg, discoveryTimeoutMs);
200
+ // cooldownDuration throttles refetches on unknown-kid; cacheMaxAge bounds key staleness.
201
+ return jose.createRemoteJWKSet(new URL(uri), { cooldownDuration: 30000, cacheMaxAge: 600000 });
202
+ })().catch((e) => {
203
+ jwksBuildPromise = null; // allow a retry on the next request (self-heal)
204
+ throw e;
205
+ });
206
+ }
207
+ jwks = await jwksBuildPromise;
208
+ return jwks;
209
+ }
210
+
211
+ async function validate(authorizationHeader) {
212
+ // Fail closed if the dependency or configuration is not coherent — never allow on misconfig.
213
+ const jose = loadJose();
214
+ if (!jose) {
215
+ return unauthorized(500, 'server_error', 'authentication unavailable', 'jose not installed');
216
+ }
217
+ if (!cfg.audience) {
218
+ // Without an audience we cannot bind the token to THIS resource (RFC 8707) — refuse rather
219
+ // than validate an unbound token (the confused-deputy hole). Belt-and-braces: the transport
220
+ // refuses to start in this state, but a request must still never slip through.
221
+ return unauthorized(500, 'server_error', 'authentication misconfigured', 'no audience configured');
222
+ }
223
+
224
+ const token = extractBearer(authorizationHeader);
225
+ if (!token) {
226
+ // No credentials → a BARE challenge (no error code), the canonical "authenticate, please".
227
+ return unauthorized(401, '', '', 'missing or malformed Authorization header');
228
+ }
229
+
230
+ let resolver;
231
+ try {
232
+ resolver = await ensureJwks();
233
+ } catch (e) {
234
+ // Cannot reach/parse the key source → cannot verify → reject (do not leak the URL/reason).
235
+ return unauthorized(401, 'invalid_token', 'unable to verify token', `jwks unavailable: ${(e && e.message) || e}`);
236
+ }
237
+
238
+ let result;
239
+ try {
240
+ // The security-critical call. algorithms PINS the allowlist (blocks alg-confusion + alg:none);
241
+ // issuer + audience are ENFORCED by jose; clockTolerance bounds exp/nbf skew. We never read the
242
+ // token header to choose the algorithm — the allowlist is fixed from config.
243
+ result = await jose.jwtVerify(token, resolver, {
244
+ issuer: cfg.issuer || undefined,
245
+ audience: cfg.audience,
246
+ algorithms: cfg.algorithms,
247
+ clockTolerance: cfg.clockToleranceSec,
248
+ // Require `exp` to be PRESENT — jose only checks exp/nbf when present, so without this a
249
+ // token that omits exp would be accepted as never-expiring. RFC 9068 access tokens carry
250
+ // exp; a resource server should reject one that does not.
251
+ requiredClaims: ['exp'],
252
+ });
253
+ } catch (e) {
254
+ return unauthorized(401, 'invalid_token', 'token verification failed', `jwtVerify: ${(e && e.code) || (e && e.message) || e}`);
255
+ }
256
+
257
+ const claims = result && result.payload ? result.payload : {};
258
+
259
+ // Optional scope enforcement — every required scope must be present, else 403 insufficient_scope.
260
+ if (Array.isArray(cfg.requiredScopes) && cfg.requiredScopes.length > 0) {
261
+ const have = scopesOf(claims);
262
+ const missing = cfg.requiredScopes.filter((s) => !have.has(s));
263
+ if (missing.length > 0) {
264
+ return {
265
+ ok: false,
266
+ status: 403,
267
+ error: 'insufficient_scope',
268
+ errorDescription: `missing scope: ${missing.join(' ')}`,
269
+ wwwAuthenticate: buildWwwAuthenticate({
270
+ resourceMetadataUrl,
271
+ error: 'insufficient_scope',
272
+ description: `missing scope: ${missing.join(' ')}`,
273
+ }),
274
+ };
275
+ }
276
+ }
277
+
278
+ return {
279
+ ok: true,
280
+ sub: typeof claims.sub === 'string' ? claims.sub : undefined,
281
+ claims,
282
+ scopes: Array.from(scopesOf(claims)),
283
+ };
284
+ }
285
+
286
+ return { validate, unauthorized, jwksReady: () => jwks != null };
287
+ }
288
+
289
+ /**
290
+ * protectedResourceMetadata — the RFC 9728 Protected Resource Metadata document for this gateway.
291
+ * Served (unauthenticated) at /.well-known/oauth-protected-resource so a client can discover which
292
+ * authorization server issues tokens for this resource. NEVER throws.
293
+ * @param {object} cfg the resolved auth config
294
+ * @returns {{ resource:string, authorization_servers:string[], bearer_methods_supported:string[],
295
+ * scopes_supported?:string[] }}
296
+ */
297
+ function protectedResourceMetadata(cfg) {
298
+ const doc = {
299
+ resource: (cfg && cfg.audience) || '',
300
+ authorization_servers: cfg && cfg.issuer ? [cfg.issuer] : [],
301
+ bearer_methods_supported: ['header'],
302
+ };
303
+ if (cfg && Array.isArray(cfg.requiredScopes) && cfg.requiredScopes.length > 0) {
304
+ doc.scopes_supported = cfg.requiredScopes.slice();
305
+ }
306
+ return doc;
307
+ }
308
+
309
+ module.exports = {
310
+ JOSE_PIN,
311
+ isJoseInstalled,
312
+ createValidator,
313
+ protectedResourceMetadata,
314
+ buildWwwAuthenticate,
315
+ extractBearer,
316
+ scopesOf,
317
+ resolveJwksUri,
318
+ _resetJoseCache,
319
+ };
@@ -0,0 +1,105 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * events.js — lifecycle event names + per-event stdin payload builder.
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
8
+ * match Claude Code's documented hook input byte-for-byte so the real hook
9
+ * scripts run unchanged. Do not rename or add fields.
10
+ *
11
+ * CommonJS only. Zero host imports (core/ must run headless under node --test).
12
+ */
13
+
14
+ /**
15
+ * The six lifecycle events v3 supports (HOOK_ENGINE.md §1).
16
+ * Values equal the `hook_event_name` written into the stdin payload.
17
+ * Frozen so callers can rely on identity comparison and can't mutate the set.
18
+ */
19
+ const EVENTS = Object.freeze({
20
+ SessionStart: 'SessionStart',
21
+ UserPromptSubmit: 'UserPromptSubmit',
22
+ PreToolUse: 'PreToolUse',
23
+ PostToolUse: 'PostToolUse',
24
+ Stop: 'Stop',
25
+ PreCompact: 'PreCompact',
26
+ });
27
+
28
+ // Set of valid event names for fast membership checks / validation.
29
+ const EVENT_NAMES = Object.freeze(Object.values(EVENTS));
30
+
31
+ /**
32
+ * Build the stdin JSON object the runner pipes to a hook command.
33
+ *
34
+ * Every event carries the four common fields (HOOK_ENGINE.md §2):
35
+ * session_id, transcript_path, cwd, hook_event_name
36
+ * supplied by `ctx`. Each event then adds its own fields from `extra`:
37
+ *
38
+ * SessionStart → source : "startup" | "resume" | "compact"
39
+ * UserPromptSubmit → prompt : "<user text>"
40
+ * PreToolUse → tool_name, tool_input
41
+ * PostToolUse → tool_name, tool_input, tool_response
42
+ * Stop → stop_hook_active : true | false
43
+ * PreCompact → trigger ("manual"|"auto"), custom_instructions
44
+ *
45
+ * @param {string} event one of EVENTS (the hook_event_name).
46
+ * @param {object} ctx common fields: { session_id, transcript_path, cwd }.
47
+ * @param {object} [extra] event-specific fields (see table above).
48
+ * @returns {object} the stdin payload, with frozen field names per §2.
49
+ * @throws {Error} if `event` is not one of the six known events.
50
+ */
51
+ function buildPayload(event, ctx, extra) {
52
+ if (!EVENT_NAMES.includes(event)) {
53
+ throw new Error(`buildPayload: unknown event "${event}"`);
54
+ }
55
+
56
+ const c = ctx || {};
57
+ const e = extra || {};
58
+
59
+ // Common fields — present on EVERY event, in the order §2 lists them.
60
+ // hook_event_name is always the canonical event string (not whatever ctx held).
61
+ const payload = {
62
+ session_id: c.session_id,
63
+ transcript_path: c.transcript_path,
64
+ cwd: c.cwd,
65
+ hook_event_name: event,
66
+ };
67
+
68
+ // Event-specific additions. Each branch sets EXACTLY the fields §2 names —
69
+ // no more, no fewer — so the payload matches Claude Code's shape precisely.
70
+ switch (event) {
71
+ case EVENTS.SessionStart:
72
+ payload.source = e.source;
73
+ break;
74
+
75
+ case EVENTS.UserPromptSubmit:
76
+ payload.prompt = e.prompt;
77
+ break;
78
+
79
+ case EVENTS.PreToolUse:
80
+ payload.tool_name = e.tool_name;
81
+ payload.tool_input = e.tool_input;
82
+ break;
83
+
84
+ case EVENTS.PostToolUse:
85
+ payload.tool_name = e.tool_name;
86
+ payload.tool_input = e.tool_input;
87
+ payload.tool_response = e.tool_response;
88
+ break;
89
+
90
+ case EVENTS.Stop:
91
+ payload.stop_hook_active = e.stop_hook_active;
92
+ break;
93
+
94
+ case EVENTS.PreCompact:
95
+ payload.trigger = e.trigger;
96
+ payload.custom_instructions = e.custom_instructions;
97
+ break;
98
+
99
+ // No default: the membership check above already rejected unknown events.
100
+ }
101
+
102
+ return payload;
103
+ }
104
+
105
+ module.exports = { EVENTS, EVENT_NAMES, buildPayload };
@@ -0,0 +1,275 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * hook-engine.js — fire a lifecycle event through its matching, enabled hooks.
5
+ *
6
+ * Contract: HOOK_ENGINE.md §6.
7
+ *
8
+ * class HookEngine {
9
+ * constructor(loader) // loader provides enabled hook specs
10
+ * async fire(event, ctx, extra) → {
11
+ * injected: string, // all non-null inject fragments joined by "\n"
12
+ * blocked: boolean, // any hook blocked
13
+ * reason: string|null, // first block reason (in manifest order)
14
+ * stopLoop: boolean, // any hook said continue:false
15
+ * results: HookResult[] // per §4, in stable (manifest) order
16
+ * }
17
+ * }
18
+ *
19
+ * Behaviour:
20
+ * - Select hooks where spec.event === event AND spec.enabled AND matches(spec.matcher, toolName).
21
+ * Tool-less events (SessionStart, UserPromptSubmit, Stop, PreCompact) ignore the matcher —
22
+ * matcher.js handles that, but we also derive toolName only from the built payload so the
23
+ * matcher gets exactly what the contract says it should see.
24
+ * - Build the stdin payload once via events.buildPayload(event, ctx, extra).
25
+ * - Run selected hooks CONCURRENTLY (they are independent) with a global concurrency cap
26
+ * (default 8) so a big event cannot fork-bomb, but return results in MANIFEST ORDER.
27
+ * - injected = every non-null inject joined by "\n", in order.
28
+ * - blocked/reason = the FIRST blocker in order.
29
+ * - stopLoop = true if ANY hook returned continue:false.
30
+ *
31
+ * The runner never throws (§4), and this engine defends against a misbehaving runner too:
32
+ * any rejection is converted into a synthetic error result so fire() never rejects.
33
+ *
34
+ * CommonJS only. Zero host imports (must run headless under `node --test`).
35
+ */
36
+
37
+ const path = require('node:path');
38
+
39
+ // Sibling core modules (authored to the same contract in this build pass).
40
+ const matcher = require('./matcher');
41
+ const events = require('./events');
42
+ const hookRunner = require('./hook-runner');
43
+
44
+ const DEFAULT_CONCURRENCY = 8;
45
+
46
+ /**
47
+ * Resolve the matcher function from matcher.js regardless of whether it is the default or
48
+ * a named export, so we interlock with the contract's `matches(matcher, toolName)`.
49
+ * @returns {(m: string|undefined, toolName: string) => boolean}
50
+ */
51
+ function getMatchesFn() {
52
+ if (typeof matcher === 'function') return matcher;
53
+ if (matcher && typeof matcher.matches === 'function') return matcher.matches;
54
+ // Defensive fallback: if matcher.js is unavailable, fire everything (never crash).
55
+ return () => true;
56
+ }
57
+
58
+ /**
59
+ * Resolve buildPayload from events.js (named export per contract).
60
+ * @returns {(event: string, ctx: object, extra: object) => object}
61
+ */
62
+ function getBuildPayloadFn() {
63
+ if (events && typeof events.buildPayload === 'function') return events.buildPayload;
64
+ // Defensive fallback: assemble a minimal common payload.
65
+ return (event, ctx, extra) =>
66
+ Object.assign(
67
+ {
68
+ session_id: (ctx && ctx.session_id) || '',
69
+ transcript_path: (ctx && ctx.transcript_path) || '',
70
+ cwd: (ctx && ctx.cwd) || process.cwd(),
71
+ hook_event_name: event,
72
+ },
73
+ extra || {}
74
+ );
75
+ }
76
+
77
+ /**
78
+ * Resolve runHook from hook-runner.js (named export per contract).
79
+ * @returns {(spec: object, payload: object, opts: object) => Promise<object>}
80
+ */
81
+ function getRunHookFn() {
82
+ if (hookRunner && typeof hookRunner.runHook === 'function') return hookRunner.runHook;
83
+ if (typeof hookRunner === 'function') return hookRunner;
84
+ throw new Error('hook-engine: hook-runner.runHook is unavailable');
85
+ }
86
+
87
+ /**
88
+ * Build a synthetic "failed" result matching the §4 shape, used when the runner rejects
89
+ * unexpectedly (it is contractually not supposed to, but we never let the loop throw).
90
+ * @param {object} spec
91
+ * @param {string} event
92
+ * @param {Error} err
93
+ * @param {number} durationMs
94
+ * @returns {object}
95
+ */
96
+ function syntheticFailure(spec, event, err, durationMs) {
97
+ return {
98
+ id: spec && spec.id,
99
+ event,
100
+ exitCode: -1,
101
+ timedOut: false,
102
+ stdout: '',
103
+ stderr: (err && err.message) || String(err),
104
+ blocked: false,
105
+ stopLoop: false,
106
+ reason: null,
107
+ inject: null,
108
+ durationMs: durationMs || 0,
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Run an array of async task factories with a concurrency cap, preserving input order in
114
+ * the returned results array. Each task is a `() => Promise<T>`; the returned slot holds
115
+ * the resolved value (tasks here are written to never reject).
116
+ *
117
+ * @template T
118
+ * @param {Array<() => Promise<T>>} taskFactories
119
+ * @param {number} cap maximum simultaneous tasks
120
+ * @returns {Promise<T[]>} results in the same order as taskFactories
121
+ */
122
+ async function runWithConcurrency(taskFactories, cap) {
123
+ const total = taskFactories.length;
124
+ const results = new Array(total);
125
+ if (total === 0) return results;
126
+
127
+ const limit = Math.max(1, Math.min(cap || DEFAULT_CONCURRENCY, total));
128
+ let nextIndex = 0;
129
+
130
+ // Each worker pulls the next index until the queue drains.
131
+ async function worker() {
132
+ // eslint-disable-next-line no-constant-condition
133
+ while (true) {
134
+ const i = nextIndex++;
135
+ if (i >= total) return;
136
+ results[i] = await taskFactories[i]();
137
+ }
138
+ }
139
+
140
+ const workers = [];
141
+ for (let w = 0; w < limit; w++) workers.push(worker());
142
+ await Promise.all(workers);
143
+ return results;
144
+ }
145
+
146
+ class HookEngine {
147
+ /**
148
+ * @param {object} loader a HookLoader (or any object exposing enabledHooksFor(event)).
149
+ * @param {object} [options]
150
+ * @param {number} [options.concurrency=8] global concurrency cap per fire().
151
+ * @param {string} [options.cwd] working dir passed to the runner.
152
+ * @param {object} [options.env] base env merged into the runner's child env.
153
+ */
154
+ constructor(loader, options) {
155
+ if (!loader || typeof loader.enabledHooksFor !== 'function') {
156
+ throw new TypeError('HookEngine: loader must expose enabledHooksFor(event)');
157
+ }
158
+ this.loader = loader;
159
+ const opts = options || {};
160
+ this.concurrency =
161
+ Number.isInteger(opts.concurrency) && opts.concurrency > 0
162
+ ? opts.concurrency
163
+ : DEFAULT_CONCURRENCY;
164
+ this.cwd = opts.cwd || (loader.hooksDir ? path.dirname(loader.hooksDir) : process.cwd());
165
+ this.env = opts.env || null;
166
+
167
+ // Resolve contract functions once.
168
+ this._matches = getMatchesFn();
169
+ this._buildPayload = getBuildPayloadFn();
170
+ this._runHook = getRunHookFn();
171
+ }
172
+
173
+ /**
174
+ * Fire a lifecycle event through its matching, enabled hooks.
175
+ *
176
+ * @param {string} event one of EVENTS (e.g. "PreToolUse")
177
+ * @param {object} [ctx] common context: { session_id, transcript_path, cwd, ... }
178
+ * @param {object} [extra] event-specific fields (tool_name, tool_input, prompt, source, …)
179
+ * @returns {Promise<{injected:string, blocked:boolean, reason:(string|null),
180
+ * stopLoop:boolean, results:object[]}>}
181
+ */
182
+ async fire(event, ctx, extra) {
183
+ const context = ctx || {};
184
+ const extraFields = extra || {};
185
+
186
+ // Build the stdin payload once (frozen field names per §2).
187
+ let payload;
188
+ try {
189
+ payload = this._buildPayload(event, context, extraFields);
190
+ } catch (err) {
191
+ // If payload assembly fails, fall back to a minimal valid payload so hooks still run.
192
+ payload = Object.assign(
193
+ {
194
+ session_id: context.session_id || '',
195
+ transcript_path: context.transcript_path || '',
196
+ cwd: context.cwd || this.cwd,
197
+ hook_event_name: event,
198
+ },
199
+ extraFields
200
+ );
201
+ }
202
+
203
+ // The toolName for matcher scoping comes from the payload (tool-bearing events only).
204
+ // For tool-less events this is undefined and matcher.js treats it as always-fire.
205
+ const toolName = payload && payload.tool_name;
206
+
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
+ // Resolve the child cwd/env once for the runner.
219
+ const runCwd = (payload && payload.cwd) || this.cwd;
220
+ const runOpts = { cwd: runCwd };
221
+ if (this.env) runOpts.env = this.env;
222
+
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;
233
+ }
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
+ }
238
+ });
239
+
240
+ // Run concurrently with the cap; results come back in manifest order.
241
+ const results = await runWithConcurrency(factories, this.concurrency);
242
+
243
+ // Aggregate per §6.
244
+ const injectFragments = [];
245
+ let blocked = false;
246
+ let reason = null;
247
+ let stopLoop = false;
248
+
249
+ for (const r of results) {
250
+ if (!r) continue;
251
+ if (r.inject != null) injectFragments.push(r.inject);
252
+ if (r.blocked && !blocked) {
253
+ // First blocker in order owns the reason.
254
+ blocked = true;
255
+ reason = r.reason != null ? r.reason : null;
256
+ }
257
+ if (r.stopLoop) stopLoop = true;
258
+ }
259
+
260
+ return {
261
+ injected: injectFragments.join('\n'),
262
+ blocked,
263
+ reason,
264
+ stopLoop,
265
+ results,
266
+ };
267
+ }
268
+ }
269
+
270
+ module.exports = {
271
+ HookEngine,
272
+ DEFAULT_CONCURRENCY,
273
+ // Exported for unit tests / reuse:
274
+ runWithConcurrency,
275
+ };