@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
@@ -0,0 +1,395 @@
1
+ /**
2
+ * The unjailed advisory lint (hook-process-boundary task 4.7, design D8).
3
+ *
4
+ * On a host with no jail backend the hook runs with ambient filesystem
5
+ * access, so a hook that reads outside its tree works on that host and fails
6
+ * on the fleet — a divergence otherwise found in production one deploy at a
7
+ * time. This file closes the gap by making the runner shim check each
8
+ * filesystem access against the SAME mount set the jail would have enforced.
9
+ * It is the derivation's second consumer, which is the point: one computation
10
+ * and two consumers cannot drift the way a consumer and a hand-maintained
11
+ * list can.
12
+ *
13
+ * **This is a lint, not a security boundary, and neither the code nor its
14
+ * output may describe it as one.** It observes `node:fs` calls from inside
15
+ * the hook's own process, and module code bypasses it trivially — a `Bun.file`
16
+ * call, a `dlopen`, or a subprocess never crosses a wrapper. What it claims,
17
+ * and all it claims, is: on a jailed host, this access would have failed.
18
+ *
19
+ * **Channel.** The mount set arrives in the child's environment
20
+ * (`HOOK_MOUNT_SET_ENV`), set only when the run is unjailed — presence is the
21
+ * signal to install, so a jailed run carries nothing and installs nothing.
22
+ *
23
+ * **WHY THIS MODULE SELF-INSTALLS AT EVALUATION, AND WHY IT MUST STAY A
24
+ * LEAF.** Measured on Bun 1.3: the first ESM import of `node:fs` anywhere in
25
+ * a process resolves the builtin's named exports and never revisits them, so
26
+ * a wrapper planted on `require('node:fs')` AFTER that first import is
27
+ * invisible to every later `import { readFileSync } from 'node:fs'`. The
28
+ * shim's other imports (`jail.ts`, `@celilo/capabilities`) ESM-import
29
+ * `node:fs`, so the wrappers must be in place before any of them load. Hence
30
+ * `hook-runner-entry.ts`: the executor spawns IT, its first statement forces
31
+ * this module's evaluation and the install, and only then does the real
32
+ * runner load. Import order inside the runner is therefore irrelevant — but
33
+ * this module still imports nothing but `node:path`, `zod` (through
34
+ * `hook-protocol.ts`, which pulls no other builtins), and type-only imports,
35
+ * because the entry's guarantee is only as good as this file's graph is
36
+ * shallow. Importing `jail.ts` from here would silently disarm the lint; the
37
+ * integration tests in `unjailed-lint.test.ts` go red if that happens (they
38
+ * did, while this was being landed).
39
+ *
40
+ * The self-install is guarded on the runner shim's own environment variable,
41
+ * so a parent celilo process that imports this module for
42
+ * `mountSetEnvValue` never wraps its own filesystem.
43
+ */
44
+
45
+ import { basename, dirname, isAbsolute, resolve } from 'node:path';
46
+ import {
47
+ HOOK_MOUNT_SET_ENV,
48
+ HOOK_SOCKET_ENV,
49
+ type MountSetWire,
50
+ MountSetWireSchema,
51
+ } from './hook-protocol';
52
+ import type { MountSet } from './mount-set';
53
+
54
+ /**
55
+ * The two paths the jail's namespace itself provides — bubblewrap's
56
+ * `--proc /proc` and `--dev /dev` (`jail.ts`'s JAIL_NAMESPACE_ARGS is built
57
+ * from this constant, so the two cannot drift) — rather than derivation
58
+ * rows. Counting them absent would be a false warning on every hook that
59
+ * touched either.
60
+ */
61
+ export const JAIL_PROVIDED_PATHS = ['/proc', '/dev'] as const;
62
+
63
+ /**
64
+ * `realpathSync` captured at module load, before the wrappers go in.
65
+ * `classifyAccess` runs from INSIDE a wrapped call (a wrapper reports, report
66
+ * classifies), so reaching for the module property here would re-enter the
67
+ * wrapper and classify forever.
68
+ */
69
+ const PRISTINE_REALPATH = (require('node:fs') as typeof import('node:fs')).realpathSync;
70
+
71
+ /** The verdict for one access, against one mount set. */
72
+ export type AccessVerdict = 'allowed' | 'absent' | 'read-only';
73
+
74
+ /**
75
+ * Distinct paths warned about before the lint stops listing and summarises.
76
+ * A hook walking a directory outside the set would otherwise emit one line
77
+ * per file; after this many distinct paths the reader has the point.
78
+ */
79
+ const WARN_CAP = 25;
80
+
81
+ /**
82
+ * Serialise a derived mount set for `HOOK_MOUNT_SET_ENV`.
83
+ *
84
+ * The executor calls this with the set it just derived; `parseLintMountSet`
85
+ * is the only thing on the reading side.
86
+ */
87
+ export function mountSetEnvValue(set: MountSet): string {
88
+ return JSON.stringify(set);
89
+ }
90
+
91
+ /**
92
+ * Read and validate the mount set out of the child's environment.
93
+ *
94
+ * `undefined` when the variable is absent — the jailed case, and every
95
+ * invocation with no module tree. A value that fails validation is reported
96
+ * to stderr (the executor forwards it through the logger) and the run
97
+ * proceeds WITHOUT the lint rather than failing the hook over a diagnostic:
98
+ * the lint must never be the reason a deploy breaks. A silent skip would be
99
+ * the one thing worse than that, hence the stderr line.
100
+ */
101
+ export function parseLintMountSet(value: string | undefined): MountSetWire | undefined {
102
+ if (value === undefined || value === '') return undefined;
103
+ let json: unknown;
104
+ try {
105
+ json = JSON.parse(value);
106
+ } catch (error) {
107
+ process.stderr.write(
108
+ `hook runner: ${HOOK_MOUNT_SET_ENV} is not valid JSON (${error instanceof Error ? error.message : String(error)}); the unjailed advisory lint is off this run.\n`,
109
+ );
110
+ return undefined;
111
+ }
112
+ const parsed = MountSetWireSchema.safeParse(json);
113
+ if (parsed.success) return parsed.data;
114
+ process.stderr.write(
115
+ `hook runner: ${HOOK_MOUNT_SET_ENV} failed validation; the unjailed advisory lint is off this run.\n`,
116
+ );
117
+ return undefined;
118
+ }
119
+
120
+ /**
121
+ * Warnings emitted before the hook's logger exists are buffered here and
122
+ * flushed by `forwardLintWarnings`. In practice there are none — the shim
123
+ * does no filesystem work between load and hook start — but the lint must
124
+ * never assume that.
125
+ */
126
+ const buffered: string[] = [];
127
+ let emit: ((message: string) => void) | undefined;
128
+ let installed = false;
129
+
130
+ /**
131
+ * Install the wrappers now, if this process is an unjailed hook runner.
132
+ *
133
+ * Idempotent, and called from TWO places by design: this module's own
134
+ * evaluation, and `hook-runner-entry.ts`'s first statement. Which one lands
135
+ * first depends on Bun's import evaluation order, which is not a thing to
136
+ * reason about twice — the guard makes both orders correct, and the entry
137
+ * makes one of them certain before any other module in the child process can
138
+ * ESM-load `node:fs` (see the docblock at the top). No-op everywhere else: no
139
+ * runner socket in the environment means this is not a hook runner process.
140
+ */
141
+ export function installUnjailedLintIfUnjailed(): void {
142
+ if (installed) return;
143
+ if (process.env[HOOK_SOCKET_ENV] === undefined) return;
144
+ const set = parseLintMountSet(process.env[HOOK_MOUNT_SET_ENV]);
145
+ if (!set) return;
146
+ installed = true;
147
+ install(set, (message) => (emit ? emit(message) : buffered.push(message)));
148
+ }
149
+
150
+ /**
151
+ * Hand the lint's output to the hook's logger, draining anything buffered.
152
+ * Call once the shim's logger exists, before the hook script is imported.
153
+ */
154
+ export function forwardLintWarnings(to: (message: string) => void): void {
155
+ emit = to;
156
+ for (const message of buffered.splice(0)) to(message);
157
+ }
158
+
159
+ /**
160
+ * Classify one access against the mount set.
161
+ *
162
+ * Last matching row wins, in `entries` order — that is bubblewrap's own rule
163
+ * (`--ro-bind` then a later `--bind` overrides), and the derivation emits its
164
+ * rows in exactly that order. A path under no row is absent, which is what
165
+ * the jail actually produces (mount-set.ts: not "denied", ABSENT).
166
+ *
167
+ * Relative paths resolve against `set.chdir`, because that is the working
168
+ * directory the jailed hook runs in (`--chdir`, task 4.2i) — the child's real
169
+ * cwd is celilo's, which is a directory the jail does not contain.
170
+ */
171
+ export function classifyAccess(set: MountSetWire, path: string, write: boolean): AccessVerdict {
172
+ const absolute = isAbsolute(path) ? path : resolve(set.chdir, path);
173
+ const candidates = realpathCandidates(absolute);
174
+ let verdict: AccessVerdict = 'absent';
175
+ for (const candidate of candidates) {
176
+ verdict = strongest(verdict, classifyOne(set, candidate, write));
177
+ }
178
+ return verdict;
179
+ }
180
+
181
+ /**
182
+ * The lexical path plus, when it can be resolved, its realpath.
183
+ *
184
+ * The mount set the lint compares against is the REALPATHED one
185
+ * (`realpathRequest`, task 4.2k — the jail binds real paths). A dev checkout
186
+ * on macOS reaches the hook through symlinked prefixes (`/tmp` →
187
+ * `/private/tmp`, `/var` → `/private/var`), so a hook writing the path celilo
188
+ * handed it and the same path as bound can disagree lexically. The lint
189
+ * accepts either form as "inside": the lint runs only where there is no jail
190
+ * to match byte-for-byte, and the alternative is a warning on every state
191
+ * write on a Mac.
192
+ *
193
+ * The realpath of the FULL path usually does not exist — the check fires
194
+ * before a create, and `realpathSync` fails on the file being created. So
195
+ * this resolves the longest existing ancestor and reattaches whatever is
196
+ * left: for `<state>/cursor`, where only `<state>` exists, the second
197
+ * candidate is the realpath of `<state>` plus `/cursor`.
198
+ */
199
+ function realpathCandidates(path: string): string[] {
200
+ const candidates = [path];
201
+ let suffix = '';
202
+ let current = path;
203
+ for (;;) {
204
+ try {
205
+ const real = PRISTINE_REALPATH(current) + suffix;
206
+ if (!candidates.includes(real)) candidates.push(real);
207
+ return candidates;
208
+ } catch {
209
+ const parent = dirname(current);
210
+ if (parent === current) return candidates;
211
+ suffix = `/${basename(current)}${suffix}`;
212
+ current = parent;
213
+ }
214
+ }
215
+ }
216
+
217
+ function classifyOne(set: MountSetWire, absolute: string, write: boolean): AccessVerdict {
218
+ let verdict: AccessVerdict = 'absent';
219
+ for (const entry of set.entries) {
220
+ if (!covers(entry.path, absolute)) continue;
221
+ verdict = entry.mode === 'ro' && write ? 'read-only' : 'allowed';
222
+ }
223
+ for (const provided of JAIL_PROVIDED_PATHS) {
224
+ if (covers(provided, absolute)) verdict = 'allowed';
225
+ }
226
+ return verdict;
227
+ }
228
+
229
+ function covers(root: string, path: string): boolean {
230
+ const prefix = root.endsWith('/') ? root : `${root}/`;
231
+ return path === root || path.startsWith(prefix);
232
+ }
233
+
234
+ /** The more informative of two verdicts, for the realpath candidate pair. */
235
+ function strongest(a: AccessVerdict, b: AccessVerdict): AccessVerdict {
236
+ if (a === 'allowed' || b === 'allowed') return 'allowed';
237
+ if (a === 'read-only' || b === 'read-only') return 'read-only';
238
+ return 'absent';
239
+ }
240
+
241
+ /**
242
+ * Wrap every path-taking function this file knows about on both `node:fs`
243
+ * and `node:fs/promises`. The wrap fires BEFORE the underlying call, so it
244
+ * observes attempts — including attempts that succeed locally only because
245
+ * there is no jail, which is exactly the divergence it exists to surface.
246
+ *
247
+ * Warnings dedupe per (verdict, path) and are capped at WARN_CAP distinct
248
+ * paths, with one notice when the cap bites.
249
+ */
250
+ function install(set: MountSetWire, warn: (message: string) => void): void {
251
+ const warned = new Set<string>();
252
+ let suppressed = 0;
253
+
254
+ const report = (rawPath: string, write: boolean): void => {
255
+ if (typeof rawPath !== 'string' || rawPath === '') return;
256
+ const absolute = isAbsolute(rawPath) ? rawPath : resolve(set.chdir, rawPath);
257
+ const verdict = classifyAccess(set, absolute, write);
258
+ if (verdict === 'allowed') return;
259
+ const key = `${verdict}:${absolute}`;
260
+ if (warned.has(key)) return;
261
+ if (warned.size >= WARN_CAP) {
262
+ suppressed += 1;
263
+ if (suppressed === 1) {
264
+ warn(
265
+ `Hook advisory: further path(s) outside the hook's mount set will be suppressed after ${WARN_CAP}.`,
266
+ );
267
+ }
268
+ return;
269
+ }
270
+ warned.add(key);
271
+ warn(advisoryMessage(verdict, absolute));
272
+ };
273
+
274
+ for (const moduleId of ['node:fs', 'node:fs/promises'] as const) {
275
+ const mod = require(moduleId) as Record<string, unknown>;
276
+ for (const [name, pathArgsFor] of Object.entries(PATH_ARGS)) {
277
+ const original = mod[name];
278
+ if (typeof original !== 'function') continue;
279
+ mod[name] = function (this: unknown, ...call: unknown[]) {
280
+ const pathArgs = typeof pathArgsFor === 'function' ? pathArgsFor(call) : pathArgsFor;
281
+ for (const [index, write] of pathArgs) {
282
+ const value = call[index];
283
+ if (typeof value === 'string') report(value, write);
284
+ else if (value instanceof URL && value.protocol === 'file:') {
285
+ report(value.pathname, write);
286
+ }
287
+ }
288
+ return (original as (...args: unknown[]) => unknown).apply(this, call);
289
+ };
290
+ }
291
+ }
292
+ }
293
+
294
+ function advisoryMessage(verdict: AccessVerdict, path: string): string {
295
+ if (verdict === 'read-only') {
296
+ return [
297
+ `Hook advisory: '${path}' is read-only in the hook's mount set,`,
298
+ 'so on a jailed host this write would fail.',
299
+ 'Advisory lint, not a security boundary; module code bypasses it trivially.',
300
+ ].join(' ');
301
+ }
302
+ return [
303
+ `Hook advisory: '${path}' is outside the hook's mount set,`,
304
+ 'so on a jailed host this access would fail (the path is absent there, ENOENT).',
305
+ 'Advisory lint, not a security boundary; module code bypasses it trivially.',
306
+ ].join(' ');
307
+ }
308
+
309
+ /**
310
+ * Which arguments of which functions carry a path, and whether the access is
311
+ * a write. Read and write variants share the table; a name missing from a
312
+ * given module is simply skipped, so one table covers `node:fs`, its `Sync`
313
+ * variants and `node:fs/promises`.
314
+ *
315
+ * Deliberately a table of the common calls rather than an exhaustive census:
316
+ * a function missed here is a warning not emitted, never a behaviour change.
317
+ * The lint is advisory and module code bypasses it trivially (design D8);
318
+ * exhaustive coverage would buy a boundary-shaped guarantee it cannot keep.
319
+ */
320
+ const READ0: readonly (readonly [number, boolean])[] = [[0, false]];
321
+ const WRITE0: readonly (readonly [number, boolean])[] = [[0, true]];
322
+ const TWO_PATH: readonly (readonly [number, boolean])[] = [
323
+ [0, false],
324
+ [1, true],
325
+ ];
326
+
327
+ const PATH_ARGS: Record<
328
+ string,
329
+ | readonly (readonly [number, boolean])[]
330
+ | ((call: unknown[]) => readonly (readonly [number, boolean])[])
331
+ > = {
332
+ // Reads.
333
+ readFile: READ0,
334
+ readFileSync: READ0,
335
+ readdir: READ0,
336
+ readdirSync: READ0,
337
+ stat: READ0,
338
+ statSync: READ0,
339
+ lstat: READ0,
340
+ lstatSync: READ0,
341
+ access: READ0,
342
+ accessSync: READ0,
343
+ existsSync: READ0,
344
+ realpath: READ0,
345
+ realpathSync: READ0,
346
+ readlink: READ0,
347
+ readlinkSync: READ0,
348
+ createReadStream: READ0,
349
+ opendir: READ0,
350
+ opendirSync: READ0,
351
+ // Writes.
352
+ writeFile: WRITE0,
353
+ writeFileSync: WRITE0,
354
+ appendFile: WRITE0,
355
+ appendFileSync: WRITE0,
356
+ mkdir: WRITE0,
357
+ mkdirSync: WRITE0,
358
+ mkdtemp: WRITE0,
359
+ mkdtempSync: WRITE0,
360
+ rm: WRITE0,
361
+ rmSync: WRITE0,
362
+ rmdir: WRITE0,
363
+ rmdirSync: WRITE0,
364
+ unlink: WRITE0,
365
+ unlinkSync: WRITE0,
366
+ chmod: WRITE0,
367
+ chmodSync: WRITE0,
368
+ chown: WRITE0,
369
+ chownSync: WRITE0,
370
+ truncate: WRITE0,
371
+ truncateSync: WRITE0,
372
+ utimes: WRITE0,
373
+ utimesSync: WRITE0,
374
+ createWriteStream: WRITE0,
375
+ symlink: [[1, true]],
376
+ // Both ends are paths.
377
+ rename: TWO_PATH,
378
+ renameSync: TWO_PATH,
379
+ copyFile: TWO_PATH,
380
+ copyFileSync: TWO_PATH,
381
+ cp: TWO_PATH,
382
+ cpSync: TWO_PATH,
383
+ // Flag-dependent: 'r' reads, everything else writes.
384
+ open: OPEN_FLAGS,
385
+ openSync: OPEN_FLAGS,
386
+ };
387
+
388
+ /** `open`'s flags argument decides read from write. */
389
+ function OPEN_FLAGS(call: unknown[]): readonly (readonly [number, boolean])[] {
390
+ const flags = call[1];
391
+ const flag = typeof flags === 'string' ? flags : 'r';
392
+ return [[0, flag !== 'r']];
393
+ }
394
+
395
+ installUnjailedLintIfUnjailed();
@@ -45,7 +45,8 @@ export interface ContractField {
45
45
  * A field carrying a path-shaped value with no annotation here is a defect.
46
46
  * `db_path` was one for months — passed by `backup-create.ts` and declared
47
47
  * nowhere, so anything reasoning from this table was wrong about what a
48
- * backup hook receives.
48
+ * backup hook receives. It is gone: design D9b replaced it with the staged
49
+ * `system_state_root` below, which is declared and annotated.
49
50
  */
50
51
  path?: { access: PathAccess };
51
52
  }
