@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,42 @@
1
+ /**
2
+ * Tests for the `jail_exemptions` audit category (per-module-jail-policy
3
+ * task 3.3/3.4).
4
+ *
5
+ * The severity contract is the point: an operator-recorded exemption is
6
+ * `todo`, never `drift`, so `system update` never treats it as work. And the
7
+ * negative half: a fleet with no exemptions contributes no findings.
8
+ */
9
+
10
+ import { describe, expect, test } from 'bun:test';
11
+ import { auditJailExemptions } from './jail-exemptions';
12
+
13
+ const EXEMPT = { moduleId: 'caddy', policy: 'off' as const, systemPolicy: 'auto' as const };
14
+
15
+ describe('auditJailExemptions (per-module-jail-policy task 3.3)', () => {
16
+ test('no exemptions produce no findings (the negative half of task 3.4)', async () => {
17
+ expect(await auditJailExemptions({ exemptions: [] })).toEqual([]);
18
+ });
19
+
20
+ test('each exemption is one finding, severity todo, never drift', async () => {
21
+ const findings = await auditJailExemptions({
22
+ exemptions: [EXEMPT, { moduleId: 'vault', policy: 'auto', systemPolicy: 'required' }],
23
+ });
24
+ expect(findings).toHaveLength(2);
25
+ for (const f of findings) {
26
+ expect(f.category).toBe('jail_exemptions');
27
+ expect(f.severity).toBe('todo');
28
+ expect(f.severity).not.toBe('drift');
29
+ expect(f.code).toBe('hook_jail_exemption');
30
+ expect(f.actionable).toBe(false);
31
+ }
32
+ });
33
+
34
+ test('the finding names the module and both policies', async () => {
35
+ const [finding] = await auditJailExemptions({ exemptions: [EXEMPT] });
36
+ expect(finding).toBeDefined();
37
+ expect(finding?.subject).toBe('caddy');
38
+ expect(finding?.message).toContain('caddy');
39
+ expect(finding?.message).toContain("'off'");
40
+ expect(finding?.message).toContain("'auto'");
41
+ });
42
+ });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Per-module hook jail exemption check (per-module-jail-policy task 3.3).
3
+ *
4
+ * One finding per module the operator exempted from the fleet's jail policy.
5
+ * Severity `todo`, deliberately NOT `drift`: the operator chose this, so it
6
+ * is a list of recorded exceptions to revisit, not a system that moved away
7
+ * from a desired state. `todo` never escalates the verdict past READY, which
8
+ * is what keeps `system update` from treating an exemption as work to do.
9
+ */
10
+
11
+ import type { JailExemption } from '../jail-exemptions';
12
+ import type { DriftFinding } from './types';
13
+
14
+ export interface JailExemptionsAuditDeps {
15
+ /**
16
+ * The exemptions, from the shared collector (`services/jail-exemptions.ts`).
17
+ * A fleet with none contributes nothing — that is the normal state, and
18
+ * emitting a "no exemptions" finding would be a surface reporting an
19
+ * exemption nobody set (task 3.4).
20
+ */
21
+ exemptions: JailExemption[];
22
+ }
23
+
24
+ export async function auditJailExemptions(deps: JailExemptionsAuditDeps): Promise<DriftFinding[]> {
25
+ return deps.exemptions.map((exemption) => ({
26
+ category: 'jail_exemptions' as const,
27
+ severity: 'todo' as const,
28
+ code: 'hook_jail_exemption',
29
+ subject: exemption.moduleId,
30
+ message: `${exemption.moduleId} runs under jail policy '${exemption.policy}', weaker than the system's '${exemption.systemPolicy}'`,
31
+ details: [
32
+ 'Its hooks run with the weaker posture while the rest of the fleet jails.',
33
+ 'The operator recorded this per-module policy with `celilo module jail`.',
34
+ 'Exemptions are deliberate, which is why this is todo and not drift —',
35
+ 'but an exemption nobody remembers is how a jail quietly stops covering',
36
+ 'the fleet. Revisit it: either the reason still holds, or clear it.',
37
+ '',
38
+ `To review: celilo module jail ${exemption.moduleId}`,
39
+ `To clear: celilo module jail ${exemption.moduleId} --clear`,
40
+ ].join('\n'),
41
+ remediation: `Clear it with 'celilo module jail ${exemption.moduleId} --clear' if the module now jails cleanly`,
42
+ actionable: false,
43
+ }));
44
+ }
@@ -58,7 +58,9 @@ describe('auditModuleIntegrity', () => {
58
58
  expect(findings).toHaveLength(1);
59
59
  expect(findings[0]?.code).toBe('module_integrity_stale_baseline');
60
60
  expect(findings[0]?.severity).toBe('unmeasured');
61
- expect(findings[0]?.remediation).toBe('celilo module update wireguard');
61
+ expect(findings[0]?.remediation).toBe(
62
+ 'no automatic remediation: the baseline predates version stamping',
63
+ );
62
64
  });
