@celilo/cli 2.0.0 → 2.2.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 (103) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +1 -0
  3. package/drizzle/0031_module_config_source.sql +20 -0
  4. package/drizzle/meta/_journal.json +8 -1
  5. package/package.json +2 -2
  6. package/schemas/system_config.json +2 -1
  7. package/src/capabilities/public-web-publish.test.ts +61 -0
  8. package/src/cli/commands/firewall-interface-list.test.ts +156 -7
  9. package/src/cli/commands/firewall-interface-list.ts +73 -7
  10. package/src/cli/commands/machine-add.ts +12 -55
  11. package/src/cli/commands/module-config.test.ts +20 -1
  12. package/src/cli/commands/module-import.ts +1 -1
  13. package/src/cli/commands/module-update.test.ts +82 -0
  14. package/src/cli/commands/module-update.ts +14 -4
  15. package/src/cli/commands/monitor.ts +2 -10
  16. package/src/cli/commands/restore.ts +16 -6
  17. package/src/cli/generate-zsh-completion.ts +1 -1
  18. package/src/cli/index.ts +4 -3
  19. package/src/cli/restore-migration-failure.test.ts +159 -0
  20. package/src/db/client.ts +5 -0
  21. package/src/db/migrate.test.ts +61 -135
  22. package/src/db/migrate.ts +7 -2
  23. package/src/db/schema.ts +10 -0
  24. package/src/hooks/broker.test.ts +106 -2
  25. package/src/hooks/broker.ts +91 -1
  26. package/src/hooks/capability-loader-firewall.test.ts +37 -0
  27. package/src/hooks/capability-loader.ts +16 -3
  28. package/src/hooks/define-hook.test.ts +4 -3
  29. package/src/hooks/executor.test.ts +19 -18
  30. package/src/hooks/executor.ts +88 -11
  31. package/src/hooks/hook-jail-toolchain-reach.test.ts +79 -29
  32. package/src/hooks/hook-jail-unreachability.test.ts +55 -29
  33. package/src/hooks/hook-protocol.ts +46 -1
  34. package/src/hooks/hook-runner.ts +36 -0
  35. package/src/hooks/hook-store-proxy.test.ts +109 -0
  36. package/src/hooks/hook-store-proxy.ts +85 -0
  37. package/src/hooks/hook-store.test.ts +162 -0
  38. package/src/hooks/hook-store.ts +290 -0
  39. package/src/hooks/hook-timeout.test.ts +3 -2
  40. package/src/hooks/hook-trespass.test.ts +94 -14
  41. package/src/hooks/jail.test.ts +1 -1
  42. package/src/hooks/jail.ts +194 -32
  43. package/src/hooks/mount-set.test.ts +296 -1
  44. package/src/hooks/mount-set.ts +216 -13
  45. package/src/hooks/run-named-hook.ts +2 -0
  46. package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
  47. package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
  48. package/src/hooks/test-fixtures/store-backed.ts +47 -0
  49. package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
  50. package/src/hooks/unjailed-lint.test.ts +27 -8
  51. package/src/manifest/schema.ts +1 -0
  52. package/src/module/packaging/build.ts +70 -2
  53. package/src/module/web-root.ts +17 -1
  54. package/src/policy/fixture-capability-coverage.test.ts +322 -0
  55. package/src/policy/module-script-scan.test.ts +42 -1
  56. package/src/policy/module-script-scan.ts +275 -5
  57. package/src/policy/no-hand-built-ssh.test.ts +34 -1
  58. package/src/policy/no-swallowed-refusal.test.ts +265 -0
  59. package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
  60. package/src/registry/client.test.ts +149 -0
  61. package/src/registry/client.ts +203 -11
  62. package/src/services/alerting/coverage-source.test.ts +86 -0
  63. package/src/services/alerting/coverage-source.ts +11 -1
  64. package/src/services/alerting/format.test.ts +57 -0
  65. package/src/services/alerting/format.ts +24 -0
  66. package/src/services/alerting/run-monitor.ts +2 -2
  67. package/src/services/backup-create.ts +7 -7
  68. package/src/services/backup-envelope-roundtrip.test.ts +45 -2
  69. package/src/services/backup-restore.ts +8 -4
  70. package/src/services/bus-interview.ts +37 -14
  71. package/src/services/config-provenance.ts +4 -0
  72. package/src/services/control-plane-bootstrap.test.ts +297 -0
  73. package/src/services/control-plane-bootstrap.ts +223 -0
  74. package/src/services/control-plane-health.test.ts +66 -0
  75. package/src/services/control-plane-health.ts +67 -0
  76. package/src/services/deploy-preflight.ts +8 -2
  77. package/src/services/deploy-validation.test.ts +22 -0
  78. package/src/services/deploy-validation.ts +8 -0
  79. package/src/services/deployed-systems.ts +12 -0
  80. package/src/services/dns-discovery.test.ts +147 -0
  81. package/src/services/dns-discovery.ts +134 -0
  82. package/src/services/fleet-checks.ts +6 -2
  83. package/src/services/fleet-key.test.ts +66 -2
  84. package/src/services/fleet-key.ts +54 -0
  85. package/src/services/health-runner.ts +36 -3
  86. package/src/services/module-config.ts +20 -2
  87. package/src/services/module-deploy.dns-repoint.test.ts +187 -0
  88. package/src/services/module-deploy.ts +214 -1
  89. package/src/services/module-validator/git-hygiene.test.ts +122 -3
  90. package/src/services/module-validator/git-hygiene.ts +83 -14
  91. package/src/services/remote-access.test.ts +88 -4
  92. package/src/services/remote-access.ts +52 -1
  93. package/src/services/restore-from-file.test.ts +20 -0
  94. package/src/services/restore-from-file.ts +21 -6
  95. package/src/services/static-content-converge.test.ts +140 -2
  96. package/src/services/static-content-converge.ts +55 -8
  97. package/src/services/system-config-schema-types.ts +1 -1
  98. package/src/services/system-config-validator.test.ts +36 -0
  99. package/src/services/system-config-validator.ts +11 -0
  100. package/src/services/trusted-sources.test.ts +30 -0
  101. package/src/services/trusted-sources.ts +47 -10
  102. package/src/templates/generator.ts +9 -2
  103. package/src/variables/context.ts +16 -5