@@ -152,6 +153,26 @@ export const V1_HOOKS: ContractHooks = {
152
153
  * terraform.tfstate.backup
153
154
  */
154
155
  cross_module_root: { required: false, path: { access: 'read' } },
156
+ /**
157
+ * Path to a directory holding copies of celilo's OWN state: a
158
+ * WAL-correct `celilo.db` snapshot, `master.key`, the fleet `ssh/`
159
+ * keypair, and `module_src/<id>/` for every module's lean source.
160
+ * Populated by the framework under the same `cross_module_read`
161
+ * allow-list as `cross_module_root`; only celilo-mgmt receives it.
162
+ *
163
+ * This input is what makes celilo's own backup possible WITHOUT
164
+ * exempting celilo-mgmt from the hook jail (design D9b). The hook
165
+ * never read those bytes — it copied them into `backup_dir` — so the
166
+ * framework copies them into a directory it creates and hands over,
167
+ * and the data directory itself stays out of every mount set.
168
+ *
169
+ * It replaces `db_path`, which `backup-create.ts` passed for months
170
+ * while the contract declared nothing about it. That is the omission
171
+ * the docblock on `ContractField.path` names: anything reasoning from
172
+ * this table was wrong about what a backup hook receives, and a
173
+ * mount-set derivation walking the declared inputs could not see it.
174
+ */
175
+ system_state_root: { required: false, path: { access: 'read' } },
155
176
  },
