toolfunnel 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +238 -235
- package/SECURITY.md +84 -84
- package/bin/toolfunnel.js +530 -451
- package/docs/KNOWN_BUGS.md +100 -80
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +84 -84
- package/docs/packaging.md +116 -115
- package/hooks/hooks.manifest.json +27 -27
- package/hooks/scripts/example-audit.js +21 -21
- package/hooks/scripts/example-deny-dangerous.js +39 -39
- package/mcp/expose.example.json +30 -30
- package/mcp/servers/mock-modern/server.js +147 -147
- package/mcp/servers/mock-upstream/server.js +379 -380
- package/package.json +67 -67
- package/src/auth/config.js +172 -174
- package/src/auth/install.js +181 -181
- package/src/auth/resource-server.js +358 -353
- package/src/core/atomic-write.js +97 -0
- package/src/core/child-reaper.js +61 -61
- package/src/core/config-home.js +226 -134
- package/src/core/events.js +105 -105
- package/src/core/hook-engine.js +304 -329
- package/src/core/hook-loader.js +766 -824
- package/src/core/hook-runner.js +615 -566
- package/src/core/logger.js +295 -297
- package/src/core/matcher.js +100 -100
- package/src/core/metrics.js +90 -75
- package/src/core/output-cap.js +31 -0
- package/src/core/rename-migration.js +178 -163
- package/src/core/requires.js +138 -138
- package/src/core/server-config.js +98 -98
- package/src/extend/add-hook.md +155 -152
- package/src/extend/add-mcp.md +119 -119
- package/src/extend/configure.md +88 -88
- package/src/extend/create-tool.md +168 -168
- package/src/extend/howto.js +102 -102
- package/src/extend/package.md +190 -190
- package/src/extend/wrap.md +99 -99
- package/src/mcp/aggregator.js +2248 -1674
- package/src/mcp/expose-store.js +755 -577
- package/src/mcp/gated-run.js +210 -210
- package/src/mcp/http-transport.js +1657 -1563
- package/src/mcp/mcp-client.js +1174 -1070
- package/src/mcp/modern.js +409 -406
- package/src/mcp/protocol.js +473 -468
- package/src/mcp/server.js +3317 -3112
- package/src/mcp/tool-approvals.js +396 -0
- package/src/tools/registry.js +607 -661
- package/src/tools/tool-state.js +170 -170
- package/src/ui/public/app.js +1246 -1187
- package/src/ui/public/index.html +286 -286
- package/src/ui/public/styles.css +535 -535
- package/src/ui/server.js +1742 -1616
- package/tools/scripts/base64.js +139 -139
- package/tools/scripts/danger.js +61 -61
- package/tools/scripts/echo.js +39 -39
- package/tools/scripts/hash.js +101 -101
- package/tools/scripts/json.js +109 -109
- package/tools/scripts/text-stats.js +131 -131
- package/tools/scripts/tf-accept-change.js +67 -0
- package/tools/scripts/tf-env.js +43 -43
- package/tools/scripts/tf-hook-add.js +151 -151
- package/tools/scripts/tf-hook-set.js +120 -120
- package/tools/scripts/tf-list.js +192 -192
- package/tools/scripts/tf-log.js +86 -86
- package/tools/scripts/tf-mcp-add.js +130 -117
- package/tools/scripts/tf-mcp-set.js +121 -121
- package/tools/scripts/tf-pack.js +237 -237
- package/tools/scripts/tf-tool-add.js +134 -134
- package/tools/scripts/tf-tool-set.js +142 -142
- package/tools/scripts/tf-wrap.js +103 -103
- package/tools/scripts/uuid.js +102 -102
- package/tools/tools.register.json +16 -5
package/src/core/hook-engine.js
CHANGED
|
@@ -1,329 +1,304 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* hook-engine.js - fire a lifecycle event through its matching, enabled hooks.
|
|
5
|
-
*
|
|
6
|
-
* Contract: fire() never rejects - see the class doc below.
|
|
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[] // runner-result shapes, 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
|
-
* - 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.
|
|
29
|
-
* - Build the stdin payload once via events.buildPayload(event, ctx, extra).
|
|
30
|
-
* - Run selected hooks CONCURRENTLY (they are independent) with a global concurrency cap
|
|
31
|
-
* (default 8) so a big event cannot fork-bomb, but return results in MANIFEST ORDER.
|
|
32
|
-
* - injected = every non-null inject joined by "\n", in order.
|
|
33
|
-
* - blocked/reason = the FIRST blocker in order.
|
|
34
|
-
* - stopLoop = true if ANY hook returned continue:false.
|
|
35
|
-
*
|
|
36
|
-
* The runner never throws (its hard guarantee), and this engine defends against a misbehaving runner too:
|
|
37
|
-
* any rejection is converted into a synthetic error result so fire() never rejects.
|
|
38
|
-
*
|
|
39
|
-
* CommonJS only. Zero host imports (must run headless under `node --test`).
|
|
40
|
-
*/
|
|
41
|
-
|
|
42
|
-
const path = require('node:path');
|
|
43
|
-
|
|
44
|
-
// Sibling core modules (authored to the same contract in this build pass).
|
|
45
|
-
const matcher = require('./matcher');
|
|
46
|
-
const events = require('./events');
|
|
47
|
-
const hookRunner = require('./hook-runner');
|
|
48
|
-
|
|
49
|
-
const DEFAULT_CONCURRENCY = 8;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
function
|
|
57
|
-
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
*
|
|
75
|
-
* @returns {
|
|
76
|
-
*/
|
|
77
|
-
function
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
*
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
this.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
if (r.inject != null) injectFragments.push(r.inject);
|
|
306
|
-
if (r.blocked && !blocked) {
|
|
307
|
-
// First blocker in order owns the reason.
|
|
308
|
-
blocked = true;
|
|
309
|
-
reason = r.reason != null ? r.reason : null;
|
|
310
|
-
}
|
|
311
|
-
if (r.stopLoop) stopLoop = true;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
return {
|
|
315
|
-
injected: injectFragments.join('\n'),
|
|
316
|
-
blocked,
|
|
317
|
-
reason,
|
|
318
|
-
stopLoop,
|
|
319
|
-
results,
|
|
320
|
-
};
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
module.exports = {
|
|
325
|
-
HookEngine,
|
|
326
|
-
DEFAULT_CONCURRENCY,
|
|
327
|
-
// Exported for unit tests / reuse:
|
|
328
|
-
runWithConcurrency,
|
|
329
|
-
};
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* hook-engine.js - fire a lifecycle event through its matching, enabled hooks.
|
|
5
|
+
*
|
|
6
|
+
* Contract: fire() never rejects - see the class doc below.
|
|
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[] // runner-result shapes, 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
|
+
* - 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.
|
|
29
|
+
* - Build the stdin payload once via events.buildPayload(event, ctx, extra).
|
|
30
|
+
* - Run selected hooks CONCURRENTLY (they are independent) with a global concurrency cap
|
|
31
|
+
* (default 8) so a big event cannot fork-bomb, but return results in MANIFEST ORDER.
|
|
32
|
+
* - injected = every non-null inject joined by "\n", in order.
|
|
33
|
+
* - blocked/reason = the FIRST blocker in order.
|
|
34
|
+
* - stopLoop = true if ANY hook returned continue:false.
|
|
35
|
+
*
|
|
36
|
+
* The runner never throws (its hard guarantee), and this engine defends against a misbehaving runner too:
|
|
37
|
+
* any rejection is converted into a synthetic error result so fire() never rejects.
|
|
38
|
+
*
|
|
39
|
+
* CommonJS only. Zero host imports (must run headless under `node --test`).
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
const path = require('node:path');
|
|
43
|
+
|
|
44
|
+
// Sibling core modules (authored to the same contract in this build pass).
|
|
45
|
+
const matcher = require('./matcher');
|
|
46
|
+
const events = require('./events');
|
|
47
|
+
const hookRunner = require('./hook-runner');
|
|
48
|
+
|
|
49
|
+
const DEFAULT_CONCURRENCY = 8;
|
|
50
|
+
|
|
51
|
+
// The engine's dependencies, bound directly, with no stand-ins: firing every hook, reading every
|
|
52
|
+
// matcher as valid or building a payload by hand would change what a gate means, and the one case
|
|
53
|
+
// that reaches them (a broken or mismatched install) is exactly when it must not. Refuse to LOAD
|
|
54
|
+
// instead, naming what is missing
|
|
55
|
+
// (hook-engine-requires.test.js).
|
|
56
|
+
function required(mod, name, from) {
|
|
57
|
+
const fn = mod && mod[name];
|
|
58
|
+
if (typeof fn !== 'function') {
|
|
59
|
+
throw new Error(`hook-engine: ${from} must export ${name}() - refusing to load without it`);
|
|
60
|
+
}
|
|
61
|
+
return fn;
|
|
62
|
+
}
|
|
63
|
+
const matchesFn = required(matcher, 'matches', './matcher');
|
|
64
|
+
const matcherErrorFn = required(matcher, 'matcherError', './matcher');
|
|
65
|
+
const buildPayloadFn = required(events, 'buildPayload', './events');
|
|
66
|
+
const runHookFn = required(hookRunner, 'runHook', './hook-runner');
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Build a synthetic "failed" result matching the runner's result shape, used when the runner rejects
|
|
70
|
+
* unexpectedly (it is contractually not supposed to, but we never let the loop throw).
|
|
71
|
+
* @param {object} spec
|
|
72
|
+
* @param {string} event
|
|
73
|
+
* @param {Error} err
|
|
74
|
+
* @param {number} durationMs
|
|
75
|
+
* @returns {object}
|
|
76
|
+
*/
|
|
77
|
+
function syntheticFailure(spec, event, err, durationMs) {
|
|
78
|
+
return {
|
|
79
|
+
id: spec && spec.id,
|
|
80
|
+
event,
|
|
81
|
+
exitCode: -1,
|
|
82
|
+
timedOut: false,
|
|
83
|
+
stdout: '',
|
|
84
|
+
stderr: (err && err.message) || String(err),
|
|
85
|
+
blocked: false,
|
|
86
|
+
stopLoop: false,
|
|
87
|
+
reason: null,
|
|
88
|
+
inject: null,
|
|
89
|
+
durationMs: durationMs || 0,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Run an array of async task factories with a concurrency cap, preserving input order in
|
|
95
|
+
* the returned results array. Each task is a `() => Promise<T>`; the returned slot holds
|
|
96
|
+
* the resolved value (tasks here are written to never reject).
|
|
97
|
+
*
|
|
98
|
+
* @template T
|
|
99
|
+
* @param {Array<() => Promise<T>>} taskFactories
|
|
100
|
+
* @param {number} cap maximum simultaneous tasks
|
|
101
|
+
* @returns {Promise<T[]>} results in the same order as taskFactories
|
|
102
|
+
*/
|
|
103
|
+
async function runWithConcurrency(taskFactories, cap) {
|
|
104
|
+
const total = taskFactories.length;
|
|
105
|
+
const results = new Array(total);
|
|
106
|
+
if (total === 0) return results;
|
|
107
|
+
|
|
108
|
+
const limit = Math.max(1, Math.min(cap || DEFAULT_CONCURRENCY, total));
|
|
109
|
+
let nextIndex = 0;
|
|
110
|
+
|
|
111
|
+
// Each worker pulls the next index until the queue drains.
|
|
112
|
+
async function worker() {
|
|
113
|
+
// eslint-disable-next-line no-constant-condition
|
|
114
|
+
while (true) {
|
|
115
|
+
const i = nextIndex++;
|
|
116
|
+
if (i >= total) return;
|
|
117
|
+
results[i] = await taskFactories[i]();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const workers = [];
|
|
122
|
+
for (let w = 0; w < limit; w++) workers.push(worker());
|
|
123
|
+
await Promise.all(workers);
|
|
124
|
+
return results;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
class HookEngine {
|
|
128
|
+
/**
|
|
129
|
+
* @param {object} loader a HookLoader (or any object exposing enabledHooksFor(event)).
|
|
130
|
+
* @param {object} [options]
|
|
131
|
+
* @param {number} [options.concurrency=8] global concurrency cap per fire().
|
|
132
|
+
* @param {string} [options.cwd] working dir passed to the runner.
|
|
133
|
+
* @param {object} [options.env] base env merged into the runner's child env.
|
|
134
|
+
*/
|
|
135
|
+
constructor(loader, options) {
|
|
136
|
+
if (!loader || typeof loader.enabledHooksFor !== 'function') {
|
|
137
|
+
throw new TypeError('HookEngine: loader must expose enabledHooksFor(event)');
|
|
138
|
+
}
|
|
139
|
+
this.loader = loader;
|
|
140
|
+
const opts = options || {};
|
|
141
|
+
this.concurrency =
|
|
142
|
+
Number.isInteger(opts.concurrency) && opts.concurrency > 0
|
|
143
|
+
? opts.concurrency
|
|
144
|
+
: DEFAULT_CONCURRENCY;
|
|
145
|
+
this.cwd = opts.cwd || (loader.hooksDir ? path.dirname(loader.hooksDir) : process.cwd());
|
|
146
|
+
this.env = opts.env || null;
|
|
147
|
+
|
|
148
|
+
// Resolve contract functions once.
|
|
149
|
+
this._matches = matchesFn;
|
|
150
|
+
this._matcherError = matcherErrorFn;
|
|
151
|
+
this._buildPayload = buildPayloadFn;
|
|
152
|
+
this._runHook = runHookFn;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Fire a lifecycle event through its matching, enabled hooks.
|
|
157
|
+
*
|
|
158
|
+
* @param {string} event one of EVENTS (e.g. "PreToolUse")
|
|
159
|
+
* @param {object} [ctx] common context: { session_id, transcript_path, cwd, ... }
|
|
160
|
+
* @param {object} [extra] event-specific fields (tool_name, tool_input, prompt, source, ...)
|
|
161
|
+
* @returns {Promise<{injected:string, blocked:boolean, reason:(string|null),
|
|
162
|
+
* stopLoop:boolean, results:object[]}>}
|
|
163
|
+
*/
|
|
164
|
+
async fire(event, ctx, extra) {
|
|
165
|
+
const context = ctx || {};
|
|
166
|
+
const extraFields = extra || {};
|
|
167
|
+
|
|
168
|
+
// Build the stdin payload once (the contract's frozen field names).
|
|
169
|
+
let payload;
|
|
170
|
+
try {
|
|
171
|
+
payload = this._buildPayload(event, context, extraFields);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
// If payload assembly fails, fall back to a minimal valid payload so hooks still run.
|
|
174
|
+
payload = Object.assign(
|
|
175
|
+
{
|
|
176
|
+
session_id: context.session_id || '',
|
|
177
|
+
transcript_path: context.transcript_path || '',
|
|
178
|
+
cwd: context.cwd || this.cwd,
|
|
179
|
+
hook_event_name: event,
|
|
180
|
+
},
|
|
181
|
+
extraFields
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// The toolName for matcher scoping comes from the payload (tool-bearing events only).
|
|
186
|
+
// For tool-less events this is undefined and matcher.js treats it as always-fire.
|
|
187
|
+
const toolName = payload && payload.tool_name;
|
|
188
|
+
|
|
189
|
+
// Resolve the child cwd/env once for the runner.
|
|
190
|
+
const runCwd = (payload && payload.cwd) || this.cwd;
|
|
191
|
+
const runOpts = { cwd: runCwd };
|
|
192
|
+
if (this.env) runOpts.env = this.env;
|
|
193
|
+
// The home the hooks were loaded from, so the runner sets HOOKS_DIR for the child from THIS
|
|
194
|
+
// tree (a relocated --config-dir home) rather than its package default - the same value the
|
|
195
|
+
// engine already uses for the child cwd above. CLAUDE_PROJECT_DIR: a value INHERITED from the
|
|
196
|
+
// gateway's own environment (a host's project - hooks ported from Claude Code read it that way)
|
|
197
|
+
// wins; the home is the fallback when nothing set it.
|
|
198
|
+
if (this.loader.hooksDir) {
|
|
199
|
+
runOpts.hooksDir = this.loader.hooksDir;
|
|
200
|
+
runOpts.projectDir = process.env.CLAUDE_PROJECT_DIR || path.dirname(this.loader.hooksDir);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Select enabled hooks for this event and build task factories, in MANIFEST ORDER.
|
|
204
|
+
//
|
|
205
|
+
// A matcher that does not COMPILE cannot be evaluated, and an unreadable gate is never
|
|
206
|
+
// permission - the same rule the runner applies to an unreadable decision (gate-semantics /
|
|
207
|
+
// matcher-compile tests). On a TOOL-BEARING event such a hook contributes a synthetic
|
|
208
|
+
// BLOCKING result in its manifest position (its command is never run); authoring surfaces
|
|
209
|
+
// refuse the pattern up front (hook-loader.addEntry), so this branch only fires for a
|
|
210
|
+
// hand-edited manifest. Tool-less events ignore the matcher entirely (existing contract).
|
|
211
|
+
const candidates = this.loader.enabledHooksFor(event) || [];
|
|
212
|
+
const factories = [];
|
|
213
|
+
for (const spec of candidates) {
|
|
214
|
+
if (toolName !== undefined && toolName !== null) {
|
|
215
|
+
let compileProblem = null;
|
|
216
|
+
try {
|
|
217
|
+
compileProblem = this._matcherError(spec && spec.matcher);
|
|
218
|
+
} catch (_) {
|
|
219
|
+
compileProblem = null; // discriminator itself failing must not take the engine down
|
|
220
|
+
}
|
|
221
|
+
if (compileProblem) {
|
|
222
|
+
const unreadable = {
|
|
223
|
+
id: spec && spec.id,
|
|
224
|
+
event,
|
|
225
|
+
exitCode: -1,
|
|
226
|
+
timedOut: false,
|
|
227
|
+
stdout: '',
|
|
228
|
+
stderr: '',
|
|
229
|
+
blocked: true,
|
|
230
|
+
stopLoop: false,
|
|
231
|
+
reason:
|
|
232
|
+
`hook-engine: hook "${(spec && spec.id) || '(unnamed)'}" has a matcher that does ` +
|
|
233
|
+
`not compile (${compileProblem}); its gate could not be read, so the call is ` +
|
|
234
|
+
'denied (fail closed). Fix or disable the hook.',
|
|
235
|
+
inject: null,
|
|
236
|
+
durationMs: 0,
|
|
237
|
+
};
|
|
238
|
+
factories.push(async () => unreadable);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let fires = false;
|
|
244
|
+
try {
|
|
245
|
+
fires = this._matches(spec && spec.matcher, toolName);
|
|
246
|
+
} catch (_) {
|
|
247
|
+
// A matcher evaluation crash should not crash the engine; skip that hook.
|
|
248
|
+
fires = false;
|
|
249
|
+
}
|
|
250
|
+
if (!fires) continue;
|
|
251
|
+
|
|
252
|
+
factories.push(async () => {
|
|
253
|
+
const started = Date.now();
|
|
254
|
+
try {
|
|
255
|
+
const res = await this._runHook(spec, payload, runOpts);
|
|
256
|
+
// Defensive: ensure the result carries id/event even if the runner omitted them.
|
|
257
|
+
if (res && typeof res === 'object') {
|
|
258
|
+
if (res.id === undefined) res.id = spec.id;
|
|
259
|
+
if (res.event === undefined) res.event = event;
|
|
260
|
+
return res;
|
|
261
|
+
}
|
|
262
|
+
return syntheticFailure(spec, event, new Error('runner returned no result'), Date.now() - started);
|
|
263
|
+
} catch (err) {
|
|
264
|
+
return syntheticFailure(spec, event, err, Date.now() - started);
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Run concurrently with the cap; results come back in manifest order.
|
|
270
|
+
const results = await runWithConcurrency(factories, this.concurrency);
|
|
271
|
+
|
|
272
|
+
// Aggregate the per-hook results into the engine outcome.
|
|
273
|
+
const injectFragments = [];
|
|
274
|
+
let blocked = false;
|
|
275
|
+
let reason = null;
|
|
276
|
+
let stopLoop = false;
|
|
277
|
+
|
|
278
|
+
for (const r of results) {
|
|
279
|
+
if (!r) continue;
|
|
280
|
+
if (r.inject != null) injectFragments.push(r.inject);
|
|
281
|
+
if (r.blocked && !blocked) {
|
|
282
|
+
// First blocker in order owns the reason.
|
|
283
|
+
blocked = true;
|
|
284
|
+
reason = r.reason != null ? r.reason : null;
|
|
285
|
+
}
|
|
286
|
+
if (r.stopLoop) stopLoop = true;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
injected: injectFragments.join('\n'),
|
|
291
|
+
blocked,
|
|
292
|
+
reason,
|
|
293
|
+
stopLoop,
|
|
294
|
+
results,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
module.exports = {
|
|
300
|
+
HookEngine,
|
|
301
|
+
DEFAULT_CONCURRENCY,
|
|
302
|
+
// Exported for unit tests / reuse:
|
|
303
|
+
runWithConcurrency,
|
|
304
|
+
};
|