@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
@@ -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
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Tests for `celilo module list`'s jail-exemption marker (per-module-jail-policy
3
+ * task 3.1/3.4).
4
+ *
5
+ * A module with a recorded policy weaker than the system's is marked in the
6
+ * human list and carries a `jailExemptions` roster entry in JSON. The negative
7
+ * half is asserted with the same rigour: no row, no marker, empty roster —
8
+ * a query bug that reported phantom exemptions would look exactly like a
9
+ * correctly empty one otherwise.
10
+ *
11
+ * Isolation mirrors module-jail.test.ts: CELILO_DB_PATH / CELILO_DATA_DIR are
12
+ * set before the SUT is imported.
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-list-jail-'));
22
+ process.env.CELILO_DB_PATH = join(testRoot, 'celilo.db');
23
+ process.env.CELILO_DATA_DIR = join(testRoot, 'data');
24
+
25
+ const { getDb, closeDb } = await import('../../db/client');
26
+ const { moduleJailPolicies, modules, systemConfig } = await import('../../db/schema');
27
+ const { handleModuleList } = await import('./module-list');
28
+ import type { CommandResult, CommandSuccess } from '../types';
29
+
30
+ /** Narrow the result union after asserting the branch, so `.message` typechecks. */
31
+ function expectOk(result: CommandResult): asserts result is CommandSuccess {
32
+ expect(result.success).toBe(true);
33
+ }
34
+
35
+ function insertModule(id: string): void {
36
+ getDb()
37
+ .insert(modules)
38
+ .values({ id, name: id, sourcePath: `/tmp/${id}`, version: '1.0.0', manifestData: {} })
39
+ .run();
40
+ }
41
+
42
+ function setModulePolicy(moduleId: string, policy: string): void {
43
+ const typed = policy as 'auto' | 'off' | 'required';
44
+ getDb()
45
+ .insert(moduleJailPolicies)
46
+ .values({ moduleId, policy: typed, updatedAt: new Date() })
47
+ .onConflictDoUpdate({
48
+ target: moduleJailPolicies.moduleId,
49
+ set: { policy: typed, updatedAt: new Date() },
50
+ })
51
+ .run();
52
+ }
53
+
54
+ function setSystemPolicy(value: string): void {
55
+ getDb()
56
+ .insert(systemConfig)
57
+ .values({ key: 'hooks.jail_policy', value })
58
+ .onConflictDoUpdate({ target: systemConfig.key, set: { value } })
59
+ .run();
60
+ }
61
+
62
+ function clearModulePolicy(moduleId: string): void {
63
+ getDb().delete(moduleJailPolicies).where(eq(moduleJailPolicies.moduleId, moduleId)).run();
64
+ }
65
+
66
+ afterAll(() => {
67
+ closeDb();
68
+ rmSync(testRoot, { recursive: true, force: true });
69
+ });
70
+
71
+ describe('module list — jail exemption marker (per-module-jail-policy task 3.1)', () => {
72
+ beforeEach(() => {
73
+ const db = getDb();
74
+ db.delete(moduleJailPolicies).run();
75
+ db.delete(systemConfig).where(eq(systemConfig.key, 'hooks.jail_policy')).run();
76
+ db.delete(modules).run();
77
+ insertModule('caddy');
78
+ });
79
+
80
+ test('a module weaker than the system is marked, with both policies', async () => {
81
+ setSystemPolicy('auto');
82
+ setModulePolicy('caddy', 'off');
83
+ const result = await handleModuleList({});
84
+ expectOk(result);
85
+ expect(result.message).toContain('caddy');
86
+ expect(result.message).toContain("[jail: off — weaker than the system's auto]");
87
+ });
88
+
89
+ test('a module with no per-module row carries no marker (the negative half of task 3.4)', async () => {
90
+ setSystemPolicy('auto');
91
+ const result = await handleModuleList({});
92
+ expectOk(result);
93
+ expect(result.message).not.toContain('jail:');
94
+ expect(result.message).not.toContain('weaker than');
95
+ });
96
+
97
+ test('a module whose row is not weaker than the system carries no marker', async () => {
98
+ setSystemPolicy('auto');
99
+ setModulePolicy('caddy', 'required');
100
+ const result = await handleModuleList({});
101
+ expectOk(result);
102
+ expect(result.message).not.toContain('jail:');
103
+ });
104
+
105
+ test('a cleared row stops the marker (the exemption is un-set, not hidden)', async () => {
106
+ setSystemPolicy('auto');
107
+ setModulePolicy('caddy', 'off');
108
+ const before = await handleModuleList({});
109
+ expectOk(before);
110
+ expect(before.message).toContain('[jail:');
111
+ clearModulePolicy('caddy');
112
+ const after = await handleModuleList({});
113
+ expectOk(after);
114
+ expect(after.message).not.toContain('[jail:');
115
+ });
116
+
117
+ test('the JSON roster carries the exemptions', async () => {
118
+ setSystemPolicy('auto');
119
+ setModulePolicy('caddy', 'off');
120
+ const result = await handleModuleList({ json: true });
121
+ expectOk(result);
122
+ const parsed = JSON.parse(result.message) as {
123
+ jailExemptions: Array<{ moduleId: string; policy: string; systemPolicy: string }>;
124
+ };
125
+ expect(parsed.jailExemptions).toEqual([
126
+ { moduleId: 'caddy', policy: 'off', systemPolicy: 'auto' },
127
+ ]);
128
+ });
129
+
130
+ test('the JSON roster is empty when nothing is exempted', async () => {
131
+ const result = await handleModuleList({ json: true });
132
+ expectOk(result);
133
+ const parsed = JSON.parse(result.message) as { jailExemptions: unknown[] };
134
+ expect(parsed.jailExemptions).toEqual([]);
135
+ });
136
+ });
@@ -1,6 +1,7 @@
1
1
  import { getDb } from '../../db/client';