156
177
  outputs: {
157
178
  artifact_count: { required: true },
@@ -202,14 +202,35 @@ const PRIVILEGED_CAPABILITY_ALLOW_LIST: Record<string, readonly string[]> = {
202
202
  cross_module_read: ['celilo-mgmt'],
203
203
  };
204
204
 
205
+ /**
206
+ * Framework-granted capabilities that any module may declare.
207
+ *
208
+ * Same satisfaction path as the allow-list above — celilo supplies them, so no
209
+ * module provides them and the resolver must not go looking for one — but a
210
+ * different authorization story, and the two were welded together while
211
+ * `cross_module_read` was the only entry.
212
+ *
213
+ * `cross_module_read` hands a module every OTHER module's terraform state, so
214
+ * who may hold it is a per-module trust decision and the list is the gate.
215
+ * `control_plane_api` mints a principal whose grants are derived from
216
+ * `readOnlyGrants(COMMANDS)` and cannot be widened by the caller, so the worst
217
+ * a wrongly-declared consumer obtains is celilo's own read verbs. The gate is
218
+ * the `requires` line, which a reviewer reads before the module is ever
219
+ * imported (web-ui-console D7b).
220
+ *
221
+ * An allow-list here was considered and rejected: it would put consumer module
222
+ * ids in core, so every new console-shaped consumer would need a core change
223
+ * and a `.deb` release before it could be imported at all.
224
+ */
225
+ const FRAMEWORK_GRANTED_CAPABILITIES: ReadonlySet<string> = new Set(['control_plane_api']);
226
+
205
227
  /**
206
228
  * Whether `name` is a framework-granted privilege rather than a normal
207
- * provider-backed capability. Privileges are satisfied by the framework
208
- * (gated by the allow-list above), so the capability-provider resolver
209
- * must NOT expect a module to "provide" them.
229
+ * provider-backed capability. Privileges are satisfied by the framework, so
230
+ * the capability-provider resolver must NOT expect a module to "provide" them.
210
231
  */
211
232
  export function isPrivilegedCapability(name: string): boolean {
212
- return name in PRIVILEGED_CAPABILITY_ALLOW_LIST;
233
+ return name in PRIVILEGED_CAPABILITY_ALLOW_LIST || FRAMEWORK_GRANTED_CAPABILITIES.has(name);
213
234
  }
214
235
 
215
236
  /**
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Where a module's built web site lives on this host.
3
+ *
4
+ * ONE application of the `site/dist` convention. It is deliberately not applied
5
+ * inside the providers: `public_web` is framework-provided, but `private_web`
6
+ * and `external_web` are implemented by modules (`caddy-internal`,
7
+ * `generic-cpanel-hosting-provider`), and three copies of one convention is how
8
+ * the copies drift.
9
+ *
10
+ * Core is also the only party that CAN resolve it in the case that matters.
11
+ * When the `public_web` provider converges a rebuilt host, no consumer is
12
+ * running to be asked where its bytes are (design D10). So the path has to come
13
+ * from the module id, and `modules.source_path` is what core already holds.
14
+ */
15
+
16
+ import { join } from 'node:path';
17
+ import { MODULE_WEB_ROOT } from '@celilo/capabilities';
18
+ import { eq } from 'drizzle-orm';
19
+ import type { DbClient } from '../db/client';
20
+ import { modules } from '../db/schema';
21
+
22
+ /**
23
+ * Absolute path to `<module root>/site/dist`, or undefined when no such module
24
+ * is installed.
25
+ *
26
+ * Does NOT check that the directory exists. Resolving a path and deciding
27
+ * whether it is usable are different jobs, and the second one belongs where the
28
+ * error can name the caller (Rule 10.1). `createPublicWeb`'s `requireWebRoot`
29
+ * does the existence check and says what to ship.
30
+ */
31
+ export function resolveModuleWebRoot(moduleId: string, db: DbClient): string | undefined {
32
+ const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
33
+ if (!module) return undefined;
34
+ return join(module.sourcePath, MODULE_WEB_ROOT);
35
+ }
@@ -255,6 +255,18 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
255
255
  count: 1,
256
256
  why: "PERMANENT — validating a manifest's declared capability names against the registry is core's job",
257
257
  },
