@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
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { existsSync } from 'node:fs';
6
- import { resolve } from 'node:path';
6
+ import { join, resolve } from 'node:path';
7
7
  import { eq } from 'drizzle-orm';
8
8
  import {
9
9
  promptForMissingCapabilitySecrets,
@@ -13,12 +13,25 @@ import { getDb } from '../../db/client';
13
13
  import { moduleConfigs, modules } from '../../db/schema';
14
14
  import type { ModuleManifest } from '../../manifest/schema';
15
15
  import { interviewForMissingSecrets, validateModuleSecrets } from '../../services/config-interview';
16
- import { getModuleBuildStatus, verifyArtifactsExist } from '../../services/module-build';
17
16
  import { generateTemplates } from '../../templates/generator';
18
17
  import { promptForMissingConfig } from '../interactive-config';
19
18
  import { getArg, getFlag, validateRequiredArgs } from '../parser';
20
19
  import type { CommandResult } from '../types';
21
20
 
21
+ /**
22
+ * Declared `build.artifacts` absent from the module's delivered payload.
23
+ *
24
+ * D1 (control-plane-stops-building-modules): generation consumes the payload.
25
+ * A declared output the payload does not carry is a publish defect, so it is
26
+ * reported to the caller and named, never repaired by running a build here.
27
+ * A fallback build is the shape that hid celilo#1307 for four months: the
28
+ * failure must fail loudly at the boundary, not be repaired downstream.
29
+ */
30
+ export function missingBuildOutputs(modulePath: string, manifest: ModuleManifest): string[] {
31
+ const artifacts = manifest.build?.artifacts ?? [];
32
+ return artifacts.filter((artifact) => !existsSync(join(modulePath, artifact)));
33
+ }
34
+
22
35
  /**
23
36
  * Handle module generate command
24
37
  *
@@ -71,31 +84,23 @@ export async function handleModuleGenerate(
71
84
  };
72
85
  }
73
86
 
74
- // Check if module requires build step
87
+ // D1 (control-plane-stops-building-modules): generation consumes the
88
+ // delivered payload. Whether this machine ever ran a build is a fact about
89
+ // its own history, not about the module in front of it — gating on it is
90
+ // what made celilo#1307 refuse generation on a production control plane
91
+ // whose payload carried the correct binaries one directory away.
75
92
  const manifest = module.manifestData as ModuleManifest;
76
- if (manifest.build) {
77
- const buildStatus = await getModuleBuildStatus(moduleId, db);
78
-
79
- if (!buildStatus || buildStatus.status !== 'success') {
80
- return {
81
- success: false,
82
- error:
83
- `Module ${moduleId} requires build step before generation.\n\n` +
84
- `Run: celilo module build ${moduleId}`,
85
- };
86
- }
87
-
88
- // Verify artifacts still exist
89
- if (buildStatus.artifacts.length > 0) {
90
- const artifactsValid = verifyArtifactsExist(buildStatus.artifacts);
91
-
92
- if (!artifactsValid) {
93
- return {
94
- success: false,
95
- error: `Build artifacts missing or invalid.\n\nRebuild: celilo module build ${moduleId}`,
96
- };
97
- }
98
- }
93
+ const missingOutputs = missingBuildOutputs(modulePath, manifest);
94
+ if (missingOutputs.length > 0) {
95
+ return {
96
+ success: false,
97
+ error: [
98
+ `Module payload is missing ${missingOutputs.length} declared build output(s):`,
99
+ ...missingOutputs.map((artifact) => ` ${artifact}`),
100
+ '',
101
+ "The payload must carry the module's build outputs; a control plane never builds. This is a publish defect: re-package the module and update it. Generation will not fall back to running a build.",
102
+ ].join('\n'),
103
+ };
99
104
  }
100
105
 
101
106
  const outputPathFlag = getFlag(flags, 'output', '');
@@ -23,6 +23,7 @@ const CHECK_ICONS: Record<string, string> = {
23
23
  pass: '✓',
24
24
  warn: '⚠',
25
25
  fail: '✗',
26
+ skip: '○',
26
27
  };
27
28
 
28
29
  function formatResult(result: HealthCheckResult): string {
@@ -12,6 +12,7 @@ import { closeDb, getDb } from '../../db/client';
12
12
  import { runMigrations } from '../../db/migrate';
13
13
  import { modules } from '../../db/schema';
14
14
  import type { BaseModuleAspect } from '../../manifest/schema';
15
+ import { resetTestDbPath } from '../../test-utils/db-path';
15
16
  import { handleAspectApprovalAfterImport } from './module-import';
16
17
 
17
18
  const aspect: BaseModuleAspect = {
@@ -61,7 +62,7 @@ describe('handleAspectApprovalAfterImport (non-interactive)', () => {
61
62
  afterEach(() => {
62
63
  setTTY(originalIsTTY);
63
64
  closeDb();
64
- process.env.CELILO_DB_PATH = undefined;
65
+ resetTestDbPath();
65
66
  try {
66
67
  rmSync(dir, { recursive: true, force: true });
67
68
  } catch {
@@ -17,6 +17,7 @@ import { tmpdir } from 'node:os';
17
17
  import { join } from 'node:path';
18
18
  import type { IndexEntry } from '../../registry/client';
19
19
  import { RegistryClient } from '../../registry/client';
20
+ import { resetTestDbPath } from '../../test-utils/db-path';
20
21
  import { handlePublicRegistryImport } from './module-import';
21
22
 
22
23
  // handlePublicRegistryImport is not exported by default — re-exported at end of module-import.ts
@@ -42,8 +43,8 @@ afterEach(() => {
42
43
  getIndexSpy.mockRestore();
43
44
  downloadSpy.mockRestore();
44
45
  rmSync(tempDir, { recursive: true, force: true });
45
- process.env.CELILO_DB_PATH = undefined;
46
- process.env.CELILO_DATA_DIR = undefined;
46
+ resetTestDbPath();
47
+ delete process.env.CELILO_DATA_DIR;
47
48
  });
48
49
 
49
50
  describe('handlePublicRegistryImport — registry lookup', () => {
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Tests for `celilo module jail` (per-module-jail-policy task 2.1/2.2).
3
+ *
4
+ * The verb records, clears and shows one module's hook jail policy, and asks
5
+ * over the bus interview when the write weakens the posture below the
6
+ * system's. Strengthening writes directly; `--force` skips the ask.
7
+ *
8
+ * Isolation mirrors system-config.test.ts: CELILO_DB_PATH / CELILO_DATA_DIR /
9
+ * EVENT_BUS_DB are set before the SUT is imported. bun test runs non-TTY, so
10
+ * a weakening write with no responder listening fails fast with "No responder
11
+ * is listening" — a successful direct weakening write in these tests is proof
12
+ * the command never asked.
13
+ */
14
+
15
+ import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
16
+ import { mkdtempSync, rmSync } from 'node:fs';
17
+ import { tmpdir } from 'node:os';
18
+ import { join } from 'node:path';
19
+ import { eq } from 'drizzle-orm';
20
+
21
+ const testRoot = mkdtempSync(join(tmpdir(), 'celilo-module-jail-'));
22
+ const busDbPath = join(testRoot, 'bus.db');
23
+ process.env.CELILO_DB_PATH = join(testRoot, 'celilo.db');
24
+ process.env.CELILO_DATA_DIR = join(testRoot, 'data');
25
+ process.env.EVENT_BUS_DB = busDbPath;
26
+
27
+ import type { CommandError, CommandResult, CommandSuccess } from '../types';
28
+
29
+ const { getDb, closeDb } = await import('../../db/client');
30
+ const { moduleJailPolicies, modules, systemConfig } = await import('../../db/schema');
31
+ const { handleModuleJail } = await import('./module-jail');
32
+ const { startProgrammaticResponder } = await import('../../services/programmatic-responder');
33
+
34
+ const MODULE_ID = 'jailmod';
35
+
36
+ /** Narrow the result union after asserting the branch, so `.message` typechecks. */
37
+ function expectOk(result: CommandResult): asserts result is CommandSuccess {
38
+ expect(result.success).toBe(true);
39
+ }
40
+
41
+ function expectErr(result: CommandResult): asserts result is CommandError {
42
+ expect(result.success).toBe(false);
43
+ }
44
+
45
+ function insertModule(id: string): void {
46
+ getDb()
47
+ .insert(modules)
48
+ .values({ id, name: id, sourcePath: `/tmp/${id}`, version: '1.0.0', manifestData: {} })
49
+ .run();
50
+ }
51
+
52
+ function storedPolicy(moduleId: string): string | undefined {
53
+ return getDb()
54
+ .select()
55
+ .from(moduleJailPolicies)
56
+ .where(eq(moduleJailPolicies.moduleId, moduleId))
57
+ .get()?.policy;
58
+ }
59
+
60
+ function setSystemPolicy(value: string | undefined): void {
61
+ const db = getDb();
62
+ if (value === undefined) {
63
+ db.delete(systemConfig).where(eq(systemConfig.key, 'hooks.jail_policy')).run();
64
+ } else {
65
+ db.insert(systemConfig)
66
+ .values({ key: 'hooks.jail_policy', value })
67
+ .onConflictDoUpdate({ target: systemConfig.key, set: { value } })
68
+ .run();
69
+ }
70
+ }
71
+
72
+ afterAll(() => {
73
+ closeDb();
74
+ rmSync(testRoot, { recursive: true, force: true });
75
+ });
76
+
77
+ describe('module jail — the write form', () => {
78
+ beforeEach(() => {
79
+ getDb().delete(moduleJailPolicies).run();
80
+ getDb().delete(systemConfig).where(eq(systemConfig.key, 'hooks.jail_policy')).run();
81
+ if (
82
+ !getDb()
83
+ .select()
84
+ .from(modules)
85
+ .all()
86
+ .some((m) => m.id === MODULE_ID)
87
+ ) {
88
+ insertModule(MODULE_ID);
89
+ }
90
+ });
91
+
92
+ test('records a policy and the read form resolves through it', async () => {
93
+ const set = await handleModuleJail([MODULE_ID, 'auto']);
94
+ expect(set.success).toBe(true);
95
+ expect(storedPolicy(MODULE_ID)).toBe('auto');
96
+
97
+ const show = await handleModuleJail([MODULE_ID]);
98
+ expectOk(show);
99
+ expect(show.message).toContain('effective: auto — source: module');
100
+ });
101
+
102
+ test('rejects a value outside the accepted set', async () => {
103
+ const result = await handleModuleJail([MODULE_ID, 'sometimes']);
104
+ expectErr(result);
105
+ expect(result.error).toContain("Invalid jail policy 'sometimes'");
106
+ expect(storedPolicy(MODULE_ID)).toBeUndefined();
107
+ });
108
+
109
+ test('rejects an unknown module', async () => {
110
+ const result = await handleModuleJail(['no-such-module', 'off']);
111
+ expect(result.success).toBe(false);
112
+ });
113
+
114
+ test('refuses to pass both a policy and --clear', async () => {
115
+ const result = await handleModuleJail([MODULE_ID, 'off'], { clear: true });
116
+ expectErr(result);
117
+ expect(result.error).toContain('not both');
118
+ });
119
+
120
+ test('module row beats the system value in the read form', async () => {
121
+ setSystemPolicy('required');
122
+ await handleModuleJail([MODULE_ID, 'off'], { force: true });
123
+
124
+ const show = await handleModuleJail([MODULE_ID]);
125
+ expectOk(show);
126
+ expect(show.message).toContain('effective: off — source: module');
127
+ expect(show.message).toContain('hooks.jail_policy = required');
128
+ });
129
+
130
+ test('--clear removes the row and the module follows the system again', async () => {
131
+ setSystemPolicy('auto');
132
+ await handleModuleJail([MODULE_ID, 'off'], { force: true });
133
+ expect(storedPolicy(MODULE_ID)).toBe('off');
134
+
135
+ const cleared = await handleModuleJail([MODULE_ID], { clear: true });
136
+ expectOk(cleared);
137
+ expect(storedPolicy(MODULE_ID)).toBeUndefined();
138
+ expect(cleared.message).toContain('follows the system: auto');
139
+
140
+ const show = await handleModuleJail([MODULE_ID]);
141
+ expectOk(show);
142
+ expect(show.message).toContain('effective: auto — source: config');
143
+ });
144
+
145
+ test('--clear with no row succeeds', async () => {
146
+ const result = await handleModuleJail([MODULE_ID], { clear: true });
147
+ expectOk(result);
148
+ expect(result.message).toContain('already follows the system');
149
+ });
150
+ });
151
+
152
+ describe('module jail — the weakening interview gate', () => {
153
+ beforeEach(() => {
154
+ getDb().delete(moduleJailPolicies).run();
155
+ getDb().delete(systemConfig).where(eq(systemConfig.key, 'hooks.jail_policy')).run();
156
+ if (
157
+ !getDb()
158
+ .select()
159
+ .from(modules)
160
+ .all()
161
+ .some((m) => m.id === MODULE_ID)
162
+ ) {
163
+ insertModule(MODULE_ID);
164
+ }
165
+ });
166
+
167
+ test('weakening with no responder fails fast and writes nothing', async () => {
168
+ setSystemPolicy('auto');
169
+ let threw: unknown;
170
+ try {
171
+ await handleModuleJail([MODULE_ID, 'off']);
172
+ } catch (error) {
173
+ threw = error;
174
+ }
175
+
176
+ expect(threw).toBeInstanceOf(Error);
177
+ expect(String((threw as Error).message)).toContain('No responder is listening');
178
+ expect(storedPolicy(MODULE_ID)).toBeUndefined();
179
+ });
180
+
181
+ test('weakening writes after a confirming reply, and the question was asked', async () => {
182
+ setSystemPolicy('auto');
183
+ const responder = startProgrammaticResponder({
184
+ busDbPath,
185
+ db: getDb(),
186
+ onMissing: 'skip',
187
+ values: { interview: { 'module-jail-policy.weaken': true } },
188
+ });
189
+ try {
190
+ const result = await handleModuleJail([MODULE_ID, 'off']);
191
+ expect(result.success).toBe(true);
192
+ expect(storedPolicy(MODULE_ID)).toBe('off');
193
+
194
+ const asked = responder.seenInterviewPayloads();
195
+ expect(asked.length).toBe(1);
196
+ expect(asked[0].scope).toBe('module-jail-policy');
197
+ expect(asked[0].key).toBe('weaken');
198
+ expect(asked[0].message).toContain('unjailed');
199
+ } finally {
200
+ responder.close();
201
+ }
202
+ });
203
+
204
+ test('a denial writes nothing', async () => {
205
+ setSystemPolicy('auto');
206
+ const responder = startProgrammaticResponder({
207
+ busDbPath,
208
+ db: getDb(),
209
+ onMissing: 'skip',
210
+ values: { interview: { 'module-jail-policy.weaken': false } },
211
+ });
212
+ try {
213
+ const result = await handleModuleJail([MODULE_ID, 'off']);
214
+ expect(result.success).toBe(false);
215
+ expect(storedPolicy(MODULE_ID)).toBeUndefined();
216
+ } finally {
217
+ responder.close();
218
+ }
219
+ });
220
+
221
+ test('weakening with --force writes directly and asks nothing', async () => {
222
+ setSystemPolicy('required');
223
+ const result = await handleModuleJail([MODULE_ID, 'off'], { force: true });
224
+ expectOk(result);
225
+ expect(storedPolicy(MODULE_ID)).toBe('off');
226
+ expect(result.message).toContain('weaker than the system');
227
+ });
228
+
229
+ test('strengthening writes directly with no responder listening', async () => {
230
+ setSystemPolicy('off');
231
+ const result = await handleModuleJail([MODULE_ID, 'required']);
232
+ expect(result.success).toBe(true);
233
+ expect(storedPolicy(MODULE_ID)).toBe('required');
234
+ });
235
+
236
+ test('setting a policy equal to the system writes directly', async () => {
237
+ setSystemPolicy('off');
238
+ const result = await handleModuleJail([MODULE_ID, 'off']);
239
+ expect(result.success).toBe(true);
240
+ expect(storedPolicy(MODULE_ID)).toBe('off');
241
+ });
242
+ });
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Module jail policy command (per-module-jail-policy task 2.1/2.2).
3
+ *
4
+ * The operator's handle on one module's hook jail posture. Absent a recorded
5
+ * row, a module follows the system's `hooks.jail_policy`; this verb records,
6
+ * clears, and shows the resolution. The precedence itself lives in
7
+ * `resolveJailPolicy` (hooks/jail.ts) and is NOT re-derived here — the read
8
+ * form calls the same function the executor calls, so what the operator sees
9
+ * is what a hook will get.
10
+ *
11
+ * Weakening below the system's posture asks first (task 2.2), the same shape
12
+ * `system config set hooks.jail_policy off` uses: an event-bus interview,
13
+ * answered by whatever responder is attached, skipped only by the registered
14
+ * `--force`. Strengthening never asks.
15
+ */
16
+
17
+ import { eq } from 'drizzle-orm';
18
+ import { getDb } from '../../db/client';
19
+ import { moduleJailPolicies, modules, systemConfig } from '../../db/schema';
20
+ import { type JailPolicy, POLICY_STRENGTH, resolveJailPolicy } from '../../hooks/jail';
21
+ import { askConfirm, withInterviewSession } from '../../services/bus-interview';
22
+ import { getArg, hasFlag, validateRequiredArgs } from '../parser';
23
+ import type { CommandResult } from '../types';
24
+
25
+ const JAIL_SYSTEM_KEY = 'hooks.jail_policy';
26
+ const JAIL_POLICIES: readonly JailPolicy[] = ['auto', 'off', 'required'];
27
+
28
+ function isJailPolicy(value: string): value is JailPolicy {
29
+ return (JAIL_POLICIES as readonly string[]).includes(value);
30
+ }
31
+
32
+ /**
33
+ * The system-wide policy a module would follow with no row of its own.
34
+ *
35
+ * Throws on a stored value outside the accepted set, matching the read-time
36
+ * refusal rule — a hand-edited or restored bad row must fail loudly, not
37
+ * quietly count as `off` for the weakening comparison.
38
+ */
39
+ function systemJailPolicy(): { value: JailPolicy | undefined; effective: JailPolicy } {
40
+ const stored = getDb()
41
+ .select()
42
+ .from(systemConfig)
43
+ .where(eq(systemConfig.key, JAIL_SYSTEM_KEY))
44
+ .get()?.value;
45
+ if (stored === undefined) return { value: undefined, effective: 'off' };
46
+ if (!isJailPolicy(stored)) {
47
+ throw new Error(
48
+ `Stored ${JAIL_SYSTEM_KEY}='${stored}' is not a hook jail policy. Fix it with 'celilo system config set ${JAIL_SYSTEM_KEY} <auto|off|required>' before setting a per-module policy.`,
49
+ );
50
+ }
51
+ return { value: stored, effective: stored };
52
+ }
53
+
54
+ /**
55
+ * Handle module jail command.
56
+ *
57
+ * Usage:
58
+ * celilo module jail <id> — show the effective policy and its source
59
+ * celilo module jail <id> <policy> — record auto|off|required for this module
60
+ * celilo module jail <id> --clear — remove the row; follow the system again
61
+ *
62
+ * @param args - Command arguments (module id, optional policy)
63
+ * @param flags - Command flags (--clear, --force)
64
+ * @returns Command result
65
+ */
66
+ export async function handleModuleJail(
67
+ args: string[],
68
+ flags: Record<string, string | boolean> = {},
69
+ ): Promise<CommandResult> {
70
+ const error = validateRequiredArgs(args, 1);
71
+ if (error) {
72
+ return {
73
+ success: false,
74
+ error: `${error}\n\nUsage:\n celilo module jail <id> # show the effective policy\n celilo module jail <id> <auto|off|required>\n celilo module jail <id> --clear`,
75
+ };
76
+ }
77
+
78
+ const moduleId = getArg(args, 0);
79
+ const policyArg = getArg(args, 1);
80
+ const clear = hasFlag(flags, 'clear');
81
+
82
+ if (policyArg && clear) {
83
+ return {
84
+ success: false,
85
+ error: 'Pass either a policy or --clear, not both.',
86
+ };
87
+ }
88
+
89
+ const db = getDb();
90
+ const module = moduleId
91
+ ? db.select().from(modules).where(eq(modules.id, moduleId)).get()
92
+ : undefined;
93
+ if (!moduleId || !module) {
94
+ return { success: false, error: `Module not found: ${moduleId}` };
95
+ }
96
+
97
+ if (clear) return clearModuleJailPolicy(moduleId);
98
+ if (policyArg) return setModuleJailPolicy(moduleId, policyArg, hasFlag(flags, 'force'));
99
+ return showModuleJailPolicy(moduleId);
100
+ }
101
+
102
+ /** The read form: the three sources, the effective value, and which one won. */
103
+ function showModuleJailPolicy(moduleId: string): CommandResult {
104
+ const db = getDb();
105
+ const moduleRow = db
106
+ .select()
107
+ .from(moduleJailPolicies)
108
+ .where(eq(moduleJailPolicies.moduleId, moduleId))
109
+ .get();
110
+ const configRow = getDb()
111
+ .select()
112
+ .from(systemConfig)
113
+ .where(eq(systemConfig.key, JAIL_SYSTEM_KEY))
114
+ .get();
115
+ const envValue = process.env.CELILO_HOOK_JAIL;
116
+
117
+ let effective: { policy: JailPolicy; source: string };
118
+ try {
119
+ effective = resolveJailPolicy(envValue, moduleRow?.policy, configRow?.value);
120
+ } catch (error) {
121
+ return {
122
+ success: false,
123
+ error: error instanceof Error ? error.message : String(error),
124
+ };
125
+ }
126
+
127
+ const lines = [`Hook jail policy for ${moduleId}:`];
128
+ lines.push(
129
+ ` environment: ${envValue === undefined || envValue === '' ? '(CELILO_HOOK_JAIL unset)' : envValue}`,
130
+ );
131
+ lines.push(
132
+ ` module: ${moduleRow ? `${moduleRow.policy} (recorded ${moduleRow.updatedAt.toISOString()})` : '(none — follows the system)'}`,
133
+ );
134
+ lines.push(
135
+ ` system: ${configRow ? `${JAIL_SYSTEM_KEY} = ${configRow.value}` : `(${JAIL_SYSTEM_KEY} unset — default off)`}`,
136
+ );
137
+ lines.push(` effective: ${effective.policy} — source: ${effective.source}`);
138
+
139
+ return {
140
+ success: true,
141
+ message: lines.join('\n'),
142
+ data: {
143
+ moduleId,
144
+ modulePolicy: moduleRow?.policy,
145
+ systemPolicy: configRow?.value,
146
+ envPolicy: envValue || undefined,
147
+ effective: effective.policy,
148
+ source: effective.source,
149
+ },
150
+ };
151
+ }
152
+
153
+ /** The write form. Weakening below the system's posture asks first. */
154
+ async function setModuleJailPolicy(
155
+ moduleId: string,
156
+ policyArg: string,
157
+ force: boolean,
158
+ ): Promise<CommandResult> {
159
+ if (!isJailPolicy(policyArg)) {
160
+ return {
161
+ success: false,
162
+ error: `Invalid jail policy '${policyArg}'. Use 'auto' (jail when a backend is available), 'required' (an unavailable jail is a hard failure), or 'off'.`,
163
+ };
164
+ }
165
+
166
+ const system = systemJailPolicy();
167
+
168
+ if (POLICY_STRENGTH[policyArg] < POLICY_STRENGTH[system.effective] && !force) {
169
+ const confirmed = await withInterviewSession(() =>
170
+ askConfirm({
171
+ scope: 'module-jail-policy',
172
+ key: 'weaken',
173
+ message: `Set ${moduleId}'s jail policy to ${policyArg}, weaker than the system's ${system.effective}? This module's hooks will run unjailed while the rest of the fleet jails.`,
174
+ defaultValue: false,
175
+ }),
176
+ );
177
+ if (!confirmed) {
178
+ return { success: false, error: 'Cancelled by user' };
179
+ }
180
+ }
181
+
182
+ const db = getDb();
183
+ db.insert(moduleJailPolicies)
184
+ .values({ moduleId, policy: policyArg, updatedAt: new Date() })
185
+ .onConflictDoUpdate({
186
+ target: moduleJailPolicies.moduleId,
187
+ set: { policy: policyArg, updatedAt: new Date() },
188
+ })
189
+ .run();
190
+
191
+ const weakerNote =
192
+ POLICY_STRENGTH[policyArg] < POLICY_STRENGTH[system.effective]
193
+ ? ` (weaker than the system's ${system.effective} — this module's hooks run unjailed)`
194
+ : '';
195
+ return {
196
+ success: true,
197
+ message: `Set jail policy for ${moduleId}: ${policyArg}${weakerNote}`,
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Remove the module's row so it follows the system again. Clearing can never
203
+ * weaken the posture — the module resolves to exactly the system value — so
204
+ * there is no interview on this path.
205
+ */
206
+ function clearModuleJailPolicy(moduleId: string): CommandResult {
207
+ const db = getDb();
208
+ const existing = db
209
+ .select()
210
+ .from(moduleJailPolicies)
211
+ .where(eq(moduleJailPolicies.moduleId, moduleId))
212
+ .get();
213
+
214
+ if (!existing) {
215
+ return {
216
+ success: true,
217
+ message: `No jail policy recorded for ${moduleId} (already follows the system)`,
218
+ };
219
+ }
220
+
221
+ db.delete(moduleJailPolicies).where(eq(moduleJailPolicies.moduleId, moduleId)).run();
222
+ const system = systemJailPolicy();
223
+ return {
224
+ success: true,
225
+ message: `Cleared jail policy for ${moduleId} (now follows the system: ${system.effective})`,
226
+ };
227
+ }