2
2
  import { modules } from '../../db/schema';
3
3
  import { loadObservedHealth } from '../../services/alerting/observed-health';
4
+ import { type JailExemption, collectJailExemptions } from '../../services/jail-exemptions';
4
5
  import { formatPausedDuration } from '../../services/module-pause';
5
6
  import { hasFlag } from '../parser';
6
7
  import type { CommandResult } from '../types';
@@ -20,14 +21,29 @@ export async function handleModuleList(
20
21
  // Query all modules
21
22
  const moduleRows = db.select().from(modules).all();
22
23
 
24
+ // Per-module jail exemptions (per-module-jail-policy task 3.1), from the
25
+ // shared collector so list, doctor and audit report the same set. A bad
26
+ // stored row throws here rather than silently listing unmarked — the same
27
+ // row will fail that module's hooks at execution time.
28
+ let jailExemptions: JailExemption[];
29
+ try {
30
+ jailExemptions = collectJailExemptions(db);
31
+ } catch (error) {
32
+ return {
33
+ success: false,
34
+ error: error instanceof Error ? error.message : String(error),
35
+ };
36
+ }
37
+ const exemptedById = new Map(jailExemptions.map((e) => [e.moduleId, e]));
38
+
23
39
  // Stable machine-readable roster — the backbone the MCP composite
24
40
  // troubleshooting tools correlate audit findings against (ce-77i.5).
25
41
  if (hasFlag(flags, 'json')) {
26
42
  return {
27
43
  success: true,
28
- message: JSON.stringify({ modules: moduleRows }, null, 2),
44
+ message: JSON.stringify({ modules: moduleRows, jailExemptions }, null, 2),
29
45
  rawOutput: true,
30
- data: moduleRows,
46
+ data: { modules: moduleRows, jailExemptions },
31
47
  };
32
48
  }
33
49
 
@@ -56,7 +72,9 @@ export async function handleModuleList(
56
72
  module.state === 'PAUSED'
57
73
  ? `PAUSED (${formatPausedDuration(module.pausedAt)})`
58
74
  : module.state;
59
- lines.push(`${module.id} (v${module.version}) - ${stateCell}${healthNote}`);
75
+ lines.push(
76
+ `${module.id} (v${module.version}) - ${stateCell}${healthNote}${jailNote(exemptedById.get(module.id))}`,
77
+ );
60
78
  if (module.state === 'PAUSED' && module.pauseReason) {
61
79
  lines.push(` Paused: ${module.pauseReason}`);
62
80
  }
@@ -75,3 +93,12 @@ export async function handleModuleList(
75
93
  data: moduleRows,
76
94
  };
77
95
  }
96
+
97
+ /**
98
+ * The list marker for an exempted module, or '' when the module has no row
99
+ * (per-module-jail-policy task 3.4: no row, no output).
100
+ */
101
+ function jailNote(exemption: JailExemption | undefined): string {
102
+ if (!exemption) return '';
103
+ return ` [jail: ${exemption.policy} — weaker than the system's ${exemption.systemPolicy}]`;
104
+ }