toolfunnel 0.4.0 → 0.6.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.
Files changed (78) hide show
  1. package/README.md +102 -213
  2. package/bin/toolfunnel.js +372 -147
  3. package/docs/KNOWN_BUGS.md +99 -225
  4. package/docs/MANUAL.pdf +0 -0
  5. package/docs/design.md +70 -0
  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 +31 -31
  10. package/mcp/expose.example.json +30 -30
  11. package/mcp/servers/mock-modern/server.js +147 -0
  12. package/mcp/servers/mock-upstream/server.js +192 -12
  13. package/package.json +1 -1
  14. package/src/auth/config.js +174 -174
  15. package/src/auth/install.js +181 -181
  16. package/src/auth/resource-server.js +344 -344
  17. package/src/core/config-home.js +134 -134
  18. package/src/core/events.js +105 -105
  19. package/src/core/hook-engine.js +275 -275
  20. package/src/core/hook-loader.js +791 -791
  21. package/src/core/hook-runner.js +346 -346
  22. package/src/core/logger.js +171 -171
  23. package/src/core/matcher.js +51 -51
  24. package/src/core/metrics.js +75 -75
  25. package/src/core/requires.js +138 -138
  26. package/src/core/server-config.js +98 -77
  27. package/src/core/tool-registry.js +180 -180
  28. package/src/extend/add-hook.md +152 -142
  29. package/src/extend/add-mcp.md +122 -121
  30. package/src/extend/configure.md +87 -0
  31. package/src/extend/create-tool.md +168 -152
  32. package/src/extend/howto.js +102 -98
  33. package/src/extend/package.md +187 -187
  34. package/src/extend/wrap.md +99 -0
  35. package/src/mcp/aggregator.js +1246 -807
  36. package/src/mcp/expose-store.js +460 -430
  37. package/src/mcp/gated-run.js +201 -194
  38. package/src/mcp/http-transport.js +1311 -981
  39. package/src/mcp/mcp-client.js +907 -530
  40. package/src/mcp/modern.js +372 -0
  41. package/src/mcp/protocol.js +468 -466
  42. package/src/mcp/server.js +2688 -1322
  43. package/src/packages/packager.js +715 -715
  44. package/src/tools/drift.js +138 -138
  45. package/src/tools/registry.js +513 -511
  46. package/src/tools/tool-state.js +170 -104
  47. package/src/ui/public/app.js +1174 -1055
  48. package/src/ui/public/index.html +286 -249
  49. package/src/ui/public/styles.css +535 -535
  50. package/src/ui/server.js +1348 -1190
  51. package/tools/scripts/base64.js +139 -139
  52. package/tools/scripts/danger.js +61 -61
  53. package/tools/scripts/echo.js +39 -39
  54. package/tools/scripts/hash.js +101 -101
  55. package/tools/scripts/json.js +109 -109
  56. package/tools/scripts/text-stats.js +131 -131
  57. package/tools/scripts/tf-env.js +43 -43
  58. package/tools/scripts/tf-hook-add.js +151 -151
  59. package/tools/scripts/tf-hook-set.js +112 -112
  60. package/tools/scripts/tf-list.js +192 -192
  61. package/tools/scripts/tf-log.js +86 -86
  62. package/tools/scripts/tf-mcp-add.js +117 -117
  63. package/tools/scripts/tf-mcp-set.js +121 -102
  64. package/tools/scripts/tf-pack.js +237 -237
  65. package/tools/scripts/tf-tool-add.js +134 -129
  66. package/tools/scripts/tf-tool-set.js +142 -142
  67. package/tools/scripts/tf-wrap.js +103 -0
  68. package/tools/scripts/uuid.js +102 -102
  69. package/tools/tools.register.json +227 -216
  70. package/docs/admin-ui.md +0 -404
  71. package/docs/configuration.md +0 -402
  72. package/docs/hooks-and-gating.md +0 -428
  73. package/docs/logging.md +0 -223
  74. package/docs/management.md +0 -429
  75. package/docs/oauth.md +0 -103
  76. package/docs/releasing.md +0 -31
  77. package/docs/setup.md +0 -352
  78. package/docs/tools-and-upstreams.md +0 -469
