@celilo/cli 1.7.0 → 1.9.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 (74) hide show
  1. package/CELILO_CORE_MODULES.md +3 -0
  2. package/CELILO_SUBSYSTEMS.md +7 -1
  3. package/drizzle/0027_dns_internal_records_consumer_cascade.sql +43 -0
  4. package/drizzle/0028_capability_bindings.sql +26 -0
  5. package/drizzle/0029_module_instances.sql +58 -0
  6. package/drizzle/meta/_journal.json +22 -1
  7. package/package.json +2 -2
  8. package/src/capabilities/validation.test.ts +51 -0
  9. package/src/capabilities/validation.ts +22 -8
  10. package/src/cli/commands/module-show.ts +1 -0
  11. package/src/db/dns-internal-cascade-migration.test.ts +184 -0
  12. package/src/db/foreign-keys.test.ts +101 -0
  13. package/src/db/schema.ts +182 -9
  14. package/src/hooks/broker.test.ts +152 -0
  15. package/src/hooks/broker.ts +307 -0
  16. package/src/hooks/capability-loader-bindings.test.ts +163 -0
  17. package/src/hooks/capability-loader-firewall.test.ts +108 -0
  18. package/src/hooks/capability-loader.test.ts +10 -2
  19. package/src/hooks/capability-loader.ts +59 -2
  20. package/src/hooks/executor.ts +234 -111
  21. package/src/hooks/hook-protocol.test.ts +192 -0
  22. package/src/hooks/hook-protocol.ts +275 -0
  23. package/src/hooks/hook-runner.ts +231 -0
  24. package/src/hooks/hook-timeout.test.ts +103 -0
  25. package/src/hooks/hook-trespass.test.ts +201 -0
  26. package/src/hooks/injected-capabilities.test.ts +75 -0
  27. package/src/hooks/test-fixtures/capability-calling-hook.ts +79 -0
  28. package/src/hooks/test-fixtures/runaway-hook.ts +26 -0
  29. package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +22 -0
  30. package/src/manifest/template-validator.test.ts +47 -0
  31. package/src/manifest/template-validator.ts +18 -1
  32. package/src/manifest/validate-provider-views.test.ts +61 -0
  33. package/src/manifest/validate.ts +21 -14
  34. package/src/module/import.ts +19 -1
  35. package/src/module/packaging/module-state-directory.test.ts +99 -0
  36. package/src/module/packaging/package-rules.ts +10 -2
  37. package/src/policy/capability-shape-baseline.ts +96 -0
  38. package/src/policy/capability-shape-drift.test.ts +162 -0
  39. package/src/policy/capability-shape.ts +129 -0
  40. package/src/policy/dns-aspect-coverage.test.ts +100 -0
  41. package/src/policy/module-business-baseline.ts +68 -7
  42. package/src/services/alerting/ack.test.ts +2 -2
  43. package/src/services/alerting/deferral.test.ts +2 -2
  44. package/src/services/alerting/delivery-loop.test.ts +2 -2
  45. package/src/services/alerting/deploy-hooks.test.ts +2 -2
  46. package/src/services/alerting/inbound-poller.test.ts +2 -2
  47. package/src/services/alerting/inbound.test.ts +2 -2
  48. package/src/services/alerting/notification-responder.test.ts +2 -2
  49. package/src/services/alerting/run-monitor.test.ts +2 -2
  50. package/src/services/alerting/store.test.ts +2 -2
  51. package/src/services/alerting/sweep-runner.test.ts +2 -2
  52. package/src/services/alerting/tokens.test.ts +2 -2
  53. package/src/services/capability-bindings.test.ts +104 -0
  54. package/src/services/capability-bindings.ts +107 -0
  55. package/src/services/capability-table-rows.test.ts +191 -0
  56. package/src/services/capability-table-rows.ts +103 -0
  57. package/src/services/consumer-cleanup.test.ts +40 -3
  58. package/src/services/consumer-cleanup.ts +13 -7
  59. package/src/services/dns-internal-records.test.ts +74 -3
  60. package/src/services/fleet-checks.test.ts +4 -4
  61. package/src/services/module-instances.test.ts +198 -0
  62. package/src/services/module-instances.ts +96 -0
  63. package/src/services/module-journal.test.ts +2 -2
  64. package/src/services/module-subscriptions.test.ts +1 -1
  65. package/src/services/module-validator/capability-versions.test.ts +6 -1
  66. package/src/services/port-forwards.test.ts +8 -4
  67. package/src/services/port-forwards.ts +0 -11
  68. package/src/services/trusted-sources.test.ts +3 -3
  69. package/src/services/trusted-sources.ts +0 -5
  70. package/src/templates/ingress-ip.test.ts +31 -0
  71. package/src/test-utils/database.ts +31 -1
  72. package/src/variables/context.ts +75 -10
  73. package/src/variables/lxc-nameserver.test.ts +144 -0
  74. package/src/test-utils/setup-test-db.ts +0 -80
