toolfunnel 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +11 -7
  2. package/SECURITY.md +84 -0
  3. package/bin/toolfunnel.js +97 -18
  4. package/docs/KNOWN_BUGS.md +71 -90
  5. package/docs/MANUAL.pdf +0 -0
  6. package/docs/design.md +24 -10
  7. package/hooks/hooks.manifest.json +1 -1
  8. package/hooks/scripts/example-deny-dangerous.js +12 -4
  9. package/mcp/servers/mock-upstream/server.js +36 -6
  10. package/package.json +67 -65
  11. package/src/auth/config.js +2 -2
  12. package/src/auth/resource-server.js +12 -3
  13. package/src/core/child-reaper.js +61 -0
  14. package/src/core/events.js +7 -7
  15. package/src/core/hook-engine.js +85 -31
  16. package/src/core/hook-loader.js +46 -13
  17. package/src/core/hook-runner.js +258 -38
  18. package/src/core/logger.js +138 -12
  19. package/src/core/matcher.js +59 -10
  20. package/src/core/rename-migration.js +163 -0
  21. package/src/core/requires.js +1 -1
  22. package/src/extend/add-hook.md +2 -2
  23. package/src/extend/add-mcp.md +3 -6
  24. package/src/extend/configure.md +2 -1
  25. package/src/extend/create-tool.md +1 -1
  26. package/src/extend/howto.js +3 -3
  27. package/src/extend/package.md +11 -8
  28. package/src/mcp/aggregator.js +480 -52
  29. package/src/mcp/expose-store.js +130 -13
  30. package/src/mcp/gated-run.js +26 -17
  31. package/src/mcp/http-transport.js +274 -22
  32. package/src/mcp/mcp-client.js +194 -31
  33. package/src/mcp/modern.js +44 -10
  34. package/src/mcp/server.js +513 -89
  35. package/src/tools/registry.js +172 -24
  36. package/src/ui/public/app.js +17 -4
  37. package/src/ui/server.js +297 -29
  38. package/tools/scripts/tf-hook-set.js +11 -3
  39. package/tools/tools.register.json +227 -227
  40. package/src/core/tool-registry.js +0 -180
  41. package/src/packages/packager.js +0 -715
  42. package/src/tools/drift.js +0 -138
@@ -3,7 +3,7 @@
3
3
  /**
4
4
  * hook-loader.js - load and persist the hook manifest, resolve runnable specs.
5
5
  *
6
- * Contract: HOOK_ENGINE.md §7 (manifest shape) and §8 (loader API).
6
+ * Contract: the manifest shape and the loader API are both specified below.
7
7
  *
8
8
  * Responsibilities:
9
9
  * - loadManifest(path): read hooks.manifest.json, expand ${HOOKS_DIR} (the absolute
@@ -14,7 +14,7 @@
14
14
  * - writeScript(id, text): save to the COPY under src/hooks/scripts - and REFUSE any
15
15
  * resolved path outside src/hooks/scripts (path-traversal guard, isolation rule).
16
16
  *
17
- * Hook Manager v2 additions (see the architecture notes §4) - all ADDITIVE; the API above
17
+ * Hook Manager v2 additions - all ADDITIVE; the API above
18
18
  * keeps its exact behaviour so the existing loader tests still pass:
19
19
  * - AUTO-DETECT: autodetect() scans <hooksDir>/scripts for hook scripts and reconciles
20
20
  * them with the manifest. The FOLDER is the source of truth for what EXISTS; the
@@ -38,6 +38,10 @@ const path = require('node:path');
38
38
  // six supported events. events.js has zero host imports, so this cannot cycle.
39
39
  const { EVENT_NAMES } = require('./events');
40
40
 
41
+ // The matcher-compile discriminator - addEntry refuses a matcher that can never be
42
+ // evaluated (matcher.js has zero host imports, so this cannot cycle either).
43
+ const { matcherError } = require('./matcher');
44
+
41
45
  /**
42
46
  * Extensions we treat as hook scripts during auto-detect. Everything else in the
43
47
  * scripts dir (e.g. *.conf files, READMEs) is config/data, not a hook.
@@ -251,7 +255,7 @@ class HookLoader {
251
255
  };
252
256
  }
253
257
 
254
- /** @returns {object} alias of {@link HookLoader#autodetect} (contract §4 naming). */
258
+ /** @returns {object} alias of {@link HookLoader#autodetect} (compatibility naming). */
255
259
  reconcile() { return this.autodetect(); }
