@celilo/cli 2.2.0 → 2.3.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 (180) hide show
  1. package/CELILO_CORE_MODULES.md +1 -0
  2. package/CELILO_SUBSYSTEMS.md +1 -1
  3. package/README.md +1 -1
  4. package/drizzle/0032_module_jail_policies.sql +28 -0
  5. package/drizzle/0033_build_bus_hook_runs.sql +37 -0
  6. package/drizzle/meta/_journal.json +15 -1
  7. package/package.json +3 -3
  8. package/schemas/system_config.json +5 -0
  9. package/src/__integration__/container-services-cli.integration.test.ts +1 -1
  10. package/src/ansible/inventory.test.ts +2 -1
  11. package/src/api/sessions.test.ts +5 -4
  12. package/src/api-clients/proxmox.ts +10 -7
  13. package/src/cli/backup-rename.test.ts +5 -4
  14. package/src/cli/cli.test.ts +3 -2
  15. package/src/cli/commands/console-get-chain.test.ts +2 -1
  16. package/src/cli/commands/events.test.ts +2 -2
  17. package/src/cli/commands/firewall-interface-list.test.ts +4 -3
  18. package/src/cli/commands/machine-list.test.ts +57 -0
  19. package/src/cli/commands/machine-list.ts +35 -4
  20. package/src/cli/commands/module-config.test.ts +2 -1
  21. package/src/cli/commands/module-deploy.ts +8 -2
  22. package/src/cli/commands/module-generate.test.ts +53 -0
  23. package/src/cli/commands/module-generate.ts +31 -26
  24. package/src/cli/commands/module-health.ts +1 -0
  25. package/src/cli/commands/module-import-aspect.test.ts +2 -1
  26. package/src/cli/commands/module-import-registry.test.ts +3 -2
  27. package/src/cli/commands/module-jail.test.ts +242 -0
  28. package/src/cli/commands/module-jail.ts +227 -0
  29. package/src/cli/commands/module-list-jail.test.ts +136 -0
  30. package/src/cli/commands/module-list.ts +30 -3
  31. package/src/cli/commands/module-operations.test.ts +2 -1
  32. package/src/cli/commands/module-publish.test.ts +11 -18
  33. package/src/cli/commands/module-update.test.ts +28 -12
  34. package/src/cli/commands/module-update.ts +4 -1
  35. package/src/cli/commands/module-upgrade.test.ts +130 -1
  36. package/src/cli/commands/module-upgrade.ts +77 -3
  37. package/src/cli/commands/module-verify.test.ts +3 -4
  38. package/src/cli/commands/module-verify.ts +0 -1
  39. package/src/cli/commands/notify-config.test.ts +5 -3
  40. package/src/cli/commands/publish/execute.ts +4 -1
  41. package/src/cli/commands/publish/index.ts +11 -1
  42. package/src/cli/commands/publish/module-registry.test.ts +24 -1
  43. package/src/cli/commands/publish/module-registry.ts +23 -4
  44. package/src/cli/commands/publish/plan.ts +1 -1
  45. package/src/cli/commands/publish/types.ts +7 -0
  46. package/src/cli/commands/registry-owner.test.ts +3 -2
  47. package/src/cli/commands/registry-token.test.ts +3 -2
  48. package/src/cli/commands/system-apply-config-equivalence.test.ts +5 -7
  49. package/src/cli/commands/system-audit.ts +20 -6
  50. package/src/cli/commands/system-config.test.ts +148 -0
  51. package/src/cli/commands/system-config.ts +26 -1
  52. package/src/cli/commands/system-doctor-remediation-gate.test.ts +295 -0
  53. package/src/cli/commands/system-doctor.test.ts +143 -6
  54. package/src/cli/commands/system-doctor.ts +142 -25
  55. package/src/cli/commands/system-init-deprecation.test.ts +7 -4
  56. package/src/cli/commands/system-migrate.test.ts +2 -1
  57. package/src/cli/commands/system-update.ts +5 -0
  58. package/src/cli/completion.ts +8 -2
  59. package/src/cli/flag-surface-gate.test.ts +279 -0
  60. package/src/cli/index.ts +70 -3
  61. package/src/cli/parser.test.ts +37 -1
  62. package/src/cli/restore-command.test.ts +5 -4
  63. package/src/cli/restore-migration-failure.test.ts +4 -3
  64. package/src/cli/tui/audit-state.ts +2 -0
  65. package/src/config/paths.test.ts +22 -22
  66. package/src/db/client.test.ts +46 -1
  67. package/src/db/client.ts +26 -0
  68. package/src/db/migrate.test.ts +2 -1
  69. package/src/db/migrate.ts +16 -16
  70. package/src/db/schema.ts +63 -0
  71. package/src/hooks/capability-loader-firewall.test.ts +9 -1
  72. package/src/hooks/capability-loader.test.ts +78 -0
  73. package/src/hooks/capability-loader.ts +45 -2
  74. package/src/hooks/executor.test.ts +161 -1
  75. package/src/hooks/executor.ts +87 -21
  76. package/src/hooks/hook-jail-unreachability.test.ts +17 -18
  77. package/src/hooks/hook-store.test.ts +6 -0
  78. package/src/hooks/hook-trespass.test.ts +26 -9
  79. package/src/hooks/jail.test.ts +152 -5
  80. package/src/hooks/jail.ts +93 -4
  81. package/src/manifest/contracts/v1.ts +13 -0
  82. package/src/module/packaging/audit.ts +9 -26
  83. package/src/module/packaging/build-paths.test.ts +127 -0
  84. package/src/module/packaging/build-paths.ts +175 -0
  85. package/src/module/packaging/build.test.ts +71 -1
  86. package/src/module/packaging/build.ts +60 -0
  87. package/src/module/packaging/extract.ts +1 -5
  88. package/src/policy/module-script-scan.ts +51 -107
  89. package/src/policy/no-hand-built-ssh.test.ts +5 -1
  90. package/src/policy/no-swallowed-refusal.test.ts +14 -15
  91. package/src/registry/client.test.ts +2 -2
  92. package/src/secrets/storage.test.ts +1 -1
  93. package/src/services/alerting/ack.test.ts +2 -1
  94. package/src/services/alerting/cadence-migration.test.ts +3 -2
  95. package/src/services/alerting/coverage-source.test.ts +2 -1
  96. package/src/services/alerting/deferral.test.ts +2 -1
  97. package/src/services/alerting/delivery-loop.test.ts +2 -1
  98. package/src/services/alerting/deploy-hooks.test.ts +2 -1
  99. package/src/services/alerting/inbound-poller.test.ts +2 -1
  100. package/src/services/alerting/inbound.test.ts +2 -1
  101. package/src/services/alerting/keys.test.ts +4 -0
  102. package/src/services/alerting/keys.ts +12 -2
  103. package/src/services/alerting/notification-responder.test.ts +2 -1
  104. package/src/services/alerting/run-monitor.test.ts +2 -1
  105. package/src/services/alerting/store.test.ts +2 -1
  106. package/src/services/alerting/sweep-runner.test.ts +2 -1
  107. package/src/services/alerting/tokens.test.ts +2 -1
  108. package/src/services/aspect-approvals.test.ts +2 -1
  109. package/src/services/aspect-reconcile.test.ts +4 -3
  110. package/src/services/aspect-runner.test.ts +2 -1
  111. package/src/services/audit/health.test.ts +97 -2
  112. package/src/services/audit/health.ts +64 -2
  113. package/src/services/audit/index.test.ts +22 -0
  114. package/src/services/audit/index.ts +8 -2
  115. package/src/services/audit/jail-exemptions.test.ts +42 -0
  116. package/src/services/audit/jail-exemptions.ts +44 -0
  117. package/src/services/audit/module-integrity.test.ts +23 -22
  118. package/src/services/audit/module-integrity.ts +7 -16
  119. package/src/services/audit/types.ts +2 -1
  120. package/src/services/backup-age-agreement.test.ts +2 -1
  121. package/src/services/backup-create.ts +9 -0
  122. package/src/services/backup-envelope-roundtrip.test.ts +3 -2
  123. package/src/services/backup-in-flight-refusal.test.ts +3 -2
  124. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +48 -0
  125. package/src/services/build-bus/hook-dispatcher.ts +49 -1
  126. package/src/services/bus-ensure-flow.test.ts +3 -2
  127. package/src/services/bus-interview-park.test.ts +4 -3
  128. package/src/services/bus-interview.test.ts +2 -2
  129. package/src/services/bus-secret-flow.test.ts +3 -2
  130. package/src/services/capability-table-rows.test.ts +2 -1
  131. package/src/services/celilo-events.test.ts +1 -1
  132. package/src/services/celilo-mgmt-hooks.test.ts +23 -5
  133. package/src/services/consumer-cleanup.test.ts +3 -2
  134. package/src/services/container-service.test.ts +3 -2
  135. package/src/services/control-plane-bootstrap.test.ts +2 -1
  136. package/src/services/cross-module-read.test.ts +4 -3
  137. package/src/services/deploy-preflight.ts +7 -0
  138. package/src/services/deploy-validation.test.ts +3 -2
  139. package/src/services/deployed-systems.ts +1 -1
  140. package/src/services/dns-internal-records.test.ts +3 -2
  141. package/src/services/dns-provider-backfill.test.ts +2 -1
  142. package/src/services/dns-registrations.test.ts +2 -1
  143. package/src/services/ensure-interview.test.ts +3 -2
  144. package/src/services/fleet-checks.test.ts +126 -6
  145. package/src/services/fleet-checks.ts +176 -3
  146. package/src/services/fleet-key.test.ts +3 -2
  147. package/src/services/health-runner.test.ts +87 -2
  148. package/src/services/health-runner.ts +40 -11
  149. package/src/services/infrastructure-selector.test.ts +3 -2
  150. package/src/services/infrastructure-variable-resolver.test.ts +2 -1
  151. package/src/services/jail-exemptions.test.ts +125 -0
  152. package/src/services/jail-exemptions.ts +81 -0
  153. package/src/services/machine-pool.test.ts +3 -2
  154. package/src/services/module-config.test.ts +2 -1
  155. package/src/services/module-deploy.dns-repoint.test.ts +2 -1
  156. package/src/services/module-deploy.ts +48 -24
  157. package/src/services/module-operations.test.ts +2 -1
  158. package/src/services/module-subscriptions.test.ts +4 -3
  159. package/src/services/network-discovery.test.ts +64 -1
  160. package/src/services/network-discovery.ts +30 -5
  161. package/src/services/port-forwards.test.ts +2 -1
  162. package/src/services/programmatic-responder.aspect.test.ts +2 -1
  163. package/src/services/proxmox-reconcile.test.ts +2 -1
  164. package/src/services/responder-probe.test.ts +1 -1
  165. package/src/services/restore-from-file.test.ts +7 -6
  166. package/src/services/restore-preflight.test.ts +3 -2
  167. package/src/services/secret-schema-loader.test.ts +2 -1
  168. package/src/services/ssh-key-manager.test.ts +4 -3
  169. package/src/services/static-content-converge.test.ts +5 -4
  170. package/src/services/static-content-converge.ts +33 -10
  171. package/src/services/system-state-stage.test.ts +4 -3
  172. package/src/services/trusted-sources.test.ts +3 -2
  173. package/src/services/update/orchestrator.test.ts +2 -0
  174. package/src/services/zone-detector.test.ts +2 -1
  175. package/src/test-utils/bus-responder.ts +6 -4
  176. package/src/test-utils/db-path.ts +25 -0
  177. package/src/test-utils/integration.ts +7 -0
  178. package/src/test-utils/module-fixtures.ts +5 -6
  179. package/src/module/packaging/generated-plane.test.ts +0 -79
  180. package/src/module/packaging/generated-plane.ts +0 -134
