@celilo/cli 1.13.0 → 2.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 (80) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +31 -5
  3. package/README.md +0 -2
  4. package/drizzle/0030_drop_module_builds_environment.sql +8 -0
  5. package/drizzle/meta/_journal.json +8 -1
  6. package/package.json +3 -3
  7. package/src/capabilities/public-web-helpers.test.ts +12 -6
  8. package/src/capabilities/public-web-publish.test.ts +42 -13
  9. package/src/capabilities/validation.test.ts +31 -0
  10. package/src/cli/commands/alerts-sweep.ts +3 -0
  11. package/src/cli/commands/console-get-chain.test.ts +96 -0
  12. package/src/cli/commands/console.ts +13 -5
  13. package/src/cli/commands/monitor.ts +15 -2
  14. package/src/cli/commands/notify-config.test.ts +79 -0
  15. package/src/cli/commands/notify-config.ts +13 -2
  16. package/src/cli/commands/system-doctor.test.ts +121 -1
  17. package/src/cli/commands/system-doctor.ts +151 -1
  18. package/src/cli/commands/system-ensure-fleet-key.ts +52 -0
  19. package/src/cli/completion.ts +10 -2
  20. package/src/cli/index.ts +7 -1
  21. package/src/console/closure.test.ts +76 -0
  22. package/src/console/closure.ts +87 -1
  23. package/src/console/control-plane-boundary.test.ts +82 -4
  24. package/src/console/projection.test.ts +63 -1
  25. package/src/console/projection.ts +39 -2
  26. package/src/db/schema.ts +0 -1
  27. package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
  28. package/src/hooks/capability-loader.ts +81 -10
  29. package/src/hooks/executor.ts +110 -17
  30. package/src/hooks/hook-jail-toolchain-reach.test.ts +224 -0
  31. package/src/hooks/hook-jail-unreachability.test.ts +28 -2
  32. package/src/hooks/hook-protocol.ts +44 -0
  33. package/src/hooks/hook-runner-entry.ts +23 -0
  34. package/src/hooks/hook-runner.ts +10 -0
  35. package/src/hooks/hook-trespass.test.ts +9 -3
  36. package/src/hooks/jail-browser-launch-flags.test.ts +34 -0
  37. package/src/hooks/jail.test.ts +92 -0
  38. package/src/hooks/jail.ts +128 -11
  39. package/src/hooks/mount-set.test.ts +28 -6
  40. package/src/hooks/mount-set.ts +34 -20
  41. package/src/hooks/remote-broker.test.ts +350 -0
  42. package/src/hooks/remote-broker.ts +404 -0
  43. package/src/hooks/run-named-hook.ts +2 -0
  44. package/src/hooks/test-fixtures/jail-probe-hook.ts +14 -1
  45. package/src/hooks/test-fixtures/jail-toolchain-hook.ts +227 -0
  46. package/src/hooks/test-fixtures/remote-bridge-probe.ts +82 -0
  47. package/src/hooks/unjailed-lint.test.ts +251 -0
  48. package/src/hooks/unjailed-lint.ts +395 -0
  49. package/src/manifest/contracts/v1.ts +22 -1
  50. package/src/manifest/validate.ts +25 -4
  51. package/src/module/web-root.ts +35 -0
  52. package/src/policy/module-business-baseline.ts +27 -3
  53. package/src/policy/module-script-scan.test.ts +22 -0
  54. package/src/policy/module-script-scan.ts +92 -1
  55. package/src/policy/no-hand-built-ssh.test.ts +39 -1
  56. package/src/policy/no-module-business-in-core.test.ts +1 -1
  57. package/src/services/alerting/hook-jail.test.ts +66 -0
  58. package/src/services/alerting/hook-jail.ts +70 -0
  59. package/src/services/alerting/run-monitor.test.ts +62 -0
  60. package/src/services/alerting/run-monitor.ts +12 -0
  61. package/src/services/alerting/sweep-runner.test.ts +1 -0
  62. package/src/services/api-principal-enrolment.test.ts +73 -0
  63. package/src/services/api-principal-enrolment.ts +55 -0
  64. package/src/services/backup-create.ts +36 -7
  65. package/src/services/backup-restore.ts +2 -0
  66. package/src/services/celilo-mgmt-hooks.test.ts +38 -79
  67. package/src/services/deploy-ansible.ts +9 -1
  68. package/src/services/fleet-key.test.ts +47 -0
  69. package/src/services/fleet-key.ts +75 -0
  70. package/src/services/health-runner.ts +2 -0
  71. package/src/services/module-build.test.ts +1 -64
  72. package/src/services/module-build.ts +10 -86
  73. package/src/services/module-deploy.ts +20 -0
  74. package/src/services/remote-access.test.ts +139 -0
  75. package/src/services/remote-access.ts +98 -0
  76. package/src/services/restore-from-file.ts +12 -6
  77. package/src/services/static-content-converge.test.ts +338 -0
  78. package/src/services/static-content-converge.ts +299 -0
  79. package/src/services/system-state-stage.test.ts +165 -0
  80. package/src/services/system-state-stage.ts +196 -0