63
65
 
64
66
  test('file drift under a TRUSTWORTHY baseline is drift, and lists every file', () => {
@@ -122,4 +124,24 @@ describe('auditModuleIntegrity', () => {
122
124
  expect(f.actionable).toBe(true);
123
125
  }
124
126
  });
127
+
128
+ test('a stale baseline says there is no command, it does not prescribe one (celilo#1308)', () => {
129
+ const findings = auditModuleIntegrity({
130
+ results: [
131
+ {
132
+ ...clean('c'),
133
+ success: false,
134
+ violations: [
135
+ { type: 'stale-baseline', path: 'x', message: 'baseline is v1, manifest v2' },
136
+ ],
137
+ },
138
+ ],
139
+ });
140
+ expect(findings).toHaveLength(1);
141
+ expect(findings[0].code).toBe('module_integrity_stale_baseline');
142
+ // `module update` takes a source path, not the module id this finding
143
+ // carries, and no other verb restamps a baseline. Name the gap.
144
+ expect(findings[0].remediation).not.toMatch(/celilo /);
145
+ expect(findings[0].actionable).toBe(false);
146
+ });
125
147
  });
@@ -52,8 +52,13 @@ export function auditModuleIntegrity(deps: ModuleIntegrityAuditDeps): DriftFindi
52
52
  severity: 'unmeasured',
53
53
  code: 'module_integrity_stale_baseline',
54
54
  message: `${result.moduleId}: ${violation.message}`,
55
- remediation: `celilo module update ${result.moduleId}`,
56
- actionable: true,
55
+ // No command restamps a baseline. `module update` takes a module
56
+ // source PATH, not an id (celilo#1308: prescribing it with the id
57
+ // named an argument form that does not exist), and `module upgrade`
58
+ // deploys the registry version instead. Say so rather than name a
59
+ // command that cannot run.
60
+ remediation: 'no automatic remediation: the baseline predates version stamping',
61
+ actionable: false,
57
62
  subject: result.moduleId,
58
63
  });
59
64
  }
@@ -84,7 +84,11 @@ export async function auditModuleVersions(deps: ModuleVersionsAuditDeps): Promis
84
84
  const [reason] = errorBuckets.keys();
