@celilo/cli 2.2.1 → 3.0.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 (173) hide show
  1. package/CELILO_CORE_MODULES.md +2 -1
  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/src/__integration__/container-services-cli.integration.test.ts +1 -1
  9. package/src/api/serve.ts +13 -1
  10. package/src/api/sessions.test.ts +3 -3
  11. package/src/api-clients/proxmox.ts +10 -7
  12. package/src/cli/backup-rename.test.ts +3 -3
  13. package/src/cli/cli.test.ts +1 -1
  14. package/src/cli/commands/events.test.ts +2 -2
  15. package/src/cli/commands/firewall-interface-list.test.ts +2 -2
  16. package/src/cli/commands/machine-list.test.ts +57 -0
  17. package/src/cli/commands/machine-list.ts +35 -4
  18. package/src/cli/commands/module-health.test.ts +35 -0
  19. package/src/cli/commands/module-health.ts +12 -3
  20. package/src/cli/commands/module-import-registry.test.ts +1 -1
  21. package/src/cli/commands/module-jail.test.ts +242 -0
  22. package/src/cli/commands/module-jail.ts +227 -0
  23. package/src/cli/commands/module-list-jail.test.ts +136 -0
  24. package/src/cli/commands/module-list.ts +30 -3
  25. package/src/cli/commands/module-publish.test.ts +28 -6
  26. package/src/cli/commands/module-publish.ts +14 -12
  27. package/src/cli/commands/module-update.test.ts +22 -7
  28. package/src/cli/commands/module-update.ts +73 -18
  29. package/src/cli/commands/module-upgrade-gate.test.ts +154 -0
  30. package/src/cli/commands/module-upgrade.test.ts +115 -1
  31. package/src/cli/commands/module-upgrade.ts +66 -4
  32. package/src/cli/commands/module-verify.test.ts +1 -1
  33. package/src/cli/commands/publish/execute.ts +4 -1
  34. package/src/cli/commands/publish/helpers.ts +4 -3
  35. package/src/cli/commands/publish/index.ts +23 -1
  36. package/src/cli/commands/publish/module-registry.test.ts +24 -1
  37. package/src/cli/commands/publish/module-registry.ts +23 -4
  38. package/src/cli/commands/publish/plan.test.ts +64 -0
  39. package/src/cli/commands/publish/plan.ts +52 -19
  40. package/src/cli/commands/publish/types.ts +20 -0
  41. package/src/cli/commands/registry-owner.test.ts +1 -1
  42. package/src/cli/commands/registry-token.test.ts +1 -1
  43. package/src/cli/commands/subscribers-install-daemon.test.ts +44 -0
  44. package/src/cli/commands/subscribers-install-daemon.ts +107 -0
  45. package/src/cli/commands/subscribers-serve.test.ts +22 -0
  46. package/src/cli/commands/subscribers-serve.ts +22 -4
  47. package/src/cli/commands/system-audit.ts +34 -11
  48. package/src/cli/commands/system-doctor-remediation-gate.test.ts +295 -0
  49. package/src/cli/commands/system-doctor.test.ts +76 -10
  50. package/src/cli/commands/system-doctor.ts +34 -3
  51. package/src/cli/commands/system-init-deprecation.test.ts +1 -1
  52. package/src/cli/commands/system-update.ts +16 -6
  53. package/src/cli/completion.ts +18 -3
  54. package/src/cli/flag-surface-gate.test.ts +279 -0
  55. package/src/cli/fuel-gauge.ts +12 -4
  56. package/src/cli/index.ts +80 -2
  57. package/src/cli/json-output.test.ts +81 -0
  58. package/src/cli/parser.test.ts +37 -1
  59. package/src/cli/restore-command.test.ts +3 -3
  60. package/src/cli/restore-migration-failure.test.ts +2 -2
  61. package/src/cli/tui/audit-state.ts +2 -0
  62. package/src/cli/types.ts +9 -0
  63. package/src/config/paths.test.ts +19 -19
  64. package/src/db/client.test.ts +46 -1
  65. package/src/db/client.ts +26 -0
  66. package/src/db/schema.ts +63 -0
  67. package/src/hooks/broker.test.ts +9 -3
  68. package/src/hooks/capability-loader-firewall.test.ts +9 -1
  69. package/src/hooks/capability-loader.ts +8 -0
  70. package/src/hooks/executor.test.ts +91 -14
  71. package/src/hooks/executor.ts +31 -1
  72. package/src/hooks/hook-jail-toolchain-reach.test.ts +8 -3
  73. package/src/hooks/hook-jail-unreachability.test.ts +27 -20
  74. package/src/hooks/hook-trespass.test.ts +22 -6
  75. package/src/hooks/jail.test.ts +105 -17
  76. package/src/hooks/jail.ts +47 -10
  77. package/src/hooks/run-named-hook.ts +19 -16
  78. package/src/hooks/test-fixtures/artifact-writing-hook.ts +0 -1
  79. package/src/hooks/test-fixtures/capability-calling-hook.ts +20 -13
  80. package/src/hooks/test-fixtures/jail-probe-hook.ts +9 -1
  81. package/src/hooks/test-fixtures/jail-toolchain-hook.ts +7 -2
  82. package/src/hooks/test-fixtures/runaway-hook.ts +0 -1
  83. package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +0 -1
  84. package/src/hooks/test-fixtures/silent-hook.ts +0 -1
  85. package/src/hooks/test-fixtures/store-writing-hook.ts +20 -11
  86. package/src/hooks/test-fixtures/success-hook.ts +4 -4
  87. package/src/manifest/contracts/v1.ts +32 -14
  88. package/src/manifest/json-schema-roundtrip.test.ts +1 -1
  89. package/src/manifest/schema.ts +66 -16
  90. package/src/manifest/validate.test.ts +47 -0
  91. package/src/policy/capability-shape-baseline.ts +63 -21
  92. package/src/policy/capability-shape-drift.test.ts +53 -1
  93. package/src/policy/capability-shape.test.ts +105 -0
  94. package/src/policy/capability-shape.ts +283 -2
  95. package/src/policy/module-script-scan.ts +52 -68
  96. package/src/policy/no-hand-built-ssh.test.ts +5 -1
  97. package/src/policy/no-swallowed-refusal.test.ts +14 -14
  98. package/src/registry/client.test.ts +67 -2
  99. package/src/registry/client.ts +7 -7
  100. package/src/secrets/storage.test.ts +70 -4
  101. package/src/secrets/storage.ts +71 -1
  102. package/src/services/alerting/keys.test.ts +4 -0
  103. package/src/services/alerting/keys.ts +12 -2
  104. package/src/services/audit/health.test.ts +155 -2
  105. package/src/services/audit/health.ts +76 -2
  106. package/src/services/audit/index.test.ts +23 -1
  107. package/src/services/audit/index.ts +8 -2
  108. package/src/services/audit/interface-classification.test.ts +16 -5
  109. package/src/services/audit/interface-classification.ts +25 -2
  110. package/src/services/audit/jail-exemptions.test.ts +42 -0
  111. package/src/services/audit/jail-exemptions.ts +44 -0
  112. package/src/services/audit/module-integrity.test.ts +23 -1
  113. package/src/services/audit/module-integrity.ts +7 -2
  114. package/src/services/audit/module-versions.ts +5 -1
  115. package/src/services/audit/public-dns.test.ts +20 -0
  116. package/src/services/audit/public-dns.ts +7 -2
  117. package/src/services/audit/recurrence-gate.test.ts +225 -0
  118. package/src/services/audit/trusted-sources.test.ts +17 -0
  119. package/src/services/audit/trusted-sources.ts +21 -0
  120. package/src/services/audit/types.ts +2 -1
  121. package/src/services/backup-create.ts +9 -0
  122. package/src/services/backup-envelope-roundtrip.test.ts +1 -1
  123. package/src/services/backup-in-flight-refusal.test.ts +1 -1
  124. package/src/services/build-bus/hook-dispatch-executor.test.ts +269 -0
  125. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +105 -105
  126. package/src/services/build-bus/hook-dispatch-path.test.ts +95 -0
  127. package/src/services/build-bus/hook-dispatch.test.ts +86 -116
  128. package/src/services/build-bus/hook-dispatch.ts +99 -121
  129. package/src/services/build-bus/hook-dispatcher.ts +143 -17
  130. package/src/services/build-bus/receiver-daemon.test.ts +189 -0
  131. package/src/services/build-bus/receiver-daemon.ts +355 -0
  132. package/src/services/build-bus/self-update.ts +156 -0
  133. package/src/services/bus-ensure-flow.test.ts +1 -1
  134. package/src/services/bus-interview-park.test.ts +2 -2
  135. package/src/services/bus-interview.test.ts +2 -2
  136. package/src/services/bus-secret-flow.test.ts +1 -1
  137. package/src/services/capability-compat.test.ts +90 -0
  138. package/src/services/capability-compat.ts +128 -0
  139. package/src/services/celilo-events.test.ts +1 -1
  140. package/src/services/celilo-mgmt-hooks.test.ts +23 -5
  141. package/src/services/container-service.test.ts +1 -1
  142. package/src/services/cross-module-read.test.ts +2 -2
  143. package/src/services/deploy-preflight.ts +7 -0
  144. package/src/services/deploy-terraform.ts +38 -1
  145. package/src/services/deployed-systems.ts +1 -1
  146. package/src/services/events-daemon.test.ts +57 -0
  147. package/src/services/events-daemon.ts +76 -0
  148. package/src/services/firewall-reach.ts +21 -8
  149. package/src/services/fleet-checks.test.ts +159 -4
  150. package/src/services/fleet-checks.ts +206 -3
  151. package/src/services/health-runner.test.ts +87 -2
  152. package/src/services/health-runner.ts +83 -16
  153. package/src/services/infrastructure-selector.test.ts +1 -1
  154. package/src/services/jail-exemptions.test.ts +125 -0
  155. package/src/services/jail-exemptions.ts +81 -0
  156. package/src/services/machine-pool.test.ts +1 -1
  157. package/src/services/module-deploy-prune.test.ts +89 -0
  158. package/src/services/module-deploy.ts +84 -129
  159. package/src/services/module-subscriptions.test.ts +2 -2
  160. package/src/services/module-types-drift.test.ts +1 -1
  161. package/src/services/module-validator/capability-versions.test.ts +13 -2
  162. package/src/services/network-discovery.test.ts +64 -1
  163. package/src/services/network-discovery.ts +30 -5
  164. package/src/services/responder-probe.test.ts +1 -1
  165. package/src/services/restore-from-file.test.ts +4 -4
  166. package/src/services/restore-preflight.test.ts +1 -1
  167. package/src/services/ssh-key-manager.test.ts +2 -2
  168. package/src/services/system-state-stage.test.ts +2 -2
  169. package/src/services/terraform-safety.test.ts +83 -0
  170. package/src/services/terraform-safety.ts +53 -0
  171. package/src/services/update/orchestrator.test.ts +3 -1
  172. package/src/test-utils/bus-responder.ts +1 -1
  173. package/tsconfig.json +2 -13