@@ -40,6 +40,7 @@ import {
40
40
  } from '../db/schema';
41
41
  import { decryptSecret } from '../secrets/encryption';
42
42
  import { getOrCreateMasterKey } from '../secrets/master-key';
43
+ import { recordCapabilityBinding, withBindingRecord } from '../services/capability-bindings';
43
44
  import { emitWebRoutesChangedAndWait } from '../services/celilo-events';
44
45
  import { getModuleSystems } from '../services/deployed-systems';
45
46
  import { withDnsInternalLedger } from '../services/dns-internal-records';
@@ -224,6 +225,11 @@ export async function loadCapabilityFunctions(
224
225
  logger: HookLogger,
225
226
  ): Promise<Record<string, unknown>> {
226
227
  const result: Record<string, unknown> = {};
228
+ // Which provider each injected capability came from, so the binding recorded
229
+ // at the return names the provider the consumer actually reached. Provider
230
+ // self-views (`firewall_registry`, `web_routes`) are deliberately absent —
231
+ // a module reading its own registry has not bound to anything.
232
+ const providerByCapability = new Map<string, string>();
227
233
 
228
234
  const masterKey = await getOrCreateMasterKey();
229
235
 
@@ -270,6 +276,11 @@ export async function loadCapabilityFunctions(
270
276
  );
271
277
  if (chain) {
272
278
  result[capName] = chain;
279
+ // The consumer talks to the OUTERMOST layer, which is not necessarily
280
+ // the edge provider. Every layer is `stampProvider`'d on the way out,
281
+ // so the one it was handed names itself.
282
+ const chainProvider = (chain as { providerModuleId?: string }).providerModuleId;
283
+ if (chainProvider) providerByCapability.set(capName, chainProvider);
273
284
  }
274
285
  // The chain hands a CONSUMER the innermost layer, which is not this
275
286
  // provider's own layer when it sits further out. `on_consumer_removed`
@@ -377,6 +388,7 @@ export async function loadCapabilityFunctions(
377
388
  // greenwave — true, useless, and a violation of the contract's
378
389
  // requirement to name the provider.
379
390
  result[capName] = withLedger(stampProvider(capabilityInterface, capability.moduleId));
391
+ providerByCapability.set(capName, capability.moduleId);
380
392
  debugLog(`${capName}: loaded via defineCapabilityFunction`);
381
393
  continue;
382
394
  }
@@ -403,6 +415,7 @@ export async function loadCapabilityFunctions(
403
415
  result[capName] = withLedger(
404
416
  wrapWithLogging(capabilityInterface as object, logger, capName),
405
417
  );
418
+ providerByCapability.set(capName, capability.moduleId);
406
419
  debugLog(`${capName}: loaded via legacy factory`);
407
420
  // Sole firewall provider running its OWN hook: the interface just built
408
421
  // IS its layer, so hand it back under the provider-view name too.
@@ -544,6 +557,10 @@ export async function loadCapabilityFunctions(
544
557
  }
545
558
 
546
559
  try {
560
+ // The name comes off the row rather than being restated here: core
561
+ // naming a capability is what `no-module-business-in-core` Scan B counts,
562
+ // and this is bookkeeping, not a branch.
563
+ providerByCapability.set(provider.capabilityName, provider.moduleId);
547
564
  result.public_web = createPublicWeb({
548
565
  moduleId: consumingModuleId,
549
566
  logger,
@@ -610,9 +627,12 @@ export async function loadCapabilityFunctions(
610
627
  // — symmetric with how consumers get the public_web capability. Consumers
611
628
  // never see this; only the provider does.
612
629
  if (consumingModuleId === provider.moduleId) {
630
+ // Async because the consumer is a hook, which now runs in its own
631
+ // process. `routeOps` itself stays synchronous: it is bun:sqlite and it
632
+ // is called in-process by `createPublicWeb`.
613
633
  const routeView: RouteReadView = {
614
- getAllRoutes: () => routeOps.getAllRoutes(),
615
- getRoutes: (m: string) => routeOps.getRoutes(m),
634
+ getAllRoutes: async () => routeOps.getAllRoutes(),
635
+ getRoutes: async (m: string) => routeOps.getRoutes(m),
616
636
  };
617
637
  result.web_routes = routeView;
618
638
  debugLog(`web_routes: read-only route view injected for provider ${consumingModuleId}`);
@@ -621,6 +641,32 @@ export async function loadCapabilityFunctions(
621
641
  debugLog('public_web: not registered in DB, skipping');
622
642
  }
623
643
 
644
+ // celilo#1072: the CALL is the binding, not the resolution. Everything above
645
+ // is injected whether or not the consumer declared it — the loop's own
646
+ // comment says "not just required ones" — so recording what was resolved
647
+ // would name every provider on the fleet. A method invocation is the only
648
+ // event that separates the optional capability a module uses from the ones it
649
+ // merely declares.
650
+ for (const [capName, iface] of Object.entries(result)) {
651
+ const providerModuleId = providerByCapability.get(capName);
652
+ // A module that provides and consumes the same capability is not bound to
653
+ // itself, and a provider reading its own registry is not a consumer.
654
+ if (!providerModuleId || providerModuleId === consumingModuleId) continue;
655
+ if (!iface || typeof iface !== 'object') continue;
656
+ result[capName] = withBindingRecord(iface as object, () => {
657
+ try {
658
+ recordCapabilityBinding(db, consumingModuleId, capName, providerModuleId);
659
+ } catch (error) {
660
+ // Bookkeeping must never fail the call it is observing. Loud, not silent.
661
+ logger.warn(
662
+ `Could not record the ${capName} binding ${consumingModuleId} → ${providerModuleId}: ${
663
+ error instanceof Error ? error.message : String(error)
664
+ }`,
665
+ );
666
+ }
667
+ });
668
+ }
669
+
624
670
  return result;
625
671
  }
626
672
 
@@ -674,6 +720,11 @@ function buildCapabilityInterface(
674
720
  trustedSubnets: zones?.trustedSubnets ?? [],
675
721
  controlPlaneSubnet: zones?.controlPlaneSubnet,
676
722
  frontedSubnets: zones?.frontedSubnets ?? [],
723
+ // Operator settings, forwarded verbatim. `parseStoredConfigValue`
724
+ // preserves each manifest-declared type, so the boolean arrives as a
725
+ // boolean and needs no coercion here.
726
+ defaultRouteZone: config.default_route_zone as string | undefined,
727
+ isolateTransitNetwork: config.isolate_transit_network as boolean | undefined,
677
728
  },
678
729
  store,
679
730
  undefined, // no upstream — the chain path handles that
@@ -1191,6 +1242,12 @@ async function buildFirewallChain(
1191
1242
  // Absent means the box has never converged cleanly, so an interface
1192
1243
  // celilo cannot attribute refuses rather than being disabled.
1193
1244
  interfaceBaseline: parseInterfaceBaseline(provConfig.interface_baseline),
1245
+ // Which declared zone carries this firewall's default route, and
1246
+ // whether fronted zones may initiate into it. Both are operator
1247
+ // settings and both are read HERE or nowhere: the module declares them
1248
+ // on `FirewallConfig`, and nothing else in celilo constructs one.
1249
+ defaultRouteZone: provConfig.default_route_zone as string | undefined,
1250
+ isolateTransitNetwork: provConfig.isolate_transit_network as boolean | undefined,
1194
1251
  },
1195
1252
  store,
1196
1253
  currentUpstream,
@@ -7,14 +7,28 @@
7
7
  * - Output validation
8
8
  * - Structured logging
9
9
  *
10
- * **Execution model:** hook scripts run *in-process* on the celilo CLI host.
11
- * `runScript` dynamically `import()`s the hook module and awaits its default
12
- * export. They do NOT execute on the target machine. A hook that needs to
13
- * touch the target initiates SSH outbound itself (see e.g.
14
- * modules/lunacycle/celilo/scripts/health-check.ts's `ssh()` helper). This
15
- * means hook scripts can freely use npm packages, make HTTP calls, read
16
- * local files, etc. — and conversely, anything they depend on (chromium,
10
+ * **Execution model:** a hook script is a program celilo RUNS, not a library
11
+ * celilo loads. `executeHookScript` spawns `bun hook-runner.ts <script>` and
12
+ * becomes a broker: it keeps the database, the master key and the live
13
+ * capability objects, and answers the child over a versioned NDJSON protocol
14
+ * on a Unix socket (`hook-protocol.ts`, `broker.ts`). See
15
+ * `openspec/changes/hook-process-boundary/design.md`.
16
+ *
17
+ * Three things follow, and they are the point:
18
+ *
19
+ * - The child's environment is an ALLOW-LIST, not `...process.env`. celilo's
20
+ * own paths and the operator's tokens stop being visible (design D5).
21
+ * - A timeout is a real `proc.kill()`. The old implementation raced the
22
+ * hook's promise against a timer and cancelled nothing, so a hook that
23
+ * "timed out" kept its capability objects and went on registering DNS
24
+ * records after celilo reported the deploy failed (celilo#1003).
25
+ * - A hook's memory is its own process's, not celilo's heap.
26
+ *
27
+ * Hooks still do NOT execute on the target machine. One that needs to touch a
28
+ * target initiates SSH outbound itself, so anything it depends on (chromium,
17
29
  * system binaries, credentials) must be available on the celilo CLI host.
30
+ * Stage 2 of the change above jails the filesystem and stage 3 scopes that
31
+ * reachability; neither has landed.
18
32
  *
19
33
  * Execution function (Rule 10.1) - performs side effects (script execution)
20
34
  */
@@ -23,7 +37,6 @@ import { existsSync, mkdirSync, readdirSync, rmdirSync, statSync } from 'node:fs
23
37
  import { dirname, join, resolve } from 'node:path';
24
38
  import {
25
39
  type DeployedSystem,
26
- isCompiledHook,
27
40
  isMissingProviderInputError,
28
41
  moduleArtifactDir,
29
42
  } from '@celilo/capabilities';
@@ -35,6 +48,14 @@ import {
35
48
  } from '../manifest/contracts';
36
49
  import { isPrivilegedCapability } from '../manifest/validate';
37
50
  import { pruneModuleArtifacts } from './artifact-retention';
51
+ import { startBroker } from './broker';
52
+ import {
53
+ HOOK_PROTOCOL_VERSION,
54
+ HOOK_PROTOCOL_VERSION_ENV,
55
+ HOOK_SOCKET_ENV,
56
+ createLineReader,
57
+ deserializeError,
58
+ } from './hook-protocol';
38
59
  import type { HookContext, HookDefinition, HookLogger, HookResult } from './types';
39
60
 
40
61
  /** Default total timeout: 60 seconds */
@@ -51,6 +72,76 @@ const DEFAULT_TIMEOUT_MS = 60_000;
51
72
  */
52
73
  const IDLE_TIMEOUT_MS = 30_000;
53
74
 
75
+ /** How often the idle tracker looks. Unchanged from the promise-based timer. */
76
+ const IDLE_POLL_MS = 5_000;
77
+
78
+ /**
79
+ * SIGTERM is a request. A hook that traps it and keeps working would leave the
80
+ * boundary buying nothing over the promise race it replaced, so SIGKILL
81
+ * follows. Short, because by this point celilo has already told the operator
82
+ * the hook failed.
83
+ */
84
+ const SIGKILL_GRACE_MS = 2_000;
85
+
86
+ /**
87
+ * Environment variables a hook inherits. Everything else is dropped (D5).
88
+ *
89
+ * Two kinds, and the distinction is the whole rule.
90
+ *
91
+ * **How to run** — `PATH`, `HOME`, `LANG`, `TZ`, `TMPDIR`. A hook that spawns
92
+ * anything needs these, and withholding them hides no path.
93
+ *
94
+ * **How to reach the network, and whom to trust** — the CA and proxy families.
95
+ * These configure the RUNTIME, not the module: they say which certificate
96
+ * authorities this host trusts and how it reaches the internet. They name no
97
+ * secret, they are not module-specific, and withholding them reduces a hook's
98
+ * authority by nothing at all. It only makes its TLS connections wrong.
99
+ *
100
+ * `NODE_EXTRA_CA_CERTS` is why this second group exists rather than being an
101
+ * afterthought. `packages/e2e/docker/Dockerfile.management:107` sets it because
102
+ * `bun fetch()` otherwise ignores `/etc/ssl/certs/ca-certificates.crt`
103
+ * entirely and rejects every certificate the simulated CA issues. Twelve
104
+ * hook-reachable module files make HTTPS requests. Dropping it would have
105
+ * broken all of them, in the rig only, as a TLS error naming no cause.
106
+ *
107
+ * The proxy family is not set anywhere today. It is here because it is the
108
+ * same kind of thing, it costs nothing when absent, and the alternative is
109
+ * discovering it the way `NODE_EXTRA_CA_CERTS` was discovered.
110
+ *
111
+ * **`TF_CLI_CONFIG_FILE` is deliberately NOT here.** It is toolchain
112
+ * configuration too, but no hook invokes the terraform binary — every mention
113
+ * of terraform in a module script is a `generated/terraform/` PATH. celilo runs
114
+ * terraform from its own process, which keeps its own environment. Recorded so
115
+ * the next reader does not have to re-derive it.
116
+ *
117
+ * What is NOT in either group: anything module-specific. A module that needs a
118
+ * value declares it (config) or is handed it (a hook input). See the note on
119
+ * `DDNS_ENDPOINT` in openspec/changes/hook-process-boundary/baseline.md for the
120
+ * one case that got this wrong and what was done about it.
121
+ */
122
+ const FORWARDED_ENV = [
123
+ // How to run.
124
+ 'PATH',
125
+ 'HOME',
126
+ 'LANG',
127
+ 'TZ',
128
+ 'TMPDIR',
129
+ // Whom to trust.
130
+ 'NODE_EXTRA_CA_CERTS',
131
+ 'SSL_CERT_FILE',
132
+ 'SSL_CERT_DIR',
133
+ // How to reach the network.
134
+ 'HTTP_PROXY',
135
+ 'HTTPS_PROXY',
136
+ 'NO_PROXY',
137
+ 'http_proxy',
138
+ 'https_proxy',
139
+ 'no_proxy',
140
+ ] as const;
141
+
142
+ /** The shim celilo spawns. Resolved from here so an npm install finds it too. */
143
+ const HOOK_RUNNER_PATH = join(import.meta.dir, 'hook-runner.ts');
144
+
54
145
  /**
55
146
  * Validate hook inputs against a contract signature.
56
147
  *
@@ -148,127 +239,159 @@ export async function executeHookScript(
148
239
  throw new Error(`Hook script not found: ${scriptPath}`);
149
240
  }
150
241
 
151
- // Create an idle timeout tracker
242
+ // Activity is now ANY frame or ANY byte the child writes, rather than only a
243
+ // `ctx.logger` call. That is strictly more generous than the old tracker and
244
+ // it removes a class of false idle-kill: a hook that prints progress to
245
+ // stdout used to look silent (celilo#622 is the same family).
152
246
  let lastActivity = Date.now();
153
- const originalLogger = context.logger;
154
-
155
- // Wrap logger to track activity for idle timeout
156
- const trackedLogger: HookLogger = {
157
- info(message: string) {
158
- lastActivity = Date.now();
159
- originalLogger.info(message);
160
- },
161
- warn(message: string) {
162
- lastActivity = Date.now();
163
- originalLogger.warn(message);
164
- },
165
- error(message: string) {
166
- lastActivity = Date.now();
167
- originalLogger.error(message);
168
- },
169
- success(message: string) {
170
- lastActivity = Date.now();
171
- originalLogger.success(message);
172
- },
247
+ const markActive = () => {
248
+ lastActivity = Date.now();
173
249
  };
174
250
 
175
- const trackedContext: HookContext = {
176
- ...context,
177
- logger: trackedLogger,
178
- };
251
+ const { logger, ...serializableContext } = context;
252
+ delete (serializableContext as Record<string, unknown>).capabilities;
179
253
 
180
- // Execute with timeout — disable idle timeout in debug mode
181
- const promises: Promise<Record<string, unknown>>[] = [
182
- runScript(scriptPath, trackedContext),
183
- createTimeoutPromise(timeoutMs, 'total'),
184
- ];
185
- if (!context.debug) {
186
- promises.push(createIdleTimeoutPromise(() => Date.now() - lastActivity, idleTimeoutMs));
187
- }
188
- const result = await Promise.race(promises);
254
+ const broker = await startBroker({
255
+ capabilities: context.capabilities,
256
+ context: serializableContext as Record<string, unknown>,
257
+ scriptPath,
258
+ logger,
259
+ onActivity: markActive,
260
+ });
189
261
 
190
- return result;
191
- }
262
+ try {
263
+ const child = Bun.spawn({
264
+ cmd: [process.execPath, HOOK_RUNNER_PATH],
265
+ env: hookChildEnv(broker.socketPath),
266
+ stdout: 'pipe',
267
+ stderr: 'pipe',
268
+ });
269
+
270
+ // The child's stdout and stderr stay what they are — human output. They
271
+ // now reach `ctx.logger` instead of celilo's own stdout, which is the
272
+ // attribution: a `console.log` from an in-process hook landed unlabelled
273
+ // in the CLI's output with nothing saying which hook wrote it.
274
+ const forwarding = Promise.all([
275
+ forwardStream(child.stdout, logger.info.bind(logger), markActive),
276
+ forwardStream(child.stderr, logger.warn.bind(logger), markActive),
277
+ ]);
278
+
279
+ let killedFor: string | null = null;
280
+ const kill = (reason: string) => {
281
+ if (killedFor) return;
282
+ killedFor = reason;
283
+ // Refuse capability calls FIRST. A hook between `kill` and its own death
284
+ // can still have a call in flight, and answering it is exactly the
285
+ // failure this replaces.
286
+ broker.stop();
287
+ child.kill('SIGTERM');
288
+ setTimeout(() => child.kill('SIGKILL'), SIGKILL_GRACE_MS).unref();
289
+ };
192
290
 
193
- /**
194
- * Run a hook script by dynamically importing it.
195
- *
196
- * HOOK_API_V2 Phase 8 (D8): the executor only accepts hook scripts that
197
- * use `defineHook` from `@celilo/capabilities`. The compiled output
198
- * carries a brand symbol (`CELILO_HOOK_BRAND`) that this function
199
- * checks via `isCompiledHook`. Raw `async function(context)` exports are
200
- * rejected with a clear error pointing at the migration guide — no more
201
- * silent failures from forgotten brand registration.
202
- *
203
- * @param scriptPath - Absolute path to script
204
- * @param context - Hook context
205
- * @returns Script outputs
206
- */
207
- async function runScript(
208
- scriptPath: string,
209
- context: HookContext,
210
- ): Promise<Record<string, unknown>> {
211
- const module = await import(scriptPath);
291
+ const totalTimer = setTimeout(() => kill('total'), timeoutMs);
292
+ // Debug runs get no idle timer, same as before: an operator stepping
293
+ // through a browser hook is silent for minutes on purpose.
294
+ const idleTimer = context.debug
295
+ ? undefined
296
+ : setInterval(() => {
297
+ if (Date.now() - lastActivity >= idleTimeoutMs) kill('idle');
298
+ }, IDLE_POLL_MS);
299
+
300
+ let exitCode: number;
301
+ try {
302
+ exitCode = await child.exited;
303
+ // Both channels, not just the process: the terminal frame can still be
304
+ // in the socket buffer when the exit is observed.
305
+ await broker.drained();
306
+ await forwarding;
307
+ } finally {
308
+ clearTimeout(totalTimer);
309
+ if (idleTimer) clearInterval(idleTimer);
310
+ }
212
311
 
213
- if (typeof module.default !== 'function') {
214
- throw new Error(`Hook script must export a default function: ${scriptPath}`);
215
- }
312
+ // Process exit is the ONE terminal event, so there is no race to lose and
313
+ // nothing keeps running past this line. That is the difference from the
314
+ // `Promise.race` this replaces (celilo#1003).
315
+ if (killedFor === 'total') {
316
+ throw new Error(`Hook total timeout exceeded (${Math.round(timeoutMs / 1000)}s)`);
317
+ }
318
+ if (killedFor === 'idle') {
319
+ throw new Error(
320
+ `Hook idle timeout exceeded (no log output for ${Math.round(idleTimeoutMs / 1000)}s)`,
321
+ );
322
+ }
216
323
 
217
- if (!isCompiledHook(module.default)) {
218
- throw new Error(
219
- `Hook script ${scriptPath} does not use defineHook(). As of HOOK_API_V2 Phase 8, all hook scripts must wrap their handler with defineHook from @celilo/capabilities so the executor can verify the brand and apply pre-flight checks. See reference/MODULE_DEVELOPMENT_GUIDE.md "Hooks" section for the migration pattern.`,
220
- );
324
+ const outcome = broker.outcome();
325
+ if (!outcome) {
326
+ const faults = broker.faults();
327
+ throw new Error(
328
+ `Hook process exited with code ${exitCode} without returning a result.${
329
+ faults.length > 0 ? ` ${faults.join('; ')}` : ''
330
+ }`,
331
+ );
332
+ }
333
+
334
+ if (!outcome.ok) throw deserializeError(outcome.error);
335
+ return outcome.outputs;
336
+ } finally {
337
+ broker.close();
221
338
  }
339
+ }
222
340
 
223
- const result = await module.default(context);
341
+ /**
342
+ * The child's environment, built from an allow-list (design D5).
343
+ *
344
+ * NOT `...process.env`. `packages/event-bus/src/dispatcher.ts` spawns handlers
345
+ * that way and it grants a subprocess MORE than the in-process call did — the
346
+ * whole environment plus an explicit database path. Copying that pattern would
347
+ * buy the crash isolation and none of the authority reduction.
348
+ *
349
+ * Measured on the operator's own shell: what a hook inherited before this was
350
+ * 24 variables naming celilo state or carrying a credential, twenty of them
351
+ * third-party API tokens with nothing to do with celilo
352
+ * (`openspec/changes/hook-process-boundary/baseline.md`).
353
+ *
354
+ * This is not the boundary — a hook can still COMPUTE the default master-key
355
+ * path from `$HOME` and read it. It is the cheap half, and it is what stops the
356
+ * dispatcher's mistake repeating here. Stage 2's mount set is what makes the
357
+ * path unreachable.
358
+ *
359
+ * Planning function (Rule 10.4) — pure.
360
+ */
361
+ export function hookChildEnv(socketPath: string): Record<string, string> {
362
+ const env: Record<string, string> = {};
224
363
 
225
- if (result === null || result === undefined) {
226
- return {};
364
+ for (const name of FORWARDED_ENV) {
365
+ const value = process.env[name];
366
+ if (value !== undefined) env[name] = value;
227
367
  }
228
368
 
229
- if (typeof result !== 'object' || Array.isArray(result)) {
230
- throw new Error('Hook script must return an object (or nothing)');
231
- }
369
+ env[HOOK_SOCKET_ENV] = socketPath;
370
+ env[HOOK_PROTOCOL_VERSION_ENV] = String(HOOK_PROTOCOL_VERSION);
371
+ // Forwarded when the operator set it, not synthesised from `debug` — a hook
372
+ // reads the flag off `ctx.debug`, which crosses in the context frame.
373
+ if (process.env.CELILO_DEBUG !== undefined) env.CELILO_DEBUG = process.env.CELILO_DEBUG;
232
374
 
233
- return result as Record<string, unknown>;
375
+ return env;
234
376
  }
235
377
 
236
- /**
237
- * Create a promise that rejects after a total timeout
238
- */
239
- function createTimeoutPromise(timeoutMs: number, type: string): Promise<never> {
240
- return new Promise((_, reject) => {
241
- setTimeout(() => {
242
- reject(new Error(`Hook ${type} timeout exceeded (${Math.round(timeoutMs / 1000)}s)`));
243
- }, timeoutMs);
378
+ /** Read a piped stream line by line into the logger. */
379
+ async function forwardStream(
380
+ stream: ReadableStream<Uint8Array>,
381
+ emit: (line: string) => void,
382
+ markActive: () => void,
383
+ ): Promise<void> {
384
+ const decoder = new TextDecoder();
385
+ const feed = createLineReader((line) => {
386
+ markActive();
387
+ emit(line);
244
388
  });
245
- }
246
389
 
247
- /**
248
- * Create a promise that rejects when idle timeout is exceeded
249
- * Polls every 5 seconds to check idle duration
250
- */
251
- function createIdleTimeoutPromise(
252
- getIdleDuration: () => number,
253
- idleTimeoutMs: number,
254
- ): Promise<never> {
255
- return new Promise((_, reject) => {
256
- const interval = setInterval(() => {
257
- if (getIdleDuration() >= idleTimeoutMs) {
258
- clearInterval(interval);
259
- reject(
260
- new Error(
261
- `Hook idle timeout exceeded (no log output for ${Math.round(idleTimeoutMs / 1000)}s)`,
262
- ),
263
- );
264
- }
265
- }, 5000);
266
-
267
- // Don't block process exit
268
- if (interval.unref) {
269
- interval.unref();
270
- }
271
- });
390
+ for await (const chunk of stream) {
391
+ feed(decoder.decode(chunk, { stream: true }));
392
+ }
393
+ // A final line with no trailing newline would otherwise be dropped.
394
+ feed('\n');
272
395
  }
273
396
 
274
397
  export interface InvokeHookOptions {