@@ -3,6 +3,11 @@
3
3
  * **modules never hand-build SSH, and the one sanctioned raw-exec path is
4
4
  * always justified in writing.**
5
5
  *
6
+ * It carries a second, weaker rule class as well — the namespace-tool rule
7
+ * below. Read that rule's own comment before touching it: it is hygiene and it
8
+ * is **not** a boundary, and the two classes in this file are not the same kind
9
+ * of thing.
10
+ *
6
11
  * ONE definition of the rules, used by both enforcement points:
7
12
  *
8
13
  * - `apps/celilo/src/policy/no-hand-built-ssh.test.ts` — every in-repo module,
@@ -63,6 +68,33 @@ const PATTERN_RULES: Array<{ rule: string; re: RegExp; hint: string }> = [
63
68
  re: /(?:from|require\()\s*['"]ssh2['"]/,
64
69
  hint: 'Modules never open their own SSH connection — use the primitives. See MODULE_PRIMITIVES.md.',
65
70
  },
71
+ /**
72
+ * HYGIENE, NOT A MITIGATION (openspec/changes/hook-process-boundary, task 4.2e,
73
+ * design D9). The boundary is the jail; this line is a courtesy to an honest
74
+ * author, and describing it as a security control would be a lie in three
75
+ * separate ways:
76
+ *
77
+ * - a hook runs arbitrary code, so anything this pattern catches can be
78
+ * spelled another way by anyone who wants to;
79
+ * - the scan walks in-repo `scripts/` and staged `.netapp` payloads, so it
80
+ * never sees a registry module or an out-of-repo one at all;
81
+ * - celilo#1014 is a live example of an exemption in this very file reading
82
+ * wider than its author intended.
83
+ *
84
+ * What it is for: a module that shells out to `bwrap`/`unshare`/`nsenter`
85
+ * gets a namespace it is uid 0 in, and under the jail that either fails or
86
+ * nests badly. Telling the author at package time beats a confusing runtime
87
+ * failure on the fleet. Task 4.2d is the related-but-different guarantee, and
88
+ * it IS load-bearing: `bwrap` is never in the derived mount set.
89
+ */
90
+ {
91
+ rule: 'namespace escape (bwrap/unshare/nsenter)',
92
+ re: /\b(?:bwrap|unshare|nsenter)\b|CLONE_NEWUSER/,
93
+ hint:
94
+ 'Hooks already run inside a namespace celilo builds; a module creating its own is ' +
95
+ 'not supported and will not survive the jail. This is a hygiene check, not a security ' +
96
+ 'boundary. If you need isolation the framework does not give you, say what for.',
97
+ },
66
98
  ];
67
99
 
68
100
  /** A `runAppCommand(` / `runAppCommandWithSecret(` CALL — not the import. */
@@ -110,7 +142,10 @@ export function scanModuleScriptSource(file: string, source: string): ScanViolat
110
142
  * `@celilo/capabilities`, whose `remote.ts` builds the `ssh … root@` string
111
143
  * that every one of these rules exists to keep OUT of module code. Scanning it
112
144
  * would fail every module in the fleet on the implementation of the primitives
113
- * they were told to use.
145
+ * they were told to use. The package itself is NOT exempt —
146
+ * `scanCapabilityPackageSource` scans it everywhere except those primitive
147
+ * files (celilo#1014), which is how `public-web.ts`'s hand-built ssh came to
148
+ * light.
114
149
  */
115
150
  export function moduleScriptFiles(scriptsDir: string): string[] {
116
151
  if (!existsSync(scriptsDir) || !statSync(scriptsDir).isDirectory()) return [];
@@ -141,3 +176,59 @@ export function scanModuleDirectory(moduleDir: string): ScanViolation[] {
141
176
  export function formatViolations(violations: ScanViolation[]): string {
142
177
  return violations.map((v) => ` ${v.file}:${v.line}\n → ${v.rule}. ${v.hint}`).join('\n');
143
178
  }
179
+
180
+ /**
181
+ * The files inside `@celilo/capabilities` that implement the remote-exec
182
+ * primitives themselves. `remote.ts` is the ONE sanctioned place the
183
+ * `ssh … root@` string is built (its own header says so), so the narrowed
184
+ * scan exempts these files and nothing else.
185
+ *
186
+ * celilo#1014: the old exemption was the whole package — `moduleScriptFiles`
187
+ * skipped `node_modules` entirely, on the reasoning that scanning the bundle
188
+ * would fail every module on `remote.ts`. The whole-package shape also
189
+ * exempted `public-web.ts`, which was hand-building the same ssh string, and
190
+ * no gate could see it. Naming the primitive files instead of skipping the
191
+ * package is the fix for that class.
192
+ */
193
+ export const REMOTE_PRIMITIVE_FILES: readonly string[] = ['remote.ts'];
194
+
195
+ /**
196
+ * The narrowed file set: every production `.ts` in the package source EXCEPT
197
+ * the primitive-implementation files. Returned separately from the violations
198
+ * so a gate can prove its own reach — an empty violation list must never be
199
+ * indistinguishable from "scanned nothing".
200
+ */
201
+ export function capabilityPackageSourceFiles(srcDir: string): string[] {
202
+ if (!existsSync(srcDir) || !statSync(srcDir).isDirectory()) return [];
203
+ return readdirSync(srcDir)
204
+ .filter(
205
+ (entry) =>
206
+ entry.endsWith('.ts') &&
207
+ !entry.endsWith('.test.ts') &&
208
+ !REMOTE_PRIMITIVE_FILES.includes(entry),
209
+ )
210
+ .sort();
211
+ }
212
+
213
+ /**
214
+ * Scan the `@celilo/capabilities` source tree under the exemption's narrowed
215
+ * shape: every production `.ts` EXCEPT the files implementing the primitive.
216
+ *
217
+ * Takes the directory rather than finding it, so the same function serves both
218
+ * copies of the package the fleet actually runs: the workspace source
219
+ * (`packages/capabilities/src`, where the code is authored) and, once the next
220
+ * version publishes, each module's bundled copy
221
+ * (`modules/<m>/scripts/node_modules/@celilo/capabilities/src`). The bundled
222
+ * copies on a checkout made before that publish still carry the pre-fix code,
223
+ * so the in-repo gate reads the workspace source until then — scanning a
224
+ * snapshot no commit in this repo can fix would leave the gate red in exactly
225
+ * the PR that repairs the defect.
226
+ */
227
+ export function scanCapabilityPackageSource(srcDir: string): ScanViolation[] {
228
+ return capabilityPackageSourceFiles(srcDir).flatMap((entry) =>
229
+ scanModuleScriptSource(
230
+ join('@celilo/capabilities/src', entry),
231
+ readFileSync(join(srcDir, entry), 'utf-8'),
232
+ ),
233
+ );
234
+ }
@@ -14,7 +14,14 @@
14
14
  import { describe, expect, test } from 'bun:test';
15
15
  import { existsSync, readdirSync, statSync } from 'node:fs';
16
16
  import { join, resolve } from 'node:path';
17
- import { formatViolations, moduleScriptFiles, scanModuleDirectory } from './module-script-scan';
17
+ import {
18
+ REMOTE_PRIMITIVE_FILES,
19
+ capabilityPackageSourceFiles,
20
+ formatViolations,
21
+ moduleScriptFiles,
22
+ scanCapabilityPackageSource,
23
+ scanModuleDirectory,
24
+ } from './module-script-scan';
18
25
 
19
26
  /** Walk up from this test to the repo root (the dir holding both modules/ and apps/). */
20
27
  function repoRoot(): string {
@@ -48,3 +55,34 @@ describe('recurrence gate: modules never hand-build SSH', () => {
48
55
  );
49
56
  });
50
57
  });
58
+
59
+ describe('recurrence gate: @celilo/capabilities itself never hand-builds SSH', () => {
60
+ // The workspace source — where the package is authored, and the only copy a
61
+ // commit in this repo can fix. Each module's bundled copy is an npm snapshot
62
+ // that refreshes on the next publish, so the gate reads the source of truth.
63
+ const capabilitiesSrc = join(repoRoot(), 'packages', 'capabilities', 'src');
64
+
65
+ test('scans the package source except the remote-primitive file, and the exclusion engaged', () => {
66
+ // Reach probe, not reasoning: the primitive file must exist and be absent
67
+ // from the scanned set, or the narrowing below is proving nothing.
68
+ const primitivePresent = REMOTE_PRIMITIVE_FILES.every((f) =>
69
+ existsSync(join(capabilitiesSrc, f)),
70
+ );
71
+ expect(primitivePresent).toBe(true);
72
+
73
+ const scannedFiles = capabilityPackageSourceFiles(capabilitiesSrc);
74
+ for (const primitive of REMOTE_PRIMITIVE_FILES) {
75
+ expect(scannedFiles).not.toContain(primitive);
76
+ }
77
+ expect(scannedFiles.length).toBeGreaterThan(10);
78
+ expect(scannedFiles).toContain('public-web.ts');
79
+ });
80
+
81
+ test('no file outside the remote-primitive seam hand-builds SSH', () => {
82
+ const violations = scanCapabilityPackageSource(capabilitiesSrc);
83
+ expect(
84
+ violations,
85
+ `Capability package policy violations:\n${formatViolations(violations)}`,
86
+ ).toEqual([]);
87
+ });
88
+ });
@@ -299,7 +299,7 @@ describe('recurrence gate: celilo core holds no module business — Scan A (tabl
299
299
  measured,
300
300
  baseline,
301
301
  (table, capability) => `table '${table}' is owned by capability '${capability}'`,
302
- "A new table modelling one capability's domain belongs in that provider's module_configs\n (the wireguard-manager D2 precedent), not in core's schema. If core genuinely must\n hold it, add it to CAPABILITY_OWNED_TABLES with the reason.",
302
+ "A new table modelling one capability's domain is DECLARED by that capability, beside its\n interface in packages/capabilities/src/<name>.ts, and registered in\n CAPABILITY_DECLARED_TABLES (declared-tables.ts). The CREATE TABLE still lives in\n core's drizzle migrations: a declaration is metadata and a version anchor, not a\n second way to ship schema. It does NOT go in the provider's module_configs. That\n destination was superseded on 2026-08-22 and ruled out by peba on 2026-09-01\n (openspec/changes/capability-owned-tables, ruling 5). If core genuinely must hold\n the table, add it to CAPABILITY_OWNED_TABLES with the reason.",
303
303
  );
304
304
  expect(
305
305
  problems,
@@ -0,0 +1,66 @@
1
+ /**
2
+ * D8's three states, asserted one branch at a time: only "used to jail and has
3
+ * stopped" raises. Steady-state unjailed — a Mac that never jailed — is a
4
+ * configuration fact the doctor reports, never an alert.
5
+ */
6
+
7
+ import { describe, expect, test } from 'bun:test';
8
+ import type { JailModeRecord } from '../../hooks/jail';
9
+ import { hookJailFailingKeys } from './hook-jail';
10
+
11
+ const HOST = 'celilo-mgr';
12
+
13
+ const regressed: JailModeRecord = {
14
+ mode: 'unjailed',
15
+ backend: 'none',
16
+ reason: 'bubblewrap is installed but could not build a namespace, so hooks run unjailed.',
17
+ host: HOST,
18
+ recordedAt: '2026-08-28T09:00:00.000Z',
19
+ lastJailed: { backend: 'bubblewrap', recordedAt: '2026-08-27T09:00:00.000Z' },
20
+ };
21
+
22
+ describe('hookJailFailingKeys', () => {
23
+ test('a host that used to jail and has stopped raises, naming the host and the reason', () => {
24
+ const failing = hookJailFailingKeys({ record: regressed, host: HOST }, 'critical');
25
+ expect(failing).toHaveLength(1);
26
+ expect(failing[0]?.key).toBe(`builtin:hook_jail/host:${HOST}`);
27
+ expect(failing[0]?.severity).toBe('critical');
28
+ expect(failing[0]?.message).toContain(HOST);
29
+ expect(failing[0]?.message).toContain('until 2026-08-27T09:00:00.000Z');
30
+ expect(failing[0]?.message).toContain('could not build a namespace');
31
+ });
32
+
33
+ test('steady-state unjailed is not an event', () => {
34
+ const neverJailed: JailModeRecord = {
35
+ mode: 'unjailed',
36
+ backend: 'none',
37
+ reason: 'macOS has no hook jail yet',
38
+ host: HOST,
39
+ recordedAt: '2026-08-28T09:00:00.000Z',
40
+ };
41
+ expect(hookJailFailingKeys({ record: neverJailed, host: HOST }, 'critical')).toEqual([]);
42
+ });
43
+
44
+ test('a jailed host raises nothing, which is also how the alert resolves', () => {
45
+ const healthy: JailModeRecord = {
46
+ mode: 'jailed',
47
+ backend: 'bubblewrap',
48
+ host: HOST,
49
+ recordedAt: '2026-08-28T09:00:00.000Z',
50
+ };
51
+ expect(hookJailFailingKeys({ record: healthy, host: HOST }, 'critical')).toEqual([]);
52
+ });
53
+
54
+ test('no record yet raises nothing', () => {
55
+ expect(hookJailFailingKeys({ record: undefined, host: HOST }, 'critical')).toEqual([]);
56
+ });
57
+
58
+ test("a record written by another host is a move, not this host's regression", () => {
59
+ expect(hookJailFailingKeys({ record: regressed, host: 'a-new-box' }, 'critical')).toEqual([]);
60
+ });
61
+
62
+ test("the monitor's severity is the alert's severity", () => {
63
+ const failing = hookJailFailingKeys({ record: regressed, host: HOST }, 'warning');
64
+ expect(failing[0]?.severity).toBe('warning');
65
+ });
66
+ });
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Hook-jail regression — a host that used to jail its hooks and has stopped.
3
+ *
4
+ * Design D8's three states want three different things (see
5
+ * openspec/changes/hook-process-boundary/design.md): jailed is normal and
6
+ * raises nothing; a host that never jailed (a Mac) is a configuration fact
7
+ * that `celilo system doctor` reports and is NOT an event; a host that ran
8
+ * hooks jailed yesterday and runs them unjailed today is a kernel upgrade, an
9
+ * AppArmor policy change, or a changed container profile, and is exactly the
10
+ * failure that looks like nothing. Only the third state raises here.
11
+ *
12
+ * The state this reads is `hook-jail-mode.json`, written by `recordJailMode`
13
+ * on every real module-hook run. The transition survives the record being
14
+ * overwritten because an unjailed record carries `lastJailed` — the jailed
15
+ * record it replaced on the same host.
16
+ *
17
+ * The monitor scheduling this check is a SELF-monitor: `celilo monitor add
18
+ * hook_jail` creates it `suppressible: false`, so per
19
+ * openspec/specs/alerting/spec.md no ancestor alert and no deploy window can
20
+ * silence it. Losing confinement during a broad outage is precisely when it
21
+ * must still page.
22
+ *
23
+ * Deliberately cheap: one local file read, no remote contact, safe on every
24
+ * sweep.
25
+ */
26
+
27
+ import type { AlertSeverity } from '../../db/schema';
28
+ import type { JailModeRecord } from '../../hooks/jail';
29
+ import { type FailingKey, builtinAlertKey } from './keys';
30
+
31
+ export const HOOK_JAIL_CHECK = 'hook_jail';
32
+
33
+ export interface HookJailState {
34
+ /** The recorded mode, or undefined when no hook has recorded one yet. */
35
+ record: JailModeRecord | undefined;
36
+ /** The host this check runs on, compared against the record's own. */
37
+ host: string;
38
+ }
39
+
40
+ /**
41
+ * One failing key when this host used to jail and has stopped; empty
42
+ * otherwise. The message names the host and the reason, which is what the
43
+ * security-model spec requires the alert to carry.
44
+ *
45
+ * A record written by another host is ignored rather than compared: a database
46
+ * or data directory restored onto a new box is a move, not a regression, and
47
+ * the new box has its own history to make.
48
+ */
49
+ export function hookJailFailingKeys(state: HookJailState, severity: AlertSeverity): FailingKey[] {
50
+ const { record, host } = state;
51
+ if (!record || record.host !== host) return [];
52
+ if (record.mode !== 'unjailed' || !record.lastJailed) return [];
53
+
54
+ return [
55
+ {
56
+ key: builtinAlertKey(HOOK_JAIL_CHECK, 'host', host),
57
+ severity,
58
+ message:
59
+ `Hooks on ${host} ran jailed (${record.lastJailed.backend}) until ` +
60
+ `${record.lastJailed.recordedAt} and now run unjailed: ` +
61
+ `${record.reason ?? 'no reason was recorded'}`,
62
+ details:
63
+ 'A host that quietly stops confining hooks is a failure that otherwise\n' +
64
+ 'looks like nothing — the usual causes are a kernel upgrade, an AppArmor\n' +
65
+ 'policy change, or a changed container profile. `celilo system doctor`\n' +
66
+ 'reports what the host can do right now. The alert resolves when a hook\n' +
67
+ 'runs jailed on this host again.',
68
+ },
69
+ ];
70
+ }
@@ -7,6 +7,7 @@ import type { DbClient } from '../../db/client';
7
7
  import { type Monitor, alerts, monitorRuns, monitors } from '../../db/schema';
8
8
  import { setupTestDatabaseAt } from '../../test-utils/database';
9
9
  import type { HealthCheckResult } from '../health-runner';
10
+ import { HOOK_JAIL_CHECK, type HookJailState } from './hook-jail';
10
11
  import { moduleAlertKey, moduleCheckAlertKey } from './keys';
11
12
  import { type MonitorRunDeps, runOneMonitor } from './run-monitor';
12
13
  import { loadLiveAlerts, markUnsuppressed } from './store';
@@ -40,6 +41,7 @@ describe('runOneMonitor', () => {
40
41
  runModuleCheck: async () => result,
41
42
  runBuiltinCheck: async () => [],
42
43
  loadModuleCoverage: () => [],
44
+ loadJailState: () => ({ record: undefined, host: 'test-host' }),
43
45
  now: () => now,
44
46
  graceMs: 60_000,
45
47
  };
@@ -182,4 +184,64 @@ describe('runOneMonitor', () => {
182
184
  expect(summary.outcome).toBe('success');
183
185
  expect(liveKeys()).toEqual([]);
184
186
  });
187
+
188
+ describe('the hook-jail self-monitor (hook-process-boundary task 4.4)', () => {
189
+ const HOST = 'celilo-mgr';
190
+ const regressed: HookJailState = {
191
+ host: HOST,
192
+ record: {
193
+ mode: 'unjailed',
194
+ backend: 'none',
195
+ reason: 'bubblewrap is installed but could not build a namespace',
196
+ host: HOST,
197
+ recordedAt: NOW.toISOString(),
198
+ lastJailed: { backend: 'bubblewrap', recordedAt: '2026-07-27T03:00:00.000Z' },
199
+ },
200
+ };
201
+ const healed: HookJailState = {
202
+ host: HOST,
203
+ record: {
204
+ mode: 'jailed',
205
+ backend: 'bubblewrap',
206
+ host: HOST,
207
+ recordedAt: LATER.toISOString(),
208
+ },
209
+ };
210
+
211
+ let jailMonitor: Monitor;
212
+
213
+ beforeEach(() => {
214
+ db.insert(monitors)
215
+ .values({
216
+ id: 'mon-jail',
217
+ kind: 'builtin_check',
218
+ target: HOOK_JAIL_CHECK,
219
+ intervalMinutes: 15,
220
+ severity: 'critical',
221
+ suppressible: false,
222
+ })
223
+ .run();
224
+ jailMonitor = db.select().from(monitors).where(eq(monitors.id, 'mon-jail')).get() as Monitor;
225
+ });
226
+
227
+ const jailDeps = (state: HookJailState, now: Date = NOW): MonitorRunDeps => ({
228
+ ...deps(healthy, now),
229
+ loadJailState: () => state,
230
+ });
231
+
232
+ test('a jailed-to-unjailed transition fires an alert naming the host', async () => {
233
+ const summary = await runOneMonitor(db, jailMonitor, jailDeps(regressed));
234
+ expect(summary.outcome).toBe('success');
235
+ const live = loadLiveAlerts(db, jailMonitor.id);
236
+ expect(live.map((a) => a.key)).toEqual([`builtin:hook_jail/host:${HOST}`]);
237
+ const row = db.select().from(alerts).where(eq(alerts.monitorId, jailMonitor.id)).get();
238
+ expect(row?.message).toContain(HOST);
239
+ });
240
+
241
+ test('re-jailing resolves it by set difference', async () => {
242
+ await runOneMonitor(db, jailMonitor, jailDeps(regressed));
243
+ await runOneMonitor(db, jailMonitor, jailDeps(healed, LATER));
244
+ expect(loadLiveAlerts(db, jailMonitor.id)).toEqual([]);
245
+ });
246
+ });
185
247
  });
@@ -19,6 +19,7 @@ import type { HealthCheckResult } from '../health-runner';
19
19
  import { failingKeysFromFindings } from './builtin-monitors';
20
20
  import { HEALTH_COVERAGE_CHECK, healthCoverageFailingKeys } from './health-coverage';
21
21
  import type { ModuleCoverageInput } from './health-coverage';
22
+ import { HOOK_JAIL_CHECK, type HookJailState, hookJailFailingKeys } from './hook-jail';
22
23
  import {
23
24
  type FailingKey,
24
25
  builtinMonitorKey,
@@ -35,6 +36,8 @@ export interface MonitorRunDeps {
35
36
  runBuiltinCheck(category: DriftCategory): Promise<DriftFinding[]>;
36
37
  /** Module roster for the health-coverage check. */
37
38
  loadModuleCoverage(): ModuleCoverageInput[];
39
+ /** Recorded jail mode and current host, for the hook-jail self-monitor. */
40
+ loadJailState(): HookJailState;
38
41
  now(): Date;
39
42
  /** Grace window before a new alert may notify. */
40
43
  graceMs: number;
@@ -95,6 +98,15 @@ async function runCheck(
95
98
  };
96
99
  }
97
100
 
101
+ // Same shape: one local file read. This is the self-monitor for D8's third
102
+ // state — a host that used to jail its hooks and has stopped.
103
+ if (monitor.target === HOOK_JAIL_CHECK) {
104
+ return {
105
+ outcome: 'success',
106
+ failingKeys: hookJailFailingKeys(deps.loadJailState(), severity),
107
+ };
108
+ }
109
+
98
110
  const category = monitor.target as DriftCategory;
99
111
  try {
100
112
  const findings = await deps.runBuiltinCheck(category);
@@ -42,6 +42,7 @@ describe('runSweep', () => {
42
42
  runModuleCheck: async () => result,
43
43
  runBuiltinCheck: async () => [],
44
44
  loadModuleCoverage: () => [],
45
+ loadJailState: () => ({ record: undefined, host: 'test-host' }),
45
46
  now: () => now,
46
47
  graceMs: 60_000,
47
48
  };
@@ -18,6 +18,7 @@ import { COMMANDS, readOnlyGrants } from '@celilo/core';
18
18
  import { closeDb } from '../db/client';
19
19
  import { getPrincipalByName, isAuthorized } from './api-access';
20
20
  import {
21
+ buildControlPlaneApi,
21
22
  controlPlaneReadGrants,
22
23
  enrolControlPlanePrincipal,
23
24
  revokeControlPlanePrincipal,
@@ -177,3 +178,75 @@ describe('revocation', () => {
177
178
  expect(await revokeControlPlanePrincipal('never-existed')).toBe(false);
178
179
  });
179
180
  });
181
+
182
+ describe('the capability handed to a consuming module', () => {
183
+ let dir: string;
184
+
185
+ beforeEach(async () => {
186
+ dir = mkdtempSync(join(tmpdir(), 'celilo-cpa-'));
187
+ process.env.CELILO_DB_PATH = join(dir, 'test.db');
188
+ closeDb();
189
+ });
190
+
191
+ afterEach(() => {
192
+ closeDb();
193
+ rmSync(dir, { recursive: true, force: true });
194
+ });
195
+
196
+ test('enrols the caller and reports the grants it may use', async () => {
197
+ const api = buildControlPlaneApi('celilo-web-console');
198
+ const result = await api.enrol_principal({
199
+ name: 'celilo-web-console',
200
+ publicKey: PUBLIC_KEY,
201
+ });
202
+
203
+ expect(result.created).toBe(true);
204
+ expect(result.grants).toEqual(controlPlaneReadGrants());
205
+ expect(await getPrincipalByName('celilo-web-console')).not.toBeNull();
206
+ });
207
+
208
+ test('a module may NOT enrol under a neighbour’s name', async () => {
209
+ // Otherwise any module holding this capability rotates the console's key by
210
+ // presenting its own public half under the console's name — the console then
211
+ // holds a principal whose authorized_keys entry it cannot log in with, and
212
+ // the attacker holds one that works.
213
+ const api = buildControlPlaneApi('hello-foo');
214
+
215
+ await expect(
216
+ api.enrol_principal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY }),
217
+ ).rejects.toThrow(/only enrol its own principal/);
218
+ expect(await getPrincipalByName('celilo-web-console')).toBeNull();
219
+ });
220
+
221
+ test('a module may NOT revoke a neighbour’s principal', async () => {
222
+ // The denial-of-service half of the same hole: revocation is idempotent and
223
+ // returns false for a name that never existed, so an unguarded revoke gives
224
+ // no signal at all that a module just deleted someone else's access.
225
+ await enrolControlPlanePrincipal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY });
226
+ const api = buildControlPlaneApi('hello-foo');
227
+
228
+ await expect(api.revoke_principal({ name: 'celilo-web-console' })).rejects.toThrow(
229
+ /only revoke its own principal/,
230
+ );
231
+ expect(await getPrincipalByName('celilo-web-console')).not.toBeNull();
232
+ });
233
+
234
+ test('withdraws its own principal, and finding nothing is not an error', async () => {
235
+ const api = buildControlPlaneApi('celilo-web-console');
236
+ await api.enrol_principal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY });
237
+
238
+ expect(await api.revoke_principal({ name: 'celilo-web-console' })).toEqual({ revoked: true });
239
+ expect(await api.revoke_principal({ name: 'celilo-web-console' })).toEqual({ revoked: false });
240
+ });
241
+
242
+ test('what it enrols cannot write, whatever the caller asked for', async () => {
243
+ // The grant set is not a parameter, so there is nothing to ask for. This
244
+ // asserts the consequence rather than the absence: the principal the
245
+ // capability produces is refused a deploy.
246
+ const api = buildControlPlaneApi('celilo-web-console');
247
+ await api.enrol_principal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY });
248
+
249
+ expect(await isAuthorized('celilo-web-console', 'module', 'list')).toBe(true);
250
+ expect(await isAuthorized('celilo-web-console', 'module', 'deploy')).toBe(false);
251
+ });
252
+ });
@@ -40,6 +40,7 @@
40
40
  * into a negotiation with the caller.