package/src/db/schema.ts CHANGED
@@ -212,6 +212,45 @@ export const capabilityBindings = sqliteTable(
212
212
  }),
213
213
  );
214
214
 
215
+ /**
216
+ * One build-bus `on_upstream_publish` hook execution (services/build-bus/hook-dispatcher.ts).
217
+ *
218
+ * The self-update path had no durable record: outcomes were console output
219
+ * inside the receiver daemon, so a CLI that never upgraded looked like a
220
+ * healthy fleet (celilo#1304). One row per (event, module, hook) run — the
221
+ * newest row answers "did the self-update for @celilo/cli@X fire and succeed?".
222
+ *
223
+ * `exit_code` is NULL when the script never ran to an exit (spawn failure).
224
+ * Not an event-bus delivery: hooks run from the receiver's in-process
225
+ * dispatcher, which no `deliveries` row was ever written for.
226
+ *
227
+ * @owner celilo — build-bus self-update audit trail (celilo#1304)
228
+ */
229
+ export const buildBusHookRuns = sqliteTable(
230
+ 'build_bus_hook_runs',
231
+ {
232
+ id: integer('id').primaryKey({ autoIncrement: true }),
233
+ /** The PublishEvent's dedup id — joins back to the bus's `build-bus.publish` event. */
234
+ eventId: text('event_id').notNull(),
235
+ packageName: text('package_name').notNull(),
236
+ packageVersion: text('package_version').notNull(),
237
+ tag: text('tag').notNull(),
238
+ moduleId: text('module_id').notNull(),
239
+ hookName: text('hook_name').notNull(),
240
+ scriptPath: text('script_path').notNull(),
241
+ /** Null means the script never reached an exit (spawn failure). */
242
+ exitCode: integer('exit_code'),
243
+ timedOut: integer('timed_out', { mode: 'boolean' }).notNull().default(false),
244
+ durationMs: integer('duration_ms').notNull(),
245
+ stdoutTail: text('stdout_tail'),
246
+ stderrTail: text('stderr_tail'),
247
+ ranAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
248
+ },
249
+ (table) => ({
250
+ ranAtLookup: index('build_bus_hook_runs_created_at_idx').on(table.ranAt),
251
+ }),
252
+ );
253
+
215
254
  /**
216
255
  * Capability secrets table - stores encrypted secrets owned by capabilities
217
256
  * Values are encrypted with AES-256-GCM using master key
@@ -310,6 +349,28 @@ export const moduleIntegrity = sqliteTable('module_integrity', {
310
349
  updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
311
350
  });
312
351
 
352
+ /**
353
+ * Per-module hook jail policy — the operator's row in the four-step
354
+ * precedence (per-module-jail-policy task 1.1), ranked between the
355
+ * `CELILO_HOOK_JAIL` environment variable and the system-wide
356
+ * `hooks.jail_policy` key.
357
+ *
358
+ * One row per module; an absent row means "follow the system", so there is
359
+ * no default column to get wrong. The value is validated at set time by the
360
+ * `module jail` verb and re-validated at read time by `resolveJailPolicy`,
361
+ * which throws rather than coercing — a restored or hand-edited row outside
362
+ * the accepted set must fail loudly, the same rule the system key follows
363
+ * (peba's ruling on ce-8832).
364
+ * @owner celilo — operator-set per-module jail policy
365
+ */
366
+ export const moduleJailPolicies = sqliteTable('module_jail_policies', {
367
+ moduleId: text('module_id')
368
+ .primaryKey()
369
+ .references(() => modules.id, { onDelete: 'cascade' }),
370
+ policy: text('policy').$type<'auto' | 'off' | 'required'>().notNull(),
371
+ updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
372
+ });
373
+
313
374
  /**
314
375
  * Lifecycle states an instance moves through.
315
376
  *
@@ -1579,3 +1640,5 @@ export type NotificationDelivery = typeof notificationDeliveries.$inferSelect;
1579
1640
  export type NewNotificationDelivery = typeof notificationDeliveries.$inferInsert;
1580
1641
  export type CapabilityBinding = typeof capabilityBindings.$inferSelect;
1581
1642
  export type NewCapabilityBinding = typeof capabilityBindings.$inferInsert;
1643
+ export type BuildBusHookRun = typeof buildBusHookRuns.$inferSelect;
1644
+ export type NewBuildBusHookRun = typeof buildBusHookRuns.$inferInsert;
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { describe, expect, test } from 'bun:test';
14
14
  import { execSync } from 'node:child_process';
15
- import { mkdtempSync, rmSync } from 'node:fs';
15
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
16
16
  import { tmpdir } from 'node:os';
17
17
  import { join } from 'node:path';
18
18
  import { MissingProviderInputError } from '@celilo/capabilities';
@@ -60,10 +60,13 @@ async function runCapabilityHook(): Promise<Record<string, unknown>> {
60
60
  stateDir: dir,
61
61
  capabilities: demoCapabilities(),
62
62
  };
63
- return await executeHookScript(join(FIXTURES, 'capability-calling-hook.ts'), context, {
63
+ await executeHookScript(join(FIXTURES, 'capability-calling-hook.ts'), context, {
64
64
  timeoutMs: 30_000,
65
65
  idleTimeoutMs: 30_000,
66
66
  });
67
+ // The fixture hands its report through the state directory: hook return
68
+ // values are no longer carried anywhere (hook-owned-state D5).
69
+ return JSON.parse(readFileSync(join(dir, 'report.json'), 'utf-8')) as Record<string, unknown>;
67
70
  } finally {
68
71
  rmSync(dir, { recursive: true, force: true });
69
72
  }
@@ -137,11 +140,14 @@ async function runStoreHook(
137
140
  capabilities: {},
138
141
  ...contextData,
139
142
  } as unknown as HookContext;
140
- return await executeHookScript(join(FIXTURES, 'store-writing-hook.ts'), context, {
143
+ await executeHookScript(join(FIXTURES, 'store-writing-hook.ts'), context, {
141
144
  timeoutMs: 30_000,
142
145
  idleTimeoutMs: 30_000,
143
146
  hookStores: () => Promise.resolve(memoryStores()),
144
147
  });
148
+ // The fixture hands its report through the state directory: hook return
149
+ // values are no longer carried anywhere (hook-owned-state D5).
150
+ return JSON.parse(readFileSync(join(dir, 'report.json'), 'utf-8')) as Record<string, unknown>;
145
151
  } finally {
146
152
  rmSync(dir, { recursive: true, force: true });
147
153
  }
@@ -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
  });
@@ -838,6 +838,10 @@ function buildCapabilityInterface(
838
838
  // baseline was recorded but never read back, so every converge stayed
839
839
  // in onboarding mode and refused instead of isolating (ce-qzxm).
840
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,
841
845
  // Operator settings, forwarded verbatim. `parseStoredConfigValue`
842
846
  // preserves each manifest-declared type, so the boolean arrives as a
843
847
  // boolean and needs no coercion here.
@@ -1361,6 +1365,10 @@ async function buildFirewallChain(
1361
1365
  // Absent means the box has never converged cleanly, so an interface
1362
1366
  // celilo cannot attribute refuses rather than being disabled.
1363
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,
1364
1372
  // Which declared zone carries this firewall's default route, and
1365
1373
  // whether fronted zones may initiate into it. Both are operator
1366
1374
  // settings and both are read HERE or nowhere: the module declares them
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { closeDb, getDb } from '../db/client';
6
6
  import { runMigrations } from '../db/migrate';
7
- import { systemConfig } from '../db/schema';
7
+ import { modules, systemConfig } from '../db/schema';
8
8
  import type { ContractHookSignature } from '../manifest/contracts';
9
9
  import { resetTestDbPath } from '../test-utils/db-path';
10
10
  import {
@@ -131,7 +131,8 @@ describe('Hook Executor', () => {
131
131
  vps_ip: '10.0.0.1',
132
132
  });
133
133
 
134
- expect(result).toEqual({ api_key: 'test-key-for-10.0.0.1' });
134
+ // Void hook: no outputs. The envelope carries an empty record.
135
+ expect(result).toEqual({});
135
136
  expect(messages).toContainEqual({ level: 'info', message: 'Starting test hook' });
136
137
  expect(messages).toContainEqual({ level: 'success', message: 'Test hook completed' });
137
138
  });
@@ -347,18 +348,53 @@ describe('Hook Executor', () => {
347
348
  getDb().insert(systemConfig).values({ key: 'hooks.jail_policy', value }).run();
348
349
  };
349
350
 
350
- const runVoidHook = (): Promise<Record<string, unknown>> => {
351
+ // The per-module row is keyed by module id, but the executor is handed
352
+ // the module's tree — the join the production read makes (task 1.4). The
353
+ // path is fake: nothing in this describe touches the filesystem beyond
354
+ // the scratch DB, and `existsSync(scriptPath)` fails before any policy
355
+ // read when it is not fake.
356
+ const moduleTree = (): string => join(dir, 'modules', 'fixture-module');
357
+ const plantModulePolicyRow = (moduleId: string, policy: string): void => {
358
+ getDb()
359
+ .insert(modules)
360
+ .values({
361
+ id: moduleId,
362
+ name: moduleId,
363
+ version: '1.0.0',
364
+ manifestData: {},
365
+ sourcePath: moduleTree(),
366
+ })
367
+ .run();
368
+ // Raw SQL, not the drizzle table, on purpose: the drizzle type only
369
+ // accepts 'auto' | 'off' | 'required', and the bad-value test models a
370
+ // row that arrived past every typed writer — a restore or a hand edit,
371
+ // the untrusted path the resolver throws on (Rule 3.7).
372
+ getDb()
373
+ .$client.prepare('INSERT INTO module_jail_policies (module_id, policy) VALUES (?, ?)')
374
+ .run(moduleId, policy);
375
+ };
376
+
377
+ const runVoidHook = (moduleTree?: string): Promise<Record<string, unknown>> => {
351
378
  const { logger } = createCapturingLogger();
352
- return executeHookScript(join(FIXTURES_DIR, 'void-hook.ts'), {
353
- config: configStore(),
354
- secrets: secretStore(),
355
- systems: [],
356
- logger,
357
- debug: false,
358
- screenshotDir: '/tmp',
359
- stateDir: '/tmp',
360
- capabilities: {},
361
- });
379
+ return executeHookScript(
380
+ join(FIXTURES_DIR, 'void-hook.ts'),
381
+ {
382
+ config: configStore(),
383
+ secrets: secretStore(),
384
+ systems: [],
385
+ logger,
386
+ debug: false,
387
+ screenshotDir: '/tmp',
388
+ stateDir: '/tmp',
389
+ capabilities: {},
390
+ },
391
+ {
392
+ // Absent means the system-config-only path: exactly what the tests
393
+ // above exercise. Present means the executor reads the module's row
394
+ // and resolves through it.
395
+ jail: moduleTree === undefined ? undefined : { modulePath: moduleTree, pathInputs: [] },
396
+ },
397
+ );
362
398
  };
363
399
 
364
400
  beforeEach(async () => {
@@ -413,6 +449,45 @@ describe('Hook Executor', () => {
413
449
  // resolver falls through to the default, which jails nobody.
414
450
  await expect(runVoidHook()).resolves.toBeDefined();
415
451
  });
452
+
453
+ test('the module row beats system config (per-module-jail-policy task 1.4)', async () => {
454
+ // System says jail hard; the module is exempted. The exempted module's
455
+ // hook runs unjailed while every other module of this host would fail —
456
+ // the spec's own "A module runs unjailed while the fleet is jailed".
457
+ plantPolicyRow('required');
458
+ plantModulePolicyRow('exempted-module', 'off');
459
+ await expect(runVoidHook(moduleTree())).resolves.toBeDefined();
460
+ // And the same host still jails a module with no row: `required` with
461
+ // no jail backend is a hard failure, so the system row stays in force.
462
+ // Which surface the failure takes is host-determined, and both prove
463
+ // the hook did not run: on a host with a jail backend it dies at mount
464
+ // planning (the fake module tree does not exist, "cannot be built");
465
+ // on a host with no backend it dies at backend detection ("no hook
466
+ // jail is available"). The point under test is policy precedence, not
467
+ // the surface. (celilo#1342: the CI runner has no bubblewrap, so the
468
+ // mount-planning surface is unreachable there.)
469
+ await expect(runVoidHook(join(dir, 'modules', 'other-module'))).rejects.toThrow(
470
+ /cannot be built|no hook jail is available/,
471
+ );
472
+ });
473
+
474
+ test('a bad per-module row fails the hook instead of running it unjailed', async () => {
475
+ plantModulePolicyRow('broken-module', 'alwayssafe');
476
+ await expect(runVoidHook(moduleTree())).rejects.toThrow(/module's jail policy='alwayssafe'/);
477
+ });
478
+
479
+ test('env beats a conflicting module row', async () => {
480
+ plantModulePolicyRow('exempted-module', 'off');
481
+ process.env.CELILO_HOOK_JAIL = 'required';
482
+ // The point is that the env overrode the module's `off`, so the hook
483
+ // is jailed and fails, not exempted and successful. Which failure it
484
+ // hits is host-determined: mount planning (the fake module tree does
485
+ // not exist) on a host with a jail backend, backend detection on a
486
+ // host without one. Either proves the hook did not run.
487
+ await expect(runVoidHook(moduleTree())).rejects.toThrow(
488
+ /cannot be built|no hook jail is available/,
489
+ );
490
+ });
416
491
  });
417
492
 
418
493
  describe('resolveHookTimeouts', () => {
@@ -548,7 +623,9 @@ describe('Hook Executor', () => {
548
623
  );
549
624
 
550
625
  expect(result.success).toBe(true);
551
- expect(result.outputs).toEqual({ api_key: 'test-key-for-10.0.0.5' });
626
+ // Hook return values are no longer consumed (hook-owned-state D5): a
627
+ // void hook's outputs are empty, and nothing persists them.
628
+ expect(result.outputs).toEqual({});
552
629
  expect(result.duration).toBeGreaterThanOrEqual(0);
553
630
  expect(messages.some((m) => m.message.includes('completed successfully'))).toBe(true);
554
631
  });
@@ -366,6 +366,32 @@ function readStoredJailPolicy(): string | undefined {
366
366
  return row?.value;
367
367
  }
368
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
+
369
395
  /**
370
396
  * Execute a hook script
371
397
  *
@@ -430,7 +456,11 @@ export async function executeHookScript(
430
456
  [process.execPath, HOOK_RUNNER_SPAWN_PATH],
431
457
  mountSet,
432
458
  detectJailBackend(),
433
- resolveJailPolicy(process.env.CELILO_HOOK_JAIL, readStoredJailPolicy()).policy,
459
+ resolveJailPolicy(
460
+ process.env.CELILO_HOOK_JAIL,
461
+ readStoredModuleJailPolicy(options.jail?.modulePath),
462
+ readStoredJailPolicy(),
463
+ ).policy,
434
464
  );
435
465
  reportJail(jail, logger);
436
466
  // Only a real module hook says anything about whether this HOST jails. An
@@ -31,7 +31,7 @@
31
31
  */
