@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
@@ -7,6 +7,7 @@ import { dirname, join } from 'node:path';
7
7
  import { eq } from 'drizzle-orm';
8
8
  import { getDb } from '../../db/client';
9
9
  import { systemConfig } from '../../db/schema';
10
+ import { askConfirm, withInterviewSession } from '../../services/bus-interview';
10
11
  import type { SystemConfigSchema } from '../../services/system-config-schema-types';
11
12
  import { validateKey, validateValue } from '../../services/system-config-validator';
12
13
  import { getArg, validateRequiredArgs } from '../parser';
@@ -47,7 +48,10 @@ function loadSchema(): SystemConfigSchema {
47
48
  * @param args - Command arguments
48
49
  * @returns Command result
49
50
  */
50
- export function handleSystemConfigSet(args: string[]): CommandResult {
51
+ export async function handleSystemConfigSet(
52
+ args: string[],
53
+ flags: Record<string, boolean | string> = {},
54
+ ): Promise<CommandResult> {
51
55
  // Validate arguments
52
56
  const error = validateRequiredArgs(args, 2);
53
57
  if (error) {
@@ -82,6 +86,27 @@ export function handleSystemConfigSet(args: string[]): CommandResult {
82
86
  return { success: false, error: valueValidation.error };
83
87
  }
84
88
 
89
+ // D5 of hook-jail-config-surface: turning jailing off fleet-wide is a security
90
+ // posture change, so the write asks first. `auto` and `required` write
91
+ // directly — `auto` is the status quo default and `required` tightens the
92
+ // posture. `--force` skips the ask, matching the house flags.force convention
93
+ // (backup-delete, machine-remove, service-remove, ...).
94
+ if (key === 'hooks.jail_policy' && value === 'off' && !flags.force) {
95
+ const confirmed = await withInterviewSession(() =>
96
+ askConfirm({
97
+ scope: 'hooks-jail-policy',
98
+ key: 'set-off',
99
+ message:
100
+ 'Set hooks.jail_policy to off? Hooks will run unjailed fleet-wide and the doctor will warn.',
101
+ defaultValue: false,
102
+ }),
103
+ );
104
+
105
+ if (!confirmed) {
106
+ return { success: false, error: 'Cancelled by user' };
107
+ }
108
+ }
109
+
85
110
  const db = getDb();
86
111
 
87
112
  // Store value in database
@@ -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,7 +1,8 @@
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
+ assembleHookExecutionSection,
5
6
  compareVersions,
6
7
  renderHookExecutionSection,
7
8
  } from './system-doctor';
@@ -61,9 +62,13 @@ describe('renderHookExecutionSection (hook-process-boundary task 4.6)', () => {
61
62
  renderHookExecutionSection({
62
63
  availability: { backend: 'bubblewrap' },
63
64
  policy: 'auto',
65
+ source: 'env',
66
+ envValue: 'auto',
67
+ configValue: undefined,
64
68
  record: jailedRecord,
65
69
  host: HOST,
66
70
  monitored: true,
71
+ exemptions: [],
67
72
  ...over,
68
73
  });
69
74
 
@@ -92,10 +97,31 @@ describe('renderHookExecutionSection (hook-process-boundary task 4.6)', () => {
92
97
  expect(r.failCount).toBe(0);
93
98
  });
94
99
 
95
- test('CELILO_HOOK_JAIL=off is reported as the operator’s own act', () => {
96
- const r = render({ policy: 'off', record: undefined });
97
- expect(text(r)).toContain('CELILO_HOOK_JAIL=off');
98
- 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);
99
125
  });
100
126
 
101
127
  test('auto deferral on sandbox-exec reads as unjailed, not jailed (ce-29z)', () => {
@@ -115,7 +141,7 @@ describe('renderHookExecutionSection (hook-process-boundary task 4.6)', () => {
115
141
  availability: { backend: 'none', reason: 'the profile did not load' },
116
142
  record: undefined,
117
143
  });
118
- 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");
119
145
  expect(r.failCount).toBe(1);
120
146
  });
121
147
 
@@ -153,4 +179,115 @@ describe('renderHookExecutionSection (hook-process-boundary task 4.6)', () => {
153
179
  expect(text(r)).toContain('belongs to host "old-box"');
154
180
  expect(r.failCount).toBe(0);
155
181
  });
182
+
183
+ test('the source line names the environment when the env var won (D4)', () => {
184
+ const r = render({
185
+ policy: 'off',
186
+ source: 'env',
187
+ envValue: 'off',
188
+ record: undefined,
189
+ });
190
+ expect(text(r)).toContain('policy from CELILO_HOOK_JAIL=off (environment override)');
191
+ });
192
+
193
+ test('the source line names system config when the stored key won (D4)', () => {
194
+ const r = render({
195
+ policy: 'required',
196
+ source: 'config',
197
+ configValue: 'required',
198
+ record: undefined,
199
+ });
200
+ expect(text(r)).toContain('policy from system config (hooks.jail_policy)');
201
+ expect(text(r)).not.toContain('policy from CELILO_HOOK_JAIL');
202
+ });
203
+
204
+ test('the source line names the default and how to change it (D4)', () => {
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');
218
+ expect(text(r)).toContain(
219
+ `policy default (${fallback.policy}) — set hooks.jail_policy or CELILO_HOOK_JAIL to change`,
220
+ );
221
+ });
222
+
223
+ test('env and config disagreeing renders the losing value (D4)', () => {
224
+ const r = render({
225
+ policy: 'required',
226
+ source: 'env',
227
+ envValue: 'required',
228
+ configValue: 'off',
229
+ record: undefined,
230
+ });
231
+ expect(text(r)).toContain(
232
+ "system config holds 'off' but CELILO_HOOK_JAIL='required' overrides it",
233
+ );
234
+ });
235
+
236
+ test('env and config agreeing renders no disagreement line (D4)', () => {
237
+ const r = render({
238
+ policy: 'off',
239
+ source: 'env',
240
+ envValue: 'off',
241
+ configValue: 'off',
242
+ record: undefined,
243
+ });
244
+ expect(text(r)).not.toContain('system config holds');
245
+ });
246
+
247
+ test('a bad stored row renders as a failing check naming the row, not a crash', () => {
248
+ const r = assembleHookExecutionSection({
249
+ envValue: () => undefined,
250
+ configValue: () => 'alwayssafe',
251
+ availability: () => ({ backend: 'bubblewrap' }),
252
+ record: () => undefined,
253
+ host: () => HOST,
254
+ monitored: () => true,
255
+ exemptions: () => [],
256
+ });
257
+ expect(r.lines[0]).toBe('Hook execution');
258
+ expect(r.lines[1]).toContain("hooks.jail_policy='alwayssafe' is not a hook jail policy");
259
+ expect(r.failCount).toBe(1);
260
+ expect(r.warnCount).toBe(0);
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
+ });
156
293
  });