toolfunnel 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +235 -167
- package/SECURITY.md +84 -0
- package/bin/toolfunnel.js +451 -147
- package/docs/KNOWN_BUGS.md +80 -33
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +84 -0
- package/docs/packaging.md +115 -115
- package/hooks/hooks.manifest.json +27 -27
- package/hooks/scripts/example-audit.js +21 -21
- package/hooks/scripts/example-deny-dangerous.js +39 -31
- package/mcp/expose.example.json +30 -30
- package/mcp/servers/mock-modern/server.js +147 -0
- package/mcp/servers/mock-upstream/server.js +222 -12
- package/package.json +67 -65
- package/src/auth/config.js +174 -174
- package/src/auth/install.js +181 -181
- package/src/auth/resource-server.js +353 -344
- package/src/core/child-reaper.js +61 -0
- package/src/core/config-home.js +134 -134
- package/src/core/events.js +105 -105
- package/src/core/hook-engine.js +329 -275
- package/src/core/hook-loader.js +824 -791
- package/src/core/hook-runner.js +566 -346
- package/src/core/logger.js +297 -171
- package/src/core/matcher.js +100 -51
- package/src/core/metrics.js +75 -75
- package/src/core/rename-migration.js +163 -0
- package/src/core/requires.js +138 -138
- package/src/core/server-config.js +98 -77
- package/src/extend/add-hook.md +152 -142
- package/src/extend/add-mcp.md +119 -121
- package/src/extend/configure.md +88 -0
- package/src/extend/create-tool.md +168 -152
- package/src/extend/howto.js +102 -98
- package/src/extend/package.md +190 -187
- package/src/extend/wrap.md +99 -0
- package/src/mcp/aggregator.js +1674 -807
- package/src/mcp/expose-store.js +577 -430
- package/src/mcp/gated-run.js +210 -194
- package/src/mcp/http-transport.js +1563 -1012
- package/src/mcp/mcp-client.js +1070 -530
- package/src/mcp/modern.js +406 -0
- package/src/mcp/protocol.js +468 -466
- package/src/mcp/server.js +3112 -1405
- package/src/tools/registry.js +661 -513
- package/src/tools/tool-state.js +170 -104
- package/src/ui/public/app.js +1187 -1055
- package/src/ui/public/index.html +286 -249
- package/src/ui/public/styles.css +535 -535
- package/src/ui/server.js +1616 -1190
- 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-env.js +43 -43
- package/tools/scripts/tf-hook-add.js +151 -151
- package/tools/scripts/tf-hook-set.js +120 -112
- package/tools/scripts/tf-list.js +192 -192
- package/tools/scripts/tf-log.js +86 -86
- package/tools/scripts/tf-mcp-add.js +117 -117
- package/tools/scripts/tf-mcp-set.js +121 -102
- package/tools/scripts/tf-pack.js +237 -237
- package/tools/scripts/tf-tool-add.js +134 -129
- package/tools/scripts/tf-tool-set.js +142 -142
- package/tools/scripts/tf-wrap.js +103 -0
- package/tools/scripts/uuid.js +102 -102
- package/tools/tools.register.json +14 -3
- package/src/core/tool-registry.js +0 -180
- package/src/packages/packager.js +0 -715
- package/src/tools/drift.js +0 -138
package/src/core/hook-engine.js
CHANGED
|
@@ -1,275 +1,329 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* hook-engine.js
|
|
5
|
-
*
|
|
6
|
-
* Contract:
|
|
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[] //
|
|
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
|
-
* -
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* -
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
*
|
|
94
|
-
* @returns {object}
|
|
95
|
-
*/
|
|
96
|
-
function
|
|
97
|
-
return
|
|
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
|
-
|
|
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
|
-
const
|
|
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
|
-
|
|
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. (0.7.0; matcher-compile.test.js is the acceptance.)
|
|
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
|
+
* Resolve the matcher function from matcher.js regardless of whether it is the default or
|
|
53
|
+
* a named export, so we interlock with the contract's `matches(matcher, toolName)`.
|
|
54
|
+
* @returns {(m: string|undefined, toolName: string) => boolean}
|
|
55
|
+
*/
|
|
56
|
+
function getMatchesFn() {
|
|
57
|
+
if (typeof matcher === 'function') return matcher;
|
|
58
|
+
if (matcher && typeof matcher.matches === 'function') return matcher.matches;
|
|
59
|
+
// Defensive fallback: if matcher.js is unavailable, fire everything (never crash).
|
|
60
|
+
return () => true;
|
|
61
|
+
}
|
|
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
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Resolve buildPayload from events.js (named export per contract).
|
|
75
|
+
* @returns {(event: string, ctx: object, extra: object) => object}
|
|
76
|
+
*/
|
|
77
|
+
function getBuildPayloadFn() {
|
|
78
|
+
if (events && typeof events.buildPayload === 'function') return events.buildPayload;
|
|
79
|
+
// Defensive fallback: assemble a minimal common payload.
|
|
80
|
+
return (event, ctx, extra) =>
|
|
81
|
+
Object.assign(
|
|
82
|
+
{
|
|
83
|
+
session_id: (ctx && ctx.session_id) || '',
|
|
84
|
+
transcript_path: (ctx && ctx.transcript_path) || '',
|
|
85
|
+
cwd: (ctx && ctx.cwd) || process.cwd(),
|
|
86
|
+
hook_event_name: event,
|
|
87
|
+
},
|
|
88
|
+
extra || {}
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Resolve runHook from hook-runner.js (named export per contract).
|
|
94
|
+
* @returns {(spec: object, payload: object, opts: object) => Promise<object>}
|
|
95
|
+
*/
|
|
96
|
+
function getRunHookFn() {
|
|
97
|
+
if (hookRunner && typeof hookRunner.runHook === 'function') return hookRunner.runHook;
|
|
98
|
+
if (typeof hookRunner === 'function') return hookRunner;
|
|
99
|
+
throw new Error('hook-engine: hook-runner.runHook is unavailable');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Build a synthetic "failed" result matching the runner's result shape, used when the runner rejects
|
|
104
|
+
* unexpectedly (it is contractually not supposed to, but we never let the loop throw).
|
|
105
|
+
* @param {object} spec
|
|
106
|
+
* @param {string} event
|
|
107
|
+
* @param {Error} err
|
|
108
|
+
* @param {number} durationMs
|
|
109
|
+
* @returns {object}
|
|
110
|
+
*/
|
|
111
|
+
function syntheticFailure(spec, event, err, durationMs) {
|
|
112
|
+
return {
|
|
113
|
+
id: spec && spec.id,
|
|
114
|
+
event,
|
|
115
|
+
exitCode: -1,
|
|
116
|
+
timedOut: false,
|
|
117
|
+
stdout: '',
|
|
118
|
+
stderr: (err && err.message) || String(err),
|
|
119
|
+
blocked: false,
|
|
120
|
+
stopLoop: false,
|
|
121
|
+
reason: null,
|
|
122
|
+
inject: null,
|
|
123
|
+
durationMs: durationMs || 0,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Run an array of async task factories with a concurrency cap, preserving input order in
|
|
129
|
+
* the returned results array. Each task is a `() => Promise<T>`; the returned slot holds
|
|
130
|
+
* the resolved value (tasks here are written to never reject).
|
|
131
|
+
*
|
|
132
|
+
* @template T
|
|
133
|
+
* @param {Array<() => Promise<T>>} taskFactories
|
|
134
|
+
* @param {number} cap maximum simultaneous tasks
|
|
135
|
+
* @returns {Promise<T[]>} results in the same order as taskFactories
|
|
136
|
+
*/
|
|
137
|
+
async function runWithConcurrency(taskFactories, cap) {
|
|
138
|
+
const total = taskFactories.length;
|
|
139
|
+
const results = new Array(total);
|
|
140
|
+
if (total === 0) return results;
|
|
141
|
+
|
|
142
|
+
const limit = Math.max(1, Math.min(cap || DEFAULT_CONCURRENCY, total));
|
|
143
|
+
let nextIndex = 0;
|
|
144
|
+
|
|
145
|
+
// Each worker pulls the next index until the queue drains.
|
|
146
|
+
async function worker() {
|
|
147
|
+
// eslint-disable-next-line no-constant-condition
|
|
148
|
+
while (true) {
|
|
149
|
+
const i = nextIndex++;
|
|
150
|
+
if (i >= total) return;
|
|
151
|
+
results[i] = await taskFactories[i]();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const workers = [];
|
|
156
|
+
for (let w = 0; w < limit; w++) workers.push(worker());
|
|
157
|
+
await Promise.all(workers);
|
|
158
|
+
return results;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
class HookEngine {
|
|
162
|
+
/**
|
|
163
|
+
* @param {object} loader a HookLoader (or any object exposing enabledHooksFor(event)).
|
|
164
|
+
* @param {object} [options]
|
|
165
|
+
* @param {number} [options.concurrency=8] global concurrency cap per fire().
|
|
166
|
+
* @param {string} [options.cwd] working dir passed to the runner.
|
|
167
|
+
* @param {object} [options.env] base env merged into the runner's child env.
|
|
168
|
+
*/
|
|
169
|
+
constructor(loader, options) {
|
|
170
|
+
if (!loader || typeof loader.enabledHooksFor !== 'function') {
|
|
171
|
+
throw new TypeError('HookEngine: loader must expose enabledHooksFor(event)');
|
|
172
|
+
}
|
|
173
|
+
this.loader = loader;
|
|
174
|
+
const opts = options || {};
|
|
175
|
+
this.concurrency =
|
|
176
|
+
Number.isInteger(opts.concurrency) && opts.concurrency > 0
|
|
177
|
+
? opts.concurrency
|
|
178
|
+
: DEFAULT_CONCURRENCY;
|
|
179
|
+
this.cwd = opts.cwd || (loader.hooksDir ? path.dirname(loader.hooksDir) : process.cwd());
|
|
180
|
+
this.env = opts.env || null;
|
|
181
|
+
|
|
182
|
+
// Resolve contract functions once.
|
|
183
|
+
this._matches = getMatchesFn();
|
|
184
|
+
this._matcherError = getMatcherErrorFn();
|
|
185
|
+
this._buildPayload = getBuildPayloadFn();
|
|
186
|
+
this._runHook = getRunHookFn();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Fire a lifecycle event through its matching, enabled hooks.
|
|
191
|
+
*
|
|
192
|
+
* @param {string} event one of EVENTS (e.g. "PreToolUse")
|
|
193
|
+
* @param {object} [ctx] common context: { session_id, transcript_path, cwd, ... }
|
|
194
|
+
* @param {object} [extra] event-specific fields (tool_name, tool_input, prompt, source, ...)
|
|
195
|
+
* @returns {Promise<{injected:string, blocked:boolean, reason:(string|null),
|
|
196
|
+
* stopLoop:boolean, results:object[]}>}
|
|
197
|
+
*/
|
|
198
|
+
async fire(event, ctx, extra) {
|
|
199
|
+
const context = ctx || {};
|
|
200
|
+
const extraFields = extra || {};
|
|
201
|
+
|
|
202
|
+
// Build the stdin payload once (the contract's frozen field names).
|
|
203
|
+
let payload;
|
|
204
|
+
try {
|
|
205
|
+
payload = this._buildPayload(event, context, extraFields);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
// If payload assembly fails, fall back to a minimal valid payload so hooks still run.
|
|
208
|
+
payload = Object.assign(
|
|
209
|
+
{
|
|
210
|
+
session_id: context.session_id || '',
|
|
211
|
+
transcript_path: context.transcript_path || '',
|
|
212
|
+
cwd: context.cwd || this.cwd,
|
|
213
|
+
hook_event_name: event,
|
|
214
|
+
},
|
|
215
|
+
extraFields
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// The toolName for matcher scoping comes from the payload (tool-bearing events only).
|
|
220
|
+
// For tool-less events this is undefined and matcher.js treats it as always-fire.
|
|
221
|
+
const toolName = payload && payload.tool_name;
|
|
222
|
+
|
|
223
|
+
// Resolve the child cwd/env once for the runner.
|
|
224
|
+
const runCwd = (payload && payload.cwd) || this.cwd;
|
|
225
|
+
const runOpts = { cwd: runCwd };
|
|
226
|
+
if (this.env) runOpts.env = this.env;
|
|
227
|
+
|
|
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;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
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
|
+
}
|
|
293
|
+
|
|
294
|
+
// Run concurrently with the cap; results come back in manifest order.
|
|
295
|
+
const results = await runWithConcurrency(factories, this.concurrency);
|
|
296
|
+
|
|
297
|
+
// Aggregate the per-hook results into the engine outcome.
|
|
298
|
+
const injectFragments = [];
|
|
299
|
+
let blocked = false;
|
|
300
|
+
let reason = null;
|
|
301
|
+
let stopLoop = false;
|
|
302
|
+
|
|
303
|
+
for (const r of results) {
|
|
304
|
+
if (!r) continue;
|
|
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
|
+
};
|