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.
- package/LICENSE +21 -0
- package/README.md +324 -0
- package/bin/toolfunnel.js +112 -0
- package/docs/admin-ui.md +404 -0
- package/docs/configuration.md +402 -0
- package/docs/hooks-and-gating.md +428 -0
- package/docs/logging.md +223 -0
- package/docs/management.md +429 -0
- package/docs/oauth.md +103 -0
- package/docs/setup.md +352 -0
- package/docs/tools-and-upstreams.md +469 -0
- package/hooks/hooks.manifest.json +27 -0
- package/hooks/scripts/.gitkeep +3 -0
- package/hooks/scripts/example-audit.js +21 -0
- package/hooks/scripts/example-deny-dangerous.js +31 -0
- package/mcp/expose.example.json +30 -0
- package/mcp/servers/mock-upstream/server.js +170 -0
- package/package.json +63 -0
- package/src/auth/config.js +172 -0
- package/src/auth/install.js +181 -0
- package/src/auth/resource-server.js +319 -0
- package/src/core/events.js +105 -0
- package/src/core/hook-engine.js +275 -0
- package/src/core/hook-loader.js +791 -0
- package/src/core/hook-runner.js +346 -0
- package/src/core/logger.js +169 -0
- package/src/core/matcher.js +51 -0
- package/src/core/metrics.js +75 -0
- package/src/core/tool-registry.js +180 -0
- package/src/extend/add-hook.md +142 -0
- package/src/extend/add-mcp.md +121 -0
- package/src/extend/create-tool.md +132 -0
- package/src/extend/howto.js +98 -0
- package/src/extend/package.md +154 -0
- package/src/mcp/aggregator.js +802 -0
- package/src/mcp/expose-store.js +430 -0
- package/src/mcp/gated-run.js +194 -0
- package/src/mcp/http-transport.js +962 -0
- package/src/mcp/mcp-client.js +524 -0
- package/src/mcp/protocol.js +466 -0
- package/src/mcp/server.js +1240 -0
- package/src/packages/packager.js +713 -0
- package/src/tools/drift.js +138 -0
- package/src/tools/registry.js +468 -0
- package/src/tools/tool-state.js +104 -0
- package/src/ui/public/app.js +1055 -0
- package/src/ui/public/index.html +249 -0
- package/src/ui/public/logo.jpg +0 -0
- package/src/ui/public/styles.css +535 -0
- package/src/ui/server.js +1144 -0
- package/tools/scripts/base64.js +139 -0
- package/tools/scripts/danger.js +61 -0
- package/tools/scripts/echo.js +39 -0
- package/tools/scripts/hash.js +101 -0
- package/tools/scripts/json.js +109 -0
- package/tools/scripts/text-stats.js +131 -0
- package/tools/scripts/tf-hook-add.js +152 -0
- package/tools/scripts/tf-hook-set.js +113 -0
- package/tools/scripts/tf-list.js +195 -0
- package/tools/scripts/tf-log.js +83 -0
- package/tools/scripts/tf-mcp-add.js +119 -0
- package/tools/scripts/tf-mcp-set.js +104 -0
- package/tools/scripts/tf-tool-add.js +129 -0
- package/tools/scripts/tf-tool-set.js +148 -0
- package/tools/scripts/uuid.js +102 -0
- 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
|
+
};
|