@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
@@ -31,9 +31,9 @@ import {
31
31
  listUnpublishedWorkspacePackages,
32
32
  planFallbacks,
33
33
  } from '../../../../scripts/workspace-fallback';
34
- import { childPath, executeHookScript, hookChildEnv } from './executor';
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';
@@ -246,19 +246,29 @@ describe('the child environment is an allow-list', () => {
246
246
  // celilo#1300: a hook that spawns `celilo` by name must find the same CLI
247
247
  // the parent runs, whatever the inherited PATH looks like.
248
248
  describe('childPath', () => {
249
- test('prepends the runtime directory so the child reaches the parent CLI', () => {
249
+ test('prepends the CLI bin and runtime directories so the child reaches the parent CLI', () => {
250
250
  const child = childPath('/usr/local/bin:/usr/bin');
251
- expect(child.startsWith(`${dirname(process.execPath)}:`)).toBe(true);
251
+ expect(child.startsWith(`${CLI_BIN_DIR}:${dirname(process.execPath)}:`)).toBe(true);
252
252
  expect(child.endsWith('/usr/local/bin:/usr/bin')).toBe(true);
253
253
  });
254
254
 
255
- test('leaves an inherited PATH that already carries the directory unchanged', () => {
256
- const already = `${dirname(process.execPath)}:/usr/bin`;
255
+ test('leaves an inherited PATH that already carries the directories unchanged', () => {
256
+ const already = `${CLI_BIN_DIR}:${dirname(process.execPath)}:/usr/bin`;
257
257
  expect(childPath(already)).toBe(already);
258
258
  });
259
259
 
260
- test('substitutes the directory when the parent has no PATH at all', () => {
261
- expect(childPath(undefined)).toBe(dirname(process.execPath));
260
+ test('substitutes both directories when the parent has no PATH at all', () => {
261
+ expect(childPath(undefined)).toBe(`${CLI_BIN_DIR}:${dirname(process.execPath)}`);
262
+ });
263
+
264
+ // The fleet gate (ce-y0fd): the .deb layout runs the CLI through
265
+ // $CELILO_HOME/node_modules/@celilo/cli/bin/celilo, so the directory beside
266
+ // process.execPath holds only bun and holds no celilo. A PATH that resolves
267
+ // `celilo` must therefore come from the CLI package's own bin directory —
268
+ // and that directory must really contain the bin, or every prepended PATH
269
+ // entry is decoration over the same accident celilo#1300 recorded.
270
+ test('prepends a CLI bin directory that actually contains the celilo bin', () => {
271
+ expect(existsSync(join(CLI_BIN_DIR, 'celilo'))).toBe(true);
262
272
  });
263
273
  });
264
274
  });
@@ -292,7 +302,14 @@ describe('hook process boundary — hello-trespass gate', () => {
292
302
  });
293
303
 
294
304
  const availability = detectJailBackend();
295
- const jailed = availability.backend !== 'none' && jailPolicy() !== 'off';
305
+ // Gated on the BACKEND alone, not on `jailPolicy()`. The ambient policy is an
306
+ // operator switch (`CELILO_HOOK_JAIL=off` is the default), and reading it here
307
+ // made stage 2 skip on every host with the default — measuring nothing. The
308
+ // jailed run pins the policy to `required` inside `runTrespass(true)` and
309
+ // restores it after, so the jail under test is this suite's own act wherever a
310
+ // backend exists. (celilo#1359: since the default became `off`, all three
311
+ // jailed suites skipped everywhere.)
312
+ const jailed = availability.backend !== 'none';
296
313
 
297
314
  describe.skipIf(!jailed)(
298
315
  `stage 2: the same hook, jailed (backend: ${availability.backend})`,
@@ -27,6 +27,7 @@ import {
27
27
  readJailMode,
28
28
  realpathRequest,
29
29
  recordJailMode,
30
+ resolveJailPolicy,
30
31
  runtimeModulePathsFor,
31
32
  } from './jail';
32
33
  import { deriveMountSet } from './mount-set';
@@ -208,9 +209,13 @@ describe('the three policies', () => {
208
209
  });
209
210
 
210
211
  test("'required' turns an unavailable jail into a hard failure (task 4.5)", () => {
211
- expect(() =>
212
- planJailedSpawn(CMD, deriveMountSet(REQUEST), UNAVAILABLE, 'required', allPresent),
213
- ).toThrow(/CELILO_HOOK_JAIL=required/);
212
+ expect(
213
+ () => planJailedSpawn(CMD, deriveMountSet(REQUEST), UNAVAILABLE, 'required', allPresent),
214
+ // Not /CELILO_HOOK_JAIL=required/. Since hook-jail-config-surface the
215
+ // policy can come from the stored `hooks.jail_policy` row or the
216
+ // default, so a message naming the variable names a source that may be
217
+ // unset.
218
+ ).toThrow(/hook jail policy is 'required'/);
214
219
  });
215
220
 
216
221
  test("'required' names the reason, so the operator can act on it", () => {
@@ -272,8 +277,12 @@ describe('jailPolicy reads the operator’s switch', () => {
272
277
  }
273
278
  };
274
279
 
275
- test('unset is auto', () => {
276
- expect(withEnv(undefined, jailPolicy)).toBe('auto');
280
+ test('unset is off (peba, ce-rez7)', () => {
281
+ expect(withEnv(undefined, jailPolicy)).toBe('off');
282
+ });
283
+
284
+ test('an explicit auto still means auto', () => {
285
+ expect(withEnv('auto', jailPolicy)).toBe('auto');
277
286
  });
278
287
 
279
288
  test('a typo is refused rather than silently meaning auto', () => {
@@ -284,6 +293,144 @@ describe('jailPolicy reads the operator’s switch', () => {
284
293
  });
285
294
  });
286
295
 
296
+ describe('resolveJailPolicy applies D2 precedence (hook-jail-config-surface)', () => {
297
+ test('an env value set and non-empty wins over stored config', () => {
298
+ expect(resolveJailPolicy('required', undefined, 'off')).toEqual({
299
+ policy: 'required',
300
+ source: 'env',
301
+ });
302
+ });
303
+
304
+ test('an EMPTY STRING env value counts as unset and falls through to config', () => {
305
+ // jailPolicy() already treats '' as unset, so the resolver agrees rather
306
+ // than inventing a second rule for what counts as set.
307
+ expect(resolveJailPolicy('', undefined, 'off')).toEqual({ policy: 'off', source: 'config' });
308
+ });
309
+
310
+ test('stored config wins when the env var is absent', () => {
311
+ expect(resolveJailPolicy(undefined, undefined, 'off')).toEqual({
312
+ policy: 'off',
313
+ source: 'config',
314
+ });
315
+ expect(resolveJailPolicy(undefined, undefined, 'required')).toEqual({
316
+ policy: 'required',
317
+ source: 'config',
318
+ });
319
+ });
320
+
321
+ test('all absent resolves to off from the default (peba, ce-rez7)', () => {
322
+ expect(resolveJailPolicy(undefined, undefined, undefined)).toEqual({
323
+ policy: 'off',
324
+ source: 'default',
325
+ });
326
+ expect(resolveJailPolicy('', undefined, undefined)).toEqual({
327
+ policy: 'off',
328
+ source: 'default',
329
+ });
330
+ });
331
+
332
+ test('an explicit env auto still wins (peba, ce-rez7)', () => {
333
+ expect(resolveJailPolicy('auto', undefined, 'off')).toEqual({ policy: 'auto', source: 'env' });
334
+ });
335
+
336
+ test('a stored empty jail_policy THROWS, it does not resolve to auto (peba, ce-8832)', () => {
337
+ // The set-time pattern ^(auto|off|required)$ rejects '', so a stored one
338
+ // can only arrive via a restore from file or a hand edit to the DB — the
339
+ // untrusted paths D3 exists to catch. It is outside the enum and fails
340
+ // closed like any other bad stored value.
341
+ expect(() => resolveJailPolicy(undefined, undefined, '')).toThrow(/is not a hook jail policy/);
342
+ expect(() => resolveJailPolicy(undefined, undefined, '')).toThrow(/hooks\.jail_policy=''/);
343
+ });
344
+
345
+ test('a typo in the STORED row is refused, not silently treated as auto (D3)', () => {
346
+ // The DB row is a trust boundary like any other (Rule 3.7): restores carry
347
+ // foreign state, and a typo that silently meant `auto` reads as an armed
348
+ // jail when nothing is armed. The config path is no laxer than the env
349
+ // path.
350
+ expect(() => resolveJailPolicy(undefined, undefined, 'alwayssafe')).toThrow(
351
+ /is not a hook jail policy/,
352
+ );
353
+ expect(() => resolveJailPolicy(undefined, undefined, 'alwayssafe')).toThrow(
354
+ /hooks\.jail_policy='alwayssafe'/,
355
+ );
356
+ });
357
+
358
+ test('a typo in the env value is still refused (unchanged jailPolicy behaviour)', () => {
359
+ expect(() => resolveJailPolicy('requried', undefined, undefined)).toThrow(
360
+ /is not a hook jail policy/,
361
+ );
362
+ expect(() => resolveJailPolicy('requried', undefined, 'off')).toThrow(
363
+ /CELILO_HOOK_JAIL='requried'/,
364
+ );
365
+ });
366
+ });
367
+
368
+ describe('resolveJailPolicy applies the four-step precedence (per-module-jail-policy task 1.3)', () => {
369
+ // Each boundary gets its own assertion, not just the ends: the chain is
370
+ // fixed and rendered, and a test that only pins (env, default) cannot tell
371
+ // a reordering of module and config apart from a correct one.
372
+
373
+ test('env beats the module row', () => {
374
+ expect(resolveJailPolicy('required', 'off', 'auto')).toEqual({
375
+ policy: 'required',
376
+ source: 'env',
377
+ });
378
+ expect(resolveJailPolicy('off', 'auto', 'auto')).toEqual({ policy: 'off', source: 'env' });
379
+ });
380
+
381
+ test('the module row beats system config', () => {
382
+ expect(resolveJailPolicy(undefined, 'off', 'auto')).toEqual({
383
+ policy: 'off',
384
+ source: 'module',
385
+ });
386
+ expect(resolveJailPolicy(undefined, 'required', 'off')).toEqual({
387
+ policy: 'required',
388
+ source: 'module',
389
+ });
390
+ });
391
+
392
+ test('system config beats the default', () => {
393
+ expect(resolveJailPolicy(undefined, undefined, 'auto')).toEqual({
394
+ policy: 'auto',
395
+ source: 'config',
396
+ });
397
+ expect(resolveJailPolicy(undefined, undefined, 'required')).toEqual({
398
+ policy: 'required',
399
+ source: 'config',
400
+ });
401
+ });
402
+
403
+ test('the default is off (peba, ce-rez7)', () => {
404
+ expect(resolveJailPolicy(undefined, undefined, undefined)).toEqual({
405
+ policy: 'off',
406
+ source: 'default',
407
+ });
408
+ });
409
+
410
+ test('an absent module row falls through to system config — absent means follow the system', () => {
411
+ // The everyday case: a fleet with no per-module rows behaves exactly as
412
+ // it does today, which is the spec's own operator guarantee.
413
+ expect(resolveJailPolicy(undefined, undefined, 'auto')).toEqual({
414
+ policy: 'auto',
415
+ source: 'config',
416
+ });
417
+ });
418
+
419
+ test('a bad stored per-module value THROWS rather than coercing (task 1.3)', () => {
420
+ // The same rule as the system key (peba, ce-8832): a restore or a hand
421
+ // edit is a trust boundary, and a typo that silently meant `off` would
422
+ // jail a module the operator exempted — or the reverse. The error names
423
+ // its source so a reader can tell which stored row is junk.
424
+ expect(() => resolveJailPolicy(undefined, 'alwayssafe', 'off')).toThrow(
425
+ /is not a hook jail policy/,
426
+ );
427
+ expect(() => resolveJailPolicy(undefined, 'alwayssafe', 'off')).toThrow(
428
+ /module's jail policy='alwayssafe'/,
429
+ );
430
+ expect(() => resolveJailPolicy(undefined, '', 'off')).toThrow(/is not a hook jail policy/);
431
+ });
432
+ });
433
+
287
434
  describe('realpath at the caller (task 4.2k)', () => {
288
435
  test('a symlinked module root is resolved before it becomes a bind', () => {
289
436
  // `mod.sourcePath` comes out of the database, and a database restored from
package/src/hooks/jail.ts CHANGED
@@ -100,7 +100,9 @@ export function autoJailDefers(availability: JailAvailability): boolean {
100
100
  }
101
101
 
102
102
  /**
103
- * What the operator asked for. `CELILO_HOOK_JAIL`, and the default is `auto`.
103
+ * What the operator asked for. `CELILO_HOOK_JAIL`, and the default is `off`
104
+ * (peba's ruling on ce-rez7; `resolveJailPolicy`'s doc comment below has the
105
+ * reversal trigger).
104
106
  *
105
107
  * `required` is D8's end state, reached as an explicit act once stage 2 has
106
108
  * been proven on a host: an unavailable jail becomes a hard failure rather
@@ -418,16 +420,98 @@ function unavailableReason(error: unknown): string {
418
420
  * `CELILO_HOOK_JAIL=requried` that silently meant `auto` would read as the
419
421
  * jail being enforced when it is not, which is the one mistake this variable
420
422
  * exists to prevent.
423
+ *
424
+ * The unset/empty default is `off` (peba's ruling on ce-rez7): jailing must
425
+ * begin because someone set a switch, not because a package upgrade installed
426
+ * a backend. Turning the jail ON is a deliberate act.
421
427
  */
422
428
  export function jailPolicy(): JailPolicy {
423
429
  const raw = process.env.CELILO_HOOK_JAIL;
424
- if (raw === undefined || raw === '') return 'auto';
430
+ if (raw === undefined || raw === '') return 'off';
425
431
  if (raw === 'auto' || raw === 'off' || raw === 'required') return raw;
426
432
  throw new Error(
427
433
  `CELILO_HOOK_JAIL='${raw}' is not a hook jail policy. Use 'auto' (jail when a backend is available), 'required' (an unavailable jail is a hard failure), or 'off'.`,
428
434
  );
429
435
  }
430
436
 
437
+ /** Where the effective jail policy came from. Rendered by `system doctor` (D4). */
438
+ export type JailPolicySource = 'env' | 'module' | 'config' | 'default';
439
+
440
+ /** How much jailing a policy does. Lower is weaker (per-module-jail-policy). */
441
+ export const POLICY_STRENGTH: Record<JailPolicy, number> = { off: 0, auto: 1, required: 2 };
442
+
443
+ /**
444
+ * Whether the module's own policy jails LESS than the system's would — the
445
+ * definition of an exemption (per-module-jail-policy task 3.1-3.3).
446
+ *
447
+ * Shared by `module jail`'s weakening interview, `module list`'s marker,
448
+ * `system doctor`'s count and `system audit`'s category, so the four
449
+ * surfaces cannot disagree about what an exemption is.
450
+ */
451
+ export function isWeakerJailPolicy(modulePolicy: JailPolicy, systemPolicy: JailPolicy): boolean {
452
+ return POLICY_STRENGTH[modulePolicy] < POLICY_STRENGTH[systemPolicy];
453
+ }
454
+
455
+ /**
456
+ * Apply the precedence to the four sources (per-module-jail-policy task 1.2):
457
+ * env wins, then the module's own recorded policy, then stored system
458
+ * config, then the default, which is `off` (peba's rulings on ce-rez7 and
459
+ * ce-8832; see openspec/changes/hook-jail-config-surface/design.md and
460
+ * openspec/changes/per-module-jail-policy/ for the fixed chain).
461
+ *
462
+ * Pure, so both consumers (executor.ts and system-doctor.ts) can fetch the
463
+ * stored rows themselves and stay testable without a database.
464
+ *
465
+ * The empty-string env value is treated as unset, matching `jailPolicy()`'s
466
+ * own handling — one rule for "what counts as set", not a second one here.
467
+ *
468
+ * The stored paths are deliberately stricter than the env path: a stored
469
+ * empty string THROWS (peba's ruling on ce-8832). The set-time pattern
470
+ * `^(auto|off|required)$` rejects `''`, so a stored one can only arrive via
471
+ * a restore from file or a hand edit to the DB — exactly the untrusted
472
+ * paths D3 exists to catch. Resolving it to a silent `auto` would let
473
+ * foreign state pick the jailing default with nobody told, and failing
474
+ * closed is the whole point. An empty CELILO_HOOK_JAIL stays "not set",
475
+ * because `jailPolicy()` relies on that and slice 1's safety rests on the
476
+ * two resolvers agreeing.
477
+ *
478
+ * The module row follows the same throw rule as the system key (task 1.3).
479
+ * `undefined` is the everyday case — no row, follow the system — and a row
480
+ * keyed to a module that was removed is impossible by schema: the FK
481
+ * cascades the delete.
482
+ */
483
+ export function resolveJailPolicy(
484
+ envValue: string | undefined,
485
+ moduleValue: string | undefined,
486
+ configValue: string | undefined,
487
+ ): { policy: JailPolicy; source: JailPolicySource } {
488
+ if (envValue !== undefined && envValue !== '') {
489
+ if (envValue === 'auto' || envValue === 'off' || envValue === 'required') {
490
+ return { policy: envValue, source: 'env' };
491
+ }
492
+ throw new Error(
493
+ `CELILO_HOOK_JAIL='${envValue}' is not a hook jail policy. Use 'auto' (jail when a backend is available), 'required' (an unavailable jail is a hard failure), or 'off'.`,
494
+ );
495
+ }
496
+ if (moduleValue !== undefined) {
497
+ if (moduleValue === 'auto' || moduleValue === 'off' || moduleValue === 'required') {
498
+ return { policy: moduleValue, source: 'module' };
499
+ }
500
+ throw new Error(
501
+ `the module's jail policy='${moduleValue}' is not a hook jail policy. Use 'auto' (jail when a backend is available), 'required' (an unavailable jail is a hard failure), or 'off'.`,
502
+ );
503
+ }
504
+ if (configValue !== undefined) {
505
+ if (configValue === 'auto' || configValue === 'off' || configValue === 'required') {
506
+ return { policy: configValue, source: 'config' };
507
+ }
508
+ throw new Error(
509
+ `hooks.jail_policy='${configValue}' is not a hook jail policy. Use 'auto' (jail when a backend is available), 'required' (an unavailable jail is a hard failure), or 'off'.`,
510
+ );
511
+ }
512
+ return { policy: 'off', source: 'default' };
513
+ }
514
+
431
515
  /**
432
516
  * Resolve every path in a mount-set request through the filesystem (task 4.2k).
433
517
  *
@@ -568,9 +652,14 @@ function realpathOrSelf(path: string): string {
568
652
  * Why an `off` policy runs unjailed, shared with `celilo system doctor` (task
569
653
  * 4.6) so the sentence the operator reads there is the sentence the record
570
654
  * carries.
655
+ *
656
+ * It names no source. Since hook-jail-config-surface there are three (the
657
+ * environment variable, the stored `hooks.jail_policy` row, and the `off`
658
+ * default), and this sentence is rendered for all of them. The doctor prints
659
+ * the source on its own line above this one; the record carries it as state.
571
660
  */
572
661
  export const JAIL_OFF_REASON =
573
- 'CELILO_HOOK_JAIL=off: the operator switched the hook jail off on this host.';
662
+ "The hook jail policy is 'off', so hooks run unjailed on this host. Set hooks.jail_policy to 'auto' or 'required' to jail them.";
574
663
 
575
664
  export function planJailedSpawn(
576
665
  cmd: readonly string[],
@@ -595,7 +684,7 @@ export function planJailedSpawn(
595
684
  : 'This invocation has no module tree to jail, so there is no mount set to enforce.';
596
685
  if (policy === 'required') {
597
686
  throw new Error(
598
- `CELILO_HOOK_JAIL=required and no hook jail is available. ${reason ?? ''}`.trim(),
687
+ `The hook jail policy is 'required' and no hook jail is available. ${reason ?? ''}`.trim(),
599
688
  );
600
689
  }
601
690
  return { cmd, mode: 'unjailed', backend: availability.backend, reason, skipped: [] };
@@ -173,6 +173,19 @@ export const V1_HOOKS: ContractHooks = {
173
173
  * mount-set derivation walking the declared inputs could not see it.
174
174
  */
175
175
  system_state_root: { required: false, path: { access: 'read' } },
176
+ /**
177
+ * The management-host inventory as a JSON array, staged by
178
+ * `backup-create.ts` from `listMachines()` under the same
179
+ * cross_module_read privilege as `system_state_root`. Not a path: the
180
+ * hook reads the value, not a file, so it carries no mount-set entry.
181
+ *
182
+ * It replaces on_backup's `celilo machine list --json` spawn, which the
183
+ * hook jail kills (no celilo binary, celilo#1225) and which never
184
+ * actually produced JSON before that — the CLI printed its human report,
185
+ * the hook's JSON.parse failed, and the catch wrote [] into every
186
+ * envelope (ce-y0we).
187
+ */
188
+ machine_pool: { required: false },
176
189
  },
177
190
  outputs: {
178
191
  artifact_count: { required: true },
@@ -13,7 +13,6 @@ import {
13
13
  } from '../../services/module-instances';
14
14
  import { computeFileChecksum } from './checksum';
15
15
  import type { IntegrityViolation } from './extract';
16
- import { compareVerbatimRoleAssets, readVerbatimRoleAssets } from './generated-plane';
17
16
  import { type HostPlaneResult, verifyModuleOnHosts } from './host-plane';
18
17
  import { classifyModulePath } from './package-rules';
19
18
 
@@ -204,30 +203,11 @@ export async function auditModule(
204
203
  }
205
204
  }
206
205
 
207
- // Plane two: is what we would deploy built from what we installed? Only
208
- // verbatim role assets have a meaningful expected digest — Ansible
209
- // templates the rest, and a `.j2` in `generated/` is SUPPOSED to differ.
210
- // A module that has never been generated has nothing to compare and is not
211
- // a finding; it is simply pre-deploy.
212
- const generatedDir = join(moduleDir, 'generated');
213
- if (existsSync(generatedDir)) {
214
- const differences = compareVerbatimRoleAssets(
215
- await readVerbatimRoleAssets(moduleDir),
216
- await readVerbatimRoleAssets(generatedDir),
217
- );
218
- for (const difference of differences) {
219
- violations.push({
220
- type: 'stale-generated',
221
- path: difference.relPath,
222
- expectedDigest: difference.installedDigest,
223
- actualDigest: difference.generatedDigest,
224
- message:
225
- difference.reason === 'missing'
226
- ? `Generated project is missing ${difference.relPath} — the next deploy would ship nothing for it. Run 'celilo module generate ${moduleId}'.`
227
- : `Generated project holds different bytes for ${difference.relPath} (generated ${difference.generatedDigest}, installed ${difference.installedDigest}) — the next deploy would ship the wrong ones. Run 'celilo module generate ${moduleId}'.`,
228
- });
229
- }
230
- }
206
+ // The second plane (is what we would deploy built from what we installed?)
207
+ // retired here: the generated tree is ephemeral now (D4 of
208
+ // control-plane-stops-building-modules) — rendered for a deploy and
209
+ // deleted on success — so a persistent generated copy that verbatim role
210
+ // assets could be compared against no longer exists to compare.
231
211
 
232
212
  // Check for extra files (not in checksums)
233
213
  const actualFiles = await scanDirectory(moduleDir, moduleDir);
@@ -244,7 +224,10 @@ export async function auditModule(
244
224
  }
245
225
 
246
226
  // Plane three: is what is running what we generated? One SSH per system,
247
- // so it is asked only when the caller says so.
227
+ // so it is asked only when the caller says so. With the generated tree
228
+ // ephemeral (D4), a deep audit after a successful deploy finds no playbook
229
+ // and says so — the remedy is generate, which is cheap and never builds.
230
+ const generatedDir = join(moduleDir, 'generated');
248
231
  let hostPlane: HostPlaneResult | undefined;
249
232
  if (options.deep) {
250
233
  hostPlane = await verifyModuleOnHosts({
@@ -0,0 +1,127 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { cpSync, existsSync, mkdtempSync, readFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { parse as parseYaml } from 'yaml';
6
+ import { resolveBuildCommandPaths } from './build-paths';
7
+
8
+ /** Repo root: this file sits at apps/celilo/src/module/packaging/. */
9
+ const REPO_ROOT = join(import.meta.dir, '..', '..', '..', '..', '..');
10
+
11
+ /** Every module in the repo that declares a `build:` block (design, Context). */
12
+ const BUILD_MODULES = [
13
+ 'celilo-registry',
14
+ 'celilo-web-console',
15
+ 'celilo-website',
16
+ 'forgejo',
17
+ 'npm-cache-node',
18
+ 'wireguard-manager',
19
+ ] as const;
20
+
21
+ function buildCommand(moduleId: string): string {
22
+ const manifestPath = join(REPO_ROOT, 'modules', moduleId, 'manifest.yml');
23
+ if (!existsSync(manifestPath)) throw new Error(`fixture missing: ${manifestPath}`);
24
+ const manifest = parseYaml(readFileSync(manifestPath, 'utf8')) as {
25
+ build?: { command?: string };
26
+ };
27
+ const command = manifest.build?.command;
28
+ if (!command) throw new Error(`${moduleId} declares no build command`);
29
+ return command;
30
+ }
31
+
32
+ describe('publish-time build path gate', () => {
33
+ test('flags celilo-registry when packaged outside the monorepo (the control-plane shape)', () => {
34
+ // The known-bad form, per celilo#1307: the script reaches a sibling of the
35
+ // module source (`../../packages/registry-server`). Inside the monorepo
36
+ // that resolves; from a tree where the module stands alone — a control
37
+ // plane, or any packaging that is not the source checkout — it does not.
38
+ // The spec scenario pins this: "packaged from a tree where that directory
39
+ // does not exist".
40
+ const scratch = mkdtempSync(join(tmpdir(), 'celilo-build-paths-'));
41
+ const moduleSourceDir = join(scratch, 'celilo-registry');
42
+ cpSync(join(REPO_ROOT, 'modules', 'celilo-registry'), moduleSourceDir, {
43
+ recursive: true,
44
+ });
45
+ const violations = resolveBuildCommandPaths(buildCommand('celilo-registry'), {
46
+ moduleSourceDir,
47
+ buildDir: moduleSourceDir,
48
+ });
49
+ expect(violations).toHaveLength(1);
50
+ expect(violations[0].rawPath).toContain('../../packages/registry-server');
51
+ expect(violations[0].resolvedPath).not.toContain(REPO_ROOT);
52
+ });
53
+
54
+ test('flags a plain relative escape that does not resolve (spec scenario)', () => {
55
+ const scratch = mkdtempSync(join(tmpdir(), 'celilo-build-paths-'));
56
+ const violations = resolveBuildCommandPaths(
57
+ 'cd ../../packages/registry-server && bun install',
58
+ { moduleSourceDir: scratch, buildDir: scratch },
59
+ );
60
+ expect(violations).toHaveLength(1);
61
+ expect(violations[0].rawPath).toBe('../../packages/registry-server');
62
+ });
63
+
64
+ test('accepts $CELILO_MODULE_SOURCE_DIR/server when it exists (npm-cache-node shape)', () => {
65
+ const scratch = mkdtempSync(join(tmpdir(), 'celilo-build-paths-'));
66
+ const moduleSourceDir = join(scratch, 'npm-cache-node');
67
+ cpSync(
68
+ join(REPO_ROOT, 'modules', 'npm-cache-node', 'server'),
69
+ join(moduleSourceDir, 'server'),
70
+ {
71
+ recursive: true,
72
+ },
73
+ );
74
+ const violations = resolveBuildCommandPaths(buildCommand('npm-cache-node'), {
75
+ moduleSourceDir,
76
+ buildDir: moduleSourceDir,
77
+ });
78
+ expect(violations).toEqual([]);
79
+ });
80
+
81
+ test('accepts every module build command at publish time in the monorepo', () => {
82
+ // The regression surface for the five healthy modules. celilo-registry
83
+ // passes here too, and that is correct: at publish, inside the monorepo,
84
+ // its `cd` target exists (the binaries shipped on 2026-08-20 prove the
85
+ // build ran). The gate refuses the script only where it genuinely cannot
86
+ // resolve — the first test above.
87
+ for (const moduleId of BUILD_MODULES) {
88
+ const moduleSourceDir = join(REPO_ROOT, 'modules', moduleId);
89
+ const violations = resolveBuildCommandPaths(buildCommand(moduleId), {
90
+ moduleSourceDir,
91
+ buildDir: moduleSourceDir,
92
+ });
93
+ expect(violations, moduleId).toEqual([]);
94
+ }
95
+ });
96
+
97
+ test('skips a cd target it cannot resolve rather than guessing (forgejo shape)', () => {
98
+ // `cd "$D"` where D was assigned earlier in the same command: the gate
99
+ // does not interpret shell, so an unresolvable target is skipped, not
100
+ // failed. A guard that rejects working modules is worse than none.
101
+ const scratch = mkdtempSync(join(tmpdir(), 'celilo-build-paths-'));
102
+ const violations = resolveBuildCommandPaths('D=files && mkdir -p "$D" && cd "$D" && ls', {
103
+ moduleSourceDir: scratch,
104
+ buildDir: scratch,
105
+ });
106
+ expect(violations).toEqual([]);
107
+ });
108
+
109
+ test('tracks the working directory across chained cds (celilo-web-console shape)', () => {
110
+ // `cd $SOURCE/../../apps/console` then `cd ../console-server`: the second
111
+ // target resolves against the FIRST cd's destination, not the build dir.
112
+ const scratch = mkdtempSync(join(tmpdir(), 'celilo-build-paths-'));
113
+ const moduleSourceDir = join(scratch, 'web');
114
+ const monorepo = join(scratch, 'mono');
115
+ cpSync(join(REPO_ROOT, 'modules', 'celilo-web-console'), moduleSourceDir, { recursive: true });
116
+ // A monorepo-shaped sibling tree, minimal but real.
117
+ const { mkdirSync } = require('node:fs') as typeof import('node:fs');
118
+ mkdirSync(join(monorepo, 'apps', 'console'), { recursive: true });
119
+ mkdirSync(join(monorepo, 'apps', 'console-server'), { recursive: true });
120
+ cpSync(moduleSourceDir, join(monorepo, 'modules', 'celilo-web-console'), { recursive: true });
121
+ const violations = resolveBuildCommandPaths(buildCommand('celilo-web-console'), {
122
+ moduleSourceDir: join(monorepo, 'modules', 'celilo-web-console'),
123
+ buildDir: join(monorepo, 'modules', 'celilo-web-console'),
124
+ });
125
+ expect(violations).toEqual([]);
126
+ });
127
+ });