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-runner.js
CHANGED
|
@@ -1,566 +1,615 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* hook-runner.js - run ONE command hook and return a structured result.
|
|
5
|
-
*
|
|
6
|
-
* Contract: the two output protocols and the exact result shape are specified below and pinned by
|
|
7
|
-
* test/gate.test.js + test/hook-runner-bounds.test.js. This module is backend-agnostic and has
|
|
8
|
-
* ZERO host-framework imports so it runs headless under `node --test`.
|
|
9
|
-
*
|
|
10
|
-
* Hard guarantee: runHook NEVER rejects. A spawn failure or a timeout always
|
|
11
|
-
* resolves with the full result object (exitCode:-1, timedOut set appropriately,
|
|
12
|
-
* blocked:false).
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
const { spawn, execFile } = require('node:child_process');
|
|
16
|
-
const reaper = require('./child-reaper');
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
//
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
*
|
|
95
|
-
* @param {
|
|
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
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
child.
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
r.
|
|
415
|
-
|
|
416
|
-
finish(r);
|
|
417
|
-
return;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
//
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
//
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
r
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* hook-runner.js - run ONE command hook and return a structured result.
|
|
5
|
+
*
|
|
6
|
+
* Contract: the two output protocols and the exact result shape are specified below and pinned by
|
|
7
|
+
* test/gate.test.js + test/hook-runner-bounds.test.js. This module is backend-agnostic and has
|
|
8
|
+
* ZERO host-framework imports so it runs headless under `node --test`.
|
|
9
|
+
*
|
|
10
|
+
* Hard guarantee: runHook NEVER rejects. A spawn failure or a timeout always
|
|
11
|
+
* resolves with the full result object (exitCode:-1, timedOut set appropriately,
|
|
12
|
+
* blocked:false).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { spawn, execFile } = require('node:child_process');
|
|
16
|
+
const reaper = require('./child-reaper');
|
|
17
|
+
const { takeWithinCap } = require('./output-cap');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
|
|
20
|
+
// Default HOOKS_DIR: <root>/hooks (the shipped hooks tree). __dirname is <root>/src/core, so up two.
|
|
21
|
+
// The engine always passes the loaded home's hooksDir, so only a direct runHook() call without
|
|
22
|
+
// options sees this default.
|
|
23
|
+
const DEFAULT_HOOKS_DIR = path.resolve(__dirname, '..', '..', 'hooks');
|
|
24
|
+
|
|
25
|
+
// Events whose exit-0 stdout is treated as injected context (the exit-code protocol's injection half).
|
|
26
|
+
const INJECTABLE_EVENTS = new Set(['SessionStart', 'UserPromptSubmit']);
|
|
27
|
+
|
|
28
|
+
// Returned by tryParseJsonProtocol when stdout clearly carried an ATTEMPTED decision (it opens with
|
|
29
|
+
// '{', or a line-opening '{' block after other output names a protocol key) that does not parse. This is DISTINCT from null: null means "no protocol was attempted,
|
|
30
|
+
// use the exit-code protocol", whereas this means "a decision was attempted but could not be read".
|
|
31
|
+
// The two must be handled differently - an unreadable decision fails closed, the same as output
|
|
32
|
+
// past the cap, so a decision that loses a character is never mistaken for "allow".
|
|
33
|
+
const CORRUPT_PROTOCOL = Symbol('corrupt-protocol');
|
|
34
|
+
|
|
35
|
+
// A line-opening '{' block that will not parse counts as an ATTEMPTED decision only when it names a
|
|
36
|
+
// protocol key - otherwise it is a diagnostic dump, not an answer.
|
|
37
|
+
// Keys may be double-quoted (JSON), single-quoted (a printed Python dict) or bare (a Node object printed
|
|
38
|
+
// without JSON.stringify) - all three are a hook TRYING to answer, and an unreadable answer is never allow.
|
|
39
|
+
const PROTOCOL_KEY_RE = /(^|[{,\s])(["']?)(continue|decision|reason|hookSpecificOutput|permissionDecision)\2\s*:/;
|
|
40
|
+
// The prefixed scan is BOUNDED: it inspects at most this many line-opening '{' lines from the tail, and
|
|
41
|
+
// tries the block-to-end parse (a pretty-printed decision) only for the last few of them. Unbounded, a
|
|
42
|
+
// hook dumping tens of thousands of object lines stalled the gateway's event loop for seconds per call.
|
|
43
|
+
const MAX_CANDIDATE_LINES = 64;
|
|
44
|
+
const MAX_BLOCK_CANDIDATES = 4;
|
|
45
|
+
|
|
46
|
+
// Output ceilings. A hook's decision is a small JSON object and its diagnostics are a few lines;
|
|
47
|
+
// anything approaching these is a runaway, and accumulating it unbounded would OOM the gateway
|
|
48
|
+
// itself. Hitting either cap is treated as a WIRING FAILURE and fails closed (see the accumulator
|
|
49
|
+
// and the close handler) - an unreadable decision must never pass for "allow".
|
|
50
|
+
const MAX_STDOUT_BYTES = 1024 * 1024; // 1 MiB
|
|
51
|
+
const MAX_STDERR_BYTES = 256 * 1024; // 256 KiB
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Decide whether a parsed stdout object should be treated as the JSON
|
|
55
|
+
* protocol. We only switch to the JSON interpretation when the object actually
|
|
56
|
+
* carries one of the protocol's known keys - otherwise an arbitrary JSON blob a
|
|
57
|
+
* hook happens to print should fall through to the exit-code protocol ("the
|
|
58
|
+
* exit-code protocol wins if stdout is not valid JSON").
|
|
59
|
+
*
|
|
60
|
+
* @param {*} parsed
|
|
61
|
+
* @returns {boolean}
|
|
62
|
+
*/
|
|
63
|
+
function isKnownJsonProtocol(parsed) {
|
|
64
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
return (
|
|
68
|
+
Object.prototype.hasOwnProperty.call(parsed, 'continue') ||
|
|
69
|
+
Object.prototype.hasOwnProperty.call(parsed, 'decision') ||
|
|
70
|
+
Object.prototype.hasOwnProperty.call(parsed, 'reason') ||
|
|
71
|
+
Object.prototype.hasOwnProperty.call(parsed, 'hookSpecificOutput')
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Try to read stdout as the JSON protocol. Returns the parsed decision object when one is found;
|
|
77
|
+
* CORRUPT_PROTOCOL when a decision was ATTEMPTED but cannot be read; otherwise null (no protocol
|
|
78
|
+
* attempt - use the exit-code protocol).
|
|
79
|
+
*
|
|
80
|
+
* When the whole of stdout is one JSON value, that is the answer (a decision if it names a
|
|
81
|
+
* protocol key, otherwise none). Otherwise - a startup line before the decision, log lines after
|
|
82
|
+
* it - the decision is a block that opens with '{' at the start of a line: the block running to
|
|
83
|
+
* the end of stdout (a pretty-printed decision) or that one line. A block that opens with '{' and
|
|
84
|
+
* parses as something else (an inspect-style dump, a JSON log line) is not a decision. An
|
|
85
|
+
* unparseable block that NAMES a protocol key (quoted or bare) AFTER the last readable decision is
|
|
86
|
+
* an attempted answer -> CORRUPT: the hook's last word could not be read. Unparseable chatter is
|
|
87
|
+
* chatter; without that narrowing an allow-gate that printed `{ tool: 'x' }` denied every call.
|
|
88
|
+
*
|
|
89
|
+
* Two readable decisions that disagree are CORRUPT too: a hook that echoes what it was given (tool
|
|
90
|
+
* arguments, an audit line) prints text the MODEL controls, and that text must never outvote the
|
|
91
|
+
* hook's own answer, before or after it. For the same reason a scan cut short by its bound
|
|
92
|
+
* (MAX_CANDIDATE_LINES, so a hook dumping thousands of object lines cannot stall the event loop)
|
|
93
|
+
* is CORRUPT when anything it did not read names a protocol key - that could be the real answer.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} stdout
|
|
96
|
+
* @returns {object|symbol|null}
|
|
97
|
+
*/
|
|
98
|
+
function tryParseJsonProtocol(stdout) {
|
|
99
|
+
const trimmed = (stdout || '').trim();
|
|
100
|
+
if (!trimmed) return null;
|
|
101
|
+
const asObject = (text) => {
|
|
102
|
+
let v;
|
|
103
|
+
try { v = JSON.parse(text); } catch (_e) { return undefined; } // undefined = did not parse
|
|
104
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v) ? v : null; // null = parsed, not an object
|
|
105
|
+
};
|
|
106
|
+
if (trimmed[0] === '{') {
|
|
107
|
+
const whole = asObject(trimmed);
|
|
108
|
+
if (whole !== undefined) return whole && isKnownJsonProtocol(whole) ? whole : null;
|
|
109
|
+
}
|
|
110
|
+
const lines = trimmed.split('\n');
|
|
111
|
+
const decisions = [];
|
|
112
|
+
let attempted = false;
|
|
113
|
+
let seen = 0;
|
|
114
|
+
let blocks = 0;
|
|
115
|
+
let i = lines.length - 1;
|
|
116
|
+
for (; i >= 0 && seen < MAX_CANDIDATE_LINES; i -= 1) {
|
|
117
|
+
if (lines[i][0] !== '{') continue;
|
|
118
|
+
seen += 1;
|
|
119
|
+
const candidates = blocks < MAX_BLOCK_CANDIDATES ? [lines.slice(i).join('\n'), lines[i]] : [lines[i]];
|
|
120
|
+
if (blocks < MAX_BLOCK_CANDIDATES) blocks += 1;
|
|
121
|
+
let readable = false;
|
|
122
|
+
for (const candidate of candidates) {
|
|
123
|
+
const obj = asObject(candidate);
|
|
124
|
+
if (obj === undefined) continue;
|
|
125
|
+
readable = true;
|
|
126
|
+
if (obj && isKnownJsonProtocol(obj)) decisions.push(obj);
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
// Scanned from the tail: before any decision is found, an unreadable answer is the LAST word.
|
|
130
|
+
if (!readable && decisions.length === 0 && PROTOCOL_KEY_RE.test(candidates[0])) attempted = true;
|
|
131
|
+
}
|
|
132
|
+
// Cut short by the bound: an unread line that names a protocol key could be the hook's answer.
|
|
133
|
+
if (attempted || lines.slice(0, i + 1).some((l) => PROTOCOL_KEY_RE.test(l))) return CORRUPT_PROTOCOL;
|
|
134
|
+
if (decisions.length === 0) return null;
|
|
135
|
+
const first = JSON.stringify(decisions[0]);
|
|
136
|
+
return decisions.every((d) => JSON.stringify(d) === first) ? decisions[0] : CORRUPT_PROTOCOL;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Run a single command hook.
|
|
141
|
+
*
|
|
142
|
+
* @param {object} hookSpec Resolved spec: { id, event, command, timeout(seconds), ... }.
|
|
143
|
+
* @param {object} payload The stdin JSON object (built by events.js buildPayload).
|
|
144
|
+
* @param {object} [opts]
|
|
145
|
+
* @param {string} [opts.cwd] child working directory.
|
|
146
|
+
* @param {object} [opts.env] extra env merged over process.env.
|
|
147
|
+
* @param {string} [opts.projectDir] value for CLAUDE_PROJECT_DIR.
|
|
148
|
+
* @param {string} [opts.hooksDir] value for HOOKS_DIR (default <root>/hooks).
|
|
149
|
+
* @param {AbortSignal} [opts.signal] external cancellation; aborts the child.
|
|
150
|
+
* @returns {Promise<object>} the full result shape. Never rejects.
|
|
151
|
+
*/
|
|
152
|
+
function runHook(hookSpec, payload, opts = {}) {
|
|
153
|
+
return new Promise((resolve) => {
|
|
154
|
+
const started = Date.now();
|
|
155
|
+
|
|
156
|
+
const id = hookSpec && hookSpec.id != null ? hookSpec.id : null;
|
|
157
|
+
const event = hookSpec && hookSpec.event != null ? hookSpec.event : null;
|
|
158
|
+
|
|
159
|
+
// Build the immutable base of every result so any early exit is well-formed.
|
|
160
|
+
const baseResult = () => ({
|
|
161
|
+
id,
|
|
162
|
+
event,
|
|
163
|
+
exitCode: -1,
|
|
164
|
+
timedOut: false,
|
|
165
|
+
stdout: '',
|
|
166
|
+
stderr: '',
|
|
167
|
+
blocked: false,
|
|
168
|
+
stopLoop: false,
|
|
169
|
+
reason: null,
|
|
170
|
+
inject: null,
|
|
171
|
+
durationMs: 0,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Guard: a malformed spec must not throw out of the runner (the never-reject guarantee).
|
|
175
|
+
if (!hookSpec || typeof hookSpec.command !== 'string' || hookSpec.command.length === 0) {
|
|
176
|
+
const r = baseResult();
|
|
177
|
+
r.stderr = 'hook-runner: invalid hookSpec (missing command)';
|
|
178
|
+
r.durationMs = Date.now() - started;
|
|
179
|
+
resolve(r);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// timeout is stored in SECONDS in the manifest -> convert to ms.
|
|
184
|
+
// Fall back to a sane default if absent or non-positive.
|
|
185
|
+
const timeoutSec =
|
|
186
|
+
typeof hookSpec.timeout === 'number' && hookSpec.timeout > 0 ? hookSpec.timeout : 60;
|
|
187
|
+
const timeoutMs = timeoutSec * 1000;
|
|
188
|
+
|
|
189
|
+
// Child env: inherit process.env, layer opts.env, then set the two paths the
|
|
190
|
+
// hook scripts rely on to resolve themselves.
|
|
191
|
+
const hooksDir = opts.hooksDir || (opts.env && opts.env.HOOKS_DIR) || DEFAULT_HOOKS_DIR;
|
|
192
|
+
const projectDir =
|
|
193
|
+
opts.projectDir ||
|
|
194
|
+
(opts.env && opts.env.CLAUDE_PROJECT_DIR) ||
|
|
195
|
+
process.env.CLAUDE_PROJECT_DIR ||
|
|
196
|
+
// The project root is the tree the hooks dir sits in (<root>/hooks -> <root>; a config home's
|
|
197
|
+
// <home>/hooks -> <home>).
|
|
198
|
+
path.dirname(hooksDir);
|
|
199
|
+
|
|
200
|
+
const childEnv = Object.assign({}, process.env, opts.env || {}, {
|
|
201
|
+
CLAUDE_PROJECT_DIR: projectDir,
|
|
202
|
+
HOOKS_DIR: hooksDir,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Own AbortController so we can enforce the timeout via kill, and chain any
|
|
206
|
+
// externally-supplied signal so the engine can cancel us too. The abort path RESOLVES
|
|
207
|
+
// immediately, exactly like the timeout: it must not depend on 'close', which a surviving
|
|
208
|
+
// grandchild holding inherited pipes can withhold forever (the same reasoning that moved
|
|
209
|
+
// the timeout onto its timer).
|
|
210
|
+
const onExternalAbort = () => {
|
|
211
|
+
timedOut = true;
|
|
212
|
+
killChildTree();
|
|
213
|
+
const r = baseResult();
|
|
214
|
+
r.stdout = stdout;
|
|
215
|
+
r.stderr = stderr || 'hook-runner: aborted by caller signal';
|
|
216
|
+
r.timedOut = true; // exitCode -1, blocked false - the caller cancelled, same class as a timeout
|
|
217
|
+
finish(r);
|
|
218
|
+
};
|
|
219
|
+
const controller = new AbortController();
|
|
220
|
+
if (opts.signal) {
|
|
221
|
+
if (opts.signal.aborted) controller.abort();
|
|
222
|
+
else opts.signal.addEventListener('abort', onExternalAbort, { once: true });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
let child;
|
|
226
|
+
try {
|
|
227
|
+
child = spawn(hookSpec.command, {
|
|
228
|
+
shell: true, // run as a shell command line (bash/PowerShell-launched scripts)
|
|
229
|
+
cwd: opts.cwd || undefined,
|
|
230
|
+
env: childEnv,
|
|
231
|
+
signal: controller.signal,
|
|
232
|
+
windowsHide: true,
|
|
233
|
+
// POSIX: give the shell its OWN PROCESS GROUP so killChildTree can tear down the
|
|
234
|
+
// grandchildren too (see the comment there - killing the shell alone orphans them and
|
|
235
|
+
// they hold the stdio pipes open forever). Windows has no process groups in this sense
|
|
236
|
+
// and uses taskkill /T instead; `detached` there only suppresses a console window, which
|
|
237
|
+
// windowsHide already covers, so it stays off to keep Windows behaviour byte-identical.
|
|
238
|
+
detached: process.platform !== 'win32',
|
|
239
|
+
});
|
|
240
|
+
// Last-resort sweep: if the gateway dies without running killChildTree (crash, signal),
|
|
241
|
+
// the reaper's exit handler kills whatever is still tracked - detached children have left
|
|
242
|
+
// our session and would otherwise run to their natural end unsupervised.
|
|
243
|
+
reaper.track(child);
|
|
244
|
+
} catch (err) {
|
|
245
|
+
// Synchronous spawn failure (rare). Resolve, never throw.
|
|
246
|
+
cleanupSignal();
|
|
247
|
+
const r = baseResult();
|
|
248
|
+
r.stderr = `hook-runner: spawn failed: ${err && err.message ? err.message : String(err)}`;
|
|
249
|
+
r.durationMs = Date.now() - started;
|
|
250
|
+
resolve(r);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
let stdout = '';
|
|
255
|
+
let stderr = '';
|
|
256
|
+
let timedOut = false;
|
|
257
|
+
let settled = false;
|
|
258
|
+
let outputCapped = null; // 'stdout' | 'stderr' once a cap was hit (fail-closed trigger)
|
|
259
|
+
|
|
260
|
+
// Kill the WHOLE child tree. Killing the shell (cmd.exe / sh) does NOT kill its node/bash
|
|
261
|
+
// grandchildren - they orphan and hold the stdio pipes open, which prevents the parent
|
|
262
|
+
// process (and node:test) from ever exiting. Both platforms need a TREE kill, by their own
|
|
263
|
+
// mechanism: Windows has taskkill /T; POSIX has process groups, which is why the spawn above
|
|
264
|
+
// sets `detached` there - the shell is a group leader, so kill(-pid) reaches every descendant.
|
|
265
|
+
// (Before 0.7.0 the POSIX branch sent SIGKILL to the shell alone: the bug was diagnosed in
|
|
266
|
+
// this comment and fixed for Windows only.)
|
|
267
|
+
function killChildTree() {
|
|
268
|
+
try {
|
|
269
|
+
if (child && child.pid != null && process.platform === 'win32') {
|
|
270
|
+
execFile('taskkill', ['/pid', String(child.pid), '/T', '/F'], () => {});
|
|
271
|
+
} else if (child && child.pid != null) {
|
|
272
|
+
// Negative pid = the whole process group. ESRCH simply means it is already gone.
|
|
273
|
+
try {
|
|
274
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
275
|
+
} catch (_eg) {
|
|
276
|
+
child.kill('SIGKILL'); // group gone or never formed - fall back to the direct child
|
|
277
|
+
}
|
|
278
|
+
} else if (child) {
|
|
279
|
+
child.kill('SIGKILL');
|
|
280
|
+
}
|
|
281
|
+
} catch (_e) {
|
|
282
|
+
try {
|
|
283
|
+
if (child) child.kill();
|
|
284
|
+
} catch (_e2) {
|
|
285
|
+
/* nothing more we can do */
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
try {
|
|
289
|
+
controller.abort();
|
|
290
|
+
} catch (_e) {
|
|
291
|
+
/* abort backstop */
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Hard timeout: kill the child tree AND resolve immediately - never wait for 'close'.
|
|
296
|
+
//
|
|
297
|
+
// 'close' fires only when every stdio pipe is closed, and a GRANDCHILD that inherited those
|
|
298
|
+
// pipes keeps them open for its own lifetime even after the shell is dead. Waiting for 'close'
|
|
299
|
+
// would make the timeout unenforceable in exactly the case it exists for: a hook whose
|
|
300
|
+
// grandchild sleeps 60 s resolves at 60 s against a 2.5 s timeout, on Windows, even WITH
|
|
301
|
+
// taskkill /T. The tree kill still matters (it stops us leaking processes), but
|
|
302
|
+
// the timeout contract must not depend on it succeeding on any platform.
|
|
303
|
+
const timer = setTimeout(() => {
|
|
304
|
+
timedOut = true;
|
|
305
|
+
killChildTree();
|
|
306
|
+
const r = baseResult();
|
|
307
|
+
r.stdout = stdout;
|
|
308
|
+
r.stderr = stderr || `hook-runner: timed out after ${timeoutMs}ms`;
|
|
309
|
+
r.timedOut = true; // exitCode -1, blocked false (the timeout contract)
|
|
310
|
+
finish(r);
|
|
311
|
+
}, timeoutMs);
|
|
312
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
313
|
+
|
|
314
|
+
function cleanupSignal() {
|
|
315
|
+
if (opts.signal) opts.signal.removeEventListener('abort', onExternalAbort);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** A capped hook resolves NOW (same reasoning as the timeout: 'close' may never come) and
|
|
319
|
+
* FAILS CLOSED - a decision we stopped reading is not a decision we may treat as allow. */
|
|
320
|
+
function finishCapped() {
|
|
321
|
+
const r = baseResult();
|
|
322
|
+
r.stdout = stdout;
|
|
323
|
+
r.stderr = stderr;
|
|
324
|
+
r.blocked = true;
|
|
325
|
+
r.reason = `hook-runner: ${event || 'hook'}${id ? ` "${id}"` : ''} exceeded the `
|
|
326
|
+
+ `${outputCapped} output cap (`
|
|
327
|
+
+ `${outputCapped === 'stdout' ? MAX_STDOUT_BYTES : MAX_STDERR_BYTES} bytes); `
|
|
328
|
+
+ 'its decision could not be read, so the call is denied (fail closed).';
|
|
329
|
+
finish(r);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function finish(result) {
|
|
333
|
+
if (settled) return;
|
|
334
|
+
settled = true;
|
|
335
|
+
clearTimeout(timer);
|
|
336
|
+
cleanupSignal();
|
|
337
|
+
// Release our ends of the pipes AND unref the child handle. The pipes alone are not
|
|
338
|
+
// enough: the ChildProcess handle itself keeps the event loop referenced until the child
|
|
339
|
+
// exits, so a tree that survives killChildTree (measured: a 30 s grandchild) pinned the
|
|
340
|
+
// process to its natural death even after we had answered. unref() lets the loop drain;
|
|
341
|
+
// the 'close'/'error' handlers stay attached and no-op behind the settled guard.
|
|
342
|
+
for (const s of [child && child.stdout, child && child.stderr, child && child.stdin]) {
|
|
343
|
+
try { if (s && typeof s.destroy === 'function') s.destroy(); } catch (_e) { /* ignore */ }
|
|
344
|
+
}
|
|
345
|
+
try { if (child && typeof child.unref === 'function') child.unref(); } catch (_e) { /* ignore */ }
|
|
346
|
+
reaper.untrack(child);
|
|
347
|
+
result.durationMs = Date.now() - started;
|
|
348
|
+
resolve(result);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// BOUNDED accumulation. An unbounded `stdout += chunk` turns a chatty or runaway hook into an
|
|
352
|
+
// out-of-memory kill of the GATEWAY - a far worse failure than any hook outcome, and in the
|
|
353
|
+
// one module whose job is running untrusted external processes. Past the cap we stop
|
|
354
|
+
// accumulating, kill the tree, and (below) FAIL CLOSED: a decision we could not read in full
|
|
355
|
+
// must never be interpreted as "allow" just because the exit code was 0.
|
|
356
|
+
// Caps are measured in REAL bytes (Buffer.byteLength), not String.length - the latter counts
|
|
357
|
+
// UTF-16 code units, which let non-ASCII output run ~3x past a nominal "byte" ceiling.
|
|
358
|
+
let hrOutBytes = 0;
|
|
359
|
+
let hrErrBytes = 0;
|
|
360
|
+
if (child.stdout) {
|
|
361
|
+
child.stdout.setEncoding('utf8');
|
|
362
|
+
child.stdout.on('data', (chunk) => {
|
|
363
|
+
if (hrOutBytes >= MAX_STDOUT_BYTES) return;
|
|
364
|
+
// Streams deliver whole chunks (64 KB on Windows), so the chunk that crosses the cap is cut to the bytes
|
|
365
|
+
// left, at a whole character: never past the cap, never half a character or half a surrogate pair.
|
|
366
|
+
const kept = takeWithinCap(chunk, hrOutBytes, MAX_STDOUT_BYTES);
|
|
367
|
+
stdout += kept;
|
|
368
|
+
hrOutBytes = kept.length < chunk.length ? MAX_STDOUT_BYTES : hrOutBytes + Buffer.byteLength(kept, 'utf8');
|
|
369
|
+
if (hrOutBytes >= MAX_STDOUT_BYTES) {
|
|
370
|
+
outputCapped = 'stdout';
|
|
371
|
+
killChildTree();
|
|
372
|
+
finishCapped();
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
if (child.stderr) {
|
|
377
|
+
child.stderr.setEncoding('utf8');
|
|
378
|
+
child.stderr.on('data', (chunk) => {
|
|
379
|
+
if (hrErrBytes >= MAX_STDERR_BYTES) return;
|
|
380
|
+
const kept = takeWithinCap(chunk, hrErrBytes, MAX_STDERR_BYTES);
|
|
381
|
+
stderr += kept;
|
|
382
|
+
hrErrBytes = kept.length < chunk.length ? MAX_STDERR_BYTES : hrErrBytes + Buffer.byteLength(kept, 'utf8');
|
|
383
|
+
if (hrErrBytes >= MAX_STDERR_BYTES) {
|
|
384
|
+
stderr += '\n[hook-runner: stderr truncated at cap]';
|
|
385
|
+
if (!outputCapped) outputCapped = 'stderr';
|
|
386
|
+
killChildTree();
|
|
387
|
+
finishCapped();
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Write the payload to the child's stdin, then close it. Guard against EPIPE
|
|
393
|
+
// (child may exit before reading) - that must not throw the runner.
|
|
394
|
+
if (child.stdin) {
|
|
395
|
+
child.stdin.on('error', () => {
|
|
396
|
+
/* swallow EPIPE / write-after-end; the exit code is what matters */
|
|
397
|
+
});
|
|
398
|
+
try {
|
|
399
|
+
child.stdin.write(JSON.stringify(payload == null ? {} : payload));
|
|
400
|
+
child.stdin.end();
|
|
401
|
+
} catch (_e) {
|
|
402
|
+
// ignore - stdin write race; child outcome still resolves below
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Spawn / runtime error (ENOENT, abort, etc.). If it's the timeout abort we
|
|
407
|
+
// report timedOut; otherwise a non-blocking error with exitCode -1.
|
|
408
|
+
child.on('error', (err) => {
|
|
409
|
+
const r = baseResult();
|
|
410
|
+
r.stdout = stdout;
|
|
411
|
+
r.stderr = stderr || (err && err.message ? err.message : String(err));
|
|
412
|
+
if (outputCapped) {
|
|
413
|
+
r.blocked = true;
|
|
414
|
+
r.reason = `hook-runner: ${event || 'hook'}${id ? ` "${id}"` : ''} exceeded the `
|
|
415
|
+
+ `${outputCapped} output cap; its decision could not be read, so the call is denied (fail closed).`;
|
|
416
|
+
finish(r);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
if (timedOut) {
|
|
420
|
+
r.timedOut = true;
|
|
421
|
+
r.stderr = stderr || `hook-runner: timed out after ${timeoutMs}ms`;
|
|
422
|
+
}
|
|
423
|
+
// blocked stays false; exitCode stays -1 (the error contract).
|
|
424
|
+
finish(r);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
child.on('close', (code, sig) => {
|
|
428
|
+
// Output cap hit: the decision is UNREADABLE (we stopped reading mid-stream), so the only
|
|
429
|
+
// safe classification is a block flagged as a wiring failure. Checked BEFORE the timeout
|
|
430
|
+
// branch because the cap also kills the tree, which can set timedOut on the way down.
|
|
431
|
+
if (outputCapped) {
|
|
432
|
+
const r = baseResult();
|
|
433
|
+
r.stdout = stdout;
|
|
434
|
+
r.stderr = stderr;
|
|
435
|
+
r.blocked = true;
|
|
436
|
+
r.reason = `hook-runner: ${event || 'hook'}${id ? ` "${id}"` : ''} exceeded the `
|
|
437
|
+
+ `${outputCapped} output cap (${outputCapped === 'stdout' ? MAX_STDOUT_BYTES : MAX_STDERR_BYTES} bytes); `
|
|
438
|
+
+ 'its decision could not be read, so the call is denied (fail closed).';
|
|
439
|
+
finish(r);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// If we aborted for the timeout, classify as timeout regardless of code.
|
|
444
|
+
if (timedOut) {
|
|
445
|
+
const r = baseResult();
|
|
446
|
+
r.stdout = stdout;
|
|
447
|
+
r.stderr = stderr || `hook-runner: timed out after ${timeoutMs}ms`;
|
|
448
|
+
r.timedOut = true; // exitCode -1, blocked false (the timeout contract)
|
|
449
|
+
finish(r);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const r = baseResult();
|
|
454
|
+
r.stdout = stdout;
|
|
455
|
+
r.stderr = stderr;
|
|
456
|
+
// On a clean close, code is a number; if killed by signal, code is null.
|
|
457
|
+
r.exitCode = typeof code === 'number' ? code : -1;
|
|
458
|
+
|
|
459
|
+
// --- Protocol interpretation ---
|
|
460
|
+
// Exit 2 is ALWAYS stderr-blocking, and stdout/JSON is ignored on any
|
|
461
|
+
// non-zero exit - this matches real Claude Code (the JSON protocol is
|
|
462
|
+
// processed ONLY on a clean exit 0; on exit 2 the reason is stderr).
|
|
463
|
+
if (r.exitCode === 2) {
|
|
464
|
+
r.blocked = true;
|
|
465
|
+
r.reason = stderr != null && stderr.trim().length > 0 ? stderr.trim() : '';
|
|
466
|
+
finish(r);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// B) Advanced / JSON protocol - honored only on exit 0.
|
|
471
|
+
const json = r.exitCode === 0 ? tryParseJsonProtocol(stdout) : null;
|
|
472
|
+
|
|
473
|
+
// An ATTEMPTED decision that FAILED TO PARSE (see tryParseJsonProtocol for what counts as an
|
|
474
|
+
// attempt) is an unreadable decision. It fails closed - denied - the same as output past the
|
|
475
|
+
// cap: the runner saw an attempted answer it could not read, and an unreadable answer is never
|
|
476
|
+
// "allow". Only stdout that made no protocol attempt (plain text, valid JSON that is not a
|
|
477
|
+
// decision) falls through to the exit-code protocol below.
|
|
478
|
+
if (json === CORRUPT_PROTOCOL) {
|
|
479
|
+
r.blocked = true;
|
|
480
|
+
r.reason = `hook-runner: ${event || 'hook'}${id ? ` "${id}"` : ''} emitted a protocol `
|
|
481
|
+
+ 'message that could not be parsed; its decision could not be read, so the call is '
|
|
482
|
+
+ 'denied (fail closed).';
|
|
483
|
+
finish(r);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (json) {
|
|
488
|
+
const hso =
|
|
489
|
+
json.hookSpecificOutput && typeof json.hookSpecificOutput === 'object'
|
|
490
|
+
? json.hookSpecificOutput
|
|
491
|
+
: null;
|
|
492
|
+
|
|
493
|
+
// The decision VOCABULARY is exact and case-sensitive: permissionDecision is
|
|
494
|
+
// allow|deny|ask, top-level decision is block. A decision SLOT carrying any other
|
|
495
|
+
// value - miscased "DENY", a synonym like "denied", the right word in the wrong
|
|
496
|
+
// slot, a non-string - is an ATTEMPTED decision the runner cannot read, and an
|
|
497
|
+
// unreadable decision is never "allow" (the same rule as CORRUPT_PROTOCOL and the
|
|
498
|
+
// output caps). A key marks an attempt ONLY when it carries a value: `null` is the
|
|
499
|
+
// readable spelling of "no value" - what typed-struct and dataclass serialisers
|
|
500
|
+
// emit for an unset optional field - so a null slot is no decision and falls
|
|
501
|
+
// through exactly as the absent key does.
|
|
502
|
+
const permAttempted =
|
|
503
|
+
hso !== null &&
|
|
504
|
+
Object.prototype.hasOwnProperty.call(hso, 'permissionDecision') &&
|
|
505
|
+
hso.permissionDecision !== null;
|
|
506
|
+
const decisionAttempted =
|
|
507
|
+
Object.prototype.hasOwnProperty.call(json, 'decision') && json.decision !== null;
|
|
508
|
+
const permUnknown =
|
|
509
|
+
permAttempted &&
|
|
510
|
+
hso.permissionDecision !== 'allow' &&
|
|
511
|
+
hso.permissionDecision !== 'deny' &&
|
|
512
|
+
hso.permissionDecision !== 'ask';
|
|
513
|
+
const decisionUnknown = decisionAttempted && json.decision !== 'block';
|
|
514
|
+
// Same rule for the OTHER protocol slots: `continue` is boolean and
|
|
515
|
+
// `hookSpecificOutput` is an object - a present NON-NULL key with any other type
|
|
516
|
+
// is an attempted instruction that cannot be read. The hso check tests the RAW
|
|
517
|
+
// value: `hso` coerces both null and a wrong type (a bare string, a number) to
|
|
518
|
+
// null, and only the wrong types are unreadable attempts.
|
|
519
|
+
const contUnknown =
|
|
520
|
+
Object.prototype.hasOwnProperty.call(json, 'continue') &&
|
|
521
|
+
json.continue !== null &&
|
|
522
|
+
typeof json.continue !== 'boolean';
|
|
523
|
+
const hsoUnknown =
|
|
524
|
+
Object.prototype.hasOwnProperty.call(json, 'hookSpecificOutput') &&
|
|
525
|
+
json.hookSpecificOutput !== null &&
|
|
526
|
+
(hso === null || Array.isArray(hso));
|
|
527
|
+
if (permUnknown || decisionUnknown || contUnknown || hsoUnknown) {
|
|
528
|
+
const slot = permUnknown
|
|
529
|
+
? 'hookSpecificOutput.permissionDecision'
|
|
530
|
+
: decisionUnknown
|
|
531
|
+
? 'decision'
|
|
532
|
+
: contUnknown
|
|
533
|
+
? 'continue'
|
|
534
|
+
: 'hookSpecificOutput';
|
|
535
|
+
const got = JSON.stringify(
|
|
536
|
+
permUnknown
|
|
537
|
+
? hso.permissionDecision
|
|
538
|
+
: decisionUnknown
|
|
539
|
+
? json.decision
|
|
540
|
+
: contUnknown
|
|
541
|
+
? json.continue
|
|
542
|
+
: json.hookSpecificOutput
|
|
543
|
+
);
|
|
544
|
+
const known = permUnknown
|
|
545
|
+
? '"allow", "deny", "ask"'
|
|
546
|
+
: decisionUnknown
|
|
547
|
+
? '"block"'
|
|
548
|
+
: contUnknown
|
|
549
|
+
? 'true, false'
|
|
550
|
+
: 'an object';
|
|
551
|
+
r.blocked = true;
|
|
552
|
+
r.reason = `hook-runner: ${event || 'hook'}${id ? ` "${id}"` : ''} emitted ${slot}: `
|
|
553
|
+
+ `${got}, which is not in the protocol vocabulary (${known}); its decision could `
|
|
554
|
+
+ 'not be read, so the call is denied (fail closed).';
|
|
555
|
+
finish(r);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// PreToolUse blocks via hookSpecificOutput.permissionDecision
|
|
560
|
+
// (allow|deny|ask), NOT top-level decision - this is the real Claude Code
|
|
561
|
+
// mechanism. "deny" blocks (reason = permissionDecisionReason). "allow"
|
|
562
|
+
// passes. "ask" has no interactive prompt in the autonomous host, so it
|
|
563
|
+
// is treated as non-blocking with the reason captured for context.
|
|
564
|
+
const permDecision =
|
|
565
|
+
hso && typeof hso.permissionDecision === 'string' ? hso.permissionDecision : null;
|
|
566
|
+
const permReason =
|
|
567
|
+
hso && typeof hso.permissionDecisionReason === 'string'
|
|
568
|
+
? hso.permissionDecisionReason
|
|
569
|
+
: null;
|
|
570
|
+
|
|
571
|
+
// Top-level decision: real Claude Code defines only "block" here (for
|
|
572
|
+
// UserPromptSubmit / PostToolUse / Stop / PreCompact). There is no "approve".
|
|
573
|
+
const decision = json.decision;
|
|
574
|
+
const cont = json.continue;
|
|
575
|
+
const jsonReason = typeof json.reason === 'string' ? json.reason : null;
|
|
576
|
+
const additional =
|
|
577
|
+
hso && typeof hso.additionalContext === 'string' ? hso.additionalContext : null;
|
|
578
|
+
|
|
579
|
+
if (permDecision === 'deny') {
|
|
580
|
+
r.blocked = true;
|
|
581
|
+
r.reason = permReason != null ? permReason : jsonReason;
|
|
582
|
+
if (r.reason == null) r.reason = '';
|
|
583
|
+
} else if (decision === 'block') {
|
|
584
|
+
r.blocked = true;
|
|
585
|
+
r.reason = jsonReason; // block reason from JSON.reason
|
|
586
|
+
}
|
|
587
|
+
if (cont === false) {
|
|
588
|
+
r.stopLoop = true; // continue:false -> stop the whole loop
|
|
589
|
+
}
|
|
590
|
+
// additionalContext is the injected text when present.
|
|
591
|
+
if (additional !== null) {
|
|
592
|
+
r.inject = additional;
|
|
593
|
+
}
|
|
594
|
+
finish(r);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// A) Simple / exit-code protocol, exit 0.
|
|
599
|
+
if (r.exitCode === 0) {
|
|
600
|
+
// Success. For SessionStart / UserPromptSubmit, stdout IS the injection.
|
|
601
|
+
// For other events stdout is advisory only (captured, not injected).
|
|
602
|
+
if (INJECTABLE_EVENTS.has(event)) {
|
|
603
|
+
const out = stdout != null ? stdout.trim() : '';
|
|
604
|
+
r.inject = out.length > 0 ? out : null;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
// Any other non-zero (non-2) code -> non-blocking error: stderr captured,
|
|
608
|
+
// blocked stays false, reason stays null.
|
|
609
|
+
|
|
610
|
+
finish(r);
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
module.exports = { runHook };
|