@@ -1,346 +1,346 @@
1
- 'use strict';
2
-
3
- /**
4
- * hook-runner.js — run ONE command hook and return a structured result.
5
- *
6
- * Authoritative contract: docs/HOOK_ENGINE.md §3 (two output protocols) and §4
7
- * (the exact result shape). This module is backend-agnostic and has ZERO host-framework
8
- * imports so it runs headless under `node --test`.
9
- *
10
- * Hard guarantee (§4): 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 path = require('node:path');
17
-
18
- // Default HOOKS_DIR: <root>/src/hooks. __dirname is <root>/src/core, so up one.
19
- const DEFAULT_HOOKS_DIR = path.resolve(__dirname, '..', 'hooks');
20
-
21
- // Events whose exit-0 stdout is treated as injected context (§3-A, §1 table).
22
- const INJECTABLE_EVENTS = new Set(['SessionStart', 'UserPromptSubmit']);
23
-
24
- /**
25
- * Decide whether a parsed stdout object should be treated as the §3-B JSON
26
- * protocol. We only switch to the JSON interpretation when the object actually
27
- * carries one of the protocol's known keys — otherwise an arbitrary JSON blob a
28
- * hook happens to print should fall through to the exit-code protocol (§3:
29
- * "Exit-code protocol wins if stdout is not valid JSON").
30
- *
31
- * @param {*} parsed
32
- * @returns {boolean}
33
- */
34
- function isKnownJsonProtocol(parsed) {
35
- if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
36
- return false;
37
- }
38
- return (
39
- Object.prototype.hasOwnProperty.call(parsed, 'continue') ||
40
- Object.prototype.hasOwnProperty.call(parsed, 'decision') ||
41
- Object.prototype.hasOwnProperty.call(parsed, 'reason') ||
42
- Object.prototype.hasOwnProperty.call(parsed, 'hookSpecificOutput')
43
- );
44
- }
45
-
46
- /**
47
- * Try to parse stdout as the JSON protocol. Returns the parsed object only if it
48
- * is valid JSON AND carries known protocol keys; otherwise null (use exit-code
49
- * protocol).
50
- *
51
- * @param {string} stdout
52
- * @returns {object|null}
53
- */
54
- function tryParseJsonProtocol(stdout) {
55
- const trimmed = (stdout || '').trim();
56
- if (!trimmed) return null;
57
- // Cheap pre-check: protocol output is always a JSON object.
58
- if (trimmed[0] !== '{') return null;
59
- let parsed;
60
- try {
61
- parsed = JSON.parse(trimmed);
62
- } catch (_e) {
63
- return null; // not valid JSON → exit-code protocol wins
64
- }
65
- return isKnownJsonProtocol(parsed) ? parsed : null;
66
- }
67
-
68
- /**
69
- * Run a single command hook.
70
- *
71
- * @param {object} hookSpec Resolved spec: { id, event, command, timeout(seconds), ... }.
72
- * @param {object} payload The stdin JSON object (built by events.js buildPayload).
73
- * @param {object} [opts]
74
- * @param {string} [opts.cwd] child working directory.
75
- * @param {object} [opts.env] extra env merged over process.env.
76
- * @param {string} [opts.projectDir] value for CLAUDE_PROJECT_DIR.
77
- * @param {string} [opts.hooksDir] value for HOOKS_DIR (default <root>/src/hooks).
78
- * @param {AbortSignal} [opts.signal] external cancellation; aborts the child.
79
- * @returns {Promise<object>} the §4 result shape. Never rejects.
80
- */
81
- function runHook(hookSpec, payload, opts = {}) {
82
- return new Promise((resolve) => {
83
- const started = Date.now();
84
-
85
- const id = hookSpec && hookSpec.id != null ? hookSpec.id : null;
86
- const event = hookSpec && hookSpec.event != null ? hookSpec.event : null;
87
-
88
- // Build the immutable base of every result so any early exit is well-formed.
89
- const baseResult = () => ({
90
- id,
91
- event,
92
- exitCode: -1,
93
- timedOut: false,
94
- stdout: '',
95
- stderr: '',
96
- blocked: false,
97
- stopLoop: false,
98
- reason: null,
99
- inject: null,
100
- durationMs: 0,
101
- });
102
-
103
- // Guard: a malformed spec must not throw out of the runner (§4: never reject).
104
- if (!hookSpec || typeof hookSpec.command !== 'string' || hookSpec.command.length === 0) {
105
- const r = baseResult();
106
- r.stderr = 'hook-runner: invalid hookSpec (missing command)';
107
- r.durationMs = Date.now() - started;
108
- resolve(r);
109
- return;
110
- }
111
-
112
- // timeout is stored in SECONDS in the manifest → convert to ms (§4).
113
- // Fall back to a sane default if absent or non-positive.
114
- const timeoutSec =
115
- typeof hookSpec.timeout === 'number' && hookSpec.timeout > 0 ? hookSpec.timeout : 60;
116
- const timeoutMs = timeoutSec * 1000;
117
-
118
- // Child env: inherit process.env, layer opts.env, then set the two paths the
119
- // hook scripts rely on to resolve themselves (§4).
120
- const hooksDir = opts.hooksDir || (opts.env && opts.env.HOOKS_DIR) || DEFAULT_HOOKS_DIR;
121
- const projectDir =
122
- opts.projectDir ||
123
- (opts.env && opts.env.CLAUDE_PROJECT_DIR) ||
124
- process.env.CLAUDE_PROJECT_DIR ||
125
- // Project root is two levels above the hooks dir (<root>/src/hooks → <root>).
126
- path.resolve(hooksDir, '..', '..');
127
-
128
- const childEnv = Object.assign({}, process.env, opts.env || {}, {
129
- CLAUDE_PROJECT_DIR: projectDir,
130
- HOOKS_DIR: hooksDir,
131
- });
132
-
133
- // Own AbortController so we can enforce the timeout via kill, and chain any
134
- // externally-supplied signal so the engine can cancel us too.
135
- const controller = new AbortController();
136
- const onExternalAbort = () => killChildTree();
137
- if (opts.signal) {
138
- if (opts.signal.aborted) controller.abort();
139
- else opts.signal.addEventListener('abort', onExternalAbort, { once: true });
140
- }
141
-
142
- let child;
143
- try {
144
- child = spawn(hookSpec.command, {
145
- shell: true, // run as a shell command line (bash/PowerShell-launched scripts)
146
- cwd: opts.cwd || undefined,
147
- env: childEnv,
148
- signal: controller.signal,
149
- windowsHide: true,
150
- });
151
- } catch (err) {
152
- // Synchronous spawn failure (rare). Resolve, never throw.
153
- cleanupSignal();
154
- const r = baseResult();
155
- r.stderr = `hook-runner: spawn failed: ${err && err.message ? err.message : String(err)}`;
156
- r.durationMs = Date.now() - started;
157
- resolve(r);
158
- return;
159
- }
160
-
161
- let stdout = '';
162
- let stderr = '';
163
- let timedOut = false;
164
- let settled = false;
165
-
166
- // Kill the WHOLE child tree. On Windows, killing the shell (cmd.exe) does NOT
167
- // kill its node/bash grandchildren — they orphan and hold the stdio pipes open,
168
- // which prevents the parent process (and node:test) from ever exiting. taskkill
169
- // /T tears down the tree so the pipes close and nothing lingers. (Real hooks
170
- // that hang would orphan the same way, so this matters beyond the test suite.)
171
- function killChildTree() {
172
- try {
173
- if (child && child.pid != null && process.platform === 'win32') {
174
- execFile('taskkill', ['/pid', String(child.pid), '/T', '/F'], () => {});
175
- } else if (child) {
176
- child.kill('SIGKILL');
177
- }
178
- } catch (_e) {
179
- try {
180
- if (child) child.kill();
181
- } catch (_e2) {
182
- /* nothing more we can do */
183
- }
184
- }
185
- try {
186
- controller.abort();
187
- } catch (_e) {
188
- /* abort backstop */
189
- }
190
- }
191
-
192
- // Hard timeout: kill the child tree; the 'close'/'error' handler resolves.
193
- const timer = setTimeout(() => {
194
- timedOut = true;
195
- killChildTree();
196
- }, timeoutMs);
197
- if (typeof timer.unref === 'function') timer.unref();
198
-
199
- function cleanupSignal() {
200
- if (opts.signal) opts.signal.removeEventListener('abort', onExternalAbort);
201
- }
202
-
203
- function finish(result) {
204
- if (settled) return;
205
- settled = true;
206
- clearTimeout(timer);
207
- cleanupSignal();
208
- result.durationMs = Date.now() - started;
209
- resolve(result);
210
- }
211
-
212
- if (child.stdout) {
213
- child.stdout.setEncoding('utf8');
214
- child.stdout.on('data', (chunk) => {
215
- stdout += chunk;
216
- });
217
- }
218
- if (child.stderr) {
219
- child.stderr.setEncoding('utf8');
220
- child.stderr.on('data', (chunk) => {
221
- stderr += chunk;
222
- });
223
- }
224
-
225
- // Write the payload to the child's stdin, then close it. Guard against EPIPE
226
- // (child may exit before reading) — that must not throw the runner.
227
- if (child.stdin) {
228
- child.stdin.on('error', () => {
229
- /* swallow EPIPE / write-after-end; the exit code is what matters */
230
- });
231
- try {
232
- child.stdin.write(JSON.stringify(payload == null ? {} : payload));
233
- child.stdin.end();
234
- } catch (_e) {
235
- // ignore — stdin write race; child outcome still resolves below
236
- }
237
- }
238
-
239
- // Spawn / runtime error (ENOENT, abort, etc.). If it's the timeout abort we
240
- // report timedOut; otherwise a non-blocking error with exitCode -1.
241
- child.on('error', (err) => {
242
- const r = baseResult();
243
- r.stdout = stdout;
244
- r.stderr = stderr || (err && err.message ? err.message : String(err));
245
- if (timedOut) {
246
- r.timedOut = true;
247
- r.stderr = stderr || `hook-runner: timed out after ${timeoutMs}ms`;
248
- }
249
- // blocked stays false; exitCode stays -1 (§4).
250
- finish(r);
251
- });
252
-
253
- child.on('close', (code, sig) => {
254
- // If we aborted for the timeout, classify as timeout regardless of code.
255
- if (timedOut) {
256
- const r = baseResult();
257
- r.stdout = stdout;
258
- r.stderr = stderr || `hook-runner: timed out after ${timeoutMs}ms`;
259
- r.timedOut = true; // exitCode -1, blocked false (§4)
260
- finish(r);
261
- return;
262
- }
263
-
264
- const r = baseResult();
265
- r.stdout = stdout;
266
- r.stderr = stderr;
267
- // On a clean close, code is a number; if killed by signal, code is null.
268
- r.exitCode = typeof code === 'number' ? code : -1;
269
-
270
- // --- Protocol interpretation (§3) ---
271
- // Exit 2 is ALWAYS stderr-blocking, and stdout/JSON is ignored on any
272
- // non-zero exit — this matches real Claude Code (the JSON protocol is
273
- // processed ONLY on a clean exit 0; on exit 2 the reason is stderr).
274
- if (r.exitCode === 2) {
275
- r.blocked = true;
276
- r.reason = stderr != null && stderr.trim().length > 0 ? stderr.trim() : '';
277
- finish(r);
278
- return;
279
- }
280
-
281
- // B) Advanced / JSON protocol (§3-B) — honored only on exit 0.
282
- const json = r.exitCode === 0 ? tryParseJsonProtocol(stdout) : null;
283
-
284
- if (json) {
285
- const hso =
286
- json.hookSpecificOutput && typeof json.hookSpecificOutput === 'object'
287
- ? json.hookSpecificOutput
288
- : null;
289
-
290
- // PreToolUse blocks via hookSpecificOutput.permissionDecision
291
- // (allow|deny|ask), NOT top-level decision — this is the real Claude Code
292
- // mechanism. "deny" blocks (reason = permissionDecisionReason). "allow"
293
- // passes. "ask" has no interactive prompt in the autonomous host, so it
294
- // is treated as non-blocking with the reason captured for context.
295
- const permDecision =
296
- hso && typeof hso.permissionDecision === 'string' ? hso.permissionDecision : null;
297
- const permReason =
298
- hso && typeof hso.permissionDecisionReason === 'string'
299
- ? hso.permissionDecisionReason
300
- : null;
301
-
302
- // Top-level decision: real Claude Code defines only "block" here (for
303
- // UserPromptSubmit / PostToolUse / Stop / PreCompact). There is no "approve".
304
- const decision = json.decision;
305
- const cont = json.continue;
306
- const jsonReason = typeof json.reason === 'string' ? json.reason : null;
307
- const additional =
308
- hso && typeof hso.additionalContext === 'string' ? hso.additionalContext : null;
309
-
310
- if (permDecision === 'deny') {
311
- r.blocked = true;
312
- r.reason = permReason != null ? permReason : jsonReason;
313
- if (r.reason == null) r.reason = '';
314
- } else if (decision === 'block') {
315
- r.blocked = true;
316
- r.reason = jsonReason; // block reason from JSON.reason (§4)
317
- }
318
- if (cont === false) {
319
- r.stopLoop = true; // continue:false → stop the whole loop (§3-B, §4)
320
- }
321
- // additionalContext is the injected text when present (§3-B, §4).
322
- if (additional !== null) {
323
- r.inject = additional;
324
- }
325
- finish(r);
326
- return;
327
- }
328
-
329
- // A) Simple / exit-code protocol (§3-A), exit 0.
330
- if (r.exitCode === 0) {
331
- // Success. For SessionStart / UserPromptSubmit, stdout IS the injection.
332
- // For other events stdout is advisory only (captured, not injected).
333
- if (INJECTABLE_EVENTS.has(event)) {
334
- const out = stdout != null ? stdout.trim() : '';
335
- r.inject = out.length > 0 ? out : null;
336
- }
337
- }
338
- // Any other non-zero (non-2) code → non-blocking error: stderr captured,
339
- // blocked stays false, reason stays null (§3-A).
340
-
341
- finish(r);
342
- });
343
- });
344
- }
345
-
346
- module.exports = { runHook };
1
+ 'use strict';
2
+
3
+ /**
4
+ * hook-runner.js - run ONE command hook and return a structured result.
5
+ *
6
+ * Authoritative contract: docs/HOOK_ENGINE.md §3 (two output protocols) and §4
7
+ * (the exact result shape). This module is backend-agnostic and has ZERO host-framework
8
+ * imports so it runs headless under `node --test`.
9
+ *
10
+ * Hard guarantee (§4): 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 path = require('node:path');
17
+
18
+ // Default HOOKS_DIR: <root>/src/hooks. __dirname is <root>/src/core, so up one.
19
+ const DEFAULT_HOOKS_DIR = path.resolve(__dirname, '..', 'hooks');
20
+
21
+ // Events whose exit-0 stdout is treated as injected context (§3-A, §1 table).
22
+ const INJECTABLE_EVENTS = new Set(['SessionStart', 'UserPromptSubmit']);
23
+
24
+ /**
25
+ * Decide whether a parsed stdout object should be treated as the §3-B JSON
26
+ * protocol. We only switch to the JSON interpretation when the object actually
27
+ * carries one of the protocol's known keys - otherwise an arbitrary JSON blob a
28
+ * hook happens to print should fall through to the exit-code protocol (§3:
29
+ * "Exit-code protocol wins if stdout is not valid JSON").
30
+ *
31
+ * @param {*} parsed
32
+ * @returns {boolean}
33
+ */
34
+ function isKnownJsonProtocol(parsed) {
35
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
36
+ return false;
37
+ }
38
+ return (
39
+ Object.prototype.hasOwnProperty.call(parsed, 'continue') ||
40
+ Object.prototype.hasOwnProperty.call(parsed, 'decision') ||
41
+ Object.prototype.hasOwnProperty.call(parsed, 'reason') ||
42
+ Object.prototype.hasOwnProperty.call(parsed, 'hookSpecificOutput')
43
+ );
44
+ }
45
+
46
+ /**
47
+ * Try to parse stdout as the JSON protocol. Returns the parsed object only if it
48
+ * is valid JSON AND carries known protocol keys; otherwise null (use exit-code
49
+ * protocol).
50
+ *
51
+ * @param {string} stdout
52
+ * @returns {object|null}
53
+ */
54
+ function tryParseJsonProtocol(stdout) {
55
+ const trimmed = (stdout || '').trim();
56
+ if (!trimmed) return null;
57
+ // Cheap pre-check: protocol output is always a JSON object.
58
+ if (trimmed[0] !== '{') return null;
59
+ let parsed;
60
+ try {
61
+ parsed = JSON.parse(trimmed);
62
+ } catch (_e) {
63
+ return null; // not valid JSON -> exit-code protocol wins
64
+ }
65
+ return isKnownJsonProtocol(parsed) ? parsed : null;
66
+ }
67
+
68
+ /**
69
+ * Run a single command hook.
70
+ *
71
+ * @param {object} hookSpec Resolved spec: { id, event, command, timeout(seconds), ... }.
72
+ * @param {object} payload The stdin JSON object (built by events.js buildPayload).
73
+ * @param {object} [opts]
74
+ * @param {string} [opts.cwd] child working directory.
75
+ * @param {object} [opts.env] extra env merged over process.env.
76
+ * @param {string} [opts.projectDir] value for CLAUDE_PROJECT_DIR.
77
+ * @param {string} [opts.hooksDir] value for HOOKS_DIR (default <root>/src/hooks).
78
+ * @param {AbortSignal} [opts.signal] external cancellation; aborts the child.
79
+ * @returns {Promise<object>} the §4 result shape. Never rejects.
80
+ */
81
+ function runHook(hookSpec, payload, opts = {}) {
82
+ return new Promise((resolve) => {
83
+ const started = Date.now();
84
+
85
+ const id = hookSpec && hookSpec.id != null ? hookSpec.id : null;
86
+ const event = hookSpec && hookSpec.event != null ? hookSpec.event : null;
87
+
88
+ // Build the immutable base of every result so any early exit is well-formed.
89
+ const baseResult = () => ({
90
+ id,
91
+ event,
92
+ exitCode: -1,
93
+ timedOut: false,
94
+ stdout: '',
95
+ stderr: '',
96
+ blocked: false,
97
+ stopLoop: false,
98
+ reason: null,
99
+ inject: null,
100
+ durationMs: 0,
101
+ });
102
+
103
+ // Guard: a malformed spec must not throw out of the runner (§4: never reject).
104
+ if (!hookSpec || typeof hookSpec.command !== 'string' || hookSpec.command.length === 0) {
105
+ const r = baseResult();
106
+ r.stderr = 'hook-runner: invalid hookSpec (missing command)';
107
+ r.durationMs = Date.now() - started;
108
+ resolve(r);
109
+ return;
110
+ }
111
+
112
+ // timeout is stored in SECONDS in the manifest -> convert to ms (§4).
113
+ // Fall back to a sane default if absent or non-positive.
114
+ const timeoutSec =
115
+ typeof hookSpec.timeout === 'number' && hookSpec.timeout > 0 ? hookSpec.timeout : 60;
116
+ const timeoutMs = timeoutSec * 1000;
117
+
118
+ // Child env: inherit process.env, layer opts.env, then set the two paths the
119
+ // hook scripts rely on to resolve themselves (§4).
120
+ const hooksDir = opts.hooksDir || (opts.env && opts.env.HOOKS_DIR) || DEFAULT_HOOKS_DIR;
121
+ const projectDir =
122
+ opts.projectDir ||
123
+ (opts.env && opts.env.CLAUDE_PROJECT_DIR) ||
124
+ process.env.CLAUDE_PROJECT_DIR ||
125
+ // Project root is two levels above the hooks dir (<root>/src/hooks -> <root>).
126
+ path.resolve(hooksDir, '..', '..');
127
+
128
+ const childEnv = Object.assign({}, process.env, opts.env || {}, {
129
+ CLAUDE_PROJECT_DIR: projectDir,
130
+ HOOKS_DIR: hooksDir,
131
+ });
132
+
133
+ // Own AbortController so we can enforce the timeout via kill, and chain any
134
+ // externally-supplied signal so the engine can cancel us too.
135
+ const controller = new AbortController();
136
+ const onExternalAbort = () => killChildTree();
137
+ if (opts.signal) {
138
+ if (opts.signal.aborted) controller.abort();
139
+ else opts.signal.addEventListener('abort', onExternalAbort, { once: true });
140
+ }
141
+
142
+ let child;
143
+ try {
144
+ child = spawn(hookSpec.command, {
145
+ shell: true, // run as a shell command line (bash/PowerShell-launched scripts)
146
+ cwd: opts.cwd || undefined,
147
+ env: childEnv,
148
+ signal: controller.signal,
149
+ windowsHide: true,
150
+ });
151
+ } catch (err) {
152
+ // Synchronous spawn failure (rare). Resolve, never throw.
153
+ cleanupSignal();
154
+ const r = baseResult();
155
+ r.stderr = `hook-runner: spawn failed: ${err && err.message ? err.message : String(err)}`;
156
+ r.durationMs = Date.now() - started;
157
+ resolve(r);
158
+ return;
159
+ }
160
+
161
+ let stdout = '';
162
+ let stderr = '';
163
+ let timedOut = false;
164
+ let settled = false;
165
+
166
+ // Kill the WHOLE child tree. On Windows, killing the shell (cmd.exe) does NOT
167
+ // kill its node/bash grandchildren - they orphan and hold the stdio pipes open,
168
+ // which prevents the parent process (and node:test) from ever exiting. taskkill
169
+ // /T tears down the tree so the pipes close and nothing lingers. (Real hooks
170
+ // that hang would orphan the same way, so this matters beyond the test suite.)
171
+ function killChildTree() {
172
+ try {
173
+ if (child && child.pid != null && process.platform === 'win32') {
174
+ execFile('taskkill', ['/pid', String(child.pid), '/T', '/F'], () => {});
175
+ } else if (child) {
176
+ child.kill('SIGKILL');
177
+ }
178
+ } catch (_e) {
179
+ try {
180
+ if (child) child.kill();
181
+ } catch (_e2) {
182
+ /* nothing more we can do */
183
+ }
184
+ }
185
+ try {
186
+ controller.abort();
187
+ } catch (_e) {
188
+ /* abort backstop */
189
+ }
190
+ }
191
+
192
+ // Hard timeout: kill the child tree; the 'close'/'error' handler resolves.
193
+ const timer = setTimeout(() => {
194
+ timedOut = true;
195
+ killChildTree();
196
+ }, timeoutMs);
197
+ if (typeof timer.unref === 'function') timer.unref();
198
+
199
+ function cleanupSignal() {
200
+ if (opts.signal) opts.signal.removeEventListener('abort', onExternalAbort);
201
+ }
202
+
203
+ function finish(result) {
204
+ if (settled) return;
205
+ settled = true;
206
+ clearTimeout(timer);
207
+ cleanupSignal();
208
+ result.durationMs = Date.now() - started;
209
+ resolve(result);
210
+ }
211
+
212
+ if (child.stdout) {
213
+ child.stdout.setEncoding('utf8');
214
+ child.stdout.on('data', (chunk) => {
215
+ stdout += chunk;
216
+ });
217
+ }
218
+ if (child.stderr) {
219
+ child.stderr.setEncoding('utf8');
220
+ child.stderr.on('data', (chunk) => {
221
+ stderr += chunk;
222
+ });
223
+ }
224
+
225
+ // Write the payload to the child's stdin, then close it. Guard against EPIPE
226
+ // (child may exit before reading) - that must not throw the runner.
227
+ if (child.stdin) {
228
+ child.stdin.on('error', () => {
229
+ /* swallow EPIPE / write-after-end; the exit code is what matters */
230
+ });
231
+ try {
232
+ child.stdin.write(JSON.stringify(payload == null ? {} : payload));
233
+ child.stdin.end();
234
+ } catch (_e) {
235
+ // ignore - stdin write race; child outcome still resolves below
236
+ }
237
+ }
238
+
239
+ // Spawn / runtime error (ENOENT, abort, etc.). If it's the timeout abort we
240
+ // report timedOut; otherwise a non-blocking error with exitCode -1.
241
+ child.on('error', (err) => {
242
+ const r = baseResult();
243
+ r.stdout = stdout;
244
+ r.stderr = stderr || (err && err.message ? err.message : String(err));
245
+ if (timedOut) {
246
+ r.timedOut = true;
247
+ r.stderr = stderr || `hook-runner: timed out after ${timeoutMs}ms`;
248
+ }
249
+ // blocked stays false; exitCode stays -1 (§4).
250
+ finish(r);
251
+ });
252
+
253
+ child.on('close', (code, sig) => {
254
+ // If we aborted for the timeout, classify as timeout regardless of code.
255
+ if (timedOut) {
256
+ const r = baseResult();
257
+ r.stdout = stdout;
258
+ r.stderr = stderr || `hook-runner: timed out after ${timeoutMs}ms`;
259
+ r.timedOut = true; // exitCode -1, blocked false (§4)
260
+ finish(r);
261
+ return;
262
+ }
263
+
264
+ const r = baseResult();
265
+ r.stdout = stdout;
266
+ r.stderr = stderr;
267
+ // On a clean close, code is a number; if killed by signal, code is null.
268
+ r.exitCode = typeof code === 'number' ? code : -1;
269
+
270
+ // --- Protocol interpretation (§3) ---
271
+ // Exit 2 is ALWAYS stderr-blocking, and stdout/JSON is ignored on any
272
+ // non-zero exit - this matches real Claude Code (the JSON protocol is
273
+ // processed ONLY on a clean exit 0; on exit 2 the reason is stderr).
274
+ if (r.exitCode === 2) {
275
+ r.blocked = true;
276
+ r.reason = stderr != null && stderr.trim().length > 0 ? stderr.trim() : '';
277
+ finish(r);
278
+ return;
279
+ }
280
+
281
+ // B) Advanced / JSON protocol (§3-B) - honored only on exit 0.
282
+ const json = r.exitCode === 0 ? tryParseJsonProtocol(stdout) : null;
283
+
284
+ if (json) {
285
+ const hso =
286
+ json.hookSpecificOutput && typeof json.hookSpecificOutput === 'object'
287
+ ? json.hookSpecificOutput
288
+ : null;
289
+
290
+ // PreToolUse blocks via hookSpecificOutput.permissionDecision
291
+ // (allow|deny|ask), NOT top-level decision - this is the real Claude Code
292
+ // mechanism. "deny" blocks (reason = permissionDecisionReason). "allow"
293
+ // passes. "ask" has no interactive prompt in the autonomous host, so it
294
+ // is treated as non-blocking with the reason captured for context.
295
+ const permDecision =
296
+ hso && typeof hso.permissionDecision === 'string' ? hso.permissionDecision : null;
297
+ const permReason =
298
+ hso && typeof hso.permissionDecisionReason === 'string'
299
+ ? hso.permissionDecisionReason
300
+ : null;
301
+
302
+ // Top-level decision: real Claude Code defines only "block" here (for
303
+ // UserPromptSubmit / PostToolUse / Stop / PreCompact). There is no "approve".
304
+ const decision = json.decision;
305
+ const cont = json.continue;
306
+ const jsonReason = typeof json.reason === 'string' ? json.reason : null;
307
+ const additional =
308
+ hso && typeof hso.additionalContext === 'string' ? hso.additionalContext : null;
309
+
310
+ if (permDecision === 'deny') {
311
+ r.blocked = true;
312
+ r.reason = permReason != null ? permReason : jsonReason;
313
+ if (r.reason == null) r.reason = '';
314
+ } else if (decision === 'block') {
315
+ r.blocked = true;
316
+ r.reason = jsonReason; // block reason from JSON.reason (§4)
317
+ }
318
+ if (cont === false) {
319
+ r.stopLoop = true; // continue:false -> stop the whole loop (§3-B, §4)
320
+ }
321
+ // additionalContext is the injected text when present (§3-B, §4).
322
+ if (additional !== null) {
323
+ r.inject = additional;
324
+ }
325
+ finish(r);
326
+ return;
327
+ }
328
+
329
+ // A) Simple / exit-code protocol (§3-A), exit 0.
330
+ if (r.exitCode === 0) {
331
+ // Success. For SessionStart / UserPromptSubmit, stdout IS the injection.
332
+ // For other events stdout is advisory only (captured, not injected).
333
+ if (INJECTABLE_EVENTS.has(event)) {
334
+ const out = stdout != null ? stdout.trim() : '';
335
+ r.inject = out.length > 0 ? out : null;
336
+ }
337
+ }
338
+ // Any other non-zero (non-2) code -> non-blocking error: stderr captured,
339
+ // blocked stays false, reason stays null (§3-A).
340
+
341
+ finish(r);
342
+ });
343
+ });
344
+ }
345
+
346
+ module.exports = { runHook };