toolfunnel 0.7.0 → 0.7.1

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