41
41
  */
42
42
 
43
+ import type { ControlPlaneApiCapability } from '@celilo/capabilities';
43
44
  import { COMMANDS, readOnlyGrants } from '@celilo/core';
44
45
  import { grantPrincipal, revokePrincipal } from './api-access';
45
46
 
@@ -101,3 +102,57 @@ export async function enrolControlPlanePrincipal(params: {
101
102
  export async function revokeControlPlanePrincipal(name: string): Promise<boolean> {
102
103
  return revokePrincipal(name);
103
104
  }
105
+
106
+ /**
107
+ * The `control_plane_api` method table handed to one consuming module.
108
+ *
109
+ * ## Why celilo builds this and no module provides it
110
+ *
111
+ * Every other function capability is implemented by a provider module's script,
112
+ * which the loader imports and calls. That is structurally impossible here.
113
+ * Enrolment writes celilo's own `api_principals` row and derives its grants from
114
+ * celilo's own command registry, and a module script may import nothing but
115
+ * `@celilo/capabilities` — its scripts are copied to a data directory at deploy,
116
+ * so a relative import into the backend breaks. celilo-mgmt has exactly the same
117
+ * limit as any other module here; being the management server's module does not
118
+ * give its SCRIPTS a database handle.
119
+ *
120
+ * The only shape that would let celilo-mgmt "provide" it is core injecting
121
+ * enrol/revoke into a factory context so the module's script forwards them
122
+ * unchanged. Core would still do all the work, and the enrolment would newly
123
+ * depend on the deployed copy of that script being current — a way to fail that
124
+ * does not exist today. So this is framework-granted, like `cross_module_read`.
125
+ *
126
+ * ## Why the caller cannot name someone else's principal
127
+ *
128
+ * `name` is on the request because the contract puts it there, and the only
129
+ * value accepted is the calling module's id. Without that check any module with
130
+ * the capability could rotate another module's key by enrolling under its name,
131
+ * or delete it outright by revoking it — the console's read access removed by a
132
+ * module that has nothing to do with the console.
133
+ *
134
+ * The parameter should come off the contract entirely, the same argument that
135
+ * kept `grants` off it: a caller that can negotiate WHOSE authority this is has
136
+ * the same problem as one that can negotiate how much. That is a breaking change
137
+ * to `@celilo/capabilities`' exported types, so it waits for the next major
138
+ * rather than forcing one (celilo#1196).
139
+ */
140
+ export function buildControlPlaneApi(consumerModuleId: string): ControlPlaneApiCapability {
141
+ function requireOwnPrincipal(name: string, verb: string): void {
142
+ if (name === consumerModuleId) return;
143
+ throw new Error(
144
+ `Module '${consumerModuleId}' tried to ${verb} the API principal '${name}'. A module may only ${verb} its own principal, named for its module id.`,
145
+ );
146
+ }
147
+
148
+ return {
149
+ async enrol_principal(request) {
150
+ requireOwnPrincipal(request.name, 'enrol');
151
+ return enrolControlPlanePrincipal(request);
152
+ },
153
+ async revoke_principal(request) {
154
+ requireOwnPrincipal(request.name, 'revoke');
155
+ return { revoked: await revokeControlPlanePrincipal(request.name) };
156
+ },
157
+ };
158
+ }
@@ -6,7 +6,7 @@
6
6
  import { copyFileSync, existsSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
8
  import { eq } from 'drizzle-orm';
9
- import { getDbPath } from '../config/paths';
9
+ import { getDbPath, getMasterKeyPath } from '../config/paths';
10
10
  import { getDb } from '../db/client';
11
11
  import { moduleConfigs, modules, secrets as secretsTable } from '../db/schema';
12
12
  import { invokeHook } from '../hooks/executor';
@@ -41,6 +41,8 @@ import {
41
41
  refuseIfInFlight,
42
42
  startOperation,
43
43
  } from './module-operations';
44
+ import { remoteAccessPolicy } from './remote-access';
45
+ import { stageSystemState } from './system-state-stage';
44
46
 
45
47
  export interface BackupCreateOptions {
46
48
  storageId?: string;
@@ -345,19 +347,44 @@ export async function createModuleBackup(
345
347
  const { configMap, secretMap } = await buildModuleContext(moduleId);
346
348
  const logger = createConsoleLogger(moduleId, 'on_backup');
347
349
 
348
- // Inject the framework-resolved DB path so the hook need not re-derive the
349
- // data dir from an env var the CLI may not export (ISS-0014). getDbPath()
350
- // is celilo's single source of truth for where the DB lives, regardless of
351
- // CELILO_DATA_DIR / XDG / explicit override; the hook derives the data dir
352
- // (master.key, fleet .ssh) as dirname(db_path) from this.
353
350
  const hookInputs: Record<string, unknown> = {
354
351
  backup_dir: dataDir,
355
- db_path: getDbPath(),
356
352
  };
353
+ // Both privileged inputs ride the same allow-list. `cross_module_root`
354
+ // mirrors OTHER modules' terraform state; `system_state_root` stages
355
+ // celilo's own — the DB snapshot, master.key, the fleet key, and every
356
+ // module's lean source.
357
+ //
358
+ // Staging is what lets celilo back ITSELF up without exempting
359
+ // celilo-mgmt from the hook jail (design D9b). The hook used to receive
360
+ // `db_path` and walk out from `dirname(db_path)` into celilo's data
361
+ // directory; it never read those bytes, it copied them, so the framework
362
+ // does the copying and the data directory stays out of the mount set.
357
363
  if (moduleHasCrossModuleRead(manifest)) {
358
364
  const crossModuleRoot = join(tempDir, 'cross-module-read');
359
365
  materializeCrossModuleRoot(crossModuleRoot, moduleId);
360
366
  hookInputs.cross_module_root = crossModuleRoot;
367
+
368
+ const staged = stageSystemState(join(tempDir, 'system-state'));
369
+ hookInputs.system_state_root = staged.root;
370
+ if (!staged.masterKeyStaged) {
371
+ logger.warn(
372
+ `master.key not found at ${getMasterKeyPath()} — secrets in the DB snapshot will be unreadable on restore.`,
373
+ );
374
+ }
375
+ if (!staged.fleetSshStaged) {
376
+ logger.info(
377
+ 'No fleet SSH keypair on this box — none staged (celilo-mgmt may not have been deployed yet).',
378
+ );
379
+ }
380
+ logger.info(`Staged celilo state: ${staged.moduleSourceCount} module source tree(s)`);
381
+ if (staged.skippedLarge.length > 0) {
382
+ // No silent caps: name what was dropped. These are build artifacts the
383
+ // target rebuilds on deploy.
384
+ logger.info(
385
+ `Skipped ${staged.skippedLarge.length} large/non-source file(s) (rebuilt on deploy): ${staged.skippedLarge.join(', ')}`,
386
+ );
387
+ }
361
388
  }
362
389
 
363
390
  // Execute on_backup hook — it writes artifacts to dataDir (envelope/data/)
@@ -373,6 +400,7 @@ export async function createModuleBackup(
373
400
  {
374
401
  debug: false,
375
402
  systems: getModuleSystems(moduleId, db),
403
+ remoteAccess: remoteAccessPolicy(moduleId, db),
376
404
  },
377
405
  );
378
406
 
@@ -537,6 +565,7 @@ export async function importModuleBackup(
537
565
  {
538
566
  debug: false,
539
567
  systems: getModuleSystems(moduleId, db),
568
+ remoteAccess: remoteAccessPolicy(moduleId, db),
540
569
  },
541
570
  );
542
571