85
85
  findings.push({
86
86
  category: 'module_versions',
87
- severity: 'drift',
87
+ // Same fact as the per-module rejection below: the registry did not
88
+ // answer, so nothing was measured. D7 severity, not drift — this
89
+ // aggregate used to say "drift" and rendered the fleet DRIFT when
90
+ // the truth was "I could not ask".
91
+ severity: 'unmeasured',
88
92
  code: 'registry_unreachable',
89
93
  message: `Registry unreachable — ${rejected.length} module${rejected.length === 1 ? '' : 's'} unchecked (${reason})`,
90
94
  remediation: 'Check network connectivity to the registry, then press R to re-audit.',
@@ -139,6 +139,9 @@ describe('auditPublicDns', () => {
139
139
  // Distinguishable from "the site is unreachable" — this says nothing is
140
140
  // currently checking, which is the state the outage hid behind.
141
141
  expect(findings[0].message).toContain('unverifiable');
142
+ // D7: "could not tell" is unmeasured, not drift. It must stop the verdict
143
+ // at UNKNOWN, never render as an ordinary divergence.
144
+ expect(findings[0].severity).toBe('unmeasured');
142
145
  });
143
146
 
144
147
  test('a run that obtained evidence resets the count', async () => {
@@ -189,6 +192,23 @@ describe('auditPublicDns', () => {
189
192
  expect(findings.map((f) => f.code)).toEqual(['public_dns_missing']);
190
193
  });
191
194
 
195
+ test('a name the resolver will not answer for is unmeasured once it stops being a blip (D7)', async () => {
196
+ const args = () => ({
197
+ records: [declared('nexus.lunacycle.net')],
198
+ probe: probe({
199
+ answers: { 'nexus.lunacycle.net': { kind: 'undetermined' as const, reason: 'timeout' } },
200
+ }),
201
+ undeterminedThreshold: 1,
202
+ now: NOW,
203
+ });
204
+
205
+ const findings = await auditPublicDns(args());
206
+ expect(findings.map((f) => f.code)).toEqual(['public_dns_unverifiable']);
207
+ // The resolver did not answer, so the truth about this name is unknown —
208
+ // not fine, not broken. It must stop the verdict at UNKNOWN.
209
+ expect(findings[0].severity).toBe('unmeasured');
210
+ });
211
+
192
212
  test('an empty ledger never touches the network', async () => {
193
213
  const findings = await auditPublicDns({
194
214
  records: [],
@@ -172,7 +172,10 @@ export async function auditPublicDns(deps: PublicDnsAuditDeps): Promise<DriftFin
172
172
  if (runs >= threshold) {
173
173
  findings.push({
174
174
  category: 'public_dns',
175
- severity: 'drift',
175
+ // The vantage point is gone: nothing was measured this run, and D7
176
+ // says that is `unmeasured`, not `drift`. This finding was `drift`
177
+ // before D7 existed; its own text already said what unmeasured means.
178
+ severity: 'unmeasured',
176
179
  code: 'public_dns_unverifiable',
177
180
  message: `Public DNS has been unverifiable for ${runs} consecutive checks`,
178
181
  details: `The echo service (${deps.probe.echoService}) could not be reached, so there is no\nexpectation to compare public DNS against. Latest reason: ${ingress.reason}.\n\nThis is NOT a report that the fleet is reachable, and not a report that it\nis unreachable — it is a report that nothing is currently checking. The\noutage this check exists for ran for nine days behind exactly this silence.`,
@@ -196,7 +199,9 @@ export async function auditPublicDns(deps: PublicDnsAuditDeps): Promise<DriftFin
196
199
  if (runs >= threshold) {
197
200
  findings.push({
198
201
  category: 'public_dns',
199
- severity: 'drift',
202
+ // The resolver would not answer for this name, so whether it resolves
203
+ // publicly is unknown — not fine, not broken. Unmeasured per D7.
204
+ severity: 'unmeasured',
200
205
  code: 'public_dns_unverifiable',
201
206
  message: `${record.fqdn}: public reachability unverifiable for ${runs} consecutive checks`,
202
207
  details: `${deps.probe.resolver} did not answer for this name. Latest reason: ${resolution.reason}.\nWhether the name resolves publicly is currently unknown — which is not the\nsame as it being fine.`,
@@ -0,0 +1,225 @@
1
+ /**
2
+ * The recurrence gate (celilo#1326, part two).
3
+ *
4
+ * D7's rule: every claim celilo makes about the fleet is measured against the
5
+ * thing it describes, or reported as `unmeasured`. There is no third option,
6
+ * because contributing nothing renders the category green and the verdict
7
+ * READY — the defect shape #1326 exists to close.
8
+ *
9
+ * A category can silently violate that rule three ways: someone adds a new
10
+ * DriftCategory with no unmeasured path, someone rewires a category's
11
+ * "could not reach" case to invent a finding of the wrong severity, or a
12
+ * refactor drops the path entirely. This gate catches all three by walking
13
+ * EVERY category — computed from the type, not from a list someone maintains
14
+ * by hand — and demanding each either:
15
+ *
16
+ * - `probed`: its audit function, given deps that cannot reach their
17
+ * subject, produces at least one `unmeasured` finding; or
18
+ * - `allow-listed`: the category structurally always reaches its subject,
19
+ * with the reason recorded here.
20
+ *
21
+ * `Record<DriftCategory, GateEntry>` is the enforcement: a category added to
22
+ * the union without a gate entry is a type error, and the author has to
23
+ * answer the question this test exists to ask.
24
+ *
25
+ * WATCHING IT FAIL (Rule 7.6): revert ce-prr5's `health.ts` change (the
26
+ * `no-checks` branch in auditHealth) and the `health` entry fails — health
27
+ * produces nothing for a module with no hook, which is exactly how six
28
+ * modules rendered READY while never having been measured.
29
+ */
30
+
31
+ import { describe, expect, test } from 'bun:test';
32
+ import { ALL_CATEGORIES } from '../../cli/tui/audit-state';
33
+ import type { DbClient } from '../../db/client';
34
+ import { auditCliVersion } from './cli-version';
35
+ import { auditDiskSpace } from './disk-space';
36
+ import { auditHealth } from './health';
37
+ import { auditInterfaceClassification } from './interface-classification';
38
+ import { auditModuleIntegrity } from './module-integrity';
39
+ import { auditModuleVersions } from './module-versions';
40
+ import { auditPublicDns } from './public-dns';
41
+ import { auditSchema } from './schema';
42
+ import { auditTerraformPlan } from './terraform-plan';
43
+ import { auditTrustedSources } from './trusted-sources';
44
+ import type { DriftCategory, DriftFinding, DriftSeverity } from './types';
45
+
46
+ type GateEntry = {
47
+ /** The probe: call the category's audit with deps that cannot reach their subject. */
48
+ readonly probe?: () => Promise<DriftFinding[]> | DriftFinding[];
49
+ /** Why the category structurally always reaches its subject. */
50
+ readonly because?: string;
51
+ };
52
+
53
+ const GATE: Record<DriftCategory, GateEntry> = {
54
+ cli_version: {
55
+ probe: () => auditCliVersion({ installedVersion: '9.9.9', fetcher: async () => null }),
56
+ because: 'probed: the npm registry can be unreachable, and that is unmeasured',
57
+ },
58
+ schema: {
59
+ probe: () =>
60
+ auditSchema({
61
+ journal: () => null,
62
+ applied: () => [],
63
+ db: {} as DbClient,
64
+ }),
65
+ because: 'probed: a missing migration journal means nothing was compared',
66
+ },
67
+ capability_abi: {
68
+ because:
69
+ 'compares capability versions claimed in installed module manifests against the framework registry and deployed providers, all read from celilo own DB and local manifests — the subject is celilo itself and is always reachable',
70
+ },
71
+ browser_pin: {
72
+ because:
73
+ 'compares a bundled client playwright revision against the locally provisioned browser binary, both on this machine — always reachable',
74
+ },
75
+ terraform_plan: {
76
+ probe: () =>
77
+ auditTerraformPlan({
78
+ modules: [{ id: 'm', terraformDir: '/tmp/does-not-matter', envVars: {} }],
79
+ run: async () => ({ exitCode: 0, stdout: '', stderr: '' }),
80
+ }),
81
+ because: 'probed: terraform exiting zero with an unreadable summary is not a clean plan',
82
+ },
83
+ module_versions: {
84
+ probe: () =>
85
+ auditModuleVersions({
86
+ installed: [{ id: 'm', version: '1.0.0' }],
87
+ fetcher: () => Promise.reject(new Error('registry unreachable')),
88
+ }),
89
+ because: 'probed: a registry that did not answer is not a version difference',
90
+ },
91
+ module_configs: {
92
+ because:
93
+ 'walks manifest variables against config rows in celilo DB — the subject is celilo itself and is always reachable',
94
+ },
95
+ health: {
96
+ probe: () => auditHealth({ results: [{ moduleId: 'm', status: 'no-checks', checks: [] }] }),
97
+ because: 'probed: a module with no hook, or whose checks all skip, has measured nothing',
98
+ },
99
+ backups: {
100
+ because:
101
+ 'reads recorded backup outcomes and effective cadences from celilo DB — the subject is celilo itself and is always reachable; a missing backup is drift, not silence',
102
+ },
103
+ abandoned_operations: {
104
+ because: 'counts reclaimed operation rows in celilo DB — always reachable',
105
+ },
106
+ undeployed_modules: {
107
+ because: 'reads module lifecycle states from celilo DB — always reachable',
108
+ },
109
+ unconfigured_modules: {
110
+ because: 'reads config-row counts from celilo DB — always reachable',
111
+ },
112
+ services_credentials: {
113
+ because:
114
+ 'reads credential envelopes from celilo DB and decrypts with the local master key; a failed decrypt is itself a measurement (blocked), never silence',
115
+ },
116
+ secrets_decryptable: {
117
+ because:
118
+ 'walks encrypted rows in celilo DB and decrypts; a failed decrypt is itself a measurement (blocked), never silence',
119
+ },
120
+ services_reachable: {
121
+ because:
122
+ 'the probe outcome IS the measurement: an unreachable service is reachable=false and a finding, so every probe measures its subject',
123
+ },
124
+ machines_reachable: {
125
+ because:
126
+ 'the SSH probe outcome IS the measurement: an unreachable machine is reachable=false and a finding, so every probe measures its subject',
127
+ },
128
+ public_dns: {
129
+ probe: () =>
130
+ auditPublicDns({
131
+ records: [{ fqdn: 'git.celilo.computer', companion: false, lastAssertedAt: new Date(0) }],
132
+ probe: {
133
+ resolver: '1.1.1.1',
134
+ echoService: 'https://echo.example',
135
+ observeIngress: async () => ({ kind: 'undetermined', reason: 'ECONNREFUSED' }),
136
+ resolve: async () => {
137
+ throw new Error('not reached: the vantage is down');
138
+ },
139
+ },
140
+ undeterminedThreshold: 1,
141
+ now: new Date(0),
142
+ }),
143
+ because: 'probed: the off-fleet echo vantage is external and can be down; unmeasured',
144
+ },
145
+ disk_space: {
146
+ probe: () =>
147
+ auditDiskSpace({
148
+ results: [
149
+ { hostname: 'box', ipAddress: '10.0.0.1', usedPercent: null, message: 'df failed' },
150
+ ],
151
+ }),
152
+ because: 'probed: a df that could not run measured nothing about the disk',
153
+ },
154
+ transport_reads: {
155
+ because:
156
+ 'reads the notification poller recorded read outcomes from celilo DB — always reachable; not happening is itself the drift finding',
157
+ },
158
+ trusted_sources: {
159
+ probe: () => auditTrustedSources({ firewalls: [], unreachableFirewalls: ['10.0.0.254'] }),
160
+ because: 'probed: a firewall whose live ruleset cannot be read is unknown, not clean',
161
+ },
162
+ interface_classification: {
163
+ probe: () => auditInterfaceClassification({ views: [], unreachableFirewalls: ['10.0.0.254'] }),
164
+ because: 'probed: a firewall whose interfaces cannot be read is unknown, not clean',
165
+ },
166
+ module_integrity: {
167
+ probe: () =>
168
+ auditModuleIntegrity({
169
+ results: [
170
+ { success: false, moduleId: 'm', violations: [], error: 'No integrity data found.' },
171
+ ],
172
+ }),
173
+ because: 'probed: no baseline row, or a stale one, means nothing was verified',
174
+ },
175
+ detect_without_converge: {
176
+ because:
177
+ 'static analysis of module manifests and system-config rows in celilo DB — always reachable',
178
+ },
179
+ jail_exemptions: {
180
+ because:
181
+ 'reads recorded per-module jail exemptions from celilo DB — the subject is celilo itself and is always reachable; no exemptions is the normal state, not silence',
182
+ },
183
+ };
184
+
185
+ const ALLOW_LISTED = Object.entries(GATE).filter(([, e]) => !e.probe);
186
+
187
+ describe('recurrence gate: every DriftCategory measures its subject or says why it cannot fail to', () => {
188
+ test('the gate covers every category in the union (compile-time) and matches the TUI list (run-time)', async () => {
189
+ // `GATE` is `Record<DriftCategory, GateEntry>`, so the compiler rejects a
190
+ // new category until it gets an entry. This run-time line additionally
191
+ // catches the reverse: an entry for a category no longer in the union.
192
+ const gateCategories = Object.keys(GATE) as DriftCategory[];
193
+ expect(gateCategories.sort()).toEqual([...ALL_CATEGORIES].sort());
194
+ });
195
+
196
+ for (const [category, entry] of Object.entries(GATE) as [DriftCategory, GateEntry][]) {
197
+ if (!entry.probe) {
198
+ test(`${category}: allow-listed — ${entry.because}`, () => {
199
+ expect(entry.because).toBeTruthy();
200
+ });
201
+ continue;
202
+ }
203
+
204
+ test(`${category}: cannot reach its subject and still reports unmeasured`, async () => {
205
+ const probe = entry.probe;
206
+ if (!probe) expect.unreachable();
207
+ const findings = await probe();
208
+ const severities: DriftSeverity[] = findings.map((f) => f.severity);
209
+ expect(severities).toContain('unmeasured');
210
+ // The gate exists because silence renders as READY. If an unmeasured
211
+ // path ever degrades back to drift, the verdict distinction D7 added
212
+ // is gone and this is the line that says so.
213
+ const unmeasured = findings.find((f) => f.severity === 'unmeasured') as DriftFinding;
214
+ expect(unmeasured.category).toBe(category);
215
+ });
216
+ }
217
+
218
+ test('the allow-list is not a dumping ground: 14 categories, each individually justified', () => {
219
+ // Not a magic number to defend — a tripwire. If this count changes, the
220
+ // diff is the review: someone moved a category in or out of the set of
221
+ // "structurally always reaches its subject", and the reason strings
222
+ // above are where they have to argue it.
223
+ expect(ALLOW_LISTED).toHaveLength(14);
224
+ });
225
+ });
@@ -59,6 +59,7 @@ describe('parsing reach rules from a live ruleset', () => {
59
59
  describe('unowned-trusted-network audit', () => {
60
60
  it('reports a network celilo does not recognise', async () => {
61
61
  const findings = await auditTrustedSources({
62
+ unreachableFirewalls: [],
62
63
  firewalls: [
63
64
  {
64
65
  firewallIp: '192.168.0.254',
@@ -78,6 +79,7 @@ describe('unowned-trusted-network audit', () => {
78
79
  // The point of the change: once a module owns the VPN and registers its
79
80
  // subnet, the rule IS the rendered output and there is nothing to report.
80
81
  const findings = await auditTrustedSources({
82
+ unreachableFirewalls: [],
81
83
  firewalls: [
82
84
  {
83
85
  firewallIp: '192.168.0.254',
@@ -96,6 +98,7 @@ describe('unowned-trusted-network audit', () => {
96
98
  ZONES,
97
99
  );
98
100
  const findings = await auditTrustedSources({
101
+ unreachableFirewalls: [],
99
102
  firewalls: [{ firewallIp: '192.168.0.254', live, known: [CONTROL_PLANE] }],
100
103
  });
101
104
 
@@ -104,6 +107,7 @@ describe('unowned-trusted-network audit', () => {
104
107
 
105
108
  it('names the interface when trust is granted by interface, and says why it cannot be registered as-is', async () => {
106
109
  const findings = await auditTrustedSources({
110
+ unreachableFirewalls: [],
107
111
  firewalls: [
108
112
  {
109
113
  firewallIp: '192.168.0.254',
@@ -120,6 +124,7 @@ describe('unowned-trusted-network audit', () => {
120
124
 
121
125
  it('a firewall with no live reach beyond what celilo composes is clean', async () => {
122
126
  const findings = await auditTrustedSources({
127
+ unreachableFirewalls: [],
123
128
  firewalls: [
124
129
  {
125
130
  firewallIp: '192.168.0.254',
@@ -134,4 +139,16 @@ describe('unowned-trusted-network audit', () => {
134
139
 
135
140
  expect(findings).toEqual([]);
136
141
  });
142
+
143
+ it('a firewall whose ruleset could not be read is unmeasured, not silent (D7)', async () => {
144
+ const findings = await auditTrustedSources({
145
+ unreachableFirewalls: ['192.168.0.254'],
146
+ firewalls: [],
147
+ });
148
+
149
+ expect(findings).toHaveLength(1);
150
+ expect(findings[0].severity).toBe('unmeasured');
151
+ expect(findings[0].code).toBe('trusted_sources_unmeasured');
152
+ expect(findings[0].subject).toBe('192.168.0.254');
153
+ });
137
154
  });
@@ -37,6 +37,13 @@ export interface FirewallReachState {
37
37
 
38
38
  export interface TrustedSourcesAuditDeps {
39
39
  firewalls: FirewallReachState[];
40
+ /**
41
+ * Firewalls celilo knows about but whose live ruleset could not be read
42
+ * (`iptables-save` failed over SSH). Each is an `unmeasured` finding, not
43
+ * silence: a check that cannot see a firewall has not measured that
44
+ * firewall, and silence renders as READY (D7).
45
+ */
46
+ unreachableFirewalls: string[];
40
47
  }
41
48
 
42
49
  /**
@@ -79,6 +86,20 @@ function originKey(origin: ReachOrigin): string {
79
86
  export async function auditTrustedSources(deps: TrustedSourcesAuditDeps): Promise<DriftFinding[]> {
80
87
  const findings: DriftFinding[] = [];
81
88
 
89
+ // Unreachable firewalls first, so the report leads with what could not be
90
+ // measured at all before anything it did measure.
91
+ for (const firewallIp of deps.unreachableFirewalls) {
92
+ findings.push({
93
+ category: 'trusted_sources',
94
+ severity: 'unmeasured',
95
+ code: 'trusted_sources_unmeasured',
96
+ message: `${firewallIp}: live ruleset could not be read, so trusted-source drift is unknown`,
97
+ remediation: `Check SSH reachability of ${firewallIp}, then re-audit. This finding records that the ruleset was not read, not that it is clean.`,
98
+ actionable: true,
99
+ subject: firewallIp,
100
+ });
101
+ }
102
+
82
103
  for (const firewall of deps.firewalls) {
83
104
  const known = new Set(firewall.known);
84
105
  const reported = new Set<string>();
@@ -45,7 +45,8 @@ export type DriftCategory =
45
45
  | 'trusted_sources'
46
46
  | 'interface_classification'
47
47
  | 'module_integrity'
48
- | 'detect_without_converge';
48
+ | 'detect_without_converge'
49
+ | 'jail_exemptions';
49
50
 
50
51
  /**
51
52
  * `unmeasured` is a check that could not reach its subject
@@ -35,6 +35,7 @@ import {
35
35
  import type { Cadence } from './cadence';
36
36
  import { materializeCrossModuleRoot, moduleHasCrossModuleRead } from './cross-module-read';
37
37
  import { getModuleSystems } from './deployed-systems';
38
+ import { listMachines } from './machine-pool';
38
39
  import { parseStoredConfigValue } from './module-config';
39
40
  import {
40
41
  completeOperation,
@@ -367,6 +368,14 @@ export async function createModuleBackup(
367
368
 
368
369
  const staged = stageSystemState(join(tempDir, 'system-state'));
369
370
  hookInputs.system_state_root = staged.root;
371
+
372
+ // The machine inventory rides the same privilege. on_backup used to
373
+ // shell `celilo machine list --json` for this, which the hook jail kills
374
+ // (no celilo binary, celilo#1225) and which never worked before that:
375
+ // the CLI had no --json flag, so the hook's JSON.parse failed and its
376
+ // catch wrote [] into every envelope. listMachines() is the same read
377
+ // the CLI performs, offered as staged data instead of a spawn.
378
+ hookInputs.machine_pool = JSON.stringify(await listMachines());
370
379
  if (!staged.masterKeyStaged) {
371
380
  logger.warn(
372
381
  `master.key not found at ${getMasterKeyPath()} — secrets in the DB snapshot will be unreadable on restore.`,
@@ -66,7 +66,7 @@ describe('backup envelope round-trip', () => {
66
66
  afterEach(() => {
67
67
  closeDb();
68
68
  resetTestDbPath();
69
- process.env.CELILO_MASTER_KEY_PATH = undefined;
69
+ delete process.env.CELILO_MASTER_KEY_PATH;
70
70
  try {
71
71
  rmSync(dir, { recursive: true, force: true });
72
72
  } catch {
@@ -36,7 +36,7 @@ describe('backup/restore in-flight refusal', () => {
36
36
  afterEach(() => {
37
37
  closeDb();
38
38
  resetTestDbPath();
39
- process.env.CELILO_MASTER_KEY_PATH = undefined;
39
+ delete process.env.CELILO_MASTER_KEY_PATH;
40
40
  try {
41
41
  rmSync(dir, { recursive: true, force: true });
42
42
  } catch {