@@ -7,8 +7,18 @@
7
7
  */
8
8
 
9
9
  import { describe, expect, test } from 'bun:test';
10
+ import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join, resolve } from 'node:path';
10
13
  import { BROWSER_ROOT } from '@celilo/capabilities';
11
- import { deriveMountSet, forbiddenPaths, isForbidden, toBwrapArgs } from './mount-set';
14
+ import { planJailedSpawn, realpathRequest, runtimeModulePathsFor } from './jail';
15
+ import {
16
+ deriveMountSet,
17
+ forbiddenPaths,
18
+ isForbidden,
19
+ toBwrapArgs,
20
+ toSandboxProfile,
21
+ } from './mount-set';
12
22
 
13
23
  const BASE = {
14
24
  modulePath: '/var/celilo/modules/caddy',
@@ -98,6 +108,27 @@ describe('ordering is semantic', () => {
98
108
  });
99
109
  });
100
110
 
111
+ describe('a hook write to the module tree is refused at mount time (celilo#1265)', () => {
112
+ // The recurrence gate for celilo#1265: hello-private-foo failed at deploy
113
+ // because its hook wrote ca.crt into site/dist, which the jail binds only
114
+ // through the module tree's read-only row. The sanctioned writable directory
115
+ // is `<module>/state` (celilo#1000). This pins the DERIVED mount set, with
116
+ // no docker and no bubblewrap: no row may make the built web root writable,
117
+ // and the state dir must stay writable. If a carve-out for site/dist ever
118
+ // appears here, this fails before a fixture can fail at deploy again.
119
+ test('no entry makes site/dist writable; state stays writable', () => {
120
+ const set = deriveMountSet(BASE);
121
+ const webRoot = `${BASE.modulePath}/site/dist`;
122
+ const writableCovering = set.entries.filter(
123
+ (e) => e.mode === 'rw' && (e.path === webRoot || webRoot.startsWith(`${e.path}/`)),
124
+ );
125
+ expect(writableCovering).toEqual([]);
126
+
127
+ const state = set.entries.find((e) => e.path === BASE.stateDir);
128
+ expect(state?.mode).toBe('rw');
129
+ });
130
+ });
131
+
101
132
  describe('contract inputs are bound at their declared access', () => {
102
133
  test("'write' is read-write and 'read' is read-only", () => {
103
134
  const set = deriveMountSet({
@@ -168,3 +199,267 @@ describe('the fleet browser is reachable, and only read-only (task 4.10)', () =>
168
199
  expect(pathsOf(BASE)).not.toContain('/var/lib/celilo');
169
200
  });
170
201
  });
202
+
203
+ describe('the sandbox-exec profile renders the same set (task 4.8)', () => {
204
+ const profileOf = (r: Parameters<typeof deriveMountSet>[0] = BASE) =>
205
+ toSandboxProfile(deriveMountSet(r));
206
+
207
+ test('a read-only mount is denied write and allowed read, in that order', () => {
208
+ const lines = profileOf().split('\n');
209
+ const deny = lines.indexOf(`(deny file-write* (subpath "${BASE.modulePath}"))`);
210
+ const allow = lines.indexOf(`(allow file-read* (subpath "${BASE.modulePath}"))`);
211
+ expect(deny).toBeGreaterThan(-1);
212
+ // SBPL is last-match-wins, so the deny must come FIRST or it would revoke
213
+ // the read it is paired with.
214
+ expect(allow).toBeGreaterThan(deny);
215
+ });
216
+
217
+ test('a writable directory nested in the read-only tree comes after it', () => {
218
+ const lines = profileOf().split('\n');
219
+ expect(
220
+ lines.indexOf(`(allow file-read* file-write* (subpath "${BASE.stateDir}"))`),
221
+ ).toBeGreaterThan(lines.indexOf(`(allow file-read* (subpath "${BASE.modulePath}"))`));
222
+ });
223
+
224
+ test('the tmpfs row grants nothing: macOS has no tmpfs and deny-default covers it', () => {
225
+ // The row still appears, as a comment, so a reader of the profile can see
226
+ // that the derivation asked for something this backend cannot give.
227
+ const profile = profileOf();
228
+ expect(profile).toContain('; /tmp: no tmpfs backend');
229
+ expect(profile).not.toContain('(subpath "/tmp")');
230
+ });
231
+
232
+ test('every ancestor is a literal, never a subpath', () => {
233
+ // A `subpath` grant on an ancestor would expose everything beneath it —
234
+ // `/var/celilo` holds master.key. `literal` permits stat and readdir of the
235
+ // directory node alone. This is the difference between D9's acceptance
236
+ // criterion holding and not.
237
+ const profile = profileOf();
238
+ expect(profile).toContain('(allow file-read* (literal "/var/celilo"))');
239
+ expect(profile).not.toContain('(allow file-read* (subpath "/var/celilo"))');
240
+ expect(profile).not.toContain('(subpath "/"))');
241
+ });
242
+
243
+ test('a forbidden path never reaches the profile', () => {
244
+ const profile = profileOf({
245
+ ...BASE,
246
+ pathInputs: [{ name: 'evil', value: '/usr/bin/bwrap', access: 'write' as const }],
247
+ });
248
+ expect(profile).not.toContain('(subpath "/usr/bin/bwrap")');
249
+ });
250
+
251
+ test('a quote in a path cannot end the rule early', () => {
252
+ const profile = profileOf({ ...BASE, modulePath: '/var/celilo/modules/od"d' });
253
+ expect(profile).toContain('(subpath "/var/celilo/modules/od\\"d")');
254
+ });
255
+
256
+ test('the network is allowed, because D9 does not namespace it', () => {
257
+ expect(profileOf()).toContain('(allow network*)');
258
+ });
259
+ });
260
+
261
+ describe('name resolution inside the jail (celilo#1225)', () => {
262
+ test('the resolver config is bound read-only', () => {
263
+ // Without these a hook resolves no NAME. getaddrinfo finds no nameserver
264
+ // and the call dies as ETIMEOUT, which reads as the remote endpoint being
265
+ // down. Measured on namecheap's validate_config against an endpoint that
266
+ // was up.
267
+ const set = deriveMountSet(BASE);
268
+ for (const path of ['/etc/resolv.conf', '/etc/nsswitch.conf', '/etc/hosts']) {
269
+ const row = set.entries.find((e) => e.path === path);
270
+ expect(row).toBeDefined();
271
+ expect(row?.mode).toBe('ro');
272
+ }
273
+ });
274
+
275
+ test('binding the resolver does not bind the rest of /etc', () => {
276
+ // The acceptance criterion for this jail is absence. Naming three files
277
+ // must not become naming a directory.
278
+ const set = deriveMountSet(BASE);
279
+ expect(set.entries.some((e) => e.path === '/etc')).toBe(false);
280
+ expect(set.entries.some((e) => e.path === '/etc/shadow')).toBe(false);
281
+ });
282
+ });
283
+
284
+ describe('every row states what its own absence means (celilo#1244 family)', () => {
285
+ const rowFor = (set: ReturnType<typeof deriveMountSet>, path: string) =>
286
+ set.entries.find((e) => e.path === path);
287
+
288
+ test('a contract path input is REQUIRED, so dropping it can never be a warning', () => {
289
+ // The case the old undifferentiated warning buried. A hook whose declared
290
+ // write path is dropped writes into the run's private tmpfs and reports
291
+ // success over a directory discarded when it exits.
292
+ const set = deriveMountSet({
293
+ ...BASE,
294
+ pathInputs: [{ name: 'backup_dir', value: '/srv/staged', access: 'write' }],
295
+ });
296
+ expect(rowFor(set, '/srv/staged')?.absence).toBe('required');
297
+ });
298
+
299
+ test('a runtime support directory is RUNTIME, so its absence is not reported', () => {
300
+ // /lib64 does not exist on arm64 and never will. A genuinely needed one
301
+ // fails the runtime, which is a louder and more specific signal than a
302
+ // warning that fired 96 times in one `cele2e run --all`.
303
+ const set = deriveMountSet(BASE);
304
+ expect(rowFor(set, '/lib64')?.absence).toBe('runtime');
305
+ expect(rowFor(set, '/usr/lib')?.absence).toBe('runtime');
306
+ });
307
+
308
+ test("the module's own tree and its state dir are REQUIRED", () => {
309
+ // Neither is ever legitimately absent: executor.ts mkdirSyncs the state dir
310
+ // before deriving the set, so an absent one means something is wrong that a
311
+ // dropped mount would only make more confusing later.
312
+ const set = deriveMountSet(BASE);
313
+ expect(rowFor(set, BASE.modulePath)?.absence).toBe('required');
314
+ expect(rowFor(set, BASE.stateDir)?.absence).toBe('required');
315
+ });
316
+
317
+ test('generated output is CONDITIONAL, because it appears only after generation', () => {
318
+ const set = deriveMountSet(BASE);
319
+ expect(rowFor(set, `${BASE.modulePath}/generated`)?.absence).toBe('conditional');
320
+ });
321
+
322
+ test('every row carries a policy, so a new mount cannot inherit a default', () => {
323
+ const set = deriveMountSet({
324
+ ...BASE,
325
+ pathInputs: [{ name: 'cert', value: '/srv/certs', access: 'read' }],
326
+ });
327
+ for (const e of set.entries) {
328
+ expect(e.absence).toBeDefined();
329
+ }
330
+ });
331
+ });
332
+
333
+ describe('a jailed hook cannot spawn /bin/sh, by derivation (e2e-recovery lane B)', () => {
334
+ // The full derivation lives in openspec/changes/e2e-suite-recovery/
335
+ // jail-shell-derivation.md. The short form: the derivation names NO system
336
+ // executable directory. It binds the interpreter as one file, the runner
337
+ // shim's directory, celilo's own node_modules, the module tree, the broker
338
+ // socket, the declared inputs, and library/resolver support dirs
339
+ // (/usr/lib, /lib, /lib64, /etc/ssl, the three resolver files). /bin and
340
+ // /usr/bin are in none of those families, so a shell is ABSENT — bubblewrap
341
+ // builds a namespace where the lookup dies with ENOENT, and sandbox-exec
342
+ // denies the read under deny-default (EPERM) — which is the measured
343
+ // `ENOENT: no such file or directory, posix_spawn '/bin/sh'` from
344
+ // e2e-suite-recovery's proposal.md.
345
+ //
346
+ // The assertion is absence of COVERAGE, never a specific errno, for the same
347
+ // reason hook-jail-unreachability.test.ts refuses to match on one: ENOENT
348
+ // and EPERM are both the jail working, and a test pinned to one goes red on
349
+ // the other platform for a jail that was working perfectly.
350
+ //
351
+ // These are tests over the DERIVED set, computed against a real mirrored
352
+ // layout with the real code path (realpathRequest -> runtimeModulePathsFor
353
+ // -> deriveMountSet -> planJailedSpawn), not against hand-built arguments:
354
+ // a hand-built set proves what the author believed, not what the jail does.
355
+
356
+ const EXECUTABLE_DIRS = ['/bin', '/usr/bin', '/sbin', '/usr/sbin', '/usr/local/bin'];
357
+ const SHELL = '/bin/sh';
358
+
359
+ /** Mirror a real module layout into a temp dir and derive through the real path. */
360
+ function derivedForMirroredModule() {
361
+ const scratch = mkdtempSync(join(tmpdir(), 'celilo-laneb-'));
362
+ const modulePath = join(scratch, 'modules', 'caddy');
363
+ const stateDir = join(modulePath, 'state');
364
+ const screenshotDir = join(modulePath, 'screenshots', 'run1');
365
+ mkdirSync(join(modulePath, 'generated'), { recursive: true });
366
+ mkdirSync(stateDir, { recursive: true });
367
+ mkdirSync(screenshotDir, { recursive: true });
368
+ const socketDir = mkdtempSync(join(tmpdir(), 'celilo-laneb-sock-'));
369
+ const stagedInput = mkdtempSync(join(tmpdir(), 'celilo-laneb-stage-'));
370
+ const runnerPath = resolve(__dirname, 'hook-runner.ts');
371
+
372
+ const request = realpathRequest({
373
+ modulePath,
374
+ stateDir,
375
+ screenshotDir,
376
+ socketDir,
377
+ runtimePath: process.execPath,
378
+ runnerPath,
379
+ runtimeModulePaths: runtimeModulePathsFor(runnerPath),
380
+ pathInputs: [{ name: 'backup_dir', value: join(stagedInput, 'data'), access: 'write' }],
381
+ });
382
+ const set = deriveMountSet(request);
383
+ return {
384
+ set,
385
+ cleanup: () => {
386
+ rmSync(scratch, { recursive: true, force: true });
387
+ rmSync(socketDir, { recursive: true, force: true });
388
+ rmSync(stagedInput, { recursive: true, force: true });
389
+ },
390
+ };
391
+ }
392
+
393
+ test('nothing in the jail covers /bin/sh, on any backend', () => {
394
+ const { set, cleanup } = derivedForMirroredModule();
395
+ try {
396
+ // A row covers /bin/sh only if it IS the shell or an ancestor of it.
397
+ // No entry is: nothing mounts /bin, and / is never a row (its grant, on
398
+ // macOS, is a literal on the node alone, which confers nothing on
399
+ // contents — pinned separately below).
400
+ const covering = set.entries.filter(
401
+ (e) => e.path === SHELL || SHELL.startsWith(`${e.path}/`),
402
+ );
403
+ expect(covering).toEqual([]);
404
+ } finally {
405
+ cleanup();
406
+ }
407
+ });
408
+
409
+ test('no system executable directory is a row, and the interpreter is the one exception', () => {
410
+ const { set, cleanup } = derivedForMirroredModule();
411
+ try {
412
+ const paths = set.entries.map((e) => e.path);
413
+ expect(paths.filter((p) => EXECUTABLE_DIRS.includes(p))).toEqual([]);
414
+
415
+ // A file bind under an executable directory (the interpreter can live in
416
+ // /usr/local/bin) is legitimate ONLY as the single runtime row. Any
417
+ // wider row there would drag the directory's other contents in.
418
+ for (const e of set.entries) {
419
+ const dir = EXECUTABLE_DIRS.find((d) => e.path.startsWith(`${d}/`));
420
+ if (dir) expect(e.path).toBe(process.execPath);
421
+ }
422
+ } finally {
423
+ cleanup();
424
+ }
425
+ });
426
+
427
+ test('the rendered bubblewrap command exposes no shell path either', () => {
428
+ // Measure reach on the artifact the kernel actually receives, not on the
429
+ // intermediate set: planJailedSpawn filters absent sources and renders the
430
+ // argv, and that argv is the last thing a future edit could corrupt.
431
+ const { set, cleanup } = derivedForMirroredModule();
432
+ try {
433
+ const plan = planJailedSpawn(
434
+ [process.execPath, resolve(__dirname, 'hook-runner.ts')],
435
+ set,
436
+ { backend: 'bubblewrap' },
437
+ 'required',
438
+ );
439
+ expect(plan.mode).toBe('jailed');
440
+ expect(plan.cmd).not.toContain(SHELL);
441
+ expect(plan.cmd).not.toContain('/bin');
442
+ } finally {
443
+ cleanup();
444
+ }
445
+ });
446
+
447
+ test('the sandbox profile grants no read on /bin, as subpath or as literal', () => {
448
+ const { set, cleanup } = derivedForMirroredModule();
449
+ try {
450
+ const profile = toSandboxProfile(set);
451
+ // A subpath grant on /bin (or any executable directory) would make the
452
+ // shell readable, and (allow process*) does the rest — exec is not
453
+ // restricted; what the hook can READ is the boundary in both backends.
454
+ for (const dir of EXECUTABLE_DIRS) {
455
+ expect(profile).not.toContain(`(subpath "${dir}")`);
456
+ }
457
+ // /bin must not even appear as an ancestor literal: a literal permits
458
+ // stat and readdir of the node alone, but its presence would still mean
459
+ // something began mounting beneath /bin.
460
+ expect(profile).not.toContain('(literal "/bin")');
461
+ } finally {
462
+ cleanup();
463
+ }
464
+ });
465
+ });
@@ -39,6 +39,8 @@ export interface MountEntry {
39
39
  readonly mode: MountMode;
40
40
  /** Why this row exists. Surfaced by the unjailed lint and by `system doctor`. */
41
41
  readonly reason: string;
42
+ /** What this row's absence from the host means. See `MountAbsence`. */
43
+ readonly absence: MountAbsence;
42
44
  }
43
45
 
44
46
  export interface MountSet {
@@ -110,6 +112,29 @@ export interface MountSetRequest {
110
112
  /** Directories whose contents the runtime needs in order to start at all. */
111
113
  const RUNTIME_SUPPORT_DIRS = ['/usr/lib', '/lib', '/lib64', '/etc/ssl'] as const;
112
114
 
115
+ /**
116
+ * What resolving a hostname needs. Read-only, and absent ones are dropped.
117
+ *
118
+ * Without these a jailed hook cannot resolve a NAME. `getaddrinfo` finds no
119
+ * nameserver, falls back to a loopback that answers nothing, and the call dies
120
+ * as `ETIMEOUT` — which reads as the remote endpoint being down rather than as
121
+ * the jail having no resolver. Measured on `namecheap`'s `validate_config`:
122
+ * `getaddrinfo ETIMEOUT dynamicdns.park-your-domain.com`, against an endpoint
123
+ * that was up and one the e2e topology answers for.
124
+ *
125
+ * This is not a widening of what a hook may reach. Design D12 already records
126
+ * as a residual that "a hook can still `fetch()` any HTTP endpoint directly;
127
+ * only `probeHttp` consults the target check" — so the network is already
128
+ * open, and a hook could always dial a literal IP. Withholding the resolver
129
+ * config did not close that door; it only made the door work for addresses and
130
+ * not for names, which is an accident rather than a policy.
131
+ *
132
+ * It sits beside `/etc/ssl` for the same reason that does: an outbound call
133
+ * needs a trust store AND a way to turn a name into an address, and binding
134
+ * one without the other leaves half a capability.
135
+ */
136
+ const RESOLVER_FILES = ['/etc/resolv.conf', '/etc/nsswitch.conf', '/etc/hosts'] as const;
137
+
113
138
  /**
114
139
  * Paths that must NEVER appear in a mount set, whatever asks for them.
115
140
  *
@@ -131,8 +156,27 @@ const RUNTIME_SUPPORT_DIRS = ['/usr/lib', '/lib', '/lib64', '/etc/ssl'] as const
131
156
  */
132
157
  const NEVER_MOUNT = ['/usr/bin/bwrap', '/usr/local/bin/bwrap', '/bin/bwrap'] as const;
133
158
 
134
- function entry(path: string, mode: MountMode, reason: string): MountEntry {
135
- return { path, mode, reason };
159
+ /**
160
+ * What a row's ABSENCE from the host means. Required, never defaulted: a default
161
+ * lets the next mount be added without deciding which kind it is, and that is
162
+ * exactly how the undifferentiated `skipped` list came to exist.
163
+ *
164
+ * bubblewrap fails the whole jail on a bind whose source is missing, so an
165
+ * absent row must be dropped. The question this answers is whether dropping it
166
+ * is fine, fatal, or nobody's business.
167
+ */
168
+ export type MountAbsence =
169
+ /** Fatal. The hook cannot do what it was asked without this. */
170
+ | 'required'
171
+ /** Fatal only for a module that declared it needs the facility. */
172
+ | 'declared-only'
173
+ /** Not reported. A genuinely needed one fails the runtime, which is louder. */
174
+ | 'runtime'
175
+ /** Expected at some lifecycle points. Debug at most. */
176
+ | 'conditional';
177
+
178
+ function entry(path: string, mode: MountMode, reason: string, absence: MountAbsence): MountEntry {
179
+ return { path, mode, reason, absence };
136
180
  }
137
181
 
138
182
  /**
@@ -159,18 +203,25 @@ export function deriveMountSet(request: MountSetRequest): MountSet {
159
203
  // silently an empty tmpfs directory writes into it, returns success, and
160
204
  // produces a backup containing NOTHING. It is found at restore. So the gate
161
205
  // on this asserts the artifact is non-empty, never that the hook exited zero.
162
- entries.push(entry('/tmp', 'tmpfs', 'private scratch, per run'));
206
+ entries.push(entry('/tmp', 'tmpfs', 'private scratch, per run', 'runtime'));
163
207
 
164
208
  // 2. The runtime. Without it nothing runs, so it is not really a policy row.
165
- entries.push(entry(request.runtimePath, 'ro', 'the interpreter'));
166
- entries.push(entry(dirname(request.runnerPath), 'ro', 'the runner shim celilo spawns'));
209
+ entries.push(entry(request.runtimePath, 'ro', 'the interpreter', 'required'));
210
+ entries.push(
211
+ entry(dirname(request.runnerPath), 'ro', 'the runner shim celilo spawns', 'required'),
212
+ );
167
213
  // See MountSetRequest.runtimeModulePaths. Without these the shim starts and
168
214
  // immediately dies on `Cannot find module`.
169
215
  for (const dir of request.runtimeModulePaths ?? []) {
170
- entries.push(entry(resolve(dir), 'ro', "celilo's own dependencies, which the shim imports"));
216
+ entries.push(
217
+ entry(resolve(dir), 'ro', "celilo's own dependencies, which the shim imports", 'required'),
218
+ );
171
219
  }
172
220
  for (const dir of RUNTIME_SUPPORT_DIRS) {
173
- entries.push(entry(dir, 'ro', 'shared libraries and trust store'));
221
+ entries.push(entry(dir, 'ro', 'shared libraries and trust store', 'runtime'));
222
+ }
223
+ for (const file of RESOLVER_FILES) {
224
+ entries.push(entry(file, 'ro', 'name resolution — see RESOLVER_FILES', 'runtime'));
174
225
  }
175
226
  // The fleet browser, read-only (task 4.10).
176
227
  //
@@ -199,7 +250,9 @@ export function deriveMountSet(request: MountSetRequest): MountSet {
199
250
  // the browser rather than here. That interim landed 2026-08-31 (celilo#1215):
200
251
  // the launch path in `test-fixtures/jail-toolchain-hook.ts` carries the flag,
201
252
  // and `jail-browser-launch-flags.test.ts` pins it there.
202
- entries.push(entry(BROWSER_ROOT, 'ro', 'the fleet browser, when one is provisioned'));
253
+ entries.push(
254
+ entry(BROWSER_ROOT, 'ro', 'the fleet browser, when one is provisioned', 'declared-only'),
255
+ );
203
256
 
204
257
  // 3. The module's own tree, read-only, then its writable directories carved
205
258
  // on top. bubblewrap resolves that in the right order, which is why the
@@ -208,19 +261,36 @@ export function deriveMountSet(request: MountSetRequest): MountSet {
208
261
  // D9 says "the module's own tree is bound read-only". These are the
209
262
  // carved exceptions to that sentence, and there are three of them rather
210
263
  // than the one D9's prose implies.
211
- entries.push(entry(modulePath, 'ro', "the module's own tree"));
264
+ entries.push(entry(modulePath, 'ro', "the module's own tree", 'required'));
212
265
  entries.push(
213
- entry(resolve(request.stateDir), 'rw', 'ctx.stateDir, the sanctioned writable directory'),
266
+ entry(
267
+ resolve(request.stateDir),
268
+ 'rw',
269
+ 'ctx.stateDir, the sanctioned writable directory',
270
+ 'required',
271
+ ),
214
272
  );
215
273
  entries.push(
216
- entry(join(modulePath, 'generated'), 'rw', "celilo's generated output the hook may amend"),
274
+ entry(
275
+ join(modulePath, 'generated'),
276
+ 'rw',
277
+ "celilo's generated output the hook may amend",
278
+ 'conditional',
279
+ ),
217
280
  );
218
281
  if (request.screenshotDir) {
219
- entries.push(entry(resolve(request.screenshotDir), 'rw', 'ctx.screenshotDir, this run only'));
282
+ entries.push(
283
+ entry(
284
+ resolve(request.screenshotDir),
285
+ 'rw',
286
+ 'ctx.screenshotDir, this run only',
287
+ 'conditional',
288
+ ),
289
+ );
220
290
  }
221
291
 
222
292
  // 4. The broker channel. Bound AFTER the tmpfs, per the note above.
223
- entries.push(entry(resolve(request.socketDir), 'rw', 'the capability broker socket'));
293
+ entries.push(entry(resolve(request.socketDir), 'rw', 'the capability broker socket', 'required'));
224
294
 
225
295
  // 5. Contract-declared path inputs, at the access the contract declares.
226
296
  // Never inferred from the name — see ContractField.path.
@@ -230,6 +300,10 @@ export function deriveMountSet(request: MountSetRequest): MountSet {
230
300
  resolve(input.value),
231
301
  input.access === 'write' ? 'rw' : 'ro',
232
302
  `contract input '${input.name}' (${input.access})`,
303
+ // Fatal, always. A hook whose declared WRITE path is dropped writes into
304
+ // the run's private tmpfs and reports success over a directory that is
305
+ // discarded when it exits. celilo#1248-adjacent; see jail.ts's note.
306
+ 'required',
233
307
  ),
234
308
  );
235
309
  }
@@ -270,3 +344,132 @@ export function toBwrapArgs(set: MountSet): string[] {
270
344
  args.push('--chdir', set.chdir);
271
345
  return args;
272
346
  }
347
+
348
+ /**
349
+ * Render a mount set as a `sandbox-exec` profile, in order (task 4.8).
350
+ *
351
+ * The SECOND renderer of the same derivation, alongside `toBwrapArgs`. That is
352
+ * the property task 4.7 asks for and the reason both live here: one
353
+ * computation, several consumers, so a macOS jail and a Linux jail cannot come
354
+ * to different conclusions about what a hook may see.
355
+ *
356
+ * **Order is semantic here for the same reason it is in `toBwrapArgs`, by a
357
+ * different mechanism.** SBPL is last-match-wins, so a read-write directory
358
+ * nested inside a read-only tree works exactly as bubblewrap's later-`--bind`-
359
+ * wins does. Measured 2026-08-27: with `state/` emitted after the module tree,
360
+ * a write to the tree gives `EPERM` and a write to `state/` succeeds.
361
+ *
362
+ * Three rules are not derived from the mount set, and each is a parity
363
+ * statement rather than a convenience:
364
+ *
365
+ * - `(import bsd.sb)` supplies what any process needs to start at all —
366
+ * the dyld shared cache, `file-read-metadata` for symlink traversal, the
367
+ * `logd`/`cfprefsd` lookups. Without it `bun` dies before `main` with no
368
+ * diagnostic (`SIGABRT`, no stderr, because stderr is denied too).
369
+ * - `(allow process*)` matches bubblewrap, which does not restrict `exec`
370
+ * either. A hook can run whatever it can READ, and what it can read is the
371
+ * mount set. Withholding the path is the boundary in both backends.
372
+ * - `(allow network*)` is D9: the network is not namespaced. D12 scopes
373
+ * reachability by withholding the credential, never by filtering packets.
374
+ *
375
+ * Everything else this profile does NOT say is deliberate. `/etc` is absent
376
+ * because it is absent from D9's table, so on macOS a hook that resolves a
377
+ * hostname is not stopped by THIS profile: `(allow network*)` lets it reach
378
+ * the system resolver, which answers out of process in mDNSResponder
379
+ * (measured 2026-08-30, task 4.13's suite). Withholding `/etc/resolv.conf`
380
+ * does withhold resolution on Linux, where the file is the resolver's
381
+ * configuration. Adding `/etc` here alone is the drift task 4.7 exists to
382
+ * prevent.
383
+ *
384
+ * @param set - Paths already resolved through `realpath`. Not optional: a rule
385
+ * naming an unresolved path does not match, and the failure is silent in
386
+ * both directions (D8). Measured: with the module tree named as `/tmp/…`
387
+ * rather than `/private/tmp/…` the rule does not apply, and bun cannot read
388
+ * the cwd it was handed.
389
+ */
390
+ export function toSandboxProfile(set: MountSet): string {
391
+ const lines = [
392
+ '(version 1)',
393
+ '(import "/System/Library/Sandbox/Profiles/bsd.sb")',
394
+ '(deny default)',
395
+ '(allow process*)',
396
+ '(allow network*)',
397
+ ];
398
+
399
+ // Every ANCESTOR of every mount, as a directory node and nothing more.
400
+ //
401
+ // This row has no bubblewrap counterpart and that is exactly why it exists.
402
+ // bubblewrap builds a new filesystem: to bind `/a/b/c` it must CREATE `/a/b`
403
+ // inside the namespace, so the parents come for free. `sandbox-exec` filters
404
+ // the tree that is already there and grants nothing implicitly, so every
405
+ // parent stays denied.
406
+ //
407
+ // What breaks is module resolution, and it breaks in a way that names none of
408
+ // this. Bun resolves a bare import by walking UP from the importing file
409
+ // testing each `<ancestor>/node_modules`. The shim's own `node_modules` is
410
+ // bound, but the directories BETWEEN are not, so the walk dies early, bun
411
+ // falls back to auto-install, and it tries to create `node_modules` in the
412
+ // read-only module tree. The message is `bun is unable to write files:
413
+ // PermissionDenied` — a write error for what is really a read denial three
414
+ // steps earlier. Measured 2026-08-27 by A/B on one variable: with a writable
415
+ // working directory the shim starts, with a read-only one it does not.
416
+ //
417
+ // `literal`, never `subpath`. A literal grant on a directory permits `stat`
418
+ // and `readdir` of that directory ALONE and confers nothing on the files in
419
+ // it. So `/var/celilo` becomes listable, which reveals that a file named
420
+ // `master.key` exists, and reading its bytes stays denied. That is the
421
+ // difference between D9's criterion holding and not, so do not "simplify"
422
+ // this to a subpath.
423
+ for (const path of ancestorsOf(set.entries)) {
424
+ lines.push(`(allow file-read* (literal ${sbplString(path)}))`);
425
+ }
426
+
427
+ for (const e of set.entries) {
428
+ // No tmpfs on macOS, and none is needed: `deny default` already makes the
429
+ // path unreadable, which is the privacy half of the row. The usability
430
+ // half — a working scratch directory — is what macOS does not get, so a
431
+ // hook writing to /tmp gets EPERM here and a discarded success on Linux.
432
+ // Louder than Linux rather than weaker, and named so nobody has to guess.
433
+ if (e.mode === 'tmpfs') {
434
+ lines.push(`; ${e.path}: no tmpfs backend; denied by default (${e.reason})`);
435
+ continue;
436
+ }
437
+ lines.push(`; ${e.reason}`);
438
+ if (e.mode === 'rw') {
439
+ lines.push(`(allow file-read* file-write* (subpath ${sbplString(e.path)}))`);
440
+ continue;
441
+ }
442
+ // The explicit deny makes `ro` mean read-only whatever preceded it, rather
443
+ // than relying on nothing earlier having granted write to a parent. That
444
+ // is true of today's derivation order and is not a property anyone should
445
+ // have to re-verify after editing it.
446
+ lines.push(`(deny file-write* (subpath ${sbplString(e.path)}))`);
447
+ lines.push(`(allow file-read* (subpath ${sbplString(e.path)}))`);
448
+ }
449
+
450
+ return `${lines.join('\n')}\n`;
451
+ }
452
+
453
+ /**
454
+ * Every directory strictly above one of these mounts, nearest-first order
455
+ * irrelevant, deduplicated. Excludes the mount paths themselves, which carry
456
+ * their own rules.
457
+ */
458
+ function ancestorsOf(entries: readonly MountEntry[]): string[] {
459
+ const own = new Set(entries.map((e) => e.path));
460
+ const found = new Set<string>();
461
+ for (const e of entries) {
462
+ let dir = dirname(e.path);
463
+ while (dir !== dirname(dir)) {
464
+ if (!own.has(dir)) found.add(dir);
465
+ dir = dirname(dir);
466
+ }
467
+ found.add('/');
468
+ }
469
+ return [...found];
470
+ }
471
+
472
+ /** A path as an SBPL string literal. */
473
+ function sbplString(path: string): string {
474
+ return `"${path.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
475
+ }
@@ -26,6 +26,7 @@ import {
26
26
  import { remoteAccessPolicy } from '../services/remote-access';
27
27
  import { loadCapabilityFunctions } from './capability-loader';
28
28
  import { invokeHook } from './executor';
29
+ import { createHookStores } from './hook-store';
29
30
  import { loadHookConfigMap } from './load-hook-config';
30
31
  import type { HookLogger, HookName, HookResult } from './types';
31
32
 
@@ -192,6 +193,7 @@ export async function runNamedHook(
192
193
  requiredCapabilities,
193
194
  systems: getModuleSystems(moduleId, db),
194
195
  remoteAccess: remoteAccessPolicy(moduleId, db),
196
+ hookStores: () => createHookStores(db, moduleId),
195
197
  timeoutMs: options.timeoutMs,
196
198
  },
197
199
  );
@@ -26,7 +26,7 @@ export default defineHook({
26
26
  hook: 'container_created',
27
27
  requires: [],
28
28
  handler: async (ctx) => {
29
- const config = ctx.config as {
29
+ const config = ctx.config as unknown as {
30
30
  planted_secret: string;
31
31
  sibling_file: string;
32
32
  staged_input: string;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Test fixture: an on_restore hook in the celilo-mgmt staging shape, minus
3
+ * everything the real one owns but the staging itself. Copies the artifact's
4
+ * celilo.db into restore_dir/system/ so the Phase 4 wrapper's
5
+ * applyStagedSystemFiles has something to swap.
6
+ */
7
+
8
+ import { cpSync, existsSync, mkdirSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { defineHook } from '@celilo/capabilities';
11
+
12
+ export default defineHook({
13
+ hook: 'on_restore',
14
+ requires: [],
15
+ optional: [],
16
+ handler: async (ctx) => {
17
+ const restoreDir = (ctx as unknown as { restore_dir: string }).restore_dir;
18
+ const systemDir = join(restoreDir, 'system');
19
+ mkdirSync(systemDir, { recursive: true });
20
+ const artifactDb = join(restoreDir, 'celilo.db');
21
+ if (existsSync(artifactDb)) {
22
+ cpSync(artifactDb, join(systemDir, 'celilo.db'));
23
+ }
24
+ return { restored_items: 1 };
25
+ },
26
+ });