@@ -293,6 +293,8 @@ describe('Firewall Chain Building', () => {
293
293
  upsertModuleConfig(db, 'iptables', 'nat_ip', '192.168.0.253');
294
294
  upsertModuleConfig(db, 'iptables', 'default_route_zone', 'isp-transit');
295
295
  upsertModuleConfig(db, 'iptables', 'isolate_transit_network', true);
296
+ upsertModuleConfig(db, 'iptables', 'interface_baseline', 'eth0,eth2');
297
+ upsertModuleConfig(db, 'iptables', 'interface_zone_map', 'eth0=isp-transit,eth2=internal');
296
298
 
297
299
  const config = await loadedConfig();
298
300
  expect(config.defaultRouteZone).toBe('isp-transit');
@@ -300,6 +302,8 @@ describe('Firewall Chain Building', () => {
300
302
  // preserves the manifest-declared type, and the module tests
301
303
  // `state.isolateTransitNetwork && ...` — where 'false' would be truthy.
302
304
  expect(config.isolateTransitNetwork).toBe(true);
305
+ expect(config.interfaceBaseline).toEqual(['eth0', 'eth2']);
306
+ expect(config.interfaceZoneMap).toBe('eth0=isp-transit,eth2=internal');
303
307
  });
304
308
 
305
309
  test('single provider: the recorded interface_baseline is forwarded as a parsed list', async () => {
@@ -358,13 +362,15 @@ describe('Firewall Chain Building', () => {
358
362
  upsertModuleConfig(db, 'iptables', 'default_route_zone', 'internal');
359
363
  upsertModuleConfig(db, 'iptables', 'isolate_transit_network', true);
360
364
  upsertModuleConfig(db, 'iptables', 'interface_baseline', 'eth0,eth1');
365
+ upsertModuleConfig(db, 'iptables', 'interface_zone_map', 'eth0=isp-transit,eth1=internal');
361
366
 
362
367
  const config = await loadedConfig();
363
368
  expect(config.defaultRouteZone).toBe('internal');
364
369
  expect(config.isolateTransitNetwork).toBe(true);
365
- // The downstream construction site already forwarded this; pinned so the
370
+ // The downstream construction site already forwarded these; pinned so the
366
371
  // two sites cannot drift apart again.
367
372
  expect(config.interfaceBaseline).toEqual(['eth0', 'eth1']);
373
+ expect(config.interfaceZoneMap).toBe('eth0=isp-transit,eth1=internal');
368
374
  });
369
375
 
370
376
  test('unset settings arrive undefined, not as a wrong default', async () => {
@@ -378,6 +384,8 @@ describe('Firewall Chain Building', () => {
378
384
  // would be silently overridden by a stale copy here.
379
385
  expect(config.defaultRouteZone).toBeUndefined();
380
386
  expect(config.isolateTransitNetwork).toBeUndefined();
387
+ expect(config.interfaceBaseline).toBeUndefined();
388
+ expect(config.interfaceZoneMap).toBeUndefined();
381
389
  });
382
390
  });
383
391
  });
@@ -7,7 +7,9 @@ import { mkdirSync, writeFileSync } from 'node:fs';
7
7
  import { tmpdir } from 'node:os';
8
8
  import { join } from 'node:path';
9
9
  import type { HookLogger, RouteReadView } from '@celilo/capabilities';
10
+ import { and, eq } from 'drizzle-orm';
10
11
  import type { DbClient } from '../db/client';
12
+ import { moduleConfigs } from '../db/schema';
11
13
  import { upsertModuleConfig } from '../services/module-config';
12
14
  import { cleanupTestDatabase, setupTestDatabase } from '../test-utils/database';
13
15
  import { loadCapabilityFunctions } from './capability-loader';
@@ -40,6 +42,36 @@ export default function createDhcpServer(context) {
40
42
  }
41
43
  `;
42
44
 
45
+ /**
46
+ * A factory that WRITES through the hook-owned-state accessor, so the loader's
47
+ * store view is proven end to end: the method's write lands as a hook-owned
48
+ * row, and an undeclared name throws instead of landing. Branded
49
+ * (defineCapabilityFunction, the Phase 8 pattern) — the branded path is the
50
+ * one the loader hands the store views to. The temp dir the fixture is
51
+ * dropped into has no node_modules link, so the import uses the workspace
52
+ * package by absolute path, the same trick capability-loader-firewall uses.
53
+ */
54
+ const TEST_STORE_WRITING_DHCP_MODULE = `
55
+ import { defineCapabilityFunction } from '${join(__dirname, '../../../..', 'packages/capabilities/src/index.ts')}';
56
+
57
+ export default defineCapabilityFunction({
58
+ capability: 'dhcp_server',
59
+ handler: (context) => ({
60
+ async setDnsServers() {},
61
+ async getDnsServers() { return [context.config.marker]; },
62
+ async setDomainName() {},
63
+ async getDomainName() { return context.config.marker; },
64
+ async claimIp() {
65
+ await context.config.set('public_ip', '203.0.113.7');
66
+ return 'claimed';
67
+ },
68
+ async claimUndeclared() {
69
+ await context.config.set('no_such_key', 'x');
70
+ },
71
+ }),
72
+ });
73
+ `;
74
+
43
75
  describe('Capability Loader', () => {
44
76
  let db: DbClient;
45
77
  let tempDir: string;
@@ -134,6 +166,52 @@ describe('Capability Loader', () => {
134
166
  expect(await dhcp.getDomainName()).toBe('internal-dhcp');
135
167
  });
136
168
 
169
+ test('a provider factory writes hook-owned config through the injected store view', async () => {
170
+ // The store view must do more than carry the plain record a factory reads.
171
+ // A method writing `context.config.set` lands a hook-owned row (validated
172
+ // against the PROVIDER's manifest, source column 'hook'), and an
173
+ // undeclared name throws instead of writing a phantom row.
174
+ const modulePath = join(tempDir, 'storewriting-vpn');
175
+ const scriptsDir = join(modulePath, 'scripts');
176
+ mkdirSync(scriptsDir, { recursive: true });
177
+ writeFileSync(join(scriptsDir, 'dhcp-server-functions.ts'), TEST_STORE_WRITING_DHCP_MODULE);
178
+ const manifest = JSON.stringify({
179
+ variables: { owns: [{ name: 'public_ip', type: 'string', source: 'hook' }] },
180
+ });
181
+ db.$client.run(
182
+ `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('storewriting-vpn', 'StoreWritingVpn', '1.0.0', '${modulePath}', '${manifest.replace(/'/g, "''")}')`,
183
+ );
184
+ db.$client.run(
185
+ `INSERT INTO capabilities (module_id, capability_name, version, data, registered_at) VALUES ('storewriting-vpn', 'dhcp_server', '1.0.0', '{}', unixepoch())`,
186
+ );
187
+ upsertModuleConfig(db, 'storewriting-vpn', 'marker', 'storewriting-vpn');
188
+
189
+ const result = await loadCapabilityFunctions('store-consumer', db, noopLogger);
190
+ const dhcp = result.dhcp_server as {
191
+ claimIp(): Promise<string>;
192
+ claimUndeclared(): Promise<void>;
193
+ };
194
+
195
+ expect(await dhcp.claimIp()).toBe('claimed');
196
+ const row = db
197
+ .select()
198
+ .from(moduleConfigs)
199
+ .where(
200
+ and(eq(moduleConfigs.moduleId, 'storewriting-vpn'), eq(moduleConfigs.key, 'public_ip')),
201
+ )
202
+ .get();
203
+ expect(row?.valueJson).toBe('"203.0.113.7"');
204
+ expect(row?.source).toBe('hook');
205
+
206
+ // The store round-trips text, so the map read the NEXT load takes sees the
207
+ // same string the write sent.
208
+ const reloaded = await loadCapabilityFunctions('store-consumer', db, noopLogger);
209
+ const dhcpAgain = reloaded.dhcp_server as { getDnsServers(): Promise<string[]> };
210
+ expect(await dhcpAgain.getDnsServers()).toEqual(['storewriting-vpn']);
211
+
212
+ await expect(dhcp.claimUndeclared()).rejects.toThrow(/no declared hook-owned config/);
213
+ });
214
+
137
215
  test('falls back to a zone-agnostic provider when no explicit zone provider exists', async () => {
138
216
  const modulePath = join(tempDir, 'upstream-dhcp');
139
217
  const scriptsDir = join(modulePath, 'scripts');
@@ -60,6 +60,9 @@ import {
60
60
  import { resolveComputedFields } from '../variables/computed/evaluate';
61
61
  import { containsComputedMarker } from '../variables/computed/marker';
62
62
  import { buildProviderLookup } from '../variables/computed/provider-lookup';
63
+ import type { BufferedStoreOp } from './hook-protocol';
64
+ import { type HookStores, createHookStores } from './hook-store';
65
+ import { type StoreTransport, buildStoreView } from './hook-store-proxy';
63
66
  import { loadHookConfigMap } from './load-hook-config';
64
67
 
65
68
  /**
@@ -260,6 +263,13 @@ export async function loadCapabilityFunctions(
260
263
  // This allows modules to optionally consume capabilities at runtime
261
264
  // (e.g., caddy uses dns_registrar if available, falls back if not).
262
265
  const verbose = process.env.CELILO_DEBUG === '1';
266
+
267
+ // The broker-side stores, resolved from the PROVIDER on the first store
268
+ // call this consumer load makes, and shared across every capability it
269
+ // builds. Nothing pays for the manifest read until a factory actually
270
+ // writes or reads through the accessor. See the storeTransport construction
271
+ // at the factory-invocation site below.
272
+ let storesPromise: Promise<HookStores> | undefined;
263
273
  const debugLog = verbose
264
274
  ? (msg: string) => process.stderr.write(`[capability-loader] ${msg}\n`)
265
275
  : (_msg: string) => {};
@@ -392,10 +402,35 @@ export async function loadCapabilityFunctions(
392
402
  // Phase 8 path: branded defineCapabilityFunction factory. Call with
393
403
  // the canonical { config, secrets, logger } context. The factory
394
404
  // applies wrapWithLogging internally so we don't double-wrap.
405
+ //
406
+ // Both maps arrive as the hook-owned-state view (buildStoreView): the
407
+ // plain record the factory reads, PLUS get/set/delete/transaction that
408
+ // reach the broker-side stores (createHookStores), validated against
409
+ // the PROVIDER module's own manifest. The transport is in-process — a
410
+ // capability factory runs here in celilo's process, not in the jail —
411
+ // but the view is the same object a jailed hook sees, so a provider's
412
+ // self-config write goes through the same validated door regardless of
413
+ // which process it runs in (hook-owned-state tasks 0.7 and 5.5 shape).
395
414
  if (isCompiledCapabilityFactory(exported)) {
415
+ const storeTransport: StoreTransport = (store, method, args) => {
416
+ storesPromise ??= createHookStores(db, capability.moduleId);
417
+ return storesPromise.then((backend: HookStores): Promise<unknown> => {
418
+ const one = backend[store];
419
+ switch (method) {
420
+ case 'transaction':
421
+ return one.applyTransaction(args[0] as readonly BufferedStoreOp[]);
422
+ case 'set':
423
+ return one.set(args[0] as string, args[1] as string);
424
+ case 'delete':
425
+ return one.delete(args[0] as string);
426
+ default:
427
+ return one.get(args[0] as string);
428
+ }
429
+ });
430
+ };
396
431
  const capabilityInterface = exported({
397
- config: providerConfig,
398
- secrets: providerSecrets,
432
+ config: buildStoreView('config', providerConfig, storeTransport),
433
+ secrets: buildStoreView('secrets', providerSecrets, storeTransport),
399
434
  systems: getModuleSystems(capability.moduleId, db),
400
435
  logger,
401
436
  // WHO IS CALLING, so a provider can scope per-consumer state to them.
@@ -803,6 +838,10 @@ function buildCapabilityInterface(
803
838
  // baseline was recorded but never read back, so every converge stayed
804
839
  // in onboarding mode and refused instead of isolating (ce-qzxm).
805
840
  interfaceBaseline: parseInterfaceBaseline(config.interface_baseline),
841
+ // The learned zone-to-leg map, same round trip and the same site that
842
+ // dropped it. The module writes it at the end of a clean converge and
843
+ // reads it back to keep a managed /32 on its declared zone leg.
844
+ interfaceZoneMap: config.interface_zone_map as string | undefined,
806
845
  // Operator settings, forwarded verbatim. `parseStoredConfigValue`
807
846
  // preserves each manifest-declared type, so the boolean arrives as a
808
847
  // boolean and needs no coercion here.
@@ -1326,6 +1365,10 @@ async function buildFirewallChain(
1326
1365
  // Absent means the box has never converged cleanly, so an interface
1327
1366
  // celilo cannot attribute refuses rather than being disabled.
1328
1367
  interfaceBaseline: parseInterfaceBaseline(provConfig.interface_baseline),
1368
+ // The managed-address resolver consults this before it reads the live
1369
+ // address table. Dropping it makes the fallback select the management
1370
+ // (SSH) interface, after which the bad alias can corrupt the learned map.
1371
+ interfaceZoneMap: provConfig.interface_zone_map as string | undefined,
1329
1372
  // Which declared zone carries this firewall's default route, and
1330
1373
  // whether fronted zones may initiate into it. Both are operator
1331
1374
  // settings and both are read HERE or nowhere: the module declares them
@@ -1,8 +1,12 @@
1
- import { describe, expect, test } from 'bun:test';
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
2
  import { existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
+ import { closeDb, getDb } from '../db/client';
6
+ import { runMigrations } from '../db/migrate';
7
+ import { modules, systemConfig } from '../db/schema';
5
8
  import type { ContractHookSignature } from '../manifest/contracts';
9
+ import { resetTestDbPath } from '../test-utils/db-path';
6
10
  import {
7
11
  checkRequiredCapabilities,
8
12
  declaredPathInputs,
@@ -329,6 +333,162 @@ describe('Hook Executor', () => {
329
333
  });
330
334
  });
331
335
 
336
+ describe('the executor resolves the jail policy through stored config (hook-jail-config-surface slice 4)', () => {
337
+ // Each test gets its own migrated scratch database and its own jail-mode
338
+ // store, so a planted row here never leaks into another test (or another
339
+ // host). With no module tree in `options.jail`, `planJailedSpawn` treats a
340
+ // `required` policy as a hard failure on EVERY host — which makes the
341
+ // resolution outcome observable without depending on what jail backend a
342
+ // given machine happens to have.
343
+ let dir: string;
344
+ const saved: Record<string, string | undefined> = {};
345
+
346
+ const plantPolicyRow = (value: string): void => {
347
+ getDb().insert(systemConfig).values({ key: 'hooks.jail_policy', value }).run();
348
+ };
349
+
350
+ // The per-module row is keyed by module id, but the executor is handed
351
+ // the module's tree — the join the production read makes (task 1.4). The
352
+ // path is fake: nothing in this describe touches the filesystem beyond
353
+ // the scratch DB, and `existsSync(scriptPath)` fails before any policy
354
+ // read when it is not fake.
355
+ const moduleTree = (): string => join(dir, 'modules', 'fixture-module');
356
+ const plantModulePolicyRow = (moduleId: string, policy: string): void => {
357
+ getDb()
358
+ .insert(modules)
359
+ .values({
360
+ id: moduleId,
361
+ name: moduleId,
362
+ version: '1.0.0',
363
+ manifestData: {},
364
+ sourcePath: moduleTree(),
365
+ })
366
+ .run();
367
+ // Raw SQL, not the drizzle table, on purpose: the drizzle type only
368
+ // accepts 'auto' | 'off' | 'required', and the bad-value test models a
369
+ // row that arrived past every typed writer — a restore or a hand edit,
370
+ // the untrusted path the resolver throws on (Rule 3.7).
371
+ getDb()
372
+ .$client.prepare('INSERT INTO module_jail_policies (module_id, policy) VALUES (?, ?)')
373
+ .run(moduleId, policy);
374
+ };
375
+
376
+ const runVoidHook = (moduleTree?: string): Promise<Record<string, unknown>> => {
377
+ const { logger } = createCapturingLogger();
378
+ return executeHookScript(
379
+ join(FIXTURES_DIR, 'void-hook.ts'),
380
+ {
381
+ config: configStore(),
382
+ secrets: secretStore(),
383
+ systems: [],
384
+ logger,
385
+ debug: false,
386
+ screenshotDir: '/tmp',
387
+ stateDir: '/tmp',
388
+ capabilities: {},
389
+ },
390
+ {
391
+ // Absent means the system-config-only path: exactly what the tests
392
+ // above exercise. Present means the executor reads the module's row
393
+ // and resolves through it.
394
+ jail: moduleTree === undefined ? undefined : { modulePath: moduleTree, pathInputs: [] },
395
+ },
396
+ );
397
+ };
398
+
399
+ beforeEach(async () => {
400
+ for (const key of ['CELILO_HOOK_JAIL', 'CELILO_HOOK_JAIL_MODE_PATH']) {
401
+ saved[key] = process.env[key];
402
+ }
403
+ dir = mkdtempSync(join(tmpdir(), 'celilo-executor-policy-'));
404
+ process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
405
+ process.env.CELILO_HOOK_JAIL_MODE_PATH = join(dir, 'mode.json');
406
+ delete process.env.CELILO_HOOK_JAIL;
407
+ await runMigrations(process.env.CELILO_DB_PATH);
408
+ });
409
+
410
+ afterEach(() => {
411
+ closeDb();
412
+ // The db path resets to the scratch path rather than restoring the
413
+ // saved value: that value may be another suite's temp database, or
414
+ // unset — which sends the next var-less reader to the operator's real
415
+ // celilo.db (celilo#1315).
416
+ resetTestDbPath();
417
+ for (const key of ['CELILO_HOOK_JAIL', 'CELILO_HOOK_JAIL_MODE_PATH']) {
418
+ if (saved[key] === undefined) delete process.env[key];
419
+ else process.env[key] = saved[key];
420
+ }
421
+ rmSync(dir, { recursive: true, force: true });
422
+ });
423
+
424
+ test('a stored row decides when the env is silent', async () => {
425
+ plantPolicyRow('required');
426
+ await expect(runVoidHook()).rejects.toThrow(/no hook jail is available/);
427
+ });
428
+
429
+ test('env wins over a conflicting stored row', async () => {
430
+ process.env.CELILO_HOOK_JAIL = 'off';
431
+ plantPolicyRow('required');
432
+ await expect(runVoidHook()).resolves.toBeDefined();
433
+ });
434
+
435
+ test('env=required still reaches the planner when the row says off', async () => {
436
+ process.env.CELILO_HOOK_JAIL = 'required';
437
+ plantPolicyRow('off');
438
+ await expect(runVoidHook()).rejects.toThrow(/no hook jail is available/);
439
+ });
440
+
441
+ test('a bad stored row fails the hook instead of running it unjailed', async () => {
442
+ plantPolicyRow('alwayssafe');
443
+ await expect(runVoidHook()).rejects.toThrow(/hooks\.jail_policy='alwayssafe'/);
444
+ });
445
+
446
+ test('nothing set resolves to the default, off (peba’s ruling on ce-rez7)', async () => {
447
+ // Migrated DB with no row: the stored-row read returns undefined and the
448
+ // resolver falls through to the default, which jails nobody.
449
+ await expect(runVoidHook()).resolves.toBeDefined();
450
+ });
451
+
452
+ test('the module row beats system config (per-module-jail-policy task 1.4)', async () => {
453
+ // System says jail hard; the module is exempted. The exempted module's
454
+ // hook runs unjailed while every other module of this host would fail —
455
+ // the spec's own "A module runs unjailed while the fleet is jailed".
456
+ plantPolicyRow('required');
457
+ plantModulePolicyRow('exempted-module', 'off');
458
+ await expect(runVoidHook(moduleTree())).resolves.toBeDefined();
459
+ // And the same host still jails a module with no row: `required` with
460
+ // no jail backend is a hard failure, so the system row stays in force.
461
+ // Which surface the failure takes is host-determined, and both prove
462
+ // the hook did not run: on a host with a jail backend it dies at mount
463
+ // planning (the fake module tree does not exist, "cannot be built");
464
+ // on a host with no backend it dies at backend detection ("no hook
465
+ // jail is available"). The point under test is policy precedence, not
466
+ // the surface. (celilo#1342: the CI runner has no bubblewrap, so the
467
+ // mount-planning surface is unreachable there.)
468
+ await expect(runVoidHook(join(dir, 'modules', 'other-module'))).rejects.toThrow(
469
+ /cannot be built|no hook jail is available/,
470
+ );
471
+ });
472
+
473
+ test('a bad per-module row fails the hook instead of running it unjailed', async () => {
474
+ plantModulePolicyRow('broken-module', 'alwayssafe');
475
+ await expect(runVoidHook(moduleTree())).rejects.toThrow(/module's jail policy='alwayssafe'/);
476
+ });
477
+
478
+ test('env beats a conflicting module row', async () => {
479
+ plantModulePolicyRow('exempted-module', 'off');
480
+ process.env.CELILO_HOOK_JAIL = 'required';
481
+ // The point is that the env overrode the module's `off`, so the hook
482
+ // is jailed and fails, not exempted and successful. Which failure it
483
+ // hits is host-determined: mount planning (the fake module tree does
484
+ // not exist) on a host with a jail backend, backend detection on a
485
+ // host without one. Either proves the hook did not run.
486
+ await expect(runVoidHook(moduleTree())).rejects.toThrow(
487
+ /cannot be built|no hook jail is available/,
488
+ );
489
+ });
490
+ });
491
+
332
492
  describe('resolveHookTimeouts', () => {
333
493
  test('no declaration: 60s total, 30s idle heuristic', () => {
334
494
  expect(resolveHookTimeouts(undefined, false)).toEqual({
@@ -51,6 +51,8 @@ import {
51
51
  moduleArtifactDir,
52
52
  moduleStateDir,
53
53
  } from '@celilo/capabilities';
54
+ import { getDbPath } from '../config/paths';
55
+ import { getDb } from '../db/client';
54
56
  import {
55
57
  type ContractHookSignature,
56
58
  contractHookSignature,
@@ -73,10 +75,10 @@ import {
73
75
  import {
74
76
  type JailPlan,
75
77
  detectJailBackend,
76
- jailPolicy,
77
78
  planJailedSpawn,
78
79
  realpathRequest,
79
80
  recordJailMode,
81
+ resolveJailPolicy,
80
82
  runtimeModulePathsFor,
81
83
  } from './jail';
82
84
  import {
@@ -171,32 +173,47 @@ const FORWARDED_ENV = [
171
173
  ] as const;
172
174
 
173
175
  /**
174
- * The PATH a hook child gets: the CLI's own directory ahead of the inherited
175
- * one, so the child can exec the SAME `celilo` the parent runs.
176
+ * The directory holding the `celilo` bin of the running CLI package, resolved
177
+ * from this file's own location: src/hooks -> the package's bin/. Every
178
+ * distribution layout lands there. The .deb wrapper execs
179
+ * `$CELILO_HOME/node_modules/@celilo/cli/bin/celilo` directly (packaging/celilo/
180
+ * scripts/wrapper.sh), and a bun global install symlinks ~/.bun/bin/celilo into
181
+ * that same bin directory, whose script self-locates its package dir.
182
+ */
183
+ export const CLI_BIN_DIR = join(import.meta.dir, '..', '..', 'bin');
184
+
185
+ /**
186
+ * The PATH a hook child gets: the CLI package's own bin directory, then the
187
+ * runtime's, ahead of the inherited one — so the child can exec the SAME
188
+ * `celilo` the parent runs, and that celilo can still find its interpreter.
176
189
  *
177
- * A bun global install lands the `celilo` bin beside the bun binary itself
178
- * (install.sh sets `BUN_INSTALL/bin`, default `~/.bun/bin`), so
179
- * `dirname(process.execPath)` is that directory. The inherited PATH carries no
180
- * such guarantee: the e2e management image runs the CLI only through an
181
- * absolute-path wrapper at /usr/local/bin because its ENV has no /root/.bun/bin,
182
- * and every hook that spawned `celilo` by name — caddy-internal's
183
- * `on_consumer_removed` route withdrawal — died with "Executable not found in
184
- * $PATH", leaving the stale routes it was withdrawing in place (celilo#1300).
185
- * A real fleet hits the same class whenever the CLI is launched by a systemd
186
- * unit or an absolute path with a minimal PATH.
190
+ * `dirname(process.execPath)` alone is NOT the CLI's directory under the fleet
191
+ * layout: the .deb runs the CLI through a wrapper at /usr/local/bin/celilo that
192
+ * execs bun on `node_modules/@celilo/cli/src/cli/index.ts`, so the directory
193
+ * beside the bun binary holds only bun (measured on the fleet, 2026-09-07,
194
+ * ce-y0fd). Hooks then found `celilo` only because the systemd unit happened to
195
+ * inherit /usr/local/bin on its PATH — the same accident that broke every hook
196
+ * spawning `celilo` by name in the e2e management image (celilo#1300).
197
+ * CLI_BIN_DIR resolves where the bin actually lives, whatever layout launched
198
+ * the parent, and comes FIRST so `celilo` is the package's own bin rather than
199
+ * an operator's wrapper. The runtime directory stays second because bin/celilo
200
+ * needs `command -v bun` (it is bash, not a shebanged TS file).
187
201
  *
188
- * Already-present wins: an operator who has the directory on PATH sees an
189
- * unchanged value. An absent inherited PATH still yields the CLI directory,
190
- * so a hook can always reach `celilo` even from a stripped parent.
202
+ * Already-present wins: an operator who has a directory on PATH sees an
203
+ * unchanged value. An absent inherited PATH still yields both directories, so
204
+ * a hook can always reach `celilo` even from a stripped parent; bash resolves
205
+ * through the loader's default search.
191
206
  *
192
207
  * Exported for the allow-list tests (hook-trespass.test.ts), which pin this
193
208
  * contract the same way they pin the rest of the child environment.
194
209
  */
195
210
  export function childPath(inherited: string | undefined): string {
196
- const cliDir = dirname(process.execPath);
197
- if (inherited === undefined) return cliDir;
198
- if (inherited.split(':').includes(cliDir)) return inherited;
199
- return `${cliDir}:${inherited}`;
211
+ const base = inherited === undefined ? [] : inherited.split(':');
212
+ const prepended: string[] = [];
213
+ for (const dir of [CLI_BIN_DIR, dirname(process.execPath)]) {
214
+ if (!base.includes(dir) && !prepended.includes(dir)) prepended.push(dir);
215
+ }
216
+ return [...prepended, ...base].join(':');
200
217
  }
201
218
 
202
219
  /** The shim celilo spawns. Resolved from here so an npm install finds it too. */
@@ -330,6 +347,51 @@ export interface ExecuteHookOptions {
330
347
  hookStores?: HookStoresProvider;
331
348
  }
332
349
 
350
+ /**
351
+ * The stored `hooks.jail_policy` value, or undefined when there is no row or
352
+ * no celilo DB to ask (a dev box). Same row-read pattern as
353
+ * `ipam/auto-allocator.ts` and the executor's fellow consumer
354
+ * `cli/commands/system-doctor.ts`; the drizzle table is `systemConfig` in
355
+ * `db/schema.ts`.
356
+ *
357
+ * Unlike `remoteAccess`, this read happens here rather than through a
358
+ * caller-built input because D2 names the executor itself as one of the two
359
+ * consumers that fetch the stored row and hand it to the pure resolver.
360
+ */
361
+ function readStoredJailPolicy(): string | undefined {
362
+ if (!existsSync(getDbPath())) return undefined;
363
+ const row = getDb()
364
+ .$client.prepare('SELECT value FROM system_config WHERE key = ?')
365
+ .get('hooks.jail_policy') as { value: string } | undefined;
366
+ return row?.value;
367
+ }
368
+
369
+ /**
370
+ * The module's own recorded jail policy, or undefined when there is no row
371
+ * (per-module-jail-policy task 1.4). The system-key read stays its own
372
+ * function because the two answers come from different tables.
373
+ *
374
+ * The executor is handed the module's tree, `options.jail.modulePath`, which
375
+ * is `mod.sourcePath` — not its id, which the caller holds. The row is found
376
+ * the way the jail inputs identify the module: by joining `modules` on
377
+ * `source_path`, the exact string the caller passed, so no second identity
378
+ * is invented and none of invokeHook's callers need plumbing. Absent jail
379
+ * inputs (an invocation that cannot be jailed) also means no module identity
380
+ * and no module row to read.
381
+ *
382
+ * A bad stored value is not coerced here — `resolveJailPolicy` throws on it,
383
+ * the same rule the system key follows (peba's ruling on ce-8832).
384
+ */
385
+ function readStoredModuleJailPolicy(modulePath: string | undefined): string | undefined {
386
+ if (!modulePath || !existsSync(getDbPath())) return undefined;
387
+ const row = getDb()
388
+ .$client.prepare(
389
+ 'SELECT p.policy AS policy FROM module_jail_policies p JOIN modules m ON m.id = p.module_id WHERE m.source_path = ?',
390
+ )
391
+ .get(modulePath) as { policy: string } | undefined;
392
+ return row?.policy;
393
+ }
394
+
333
395
  /**
334
396
  * Execute a hook script
335
397
  *
@@ -394,7 +456,11 @@ export async function executeHookScript(
394
456
  [process.execPath, HOOK_RUNNER_SPAWN_PATH],
395
457
  mountSet,
396
458
  detectJailBackend(),
397
- jailPolicy(),
459
+ resolveJailPolicy(
460
+ process.env.CELILO_HOOK_JAIL,
461
+ readStoredModuleJailPolicy(options.jail?.modulePath),
462
+ readStoredJailPolicy(),
463
+ ).policy,
398
464
  );
399
465
  reportJail(jail, logger);
400
466
  // Only a real module hook says anything about whether this HOST jails. An
@@ -33,7 +33,7 @@ import { join, resolve } from 'node:path';
33
33
  /** Uniquely named so it never collides with a real key on a jailed dev box. */
34
34
  const PLANTED_KEY_NAME = 'id_celilo_jail_probe';
35
35
  import { executeHookScript } from './executor';
36
- import { detectJailBackend, jailPolicy } from './jail';
36
+ import { detectJailBackend } from './jail';
37
37
  import { createCapturingLogger } from './logger';
38
38
  import { configStore, secretStore } from './test-fixtures/store-backed';
39
39
  import type { HookContext } from './types';
@@ -46,12 +46,15 @@ interface Probe {
46
46
  }
47
47
 
48
48
  const availability = detectJailBackend();
49
- // A host with a backend is not a host that JAILS: `CELILO_HOOK_JAIL=off` is an
50
- // operator switch and the executor honours it, so without this the suite runs
51
- // its assertions against a deliberately unjailed hook and reports the jail
52
- // broken. Only reachable since macOS gained a backend (task 4.8) — before that
53
- // every Mac skipped for want of one and the hole never showed.
54
- const jailed = availability.backend !== 'none' && jailPolicy() !== 'off';
49
+ // Gated on the BACKEND alone, not on `jailPolicy()`. The ambient policy is an
50
+ // operator switch (`CELILO_HOOK_JAIL=off` is the default), and reading it here
51
+ // made the suite skip on every host with the default — measuring nothing, on
52
+ // any platform, forever. The suite does not need the operator's blessing: it
53
+ // sets the policy to `required` inside `runProbe()` and restores it after, so
54
+ // the jail under test is this suite's own act, deterministic wherever a
55
+ // backend exists. (celilo#1359: since the default became `off`, all three
56
+ // jailed suites skipped everywhere.)
57
+ const jailed = availability.backend !== 'none';
55
58
 
56
59
  interface Rig {
57
60
  outputs: {
@@ -208,18 +211,14 @@ describe.skipIf(!jailed)(`the jail is real (backend: ${availability.backend})`,
208
211
  describe.skipIf(jailed)('this run is not jailed', () => {
209
212
  test('says so, rather than reporting a pass it did not earn', () => {
210
213
  // Not an assertion about the product. It is the line that stops a green
211
- // run on an unjailed host reading as "the jail was proven".
212
- const why =
213
- availability.backend === 'none'
214
- ? (availability.reason ?? 'no backend')
215
- : `CELILO_HOOK_JAIL=${process.env.CELILO_HOOK_JAIL}, so the operator switched the ${availability.backend} jail off here`;
214
+ // run on a backend-less host reading as "the jail was proven". The only
215
+ // way here is `backend === 'none'` — an operator who set the policy off
216
+ // does NOT land in this companion, because the live suite above now pins
217
+ // the policy itself and runs anyway (celilo#1359).
218
+ const why = availability.reason ?? 'no backend';
216
219
  console.log(
217
- `\nhook jail: SKIPPED the live suite — ${why}\nThese properties are proven wherever a backend is available and enabled. The hermetic half runs everywhere (jail.test.ts).`,
220
+ `\nhook jail: SKIPPED the live suite — ${why}\nThese properties are proven wherever a jail backend is available. The hermetic half runs everywhere (jail.test.ts).`,
218
221
  );
219
- // Both reasons are legitimate and they are different facts, so assert the
220
- // disjunction rather than one of them. Asserting `backend === 'none'` alone
221
- // went red the day macOS got a backend AND the operator turned it off — a
222
- // combination that is not a defect in anything.
223
- expect(availability.backend === 'none' || jailPolicy() === 'off').toBe(true);
222
+ expect(availability.backend === 'none').toBe(true);
224
223
  });
225
224
  });
@@ -14,6 +14,7 @@ import { join } from 'node:path';
14
14
  import { type DbClient, closeDb, createDbClient } from '../db/client';
15
15
  import { runMigrations } from '../db/migrate';
16
16
  import { moduleConfigs, modules, secrets } from '../db/schema';
17
+ import { resetTestDbPath } from '../test-utils/db-path';
17
18
  import { createHookStores } from './hook-store';
18
19
 
19
20
  describe('hook stores', () => {
@@ -54,6 +55,11 @@ describe('hook stores', () => {
54
55
 
55
56
  afterEach(() => {
56
57
  closeDb();
58
+ // The variable pointed at this suite's (now removed) temp database;
59
+ // leaving it set sends the next var-less reader at a dangling path.
60
+ // Reset to the scratch path so no later reader reaches the operator's
61
+ // real celilo.db (celilo#1315).
62
+ resetTestDbPath();
57
63
  rmSync(testDir, { recursive: true, force: true });
58
64
  delete process.env.CELILO_MASTER_KEY_PATH;
59
65
  });