@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
@@ -1,14 +1,109 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
2
  import type { HealthCheckResult } from '../health-runner';
3
3
  import { auditHealth } from './health';
4
+ import { computeVerdict } from './types';
4
5
 
5
6
  describe('auditHealth', () => {
6
- test('no findings when every module is healthy or has no checks', async () => {
7
+ test('a healthy module produces no finding; a module with no hook is unmeasured, never green', async () => {
7
8
  const results: HealthCheckResult[] = [
8
9
  { moduleId: 'caddy', status: 'healthy', checks: [] },
9
10
  { moduleId: 'iptables', status: 'no-checks', checks: [] },
10
11
  ];
11
- expect(await auditHealth({ results })).toEqual([]);
12
+ const result = await auditHealth({ results });
13
+ expect(result).toHaveLength(1);
14
+ expect(result[0]).toMatchObject({
15
+ category: 'health',
16
+ severity: 'unmeasured',
17
+ code: 'health_unmeasured',
18
+ subject: 'iptables',
19
+ actionable: false,
20
+ });
21
+ expect(result[0].message).toContain('no health_check hook');
22
+ expect(computeVerdict(result)).toBe('UNKNOWN');
23
+ });
24
+
25
+ test('a module whose checks all report skip is unmeasured and names the skipped checks', async () => {
26
+ // The smoketest_bot_token incident, as an audit finding: the hook ran,
27
+ // every meaningful check refused to guess, and the fleet must not read
28
+ // READY off that.
29
+ const results: HealthCheckResult[] = [
30
+ {
31
+ moduleId: 'smoketest',
32
+ status: 'no-checks',
33
+ checks: [
34
+ {
35
+ name: 'bot_token_valid',
36
+ status: 'skip',
37
+ message: 'smoketest_bot_token not yet provisioned',
38
+ },
39
+ {
40
+ name: 'webhook_reachable',
41
+ status: 'skip',
42
+ message: 'smoketest_bot_token not yet provisioned',
43
+ },
44
+ ],
45
+ },
46
+ ];
47
+ const result = await auditHealth({ results });
48
+ expect(result).toHaveLength(1);
49
+ expect(result[0]).toMatchObject({
50
+ severity: 'unmeasured',
51
+ code: 'health_unmeasured',
52
+ subject: 'smoketest',
53
+ actionable: true,
54
+ remediation: 'celilo module health smoketest --debug',
55
+ });
56
+ expect(result[0].message).toContain('all 2 health checks skipped');
57
+ expect(result[0].details).toContain('bot_token_valid');
58
+ expect(result[0].details).toContain('not yet provisioned');
59
+ });
60
+
61
+ test('skipped checks on an otherwise healthy module render unmeasured', async () => {
62
+ const results: HealthCheckResult[] = [
63
+ {
64
+ moduleId: 'caddy',
65
+ status: 'healthy',
66
+ checks: [
67
+ { name: 'config_valid', status: 'pass', message: 'config parses' },
68
+ {
69
+ name: 'origin_reachable',
70
+ status: 'skip',
71
+ message: 'peer unreachable from control plane',
72
+ },
73
+ ],
74
+ },
75
+ ];
76
+ const result = await auditHealth({ results });
77
+ expect(result).toHaveLength(1);
78
+ expect(result[0]).toMatchObject({
79
+ severity: 'unmeasured',
80
+ code: 'health_checks_skipped',
81
+ subject: 'caddy',
82
+ });
83
+ expect(result[0].message).toContain('1 of 2');
84
+ expect(result[0].details).toContain('origin_reachable');
85
+ expect(computeVerdict(result)).toBe('UNKNOWN');
86
+ });
87
+
88
+ test('a degraded module with a skipped check yields both a drift and an unmeasured finding', async () => {
89
+ const results: HealthCheckResult[] = [
90
+ {
91
+ moduleId: 'iptables',
92
+ status: 'degraded',
93
+ checks: [
94
+ {
95
+ name: 'persistence_drift',
96
+ status: 'warn',
97
+ message: '1 live rule(s) absent from rules.v4',
98
+ },
99
+ { name: 'ssh_access', status: 'skip', message: 'fw01 unreachable' },
100
+ ],
101
+ },
102
+ ];
103
+ const result = await auditHealth({ results });
104
+ expect(result).toHaveLength(2);
105
+ expect(result.map((f) => f.severity).sort()).toEqual(['drift', 'unmeasured']);
106
+ expect(computeVerdict(result)).toBe('UNKNOWN');
12
107
  });
13
108
 
14
109
  test('drift finding for unhealthy module', async () => {
@@ -5,6 +5,14 @@
5
5
  * informational (DRIFT, not BLOCKED) — the user might want to update
6
6
  * even if some module is currently unhealthy (an update could fix it).
7
7
  *
8
+ * Unmeasured is reported, never folded into green: a module with no
9
+ * health_check hook, and a module whose checks all reported skip, have
10
+ * contributed no measurement at all, and both render the fleet UNKNOWN
11
+ * rather than READY (celilo#1326). The same rule reaches one level down —
12
+ * a module that measured something but skipped a check gets an
13
+ * `unmeasured` finding beside its verdict, so a VERIFIED module whose
14
+ * meaningful probe was skipped is still visible.
15
+ *
8
16
  * The health-runner result is passed in directly so this file stays
9
17
  * pure for testing. The aggregator wires `runAllHealthChecks` to it.
10
18
  */
@@ -16,13 +24,67 @@ export interface HealthAuditDeps {
16
24
  results: HealthCheckResult[];
17
25
  }
18
26
 
27
+ function skippedChecksFinding(r: HealthCheckResult, code: string, message: string): DriftFinding {
28
+ const details = r.checks
29
+ .filter((c) => c.status === 'skip')
30
+ .map((c) => ` • ${c.name}: ${c.message}`)
31
+ .join('\n');
32
+
33
+ return {
34
+ category: 'health',
35
+ severity: 'unmeasured',
36
+ code,
37
+ message,
38
+ details: details || undefined,
39
+ remediation: `celilo module health ${r.moduleId} --debug`,
40
+ actionable: true,
41
+ subject: r.moduleId,
42
+ };
43
+ }
44
+
19
45
  export async function auditHealth(deps: HealthAuditDeps): Promise<DriftFinding[]> {
20
46
  const findings: DriftFinding[] = [];
21
47
 
22
48
  for (const r of deps.results) {
23
- if (r.status === 'healthy' || r.status === 'no-checks') continue;
49
+ const skipped = r.checks.filter((c) => c.status === 'skip');
50
+ const measured = r.checks.filter((c) => c.status !== 'skip');
51
+
52
+ if (r.status === 'no-checks') {
53
+ // Either the module declares no health_check hook, or the hook ran
54
+ // and every check reported skip. Neither measured anything.
55
+ findings.push(
56
+ skipped.length > 0
57
+ ? skippedChecksFinding(
58
+ r,
59
+ 'health_unmeasured',
60
+ `${r.moduleId}: all ${skipped.length} health checks skipped — nothing measured`,
61
+ )
62
+ : {
63
+ category: 'health',
64
+ severity: 'unmeasured',
65
+ code: 'health_unmeasured',
66
+ message: `${r.moduleId}: no health_check hook — never measured`,
67
+ remediation: `Add a health_check hook to the ${r.moduleId} module manifest`,
68
+ actionable: false,
69
+ subject: r.moduleId,
70
+ },
71
+ );
72
+ continue;
73
+ }
74
+
75
+ if (skipped.length > 0) {
76
+ findings.push(
77
+ skippedChecksFinding(
78
+ r,
79
+ 'health_checks_skipped',
80
+ `${r.moduleId}: ${skipped.length} of ${r.checks.length} health checks skipped`,
81
+ ),
82
+ );
83
+ }
84
+
85
+ if (r.status === 'healthy') continue;
24
86
 
25
- const failingChecks = r.checks
87
+ const failingChecks = measured
26
88
  .filter((c) => c.status === 'fail' || c.status === 'warn')
27
89
  .map((c) => ` • ${c.name}: ${c.message}`)
28
90
  .join('\n');
@@ -32,6 +32,7 @@ const emptyDeps = {
32
32
  moduleConfigs: { modules: [] },
33
33
  moduleIntegrity: { results: [] },
34
34
  detectWithoutConverge: { modules: [] },
35
+ jailExemptions: { exemptions: [] },
35
36
  health: { results: [] },
36
37
  backups: { modules: [] },
37
38
  abandonedOperations: { records: [] },
@@ -41,6 +42,7 @@ const emptyDeps = {
41
42
  secretsDecryptable: { results: [] },
42
43
  servicesReachable: { results: [] },
43
44
  machinesReachable: { results: [] },
45
+ diskSpace: { results: [] },
44
46
  transportReads: { statuses: [], now: new Date(), staleAfterMs: 30 * 60_000 },
45
47
  trustedSources: { firewalls: [] },
46
48
  publicDns: { records: [], probe: unusedPublicDnsProbe },
@@ -88,6 +90,26 @@ describe('runAudit', () => {
88
90
  expect(report.findings.some((f) => f.category === 'schema')).toBe(true);
89
91
  });
90
92
 
93
+ test('disk_space is wired into the report (celilo#1336)', async () => {
94
+ const events: string[] = [];
95
+ const report = await runAudit(
96
+ {
97
+ ...emptyDeps,
98
+ diskSpace: {
99
+ results: [{ hostname: 'celilo-mgr', ipAddress: '10.0.0.5', usedPercent: 96 }],
100
+ },
101
+ },
102
+ (event) => events.push(`${event.category}:${event.phase}`),
103
+ );
104
+
105
+ // 96% is at the blocked threshold, so the wired category alone flips
106
+ // the fleet verdict — which is the point of wiring it.
107
+ expect(report.verdict).toBe('BLOCKED');
108
+ expect(report.findings.map((f) => f.category)).toContain('disk_space');
109
+ expect(events).toContain('disk_space:start');
110
+ expect(events).toContain('disk_space:end');
111
+ });
112
+
91
113
  test('aggregates findings across all categories', async () => {
92
114
  const report = await runAudit({
93
115
  ...emptyDeps,
@@ -22,7 +22,9 @@ import {
22
22
  type DetectWithoutConvergeAuditDeps,
23
23
  auditDetectWithoutConverge,
24
24
  } from './detect-without-converge';
25
+ import { type DiskSpaceAuditDeps, auditDiskSpace } from './disk-space';
25
26
  import { type HealthAuditDeps, auditHealth } from './health';
27
+ import { type JailExemptionsAuditDeps, auditJailExemptions } from './jail-exemptions';
26
28
  import { type MachinesReachableAuditDeps, auditMachinesReachable } from './machines-reachable';
27
29
  import { type ModuleConfigsAuditDeps, auditModuleConfigs } from './module-configs';
28
30
  import { type ModuleIntegrityAuditDeps, auditModuleIntegrity } from './module-integrity';
@@ -60,6 +62,7 @@ export interface AuditDeps {
60
62
  moduleConfigs: ModuleConfigsAuditDeps;
61
63
  moduleIntegrity: ModuleIntegrityAuditDeps;
62
64
  detectWithoutConverge: DetectWithoutConvergeAuditDeps;
65
+ jailExemptions: JailExemptionsAuditDeps;
63
66
  health: HealthAuditDeps;
64
67
  backups: BackupsAuditDeps;
65
68
  abandonedOperations: AbandonedOperationsAuditDeps;
@@ -70,6 +73,7 @@ export interface AuditDeps {
70
73
  servicesReachable: ServicesReachableAuditDeps;
71
74
  machinesReachable: MachinesReachableAuditDeps;
72
75
  publicDns: PublicDnsAuditDeps;
76
+ diskSpace: DiskSpaceAuditDeps;
73
77
  transportReads: TransportReadsAuditDeps;
74
78
  trustedSources: TrustedSourcesAuditDeps;
75
79
  /** Defaults to `Date.now()`-based ISO string. */
@@ -95,8 +99,8 @@ export async function runAudit(
95
99
  ): Promise<SystemAuditReport> {
96
100
  // Wrap each category's promise so the caller sees per-category
97
101
  // start/end events. `start` fires synchronously when this function
98
- // schedules the category; in practice all 14 fire roughly at once
99
- // since Promise.all spawns them in parallel.
102
+ // schedules the category; in practice all of them fire roughly at
103
+ // once since Promise.all spawns them in parallel.
100
104
  const wrap = (category: DriftCategory, p: Promise<DriftFinding[]>): Promise<DriftFinding[]> => {
101
105
  onProgress?.({ category, phase: 'start' });
102
106
  return p.then((findings) => {
@@ -119,6 +123,7 @@ export async function runAudit(
119
123
  'detect_without_converge',
120
124
  Promise.resolve(auditDetectWithoutConverge(deps.detectWithoutConverge)),
121
125
  ),
126
+ wrap('jail_exemptions', Promise.resolve(auditJailExemptions(deps.jailExemptions))),
122
127
  wrap('health', auditHealth(deps.health)),
123
128
  wrap('backups', auditBackups(deps.backups)),
124
129
  wrap(
@@ -132,6 +137,7 @@ export async function runAudit(
132
137
  wrap('services_reachable', auditServicesReachable(deps.servicesReachable)),
133
138
  wrap('machines_reachable', auditMachinesReachable(deps.machinesReachable)),
134
139
  wrap('public_dns', auditPublicDns(deps.publicDns)),
140
+ wrap('disk_space', Promise.resolve(auditDiskSpace(deps.diskSpace))),
135
141
  wrap('transport_reads', auditTransportReads(deps.transportReads)),
136
142
  wrap('trusted_sources', auditTrustedSources(deps.trustedSources)),
137
143
  ]);
@@ -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
  }
@@ -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 {
@@ -155,3 +155,51 @@ exit 0
155
155
  expect(results).toEqual([]);
156
156
  });
157
157
  });
158
+
159
+ /**
160
+ * The durable outcome record (celilo#1304): every dispatched run lands in
161
+ * the `build_bus_hook_runs` ledger via the injectable recordRun. Without it
162
+ * a failed self-update existed only as daemon console output, which no
163
+ * surface reads.
164
+ */
165
+ describe('hook-dispatcher outcome record', () => {
166
+ test('a failing script records exit code, stderr tail, and the event identity', async () => {
167
+ const failDir = mkdtempSync(join(tmpdir(), 'hook-record-test-'));
168
+ const scriptPath = join(failDir, 'fail.sh');
169
+ writeFileSync(scriptPath, '#!/usr/bin/env bash\necho "boom" >&2\nexit 7\n');
170
+ chmodSync(scriptPath, 0o755);
171
+
172
+ const recorded: Array<{
173
+ event: PublishEvent;
174
+ result: { exitCode: number | null; stderr: string };
175
+ }> = [];
176
+ const dispatcher = await startHookDispatcher({
177
+ loadModules: () => [
178
+ {
179
+ moduleId: 'celilo-mgmt',
180
+ sourcePath: failDir,
181
+ hooks: [
182
+ {
183
+ name: 'self-update-celilo-cli',
184
+ match: { registry: 'npm', tag: 'latest', package_pattern: '@celilo/cli' },
185
+ script: 'fail.sh',
186
+ },
187
+ ],
188
+ },
189
+ ],
190
+ recordRun: (event, result) => recorded.push({ event, result }),
191
+ });
192
+
193
+ await dispatcher.handleEvent(
194
+ buildEvent({ package: { name: '@celilo/cli', version: '2.2.1' } }),
195
+ );
196
+ await dispatcher.stop();
197
+ rmSync(failDir, { recursive: true, force: true });
198
+
199
+ expect(recorded).toHaveLength(1);
200
+ expect(recorded[0].event.eventId).toBe('evt-1');
201
+ expect(recorded[0].event.package.name).toBe('@celilo/cli');
202
+ expect(recorded[0].result.exitCode).toBe(7);
203
+ expect(recorded[0].result.stderr).toContain('boom');
204
+ });
205
+ });
@@ -20,7 +20,7 @@ import { join } from 'node:path';
20
20
  import type { PublishEvent } from '@celilo/event-bus/build-bus';
21
21
  import { parse as parseYaml } from 'yaml';
22
22
  import { getDb } from '../../db/client';
23
- import { modules } from '../../db/schema';
23
+ import { buildBusHookRuns, modules } from '../../db/schema';
24
24
  import {
25
25
  type HookRunResult,
26
26
  type ModuleHookContext,
@@ -43,12 +43,59 @@ export interface HookDispatcherOptions {
43
43
  * modules table.
44
44
  */
45
45
  loadModules?: () => ModuleHookContext[];
46
+ /**
47
+ * Override the durable outcome record. The production default writes one
48
+ * `build_bus_hook_runs` row per run — without it a failed self-update
49
+ * exists only as daemon console output, which no surface reads
50
+ * (celilo#1304). Tests inject a capture to stay DB-free.
51
+ */
52
+ recordRun?: (event: PublishEvent, result: HookRunResult) => void;
53
+ }
54
+
55
+ /** Bound on stdout/stderr tails so one chatty hook cannot bloat the ledger. */
56
+ const TAIL_CHARS = 2000;
57
+
58
+ function tail(s: string): string {
59
+ return s.length > TAIL_CHARS ? s.slice(-TAIL_CHARS) : s;
60
+ }
61
+
62
+ /**
63
+ * The production outcome record: one `build_bus_hook_runs` row per run.
64
+ * Throwing here would 500 nothing (the webhook already succeeded) but
65
+ * would kill dispatch of later plans, so a record failure is logged and
66
+ * swallowed — the console outcome lines below remain the fallback signal.
67
+ */
68
+ function recordHookRun(event: PublishEvent, result: HookRunResult): void {
69
+ try {
70
+ getDb()
71
+ .insert(buildBusHookRuns)
72
+ .values({
73
+ eventId: event.eventId,
74
+ packageName: event.package.name,
75
+ packageVersion: event.package.version,
76
+ tag: event.tag,
77
+ moduleId: result.module,
78
+ hookName: result.hookName,
79
+ scriptPath: result.scriptPath,
80
+ exitCode: result.exitCode,
81
+ timedOut: result.timedOut,
82
+ durationMs: result.durationMs,
83
+ stdoutTail: result.stdout ? tail(result.stdout) : null,
84
+ stderrTail: result.stderr ? tail(result.stderr) : null,
85
+ })
86
+ .run();
87
+ } catch (err) {
88
+ console.warn(
89
+ `[build-bus] could not record hook run outcome: ${err instanceof Error ? err.message : String(err)}`,
90
+ );
91
+ }
46
92
  }
47
93
 
48
94
  export async function startHookDispatcher(
49
95
  opts: HookDispatcherOptions = {},
50
96
  ): Promise<HookDispatcher> {
51
97
  const loadModules = opts.loadModules ?? loadInstalledModulesWithHooks;
98
+ const recordRun = opts.recordRun ?? recordHookRun;
52
99
 
53
100
  return {
54
101
  async handleEvent(event) {
@@ -66,6 +113,7 @@ export async function startHookDispatcher(
66
113
  `[build-bus] ${event.package.name}@${event.package.version} → ${plan.module.moduleId} (${plan.hook.name ?? plan.hook.script})`,
67
114
  );
68
115
  const result = await runHookDispatch(plan);
116
+ recordRun(event, result);
69
117
  results.push(result);
70
118
  if (result.exitCode === 0) {
71
119
  console.log(` ✓ exit 0 in ${result.durationMs}ms`);
@@ -176,7 +176,7 @@ describe('bus-mediated interviewForEnsureInputs', () => {
176
176
  bus.close();
177
177
  rmSync(tempDir, { recursive: true, force: true });
178
178
  resetTestDbPath();
179
- process.env.EVENT_BUS_DB = undefined;
179
+ delete process.env.EVENT_BUS_DB;
180
180
  });
181
181
 
182
182
  test('mixed inputs: array applied deterministic, config + secret routed through bus', async () => {
@@ -93,8 +93,8 @@ afterEach(() => {
93
93
  server.stop(true);
94
94
  rmSync(dir, { recursive: true, force: true });
95
95
  resetTestDbPath();
96
- process.env.CELILO_ORIGINAL_CWD = undefined;
97
- process.env.EVENT_BUS_DB = undefined;
96
+ delete process.env.CELILO_ORIGINAL_CWD;
97
+ delete process.env.EVENT_BUS_DB;
98
98
  });
99
99
 
100
100
  test('a command parks on an unanswered interview, then resumes with an answer given later', async () => {
@@ -24,7 +24,7 @@ describe('busInterview', () => {
24
24
  process.env.EVENT_BUS_DB = dbPath;
25
25
  });
26
26
  afterEach(() => {
27
- if (origEnv === undefined) process.env.EVENT_BUS_DB = undefined;
27
+ if (origEnv === undefined) delete process.env.EVENT_BUS_DB;
28
28
  else process.env.EVENT_BUS_DB = origEnv;
29
29
  try {
30
30
  rmSync(dir, { recursive: true, force: true });
@@ -156,7 +156,7 @@ describe('multi-select payload roundtrip', () => {
156
156
  process.env.EVENT_BUS_DB = dbPath;
157
157
  });
158
158
  afterEach(() => {
159
- if (origEnv === undefined) process.env.EVENT_BUS_DB = undefined;
159
+ if (origEnv === undefined) delete process.env.EVENT_BUS_DB;
160
160
  else process.env.EVENT_BUS_DB = origEnv;
161
161
  try {
162
162
  rmSync(dir, { recursive: true, force: true });
@@ -156,7 +156,7 @@ describe('bus-mediated interviewForMissingSecrets', () => {
156
156
  bus.close();
157
157
  rmSync(tempDir, { recursive: true, force: true });
158
158
  resetTestDbPath();
159
- process.env.EVENT_BUS_DB = undefined;
159
+ delete process.env.EVENT_BUS_DB;
160
160
  });
161
161
 
162
162
  test('user_provided: emits secret.required, responder writes, value lands in store', async () => {