256
260
  /** @returns {object} alias of {@link HookLoader#autodetect}. */
257
261
  reconcileScripts() { return this.autodetect(); }
@@ -313,12 +317,7 @@ class HookLoader {
313
317
  const liveSpec = this.getSpec(id);
314
318
  if (liveSpec) liveSpec.enabled = desired;
315
319
 
316
- // (1) Persist the overlay - the authoritative, precedence-winning store.
317
- const overlay = this.readState();
318
- overlay[id] = desired;
319
- this.writeState(overlay);
320
-
321
- // (2) Mirror into the manifest when a matching row exists (keeps the manifest a
320
+ // (1) Mirror into the manifest when a matching row exists (keeps the manifest a
322
321
  // faithful default and preserves the v1 setEnabled contract). Skipped for
323
322
  // detected-only ids that have no manifest row.
324
323
  let raw;
@@ -330,17 +329,32 @@ class HookLoader {
330
329
  raw = collapseManifest(this.manifest, this.hooksDir);
331
330
  }
332
331
 
332
+ let manifestRow = false;
333
333
  if (raw && Array.isArray(raw.hooks)) {
334
334
  const rawSpec = raw.hooks.find((h) => h && h.id === id);
335
335
  if (rawSpec) {
336
+ manifestRow = true;
336
337
  rawSpec.enabled = desired;
337
338
  atomicWriteJson(this.manifestPath, raw);
338
339
  }
339
340
  }
340
341
 
341
- // True if we toggled a known manifest hook; also true if we recorded the overlay
342
- // for a detected (manifest-less) hook so the manager can persist its state.
343
- return !!liveSpec || true;
342
+ // (2) Persist the overlay - the authoritative, precedence-winning store - ONLY for an id
343
+ // that matched something. The first correction here fixed the constant-true return
344
+ // (`!!liveSpec || true`) but left this write unconditional, so a typo'd id still left a
345
+ // stray key in hooks.state.json forever. Strays are not inert: applyState() lays the
346
+ // overlay over the manifest at every load, so a future hook adopting the id would boot
347
+ // silently in the stale recorded state - a pre-disabled gate.
348
+ if (liveSpec || manifestRow) {
349
+ const overlay = this.readState();
350
+ overlay[id] = desired;
351
+ this.writeState(overlay);
352
+ }
353
+
354
+ // True if we toggled a known manifest hook; also true for a detected (manifest-less) hook
355
+ // so the manager can persist its state. An id matching NEITHER is a miss: report false,
356
+ // write nothing.
357
+ return !!liveSpec || manifestRow;
344
358
  }
345
359
 
346
360
  /**
@@ -357,7 +371,9 @@ class HookLoader {
357
371
  * - id: non-empty string; must not already exist in the manifest (rejected if it does)
358
372
  * - event: one of the lifecycle EVENTS (EVENT_NAMES)
359
373
  * - command: non-empty string
360
- * - matcher/script/timeout/description: optional, stored verbatim
374
+ * - matcher: optional string; must COMPILE under matcher.js's anchoring (rejected
375
+ * otherwise - an uncompilable matcher can never be evaluated)
376
+ * - script/timeout/description: optional, stored verbatim
361
377
  * - enabled: defaults to (spec.enabled === true)
362
378
  * @returns {object} the stored spec (exactly as written to the manifest)
363
379
  * @throws TypeError/Error if validation fails or the id already exists
@@ -377,6 +393,23 @@ class HookLoader {
377
393
  if (typeof spec.command !== 'string' || spec.command.length === 0) {
378
394
  throw new Error('addEntry: spec.command must be a non-empty string');
379
395
  }
396
+ // A matcher that does not compile can never be evaluated - storing it would author a
397
+ // hook whose gate the engine must refuse on every tool-bearing call (hook-engine's
398
+ // unreadable-gate rule). Refuse HERE so the UI's POST /api/hooks/add and tf_hook_add
399
+ // both answer with the problem instead of 200/ok. (0.7.0; matcher-compile.test.js.)
400
+ if (spec.matcher !== undefined && spec.matcher !== null && typeof spec.matcher !== 'string') {
401
+ throw new Error('addEntry: spec.matcher must be a string when provided');
402
+ }
403
+ {
404
+ const compileProblem = matcherError(spec.matcher);
405
+ if (compileProblem) {
406
+ throw new Error(
407
+ `addEntry: spec.matcher ${JSON.stringify(spec.matcher)} does not compile as a ` +
408
+ `regular expression (${compileProblem}) - matchers are anchored regexes ` +
409
+ '(e.g. "Bash|Write"), not globs'
410
+ );
411
+ }
412
+ }
380
413
 
381
414
  // The stored spec: every supplied field VERBATIM, with enabled normalized to a
382
415
  // strict boolean. The command is left untouched so a portable ${HOOKS_DIR} token
@@ -3,30 +3,45 @@
3
3
  /**
4
4
  * hook-runner.js - run ONE command hook and return a structured result.
5
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`.
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
9
  *
10
- * Hard guarantee (§4): runHook NEVER rejects. A spawn failure or a timeout always
10
+ * Hard guarantee: runHook NEVER rejects. A spawn failure or a timeout always
11
11
  * resolves with the full result object (exitCode:-1, timedOut set appropriately,
12
12
  * blocked:false).
13
13
  */
