@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.
- package/CELILO_CORE_MODULES.md +1 -1
- package/CELILO_SUBSYSTEMS.md +31 -5
- package/README.md +0 -2
- package/drizzle/0030_drop_module_builds_environment.sql +8 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +3 -3
- package/src/capabilities/public-web-helpers.test.ts +12 -6
- package/src/capabilities/public-web-publish.test.ts +42 -13
- package/src/capabilities/validation.test.ts +31 -0
- package/src/cli/commands/alerts-sweep.ts +3 -0
- package/src/cli/commands/console-get-chain.test.ts +96 -0
- package/src/cli/commands/console.ts +13 -5
- package/src/cli/commands/monitor.ts +15 -2
- package/src/cli/commands/notify-config.test.ts +79 -0
- package/src/cli/commands/notify-config.ts +13 -2
- package/src/cli/commands/system-doctor.test.ts +121 -1
- package/src/cli/commands/system-doctor.ts +151 -1
- package/src/cli/commands/system-ensure-fleet-key.ts +52 -0
- package/src/cli/completion.ts +10 -2
- package/src/cli/index.ts +7 -1
- package/src/console/closure.test.ts +76 -0
- package/src/console/closure.ts +87 -1
- package/src/console/control-plane-boundary.test.ts +82 -4
- package/src/console/projection.test.ts +63 -1
- package/src/console/projection.ts +39 -2
- package/src/db/schema.ts +0 -1
- package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
- package/src/hooks/capability-loader.ts +81 -10
- package/src/hooks/executor.ts +110 -17
- package/src/hooks/hook-jail-toolchain-reach.test.ts +224 -0
- package/src/hooks/hook-jail-unreachability.test.ts +28 -2
- package/src/hooks/hook-protocol.ts +44 -0
- package/src/hooks/hook-runner-entry.ts +23 -0
- package/src/hooks/hook-runner.ts +10 -0
- package/src/hooks/hook-trespass.test.ts +9 -3
- package/src/hooks/jail-browser-launch-flags.test.ts +34 -0
- package/src/hooks/jail.test.ts +92 -0
- package/src/hooks/jail.ts +128 -11
- package/src/hooks/mount-set.test.ts +28 -6
- package/src/hooks/mount-set.ts +34 -20
- package/src/hooks/remote-broker.test.ts +350 -0
- package/src/hooks/remote-broker.ts +404 -0
- package/src/hooks/run-named-hook.ts +2 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +14 -1
- package/src/hooks/test-fixtures/jail-toolchain-hook.ts +227 -0
- package/src/hooks/test-fixtures/remote-bridge-probe.ts +82 -0
- package/src/hooks/unjailed-lint.test.ts +251 -0
- package/src/hooks/unjailed-lint.ts +395 -0
- package/src/manifest/contracts/v1.ts +22 -1
- package/src/manifest/validate.ts +25 -4
- package/src/module/web-root.ts +35 -0
- package/src/policy/module-business-baseline.ts +27 -3
- package/src/policy/module-script-scan.test.ts +22 -0
- package/src/policy/module-script-scan.ts +92 -1
- package/src/policy/no-hand-built-ssh.test.ts +39 -1
- package/src/policy/no-module-business-in-core.test.ts +1 -1
- package/src/services/alerting/hook-jail.test.ts +66 -0
- package/src/services/alerting/hook-jail.ts +70 -0
- package/src/services/alerting/run-monitor.test.ts +62 -0
- package/src/services/alerting/run-monitor.ts +12 -0
- package/src/services/alerting/sweep-runner.test.ts +1 -0
- package/src/services/api-principal-enrolment.test.ts +73 -0
- package/src/services/api-principal-enrolment.ts +55 -0
- package/src/services/backup-create.ts +36 -7
- package/src/services/backup-restore.ts +2 -0
- package/src/services/celilo-mgmt-hooks.test.ts +38 -79
- package/src/services/deploy-ansible.ts +9 -1
- package/src/services/fleet-key.test.ts +47 -0
- package/src/services/fleet-key.ts +75 -0
- package/src/services/health-runner.ts +2 -0
- package/src/services/module-build.test.ts +1 -64
- package/src/services/module-build.ts +10 -86
- package/src/services/module-deploy.ts +20 -0
- package/src/services/remote-access.test.ts +139 -0
- package/src/services/remote-access.ts +98 -0
- package/src/services/restore-from-file.ts +12 -6
- package/src/services/static-content-converge.test.ts +338 -0
- package/src/services/static-content-converge.ts +299 -0
- package/src/services/system-state-stage.test.ts +165 -0
- 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 {
|
|
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
|
|
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);
|
|
@@ -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
|
|