258
+ {
259
+ file: 'apps/celilo/src/manifest/validate.ts',
260
+ capability: 'control_plane_api',
261
+ count: 1,
262
+ why: 'PERMANENT — framework-granted, so celilo satisfies it and no module provides it; the resolver has to be told that by name (web-ui-console D7b)',
263
+ },
264
+ {
265
+ file: 'apps/celilo/src/hooks/capability-loader.ts',
266
+ capability: 'control_plane_api',
267
+ count: 4,
268
+ why: "PERMANENT — framework-granted like web_routes above; core builds the method table because enrolment writes celilo's own api_principals row, which no module script can reach (web-ui-console D7b)",
269
+ },
258
270
  {
259
271
  file: 'apps/celilo/src/services/alerting/inbound-poller.ts',
260
272
  capability: 'notification',
@@ -297,6 +309,12 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
297
309
  count: 2,
298
310
  why: "S16 — two more copies of S15's decision; fixing S15 removes all three (#938)",
299
311
  },
312
+ {
313
+ file: 'apps/celilo/src/services/module-deploy.ts',
314
+ capability: 'public_web',
315
+ count: 1,
316
+ why: "D10 (capability-owned-tables stage 4): the public_web PROVIDER's own deploy writes the static-content release set into its generated inventory, so a rebuilt host recovers with no consumer involvement. The branch checks the deploying module's manifest `provides`, not a module name; the capability-loader callback is the other half of the same seam.",
317
+ },
300
318
  {
301
319
  file: 'apps/celilo/src/services/zone-policy.ts',
302
320
  capability: 'public_web',
@@ -420,8 +438,8 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
420
438
  {
421
439
  file: 'packages/capabilities/src/firewall.ts',
422
440
  capability: 'firewall',
423
- count: 2,
424
- why: 'PERMANENT — X10, the firewall capability contract',
441
+ count: 3,
442
+ why: 'PERMANENT — X10, the firewall capability contract. The third is FIREWALL_CAPABILITY_NAME, exported so core queries the provider registry without spelling the literal; it moved a name OUT of apps/celilo/src/console/projection.ts rather than adding one',
425
443
  },
426
444
  {
427
445
  file: 'packages/capabilities/src/public-web.ts',
@@ -476,7 +494,13 @@ export const PROVIDER_LITERAL_BASELINE: readonly ProviderLiteralRow[] = [
476
494
  {
477
495
  file: 'packages/capabilities/src/public-web.ts',
478
496
  literal: '/srv/www',
479
- count: 4,
497
+ count: 3,
480
498
  why: "X8 — the Caddyfile generator knows caddy's on-disk asset layout (#940)",
481
499
  },
500
+ {
501
+ file: 'apps/celilo/src/services/static-content-converge.ts',
502
+ literal: '/srv/www',
503
+ count: 1,
504
+ why: "D10 (capability-owned-tables stage 4): the converge's slug-collision error names the on-disk release path an operator must fix. The role under modules/caddy owns the real path handling; this is message text, not path logic.",
505
+ },
482
506
  ];
@@ -38,6 +38,28 @@ describe('module script scan — SSH rules', () => {
38
38
  });
39
39
  });