14
14
 
15
15
  const { spawn, execFile } = require('node:child_process');
16
+ const reaper = require('./child-reaper');
16
17
  const path = require('node:path');
17
18
 
18
19
  // Default HOOKS_DIR: <root>/src/hooks. __dirname is <root>/src/core, so up one.
19
20
  const DEFAULT_HOOKS_DIR = path.resolve(__dirname, '..', 'hooks');
20
21
 
21
- // Events whose exit-0 stdout is treated as injected context (§3-A, §1 table).
22
+ // Events whose exit-0 stdout is treated as injected context (the exit-code protocol's injection half).
22
23
  const INJECTABLE_EVENTS = new Set(['SessionStart', 'UserPromptSubmit']);
23
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
+
24
39
  /**
25
- * Decide whether a parsed stdout object should be treated as the §3-B JSON
40
+ * Decide whether a parsed stdout object should be treated as the JSON
26
41
  * protocol. We only switch to the JSON interpretation when the object actually
27
42
  * 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").
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").
30
45
  *
31
46
  * @param {*} parsed
32
47
  * @returns {boolean}
@@ -44,23 +59,28 @@ function isKnownJsonProtocol(parsed) {
44
59
  }
45
60
 
46
61
  /**
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).
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).
50
66
  *
51
67
  * @param {string} stdout
52
- * @returns {object|null}
68
+ * @returns {object|symbol|null}
53
69
  */
54
70
  function tryParseJsonProtocol(stdout) {
55
71
  const trimmed = (stdout || '').trim();
56
72
  if (!trimmed) return null;
57
- // Cheap pre-check: protocol output is always a JSON object.
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).
58
75
  if (trimmed[0] !== '{') return null;
59
76
  let parsed;
60
77
  try {
61
78
  parsed = JSON.parse(trimmed);
62
79
  } catch (_e) {
63
- return null; // not valid JSON -> exit-code protocol wins
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;
64
84
  }
65
85
  return isKnownJsonProtocol(parsed) ? parsed : null;
66
86
  }