32
32
 
33
33
  import { describe, expect, test } from 'bun:test';
34
- import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
34
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
35
35
  import { tmpdir } from 'node:os';
36
36
  import { dirname, join, resolve } from 'node:path';
37
37
  import { executeHookScript } from './executor';
@@ -132,7 +132,7 @@ async function measureReach(): Promise<{ reach: Reach; cleanup: () => void }> {
132
132
  const savedPolicy = process.env.CELILO_HOOK_JAIL;
133
133
  process.env.CELILO_HOOK_JAIL = 'required';
134
134
  try {
135
- const reach = (await executeHookScript(PROBE_HOOK, context, {
135
+ await executeHookScript(PROBE_HOOK, context, {
136
136
  timeoutMs: 60_000,
137
137
  idleTimeoutMs: 60_000,
138
138
  jail: {
@@ -145,7 +145,12 @@ async function measureReach(): Promise<{ reach: Reach; cleanup: () => void }> {
145
145
  ? [{ name: 'browser_executable', value: probeBrowserDir(), access: 'read' as const }]
146
146
  : [],
147
147
  },
148
- })) as unknown as Reach;
148
+ });
149
+ // The fixture hands its report through the state directory: hook return
150
+ // values are no longer carried anywhere (hook-owned-state D5).
151
+ const reach = JSON.parse(
152
+ readFileSync(join(stateDir, 'report.json'), 'utf-8'),
153
+ ) as unknown as Reach;
149
154
 
150
155
  return { reach, cleanup: restorePolicy };
151
156
  } catch (error) {
@@ -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: {
@@ -66,6 +69,9 @@ interface Rig {
66
69
  cleanup: () => void;
67
70
  }
68
71
 
72
+ /** Where the probe fixture hands its report to the parent (hook-owned-state D5). */
73
+ const REPORT_PATH = 'report.json';
74
+
69
75
  /**
70
76
  * Lay out a module store the way celilo does, run the probe hook against it,
71
77
  * and hand back both the hook's report and the paths so the parent can check
@@ -131,14 +137,19 @@ async function runProbe(): Promise<Rig> {
131
137
  const savedPolicy = process.env.CELILO_HOOK_JAIL;
132
138
  process.env.CELILO_HOOK_JAIL = 'required';
133
139
  try {
134
- const outputs = (await executeHookScript(PROBE_HOOK, context, {
140
+ await executeHookScript(PROBE_HOOK, context, {
135
141
  timeoutMs: 60_000,
136
142
  idleTimeoutMs: 60_000,
137
143
  jail: {
138
144
  modulePath,
139
145
  pathInputs: [{ name: 'staged_input', value: stagedInput, access: 'write' }],
140
146
  },
141
- })) as unknown as Rig['outputs'];
147
+ });
148
+ // The fixture hands its report through the state directory: hook return
149
+ // values are no longer carried anywhere (hook-owned-state D5).
150
+ const outputs = JSON.parse(
151
+ readFileSync(join(stateDir, REPORT_PATH), 'utf-8'),
152
+ ) as unknown as Rig['outputs'];
142
153
  return {
143
154
  outputs,
144
155
  stagedInput,
@@ -208,18 +219,14 @@ describe.skipIf(!jailed)(`the jail is real (backend: ${availability.backend})`,
208
219
  describe.skipIf(jailed)('this run is not jailed', () => {
209
220
  test('says so, rather than reporting a pass it did not earn', () => {
210
221
  // 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`;
222
+ // run on a backend-less host reading as "the jail was proven". The only
223
+ // way here is `backend === 'none'` — an operator who set the policy off
224
+ // does NOT land in this companion, because the live suite above now pins
225
+ // the policy itself and runs anyway (celilo#1359).
226
+ const why = availability.reason ?? 'no backend';
216
227
  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).`,
228
+ `\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
229
  );
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);
230
+ expect(availability.backend === 'none').toBe(true);
224
231
  });
225
232
  });
@@ -33,7 +33,7 @@ import {
33
33
  } from '../../../../scripts/workspace-fallback';
34
34
  import { CLI_BIN_DIR, childPath, executeHookScript, hookChildEnv } from './executor';
35
35
  import { HOOK_PROTOCOL_VERSION } from './hook-protocol';
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';
@@ -155,14 +155,16 @@ afterAll(() => {
155
155
 
156
156
  async function runTrespass(jail = false): Promise<{ report: TrespassReport; lines: string[] }> {
157
157
  const { logger, messages } = createCapturingLogger();
158
+ const screenshotDir = mkdtempSync(join(tmpdir(), 'celilo-trespass-artifacts-'));
159
+ const stateDir = mkdtempSync(join(tmpdir(), 'celilo-trespass-artifacts-'));
158
160
  const context: HookContext = {
159
161
  config: configStore({ sibling_module_id: 'hello-foo', other_system_ip: '' }),
160
162
  secrets: secretStore(),
161
163
  systems: [],
162
164
  logger,
163
165
  debug: false,
164
- screenshotDir: mkdtempSync(join(tmpdir(), 'celilo-trespass-artifacts-')),
165
- stateDir: mkdtempSync(join(tmpdir(), 'celilo-trespass-artifacts-')),
166
+ screenshotDir,
167
+ stateDir,
166
168
  capabilities: {},
167
169
  };
168
170
 
@@ -173,7 +175,7 @@ async function runTrespass(jail = false): Promise<{ report: TrespassReport; line
173
175
  const savedPolicy = process.env.CELILO_HOOK_JAIL;
174
176
  if (jail) process.env.CELILO_HOOK_JAIL = 'required';
175
177
  try {
176
- const outputs = await executeHookScript(TRESPASS_SCRIPT, context, {
178
+ await executeHookScript(TRESPASS_SCRIPT, context, {
177
179
  timeoutMs: 60_000,
178
180
  idleTimeoutMs: 60_000,
179
181
  // The module's own tree, two levels above the hook script. Absent means
@@ -181,12 +183,19 @@ async function runTrespass(jail = false): Promise<{ report: TrespassReport; line
181
183
  ...(jail ? { jail: { modulePath: dirname(dirname(TRESPASS_SCRIPT)), pathInputs: [] } } : {}),
182
184
  });
183
185
  return {
184
- report: outputs as unknown as TrespassReport,
186
+ // The fixture hands its report through the state directory the jail
187
+ // binds read-write: the same channel works jailed and unjailed (D5
188
+ // removed the return channel).
189
+ report: JSON.parse(
190
+ readFileSync(join(stateDir, 'report.json'), 'utf-8'),
191
+ ) as unknown as TrespassReport,
185
192
  lines: messages.map((m) => m.message),
186
193
  };
187
194
  } finally {
188
195
  if (savedPolicy === undefined) delete process.env.CELILO_HOOK_JAIL;
189
196
  else process.env.CELILO_HOOK_JAIL = savedPolicy;
197
+ rmSync(screenshotDir, { recursive: true, force: true });
198
+ rmSync(stateDir, { recursive: true, force: true });
190
199
  }
191
200
  }
192
201
 
@@ -302,7 +311,14 @@ describe('hook process boundary — hello-trespass gate', () => {
302
311
  });
303
312
 
304
313
  const availability = detectJailBackend();
305
- const jailed = availability.backend !== 'none' && jailPolicy() !== 'off';
314
+ // Gated on the BACKEND alone, not on `jailPolicy()`. The ambient policy is an
315
+ // operator switch (`CELILO_HOOK_JAIL=off` is the default), and reading it here
316
+ // made stage 2 skip on every host with the default — measuring nothing. The
317
+ // jailed run pins the policy to `required` inside `runTrespass(true)` and
318
+ // restores it after, so the jail under test is this suite's own act wherever a
319
+ // backend exists. (celilo#1359: since the default became `off`, all three
320
+ // jailed suites skipped everywhere.)
321
+ const jailed = availability.backend !== 'none';
306
322
 
307
323
  describe.skipIf(!jailed)(
308
324
  `stage 2: the same hook, jailed (backend: ${availability.backend})`,