40
40
 
41
+ describe('module script scan — namespace tools (hygiene, not a boundary)', () => {
42
+ const NS = 'namespace escape (bwrap/unshare/nsenter)';
43
+
44
+ // Each token asserted separately and by name. An alternation that silently
45
+ // loses one leg still passes a test that only ever exercises the first.
46
+ it.each([
47
+ ['bwrap', 'run(`bwrap --dev-bind / / ${cmd}`);'],
48
+ ['unshare', "run('unshare --user --map-root-user id');"],
49
+ ['nsenter', "run('nsenter -t 1 -m -- ls /');"],
50
+ ['CLONE_NEWUSER', 'const flags = CLONE_NEWUSER | CLONE_NEWNS;'],
51
+ ])('catches %s', (_token, src) => {
52
+ expect(rules(src)).toContain(NS);
53
+ });
54
+
55
+ // The word boundaries are the whole of this rule's false-positive defence, so
56
+ // the negative case has to be a word that CONTAINS a token. `buildSharedConfig`
57
+ // would pass whether or not the `\b`s are there, which makes it no test at all.
58
+ it('does not fire on an identifier that merely contains a token', () => {
59
+ expect(rules('const unshared = pending.filter((p) => !p.shared);')).toEqual([]);
60
+ });
61
+ });
62
+
41
63
  describe('module script scan — raw-exec escape hatch', () => {
42
64
  it('flags a runAppCommand call with no justification', () => {
43
65
  expect(rules('const r = runAppCommand(system, "rm -f /tmp/x", run);')).toContain(