@@ -76,7 +96,7 @@ function tryParseJsonProtocol(stdout) {
76
96
  * @param {string} [opts.projectDir] value for CLAUDE_PROJECT_DIR.
77
97
  * @param {string} [opts.hooksDir] value for HOOKS_DIR (default <root>/src/hooks).
78
98
  * @param {AbortSignal} [opts.signal] external cancellation; aborts the child.
79
- * @returns {Promise<object>} the §4 result shape. Never rejects.
99
+ * @returns {Promise<object>} the full result shape. Never rejects.
80
100
  */
81
101
  function runHook(hookSpec, payload, opts = {}) {
82
102
  return new Promise((resolve) => {
@@ -100,7 +120,7 @@ function runHook(hookSpec, payload, opts = {}) {
100
120
  durationMs: 0,
101
121
  });
102
122
 
103
- // Guard: a malformed spec must not throw out of the runner (§4: never reject).
123
+ // Guard: a malformed spec must not throw out of the runner (the never-reject guarantee).
104
124
  if (!hookSpec || typeof hookSpec.command !== 'string' || hookSpec.command.length === 0) {
105
125
  const r = baseResult();
106
126
  r.stderr = 'hook-runner: invalid hookSpec (missing command)';
@@ -109,14 +129,14 @@ function runHook(hookSpec, payload, opts = {}) {
109
129
  return;
110
130
  }
111
131
 
112
- // timeout is stored in SECONDS in the manifest -> convert to ms (§4).
132
+ // timeout is stored in SECONDS in the manifest -> convert to ms.
113
133
  // Fall back to a sane default if absent or non-positive.
114
134
  const timeoutSec =
115
135
  typeof hookSpec.timeout === 'number' && hookSpec.timeout > 0 ? hookSpec.timeout : 60;
116
136
  const timeoutMs = timeoutSec * 1000;
117
137
 
118
138
  // Child env: inherit process.env, layer opts.env, then set the two paths the
119
- // hook scripts rely on to resolve themselves (§4).
139
+ // hook scripts rely on to resolve themselves.
120
140
  const hooksDir = opts.hooksDir || (opts.env && opts.env.HOOKS_DIR) || DEFAULT_HOOKS_DIR;
121
141
  const projectDir =
122
142
  opts.projectDir ||
@@ -131,9 +151,20 @@ function runHook(hookSpec, payload, opts = {}) {
131
151
  });
132
152
 
133
153
  // Own AbortController so we can enforce the timeout via kill, and chain any
134
- // externally-supplied signal so the engine can cancel us too.
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
+ };
135
167
  const controller = new AbortController();
136
- const onExternalAbort = () => killChildTree();
137
168
  if (opts.signal) {
138
169
  if (opts.signal.aborted) controller.abort();
139
170
  else opts.signal.addEventListener('abort', onExternalAbort, { once: true });
@@ -147,7 +178,17 @@ function runHook(hookSpec, payload, opts = {}) {
147
178
  env: childEnv,
148
179
  signal: controller.signal,
149
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',
150
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);
151
192
  } catch (err) {
152
193
  // Synchronous spawn failure (rare). Resolve, never throw.
153
194
  cleanupSignal();
@@ -162,16 +203,26 @@ function runHook(hookSpec, payload, opts = {}) {
162
203
  let stderr = '';
163
204
  let timedOut = false;
164
205
  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.)
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.)
171
215
  function killChildTree() {
172
216
  try {
173
217
  if (child && child.pid != null && process.platform === 'win32') {
174
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
+ }
175
226
  } else if (child) {
176
227
  child.kill('SIGKILL');
177
228
  }
@@ -189,10 +240,22 @@ function runHook(hookSpec, payload, opts = {}) {
189
240
  }
190
241
  }
191
242
 
192
- // Hard timeout: kill the child tree; the 'close'/'error' handler resolves.
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.
193
251
  const timer = setTimeout(() => {
194
252
  timedOut = true;
195
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);
196
259
  }, timeoutMs);
197
260
  if (typeof timer.unref === 'function') timer.unref();
198
261
 
@@ -200,25 +263,79 @@ function runHook(hookSpec, payload, opts = {}) {
200
263
  if (opts.signal) opts.signal.removeEventListener('abort', onExternalAbort);
201
264
  }
202
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
+
203
280
  function finish(result) {
204
281
  if (settled) return;
205
282
  settled = true;
206
283
  clearTimeout(timer);
207
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);
208
295
  result.durationMs = Date.now() - started;
209
296
  resolve(result);
210
297
  }
211
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;
212
308
  if (child.stdout) {
213
309
  child.stdout.setEncoding('utf8');
214
310
  child.stdout.on('data', (chunk) => {
215
- stdout += 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
+ }
216
324
  });
217
325
  }
218
326
  if (child.stderr) {
219
327
  child.stderr.setEncoding('utf8');
220
328
  child.stderr.on('data', (chunk) => {
221
- stderr += 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
+ }
222
339
  });
223
340
  }
224
341
 
