@celilo/cli 2.2.1 → 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 (112) 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/src/__integration__/container-services-cli.integration.test.ts +1 -1
  9. package/src/api/sessions.test.ts +3 -3
  10. package/src/api-clients/proxmox.ts +10 -7
  11. package/src/cli/backup-rename.test.ts +3 -3
  12. package/src/cli/cli.test.ts +1 -1
  13. package/src/cli/commands/events.test.ts +2 -2
  14. package/src/cli/commands/firewall-interface-list.test.ts +2 -2
  15. package/src/cli/commands/machine-list.test.ts +57 -0
  16. package/src/cli/commands/machine-list.ts +35 -4
  17. package/src/cli/commands/module-health.ts +1 -0
  18. package/src/cli/commands/module-import-registry.test.ts +1 -1
  19. package/src/cli/commands/module-jail.test.ts +242 -0
  20. package/src/cli/commands/module-jail.ts +227 -0
  21. package/src/cli/commands/module-list-jail.test.ts +136 -0
  22. package/src/cli/commands/module-list.ts +30 -3
  23. package/src/cli/commands/module-publish.test.ts +6 -6
  24. package/src/cli/commands/module-update.test.ts +22 -7
  25. package/src/cli/commands/module-update.ts +4 -1
  26. package/src/cli/commands/module-upgrade.test.ts +115 -1
  27. package/src/cli/commands/module-upgrade.ts +23 -1
  28. package/src/cli/commands/module-verify.test.ts +1 -1
  29. package/src/cli/commands/publish/execute.ts +4 -1
  30. package/src/cli/commands/publish/index.ts +11 -1
  31. package/src/cli/commands/publish/module-registry.test.ts +24 -1
  32. package/src/cli/commands/publish/module-registry.ts +23 -4
  33. package/src/cli/commands/publish/plan.ts +1 -1
  34. package/src/cli/commands/publish/types.ts +7 -0
  35. package/src/cli/commands/registry-owner.test.ts +1 -1
  36. package/src/cli/commands/registry-token.test.ts +1 -1
  37. package/src/cli/commands/system-audit.ts +20 -6
  38. package/src/cli/commands/system-doctor-remediation-gate.test.ts +295 -0
  39. package/src/cli/commands/system-doctor.test.ts +76 -10
  40. package/src/cli/commands/system-doctor.ts +34 -3
  41. package/src/cli/commands/system-init-deprecation.test.ts +1 -1
  42. package/src/cli/commands/system-update.ts +5 -0
  43. package/src/cli/completion.ts +8 -2
  44. package/src/cli/flag-surface-gate.test.ts +279 -0
  45. package/src/cli/index.ts +68 -2
  46. package/src/cli/parser.test.ts +37 -1
  47. package/src/cli/restore-command.test.ts +3 -3
  48. package/src/cli/restore-migration-failure.test.ts +2 -2
  49. package/src/cli/tui/audit-state.ts +2 -0
  50. package/src/config/paths.test.ts +19 -19
  51. package/src/db/client.test.ts +46 -1
  52. package/src/db/client.ts +26 -0
  53. package/src/db/schema.ts +63 -0
  54. package/src/hooks/capability-loader-firewall.test.ts +9 -1
  55. package/src/hooks/capability-loader.ts +8 -0
  56. package/src/hooks/executor.test.ts +86 -12
  57. package/src/hooks/executor.ts +31 -1
  58. package/src/hooks/hook-jail-unreachability.test.ts +17 -18
  59. package/src/hooks/hook-trespass.test.ts +9 -2
  60. package/src/hooks/jail.test.ts +105 -17
  61. package/src/hooks/jail.ts +47 -10
  62. package/src/manifest/contracts/v1.ts +13 -0
  63. package/src/policy/module-script-scan.ts +52 -68
  64. package/src/policy/no-hand-built-ssh.test.ts +5 -1
  65. package/src/policy/no-swallowed-refusal.test.ts +14 -14
  66. package/src/registry/client.test.ts +2 -2
  67. package/src/secrets/storage.test.ts +1 -1
  68. package/src/services/alerting/keys.test.ts +4 -0
  69. package/src/services/alerting/keys.ts +12 -2
  70. package/src/services/audit/health.test.ts +97 -2
  71. package/src/services/audit/health.ts +64 -2
  72. package/src/services/audit/index.test.ts +22 -0
  73. package/src/services/audit/index.ts +8 -2
  74. package/src/services/audit/jail-exemptions.test.ts +42 -0
  75. package/src/services/audit/jail-exemptions.ts +44 -0
  76. package/src/services/audit/module-integrity.test.ts +23 -1
  77. package/src/services/audit/module-integrity.ts +7 -2
  78. package/src/services/audit/types.ts +2 -1
  79. package/src/services/backup-create.ts +9 -0
  80. package/src/services/backup-envelope-roundtrip.test.ts +1 -1
  81. package/src/services/backup-in-flight-refusal.test.ts +1 -1
  82. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +48 -0
  83. package/src/services/build-bus/hook-dispatcher.ts +49 -1
  84. package/src/services/bus-ensure-flow.test.ts +1 -1
  85. package/src/services/bus-interview-park.test.ts +2 -2
  86. package/src/services/bus-interview.test.ts +2 -2
  87. package/src/services/bus-secret-flow.test.ts +1 -1
  88. package/src/services/celilo-events.test.ts +1 -1
  89. package/src/services/celilo-mgmt-hooks.test.ts +23 -5
  90. package/src/services/container-service.test.ts +1 -1
  91. package/src/services/cross-module-read.test.ts +2 -2
  92. package/src/services/deploy-preflight.ts +7 -0
  93. package/src/services/deployed-systems.ts +1 -1
  94. package/src/services/fleet-checks.test.ts +123 -4
  95. package/src/services/fleet-checks.ts +176 -3
  96. package/src/services/health-runner.test.ts +87 -2
  97. package/src/services/health-runner.ts +40 -11
  98. package/src/services/infrastructure-selector.test.ts +1 -1
  99. package/src/services/jail-exemptions.test.ts +125 -0
  100. package/src/services/jail-exemptions.ts +81 -0
  101. package/src/services/machine-pool.test.ts +1 -1
  102. package/src/services/module-deploy.ts +12 -4
  103. package/src/services/module-subscriptions.test.ts +2 -2
  104. package/src/services/network-discovery.test.ts +64 -1
  105. package/src/services/network-discovery.ts +30 -5
  106. package/src/services/responder-probe.test.ts +1 -1
  107. package/src/services/restore-from-file.test.ts +4 -4
  108. package/src/services/restore-preflight.test.ts +1 -1
  109. package/src/services/ssh-key-manager.test.ts +2 -2
  110. package/src/services/system-state-stage.test.ts +2 -2
  111. package/src/services/update/orchestrator.test.ts +2 -0
  112. package/src/test-utils/bus-responder.ts +1 -1
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Doctor remediation-arrow gate (celilo#1308).
3
+ *
4
+ * `celilo system doctor` renders `→ <command>` remediation lines under failing
5
+ * checks. Those strings are built by hand in three files and were never checked
6
+ * against the CLI's own command table, so doctor prescribed commands that
7
+ * cannot run:
8
+ *
9
+ * - `celilo module update <module-id>` — `module update` takes a PATH, so a
10
+ * module id names an argument form that does not exist (the lunacycle case
11
+ * in #1308).
12
+ * - `celilo system doctor --deep` — the doctor's own instruction, rejected by
13
+ * `checkFlags` because `deep` was never declared on `system doctor`.
14
+ * - `celilo capability chain <module> <var>` — a command the ISS-0115 rework
15
+ * removed from the registry.
16
+ *
17
+ * The gate scans the three files that produce the doctor's `→` surface
18
+ * (system-doctor.ts, fleet-checks.ts, module-integrity.ts) for `celilo ...`
19
+ * command spans and validates each against COMMANDS from @celilo/core:
20
+ *
21
+ * 1. every command word must resolve along the registry tree,
22
+ * 2. every flag must be declared on the leaf command,
23
+ * 3. positional count must fit the leaf command's declared args,
24
+ * 4. a value known to be a module id must never fill a path slot (an arg whose
25
+ * completion is `directories` or `files`), variadic or not.
26
+ *
27
+ * Commands are parsed, never executed. A span is "strict" when a quote or
28
+ * backtick delimits it (the author wrote a command); a `celilo ...` occurrence
29
+ * without a delimiter is a hint embedded in a sentence and is only validated
30
+ * when everything after the command path is argument-shaped — trailing plain
31
+ * words mean the line talks ABOUT celilo ("celilo knows where its control
32
+ * plane runs"), which is prose, not a prescription.
33
+ *
34
+ * Reach floors: the scan asserts it resolved a real number of command spans
35
+ * and contains the anchors it exists to police, because a scan that resolves
36
+ * nothing exits 0 and proves nothing.
37
+ */
38
+
39
+ import { describe, expect, it } from 'bun:test';
40
+ import { readFileSync } from 'node:fs';
41
+ import { join } from 'node:path';
42
+ import { type ArgDef, COMMANDS, type CommandDef } from '@celilo/core';
43
+
44
+ const SOURCES = [
45
+ join(import.meta.dir, 'system-doctor.ts'),
46
+ join(import.meta.dir, '../../services/fleet-checks.ts'),
47
+ join(import.meta.dir, '../../services/audit/module-integrity.ts'),
48
+ ];
49
+
50
+ /** Completion kinds whose value is a filesystem path, not an id. */
51
+ const PATH_COMPLETIONS = new Set(['directories', 'files']);
52
+
53
+ const INTERP = '\u0000';
54
+
55
+ /** `${...}` expressions found by masking, in order. */
56
+ const interps: string[] = [];
57
+
58
+ function maskInterpolations(source: string): string {
59
+ interps.length = 0;
60
+ return source.replace(/\$\{([^{}]*)\}/g, (_m, expr: string) => {
61
+ interps.push(expr);
62
+ return `${INTERP}${interps.length - 1}${INTERP}`;
63
+ });
64
+ }
65
+
66
+ function interpText(masked: string): string {
67
+ const m = masked.match(new RegExp(`${INTERP}(\\d+)${INTERP}`));
68
+ return m ? (interps[Number(m[1])] ?? masked) : masked;
69
+ }
70
+
71
+ interface ParsedToken {
72
+ kind: 'word' | 'flag' | 'positional';
73
+ text: string;
74
+ /** True when the token's runtime value is a module id. */
75
+ isModuleId: boolean;
76
+ }
77
+
78
+ /**
79
+ * One `celilo ...` occurrence: the tokens after the word `celilo`, plus
80
+ * whether a quote delimited it (strict).
81
+ */
82
+ interface Span {
83
+ tokens: ParsedToken[];
84
+ strict: boolean;
85
+ raw: string;
86
+ }
87
+
88
+ const FLAG = /^--[a-z0-9][a-z0-9-]*/;
89
+ const PLACEHOLDER = /^<[^<>\s]+>/;
90
+
91
+ function tokenize(text: string): ParsedToken[] {
92
+ const tokens: ParsedToken[] = [];
93
+ let rest = text;
94
+ while (rest.length > 0) {
95
+ rest = rest.replace(/^[\s,;]+/, '');
96
+ if (rest.length === 0) break;
97
+ let m = rest.match(FLAG);
98
+ if (m) {
99
+ tokens.push({ kind: 'flag', text: m[0].slice(2), isModuleId: false });
100
+ rest = rest.slice(m[0].length);
101
+ continue;
102
+ }
103
+ m = rest.match(PLACEHOLDER);
104
+ if (m) {
105
+ tokens.push({ kind: 'positional', text: m[0], isModuleId: false });
106
+ rest = rest.slice(m[0].length);
107
+ continue;
108
+ }
109
+ m = rest.match(/^\u0000(\d+)\u0000/);
110
+ if (m) {
111
+ const expr = interps[Number(m[1])] ?? '';
112
+ rest = rest.slice(m[0].length);
113
+ // ANSI chrome (${ANSI.reset} and friends) renders as an escape code,
114
+ // not as a value the operator types.
115
+ if (/^ANSI\.\w+$/.test(expr.trim())) continue;
116
+ tokens.push({ kind: 'positional', text: expr, isModuleId: /\bmoduleId\b/.test(expr) });
117
+ continue;
118
+ }
119
+ m = rest.match(/^[a-z][a-z0-9-]*(?:\.\u0000\d+\u0000[a-z0-9-]*)*/);
120
+ if (m) {
121
+ // A word that carries an interpolation (a key built at runtime, like
122
+ // network.<zone>.subnet) is a value, never a subcommand.
123
+ const isInterp = m[0].includes(INTERP);
124
+ const isModuleId = isInterp
125
+ ? (m[0].match(/\u0000(\d+)\u0000/g) ?? []).some((mark) =>
126
+ /\bmoduleId\b/.test(interpText(mark)),
127
+ )
128
+ : false;
129
+ tokens.push({ kind: isInterp ? 'positional' : 'word', text: m[0], isModuleId });
130
+ rest = rest.slice(m[0].length);
131
+ continue;
132
+ }
133
+ break; // punctuation, a closing quote, prose — the command part is over
134
+ }
135
+ return tokens;
136
+ }
137
+
138
+ /** Extract every `celilo ` occurrence with its span, per the rules above. */
139
+ function extractSpans(maskedLine: string): Span[] {
140
+ const spans: Span[] = [];
141
+ const re = /\bcelilo(?=\s)/g;
142
+ let m: RegExpExecArray | null = re.exec(maskedLine);
143
+ while (m !== null) {
144
+ const before = m.index > 0 ? maskedLine[m.index - 1] : '';
145
+ const delimiter = before === '`' || before === "'" || before === '"' ? before : null;
146
+ const start = m.index + 'celilo'.length;
147
+ let content: string;
148
+ let strict: boolean;
149
+ if (delimiter) {
150
+ const closer = maskedLine.indexOf(delimiter, start);
151
+ strict = true;
152
+ content = closer === -1 ? maskedLine.slice(start) : maskedLine.slice(start, closer);
153
+ if (closer !== -1) re.lastIndex = closer + 1;
154
+ } else {
155
+ strict = false;
156
+ content = maskedLine.slice(start);
157
+ }
158
+ const tokens = tokenize(content);
159
+ if (tokens.length > 0) spans.push({ tokens, strict, raw: content.trim() });
160
+ if (re.lastIndex <= m.index) re.lastIndex = start;
161
+ m = re.exec(maskedLine);
162
+ }
163
+ return spans;
164
+ }
165
+
166
+ function walkPath(tokens: ParsedToken[]): { path: CommandDef[]; rest: ParsedToken[] } {
167
+ const path: CommandDef[] = [];
168
+ let node: CommandDef | undefined = COMMANDS.find((c) => c.name === tokens[0]?.text);
169
+ if (!node) return { path, rest: tokens };
170
+ path.push(node);
171
+ let i = 1;
172
+ while (i < tokens.length && tokens[i].kind === 'word') {
173
+ const next: CommandDef | undefined = node.subcommands?.find((s) => s.name === tokens[i].text);
174
+ if (!next) break;
175
+ node = next;
176
+ path.push(next);
177
+ i++;
178
+ }
179
+ return { path, rest: tokens.slice(i) };
180
+ }
181
+
182
+ /** Which declared arg the i-th positional fills (variadic absorbs the tail). */
183
+ function slotFor(args: ArgDef[], i: number): ArgDef | undefined {
184
+ const variadicIndex = args.findIndex((a) => a.variadic);
185
+ const fixed = variadicIndex === -1 ? args.length : variadicIndex;
186
+ if (i < fixed) return args[i];
187
+ return variadicIndex === -1 ? undefined : args[variadicIndex];
188
+ }
189
+
190
+ function validateSpan(span: Span): string[] {
191
+ const { path, rest } = walkPath(span.tokens);
192
+ if (path.length === 0) return []; // first word is not a command: prose about celilo
193
+
194
+ // A hint embedded in prose is only a prescription when everything after the
195
+ // command path is argument-shaped. Trailing plain words mean the sentence
196
+ // continues ("celilo system migrate` to apply pending migrations...").
197
+ if (!span.strict && rest.some((t) => t.kind === 'word')) return [];
198
+
199
+ const name = `celilo ${path.map((c) => c.name).join(' ')}`;
200
+ const leaf = path[path.length - 1];
201
+ const violations: string[] = [];
202
+
203
+ for (const t of rest.filter((t) => t.kind === 'word')) {
204
+ violations.push(`${name}: unknown command word '${t.text}'`);
205
+ }
206
+
207
+ const declaredFlags = new Set([...(leaf.flags ?? []).map((f) => f.name), 'help', 'h']);
208
+ for (const t of rest.filter((t) => t.kind === 'flag')) {
209
+ if (!declaredFlags.has(t.text)) {
210
+ violations.push(`${name}: flag --${t.text} is not declared in the command registry`);
211
+ }
212
+ }
213
+
214
+ const args = leaf.args ?? [];
215
+ const positionals = rest.filter((t) => t.kind === 'positional');
216
+ if (!args.some((a) => a.variadic) && positionals.length > args.length) {
217
+ violations.push(
218
+ `${name}: ${positionals.length} positional value(s) but only ${args.length} declared arg(s)`,
219
+ );
220
+ }
221
+ positionals.forEach((token, i) => {
222
+ const slot = slotFor(args, i);
223
+ if (!slot) return;
224
+ if (
225
+ token.isModuleId &&
226
+ slot.completion !== undefined &&
227
+ PATH_COMPLETIONS.has(slot.completion)
228
+ ) {
229
+ violations.push(
230
+ `${name}: module id '${token.text}' fills the '${slot.name}' slot, which takes a path (${slot.completion})`,
231
+ );
232
+ }
233
+ });
234
+
235
+ return violations;
236
+ }
237
+
238
+ describe('doctor remediation arrow gate (celilo#1308)', () => {
239
+ it('flags a module id passed into a path slot, even a variadic one', () => {
240
+ // Rule 7.6 anchor: the exact defect shape from #1308 (`module update
241
+ // <module-id>`), pinned synthetically so the rule cannot silently stop
242
+ // matching the registry.
243
+ maskInterpolations(''); // reset
244
+ interps.push('result.moduleId');
245
+ const violations = validateSpan({
246
+ tokens: tokenize(`module update ${INTERP}0${INTERP}`),
247
+ strict: true,
248
+ raw: 'module update ${result.moduleId}',
249
+ });
250
+ expect(violations.some((v) => v.includes('takes a path'))).toBe(true);
251
+ });
252
+
253
+ it('every command the doctor prescribes exists, with declared flags and fitting args', () => {
254
+ const violations: string[] = [];
255
+ let resolvedCount = 0;
256
+ const resolvedHeads = new Set<string>();
257
+
258
+ for (const file of SOURCES) {
259
+ const raw = readFileSync(file, 'utf8');
260
+ // Strip block comments and whole-line // comments so doc headers that
261
+ // mention commands historically are not prescribed commands now.
262
+ const stripped = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
263
+ const masked = maskInterpolations(stripped);
264
+ const shortFile = file.split('/').pop() ?? file;
265
+
266
+ for (const line of masked.split('\n')) {
267
+ for (const span of extractSpans(line)) {
268
+ const found = validateSpan(span);
269
+ if (found.length === 0 && span.strict) resolvedCount++;
270
+ for (const v of found) violations.push(`[${shortFile}] ${v} (span: "${span.raw}")`);
271
+ const head = span.tokens
272
+ .slice(0, 2)
273
+ .map((t) => t.text)
274
+ .join(' ');
275
+ if (head) resolvedHeads.add(head);
276
+ }
277
+ }
278
+ }
279
+
280
+ // Reach floors. A scan that resolves nothing proves nothing.
281
+ expect(resolvedCount).toBeGreaterThanOrEqual(12);
282
+ for (const anchor of [
283
+ 'system doctor',
284
+ 'module verify',
285
+ 'monitor add',
286
+ 'events repair',
287
+ 'system migrate',
288
+ ]) {
289
+ const found = [...resolvedHeads].some((h) => h.startsWith(anchor));
290
+ expect(found).toBe(true);
291
+ }
292
+
293
+ expect(violations).toEqual([]);
294
+ });
295
+ });
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import type { JailModeRecord } from '../../hooks/jail';
2
+ import { type JailModeRecord, resolveJailPolicy } from '../../hooks/jail';
3
3
  import {
4
4
  type HookExecutionInput,
5
5
  assembleHookExecutionSection,
@@ -62,12 +62,13 @@ describe('renderHookExecutionSection (hook-process-boundary task 4.6)', () => {
62
62
  renderHookExecutionSection({
63
63
  availability: { backend: 'bubblewrap' },
64
64
  policy: 'auto',
65
- source: 'default',
66
- envValue: undefined,
65
+ source: 'env',
66
+ envValue: 'auto',
67
67
  configValue: undefined,
68
68
  record: jailedRecord,
69
69
  host: HOST,
70
70
  monitored: true,
71
+ exemptions: [],
71
72
  ...over,
72
73
  });
73
74
 
@@ -96,10 +97,31 @@ describe('renderHookExecutionSection (hook-process-boundary task 4.6)', () => {
96
97
  expect(r.failCount).toBe(0);
97
98
  });
98
99
 
99
- test('CELILO_HOOK_JAIL=off is reported as the operator’s own act', () => {
100
- const r = render({ policy: 'off', record: undefined });
101
- expect(text(r)).toContain('CELILO_HOOK_JAIL=off');
102
- expect(r.warnCount).toBe(1);
100
+ test('an off policy warns, and the reason claims no source of its own', () => {
101
+ // This test used to be named "CELILO_HOOK_JAIL=off is reported as the
102
+ // operator's own act" and asserted that string. It stopped being true when
103
+ // ce-rez7 made `off` the DEFAULT: on a host where nobody set anything, the
104
+ // doctor named a variable that was unset and an act nobody performed. The
105
+ // source belongs on the source line, which is rendered separately.
106
+ const fromDefault = render({
107
+ policy: 'off',
108
+ source: 'default',
109
+ envValue: undefined,
110
+ record: undefined,
111
+ });
112
+ expect(text(fromDefault)).toContain('hooks run unjailed');
113
+ expect(text(fromDefault)).toContain('policy default (off)');
114
+ expect(text(fromDefault)).not.toContain('CELILO_HOOK_JAIL=off');
115
+ expect(fromDefault.warnCount).toBe(1);
116
+
117
+ const fromEnv = render({
118
+ policy: 'off',
119
+ source: 'env',
120
+ envValue: 'off',
121
+ record: undefined,
122
+ });
123
+ expect(text(fromEnv)).toContain('policy from CELILO_HOOK_JAIL=off (environment override)');
124
+ expect(fromEnv.warnCount).toBe(1);
103
125
  });
104
126
 
105
127
  test('auto deferral on sandbox-exec reads as unjailed, not jailed (ce-29z)', () => {
@@ -119,7 +141,7 @@ describe('renderHookExecutionSection (hook-process-boundary task 4.6)', () => {
119
141
  availability: { backend: 'none', reason: 'the profile did not load' },
120
142
  record: undefined,
121
143
  });
122
- expect(text(r)).toContain('CELILO_HOOK_JAIL=required and no jail is available');
144
+ expect(text(r)).toContain("the hook jail policy is 'required' and no jail is available");
123
145
  expect(r.failCount).toBe(1);
124
146
  });
125
147
 
@@ -180,9 +202,21 @@ describe('renderHookExecutionSection (hook-process-boundary task 4.6)', () => {
180
202
  });
181
203
 
182
204
  test('the source line names the default and how to change it (D4)', () => {
183
- const r = render({ policy: 'auto', source: 'default', record: undefined });
205
+ // Ask the resolver what the default IS rather than naming it. The literal
206
+ // that stood here said `auto`, and it kept passing after ce-rez7 flipped
207
+ // the default to `off`, because it rendered a { policy, source } pair the
208
+ // resolver can no longer produce. A correct assertion about a state that
209
+ // never arrives.
210
+ const fallback = resolveJailPolicy(undefined, undefined, undefined);
211
+ const r = render({
212
+ policy: fallback.policy,
213
+ source: fallback.source,
214
+ envValue: undefined,
215
+ record: undefined,
216
+ });
217
+ expect(fallback.source).toBe('default');
184
218
  expect(text(r)).toContain(
185
- 'policy default (auto) — set hooks.jail_policy or CELILO_HOOK_JAIL to change',
219
+ `policy default (${fallback.policy}) — set hooks.jail_policy or CELILO_HOOK_JAIL to change`,
186
220
  );
187
221
  });
188
222
 
@@ -218,10 +252,42 @@ describe('renderHookExecutionSection (hook-process-boundary task 4.6)', () => {
218
252
  record: () => undefined,
219
253
  host: () => HOST,
220
254
  monitored: () => true,
255
+ exemptions: () => [],
221
256
  });
222
257
  expect(r.lines[0]).toBe('Hook execution');
223
258
  expect(r.lines[1]).toContain("hooks.jail_policy='alwayssafe' is not a hook jail policy");
224
259
  expect(r.failCount).toBe(1);
225
260
  expect(r.warnCount).toBe(0);
226
261
  });
262
+
263
+ test('an exemption is counted and named (per-module-jail-policy task 3.2)', () => {
264
+ const r = render({
265
+ exemptions: [
266
+ { moduleId: 'caddy', policy: 'off', systemPolicy: 'auto' },
267
+ { moduleId: 'vault', policy: 'auto', systemPolicy: 'auto' },
268
+ ],
269
+ });
270
+ expect(text(r)).toContain("2 modules run under a weaker jail policy than the system's");
271
+ expect(text(r)).toContain('caddy (off)');
272
+ expect(text(r)).toContain('vault (auto)');
273
+ expect(r.warnCount).toBe(1);
274
+ });
275
+
276
+ test('one exemption reads as singular', () => {
277
+ const r = render({
278
+ exemptions: [{ moduleId: 'caddy', policy: 'off', systemPolicy: 'auto' }],
279
+ });
280
+ expect(text(r)).toContain('1 module runs under a weaker jail policy');
281
+ expect(text(r)).not.toContain('modules run');
282
+ });
283
+
284
+ test('no exemptions render no exemption line (per-module-jail-policy task 3.4)', () => {
285
+ // The negative half: a surface that reports an exemption nobody set is
286
+ // as bad as one that hides a real one. With no rows the section must be
287
+ // word-for-word what it was before this slice.
288
+ const r = render({});
289
+ expect(text(r)).not.toContain('weaker jail policy');
290
+ expect(text(r)).not.toContain('exempt');
291
+ expect(r.warnCount).toBe(0);
292
+ });
227
293
  });
@@ -60,6 +60,7 @@ import {
60
60
  checkSubscribers,
61
61
  runFleetChecks,
62
62
  } from '../../services/fleet-checks';
63
+ import { type JailExemption, collectJailExemptions } from '../../services/jail-exemptions';
63
64
  import { resyncAllSubscriptions } from '../../services/module-subscriptions';
64
65
  import { checkAllPrerequisites, failingPrerequisites } from '../../system/prereqs';
65
66
  import type { CommandResult } from '../types';
@@ -368,6 +369,12 @@ export interface HookExecutionInput {
368
369
  * ask (a dev box), which also suppresses the add-the-monitor hint.
369
370
  */
370
371
  monitored: boolean | null;
372
+ /**
373
+ * Modules running under a recorded policy weaker than the system's
374
+ * (per-module-jail-policy task 3.2), from the shared collector. Empty is
375
+ * the normal state and renders nothing.
376
+ */
377
+ exemptions: readonly JailExemption[];
371
378
  }
372
379
 
373
380
  /**
@@ -408,8 +415,11 @@ export function renderHookExecutionSection(input: HookExecutionInput): {
408
415
  } else if (input.source === 'config') {
409
416
  lines.push(` ${ANSI.dim}policy from system config (hooks.jail_policy)${ANSI.reset}`);
410
417
  } else {
418
+ // Read the default off `policy` rather than naming it. The literal that
419
+ // stood here said `auto` and stayed saying it after ce-rez7 flipped the
420
+ // default to `off`, so the doctor named the one value not in play.
411
421
  lines.push(
412
- ` ${ANSI.dim}policy default (auto) — set hooks.jail_policy or CELILO_HOOK_JAIL to change${ANSI.reset}`,
422
+ ` ${ANSI.dim}policy default (${policy}) — set hooks.jail_policy or CELILO_HOOK_JAIL to change${ANSI.reset}`,
413
423
  );
414
424
  }
415
425
  if (
@@ -428,7 +438,7 @@ export function renderHookExecutionSection(input: HookExecutionInput): {
428
438
  const liveJailed = policy !== 'off' && availability.backend !== 'none' && !autoDeferred;
429
439
  if (policy === 'required' && availability.backend === 'none') {
430
440
  lines.push(
431
- ` ${ANSI.red}✗${ANSI.reset} CELILO_HOOK_JAIL=required and no jail is available — every module hook on this host fails rather than run unjailed`,
441
+ ` ${ANSI.red}✗${ANSI.reset} the hook jail policy is 'required' and no jail is available — every module hook on this host fails rather than run unjailed`,
432
442
  );
433
443
  lines.push(` ${ANSI.dim}${availability.reason ?? 'no reason was recorded'}${ANSI.reset}`);
434
444
  failCount++;
@@ -449,6 +459,18 @@ export function renderHookExecutionSection(input: HookExecutionInput): {
449
459
  lines.push(` ${ANSI.green}✔${ANSI.reset} hooks run jailed (${availability.backend})`);
450
460
  }
451
461
 
462
+ // Per-module exemptions (per-module-jail-policy task 3.2). Loud and
463
+ // countable is the whole design: the count AND the names, every time, and
464
+ // nothing at all when there are none.
465
+ if (input.exemptions.length > 0) {
466
+ const n = input.exemptions.length;
467
+ const named = input.exemptions.map((e) => `${e.moduleId} (${e.policy})`).join(', ');
468
+ lines.push(
469
+ ` ${ANSI.yellow}⚠${ANSI.reset} ${n} module${n === 1 ? '' : 's'} run${n === 1 ? 's' : ''} under a weaker jail policy than the system's: ${named}`,
470
+ );
471
+ warnCount++;
472
+ }
473
+
452
474
  const regressed =
453
475
  record && record.host === host && record.mode === 'unjailed' && record.lastJailed;
454
476
  if (regressed && record.lastJailed) {
@@ -498,6 +520,8 @@ export interface HookExecutionIO {
498
520
  record: () => JailModeRecord | undefined;
499
521
  host: () => string;
500
522
  monitored: () => boolean | null;
523
+ /** Modules exempted from the system's jail policy (per-module-jail-policy task 3.2). */
524
+ exemptions: () => JailExemption[];
501
525
  }
502
526
 
503
527
  /**
@@ -516,7 +540,12 @@ export function assembleHookExecutionSection(io: HookExecutionIO): {
516
540
  try {
517
541
  const envValue = io.envValue();
518
542
  const configValue = io.configValue();
519
- const resolved = resolveJailPolicy(envValue, configValue);
543
+ // The module step of the four-step precedence stays out of the headline
544
+ // resolution — the headline is the policy the EXECUTOR would use for a
545
+ // module with no row of its own. Per-module rows are surfaced separately
546
+ // as exemptions (per-module-jail-policy task 3.2), which is the shape the
547
+ // operator acts on.
548
+ const resolved = resolveJailPolicy(envValue, undefined, configValue);
520
549
  return renderHookExecutionSection({
521
550
  availability: io.availability(),
522
551
  policy: resolved.policy,
@@ -526,6 +555,7 @@ export function assembleHookExecutionSection(io: HookExecutionIO): {
526
555
  record: io.record(),
527
556
  host: io.host(),
528
557
  monitored: io.monitored(),
558
+ exemptions: io.exemptions(),
529
559
  });
530
560
  } catch (error) {
531
561
  return {
@@ -845,6 +875,7 @@ export async function handleSystemDoctor(
845
875
  host: () => hostname(),
846
876
  monitored: () =>
847
877
  existsSync(getDbPath()) ? findMonitorByTarget(getDb(), HOOK_JAIL_CHECK) !== undefined : null,
878
+ exemptions: () => (existsSync(getDbPath()) ? collectJailExemptions(getDb()) : []),
848
879
  });
849
880
  lines.push(...hookExec.lines);
850
881
  lines.push('');
@@ -42,7 +42,7 @@ describe('celilo system init deprecation banner', () => {
42
42
  tmpDir = mkdtempSync(join(tmpdir(), 'celilo-deprecation-test-'));
43
43
  savedSuppress = process.env.CELILO_SUPPRESS_DEPRECATION;
44
44
  process.env.CELILO_DB_PATH = join(tmpDir, 'init.db');
45
- process.env.CELILO_SUPPRESS_DEPRECATION = undefined;
45
+ delete process.env.CELILO_SUPPRESS_DEPRECATION;
46
46
  });
47
47
 
48
48
  afterEach(() => {
@@ -34,6 +34,7 @@ import { makeJournalReader, readAppliedMigrations } from '../../services/audit/s
34
34
  import { createModuleBackup, createSystemStateBackup } from '../../services/backup-create';
35
35
  import { BACKUP_SCHEDULE_CONFIG_KEY } from '../../services/backup-schedule';
36
36
  import { runAllHealthChecks, runModuleHealthCheck } from '../../services/health-runner';
37
+ import { collectJailExemptions } from '../../services/jail-exemptions';
37
38
  import { configOverride, parseStoredConfigValue } from '../../services/module-config';
38
39
  import { deployModule } from '../../services/module-deploy';
39
40
  import { buildModuleGraph } from '../../services/update/dep-graph';
@@ -576,6 +577,7 @@ export async function handleSystemUpdate(
576
577
  manifest: m.manifestData as ModuleManifest,
577
578
  })),
578
579
  },
580
+ jailExemptions: { exemptions: collectJailExemptions(db) },
579
581
  health: { results: healthResults },
580
582
  backups: {
581
583
  modules: upgradableModules.map((m) => ({
@@ -613,6 +615,9 @@ export async function handleSystemUpdate(
613
615
  // flow's partial audit does no probing. `system audit` and the scheduled
614
616
  // monitor own this check.
615
617
  publicDns: { records: [], probe: unusedPublicDnsProbe },
618
+ // Disk probing is one SSH per system; the update flow's partial audit
619
+ // does no probing. `system audit` and the monitor sweep own this check.
620
+ diskSpace: { results: [] },
616
621
  transportReads: { statuses: [], now: new Date(), staleAfterMs: 30 * 60_000 },
617
622
  trustedSources: { firewalls: [] },
618
623
  };
@@ -171,6 +171,7 @@ export async function getCompletions(words: string[], current: number): Promise<
171
171
  if (command === 'module' && currentIndex === 1) {
172
172
  const subcommands = [
173
173
  'check',
174
+ 'changeset',
174
175
  'import',
175
176
  'list',
176
177
  'publish',
@@ -187,7 +188,6 @@ export async function getCompletions(words: string[], current: number): Promise<
187
188
  'build',
188
189
  'generate',
189
190
  'deploy',
190
- 'destroy',
191
191
  'health',
192
192
  'logs',
193
193
  'journal',
@@ -201,6 +201,7 @@ export async function getCompletions(words: string[], current: number): Promise<
201
201
  'validate',
202
202
  'pause',
203
203
  'unpause',
204
+ 'jail',
204
205
  ];
205
206
  return filterSuggestions(subcommands, args[1] || '');
206
207
  }
@@ -470,7 +471,6 @@ export async function getCompletions(words: string[], current: number): Promise<
470
471
  const moduleCommands = [
471
472
  'generate',
472
473
  'deploy',
473
- 'destroy',
474
474
  'logs',
475
475
  'journal',
476
476
  'remove',
@@ -483,6 +483,7 @@ export async function getCompletions(words: string[], current: number): Promise<
483
483
  'audit', // deprecation alias for `verify`
484
484
  'pause',
485
485
  'unpause',
486
+ 'jail',
486
487
  ];
487
488
  if (moduleCommands.includes(args[1] || '')) {
488
489
  const db = getDb();
@@ -492,6 +493,11 @@ export async function getCompletions(words: string[], current: number): Promise<
492
493
  }
493
494
  }
494
495
 
496
+ // Module jail <module-id> - complete with policy values
497
+ if (command === 'module' && args[1] === 'jail' && currentIndex === 3) {
498
+ return filterSuggestions(['auto', 'off', 'required'], args[3] || '');
499
+ }
500
+
495
501
  // Module run-hook <module-id> - complete with hook names from manifest
496
502
  if (command === 'module' && args[1] === 'run-hook' && currentIndex === 3) {
497
503
  const db = getDb();