@@ -242,21 +359,43 @@ function runHook(hookSpec, payload, opts = {}) {
242
359
  const r = baseResult();
243
360
  r.stdout = stdout;
244
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
+ }
245
369
  if (timedOut) {
246
370
  r.timedOut = true;
247
371
  r.stderr = stderr || `hook-runner: timed out after ${timeoutMs}ms`;
248
372
  }
249
- // blocked stays false; exitCode stays -1 (§4).
373
+ // blocked stays false; exitCode stays -1 (the error contract).
250
374
  finish(r);
251
375
  });
252
376
 
253
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
+
254
393
  // If we aborted for the timeout, classify as timeout regardless of code.
255
394
  if (timedOut) {
256
395
  const r = baseResult();
257
396
  r.stdout = stdout;
258
397
  r.stderr = stderr || `hook-runner: timed out after ${timeoutMs}ms`;
259
- r.timedOut = true; // exitCode -1, blocked false (§4)
398
+ r.timedOut = true; // exitCode -1, blocked false (the timeout contract)
260
399
  finish(r);
261
400
  return;
262
401
  }
@@ -267,7 +406,7 @@ function runHook(hookSpec, payload, opts = {}) {
267
406
  // On a clean close, code is a number; if killed by signal, code is null.
268
407
  r.exitCode = typeof code === 'number' ? code : -1;
269
408
 
270
- // --- Protocol interpretation (§3) ---
409
+ // --- Protocol interpretation ---
271
410
  // Exit 2 is ALWAYS stderr-blocking, and stdout/JSON is ignored on any
272
411
  // non-zero exit - this matches real Claude Code (the JSON protocol is
273
412
  // processed ONLY on a clean exit 0; on exit 2 the reason is stderr).
@@ -278,15 +417,96 @@ function runHook(hookSpec, payload, opts = {}) {
278
417
  return;
279
418
  }
280
419
 
281
- // B) Advanced / JSON protocol (§3-B) - honored only on exit 0.
420
+ // B) Advanced / JSON protocol - honored only on exit 0.
282
421
  const json = r.exitCode === 0 ? tryParseJsonProtocol(stdout) : null;
283
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
+
284
437
  if (json) {
285
438
  const hso =
286
439
  json.hookSpecificOutput && typeof json.hookSpecificOutput === 'object'
287
440
  ? json.hookSpecificOutput
288
441
  : null;
289
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
+
290
510
  // PreToolUse blocks via hookSpecificOutput.permissionDecision
291
511
  // (allow|deny|ask), NOT top-level decision - this is the real Claude Code
292
512
  // mechanism. "deny" blocks (reason = permissionDecisionReason). "allow"
@@ -313,12 +533,12 @@ function runHook(hookSpec, payload, opts = {}) {
313
533
  if (r.reason == null) r.reason = '';
314
534
  } else if (decision === 'block') {
315
535
  r.blocked = true;
316
- r.reason = jsonReason; // block reason from JSON.reason (§4)
536
+ r.reason = jsonReason; // block reason from JSON.reason
317
537
  }
318
538
  if (cont === false) {
319
- r.stopLoop = true; // continue:false -> stop the whole loop (§3-B, §4)
539
+ r.stopLoop = true; // continue:false -> stop the whole loop
320
540
  }
321
- // additionalContext is the injected text when present (§3-B, §4).
541
+ // additionalContext is the injected text when present.
322
542
  if (additional !== null) {
323
543
  r.inject = additional;
324
544
  }
@@ -326,7 +546,7 @@ function runHook(hookSpec, payload, opts = {}) {
326
546
  return;
327
547
  }
328
548
 
329
- // A) Simple / exit-code protocol (§3-A), exit 0.
549
+ // A) Simple / exit-code protocol, exit 0.
330
550
  if (r.exitCode === 0) {
331
551
  // Success. For SessionStart / UserPromptSubmit, stdout IS the injection.
332
552
  // For other events stdout is advisory only (captured, not injected).
@@ -336,7 +556,7 @@ function runHook(hookSpec, payload, opts = {}) {
336
556
  }
337
557
  }
338
558
  // Any other non-zero (non-2) code -> non-blocking error: stderr captured,
339
- // blocked stays false, reason stays null (§3-A).
559
+ // blocked stays false, reason stays null.
340
560
 
341
561
  finish(r);
342
562
  });