@celilo/cli 1.14.0 → 2.1.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 (67) hide show
  1. package/CELILO_CORE_MODULES.md +2 -2
  2. package/CELILO_SUBSYSTEMS.md +26 -3
  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-publish.test.ts +18 -0
  8. package/src/cli/commands/alerts-sweep.ts +3 -0
  9. package/src/cli/commands/monitor.ts +15 -2
  10. package/src/cli/commands/system-doctor.test.ts +121 -1
  11. package/src/cli/commands/system-doctor.ts +151 -1
  12. package/src/cli/completion.ts +9 -2
  13. package/src/cli/index.ts +1 -1
  14. package/src/console/control-plane-boundary.test.ts +82 -4
  15. package/src/db/schema.ts +0 -1
  16. package/src/hooks/capability-loader.ts +15 -2
  17. package/src/hooks/executor.ts +116 -17
  18. package/src/hooks/hook-jail-toolchain-reach.test.ts +273 -0
  19. package/src/hooks/hook-jail-unreachability.test.ts +77 -26
  20. package/src/hooks/hook-protocol.ts +44 -0
  21. package/src/hooks/hook-runner-entry.ts +23 -0
  22. package/src/hooks/hook-runner.ts +10 -0
  23. package/src/hooks/hook-trespass.test.ts +74 -12
  24. package/src/hooks/jail-browser-launch-flags.test.ts +34 -0
  25. package/src/hooks/jail.test.ts +92 -0
  26. package/src/hooks/jail.ts +304 -32
  27. package/src/hooks/mount-set.test.ts +116 -7
  28. package/src/hooks/mount-set.ts +189 -20
  29. package/src/hooks/remote-broker.test.ts +350 -0
  30. package/src/hooks/remote-broker.ts +404 -0
  31. package/src/hooks/run-named-hook.ts +2 -0
  32. package/src/hooks/test-fixtures/jail-probe-hook.ts +14 -1
  33. package/src/hooks/test-fixtures/jail-toolchain-hook.ts +227 -0
  34. package/src/hooks/test-fixtures/remote-bridge-probe.ts +82 -0
  35. package/src/hooks/unjailed-lint.test.ts +254 -0
  36. package/src/hooks/unjailed-lint.ts +395 -0
  37. package/src/policy/module-business-baseline.ts +13 -1
  38. package/src/policy/module-script-scan.ts +60 -1
  39. package/src/policy/no-hand-built-ssh.test.ts +39 -1
  40. package/src/policy/no-module-business-in-core.test.ts +1 -1
  41. package/src/services/alerting/coverage-source.test.ts +86 -0
  42. package/src/services/alerting/coverage-source.ts +11 -1
  43. package/src/services/alerting/hook-jail.test.ts +66 -0
  44. package/src/services/alerting/hook-jail.ts +70 -0
  45. package/src/services/alerting/run-monitor.test.ts +62 -0
  46. package/src/services/alerting/run-monitor.ts +12 -0
  47. package/src/services/alerting/sweep-runner.test.ts +1 -0
  48. package/src/services/backup-create.ts +3 -0
  49. package/src/services/backup-restore.ts +2 -0
  50. package/src/services/control-plane-bootstrap.test.ts +177 -0
  51. package/src/services/control-plane-bootstrap.ts +176 -0
  52. package/src/services/control-plane-health.test.ts +66 -0
  53. package/src/services/control-plane-health.ts +67 -0
  54. package/src/services/deploy-ansible.ts +9 -1
  55. package/src/services/deployed-systems.ts +12 -0
  56. package/src/services/dns-discovery.test.ts +93 -0
  57. package/src/services/dns-discovery.ts +92 -0
  58. package/src/services/fleet-checks.ts +6 -2
  59. package/src/services/health-runner.ts +36 -3
  60. package/src/services/module-build.test.ts +1 -64
  61. package/src/services/module-build.ts +10 -86
  62. package/src/services/module-deploy.ts +71 -0
  63. package/src/services/remote-access.test.ts +223 -0
  64. package/src/services/remote-access.ts +149 -0
  65. package/src/services/restore-from-file.ts +6 -1
  66. package/src/services/static-content-converge.test.ts +338 -0
  67. package/src/services/static-content-converge.ts +299 -0
package/src/hooks/jail.ts CHANGED
@@ -21,25 +21,82 @@
21
21
  * reads.
22
22
  */
23
23
 
24
- import { execFileSync } from 'node:child_process';
24
+ import { execFileSync, spawnSync } from 'node:child_process';
25
25
  import type { Dirent } from 'node:fs';
26
26
  import {
27
27
  existsSync,
28
28
  mkdirSync,
29
+ mkdtempSync,
29
30
  readFileSync,
30
31
  readdirSync,
31
32
  realpathSync,
32
33
  renameSync,
34
+ rmSync,
33
35
  statSync,
34
36
  writeFileSync,
35
37
  } from 'node:fs';
36
- import { hostname } from 'node:os';
38
+ import { hostname, tmpdir } from 'node:os';
37
39
  import { dirname, join } from 'node:path';
38
40
  import { getDataDir } from '../config/paths';
39
- import { type MountSet, type MountSetRequest, toBwrapArgs } from './mount-set';
41
+ import { HOOK_SOCKET_ENV } from './hook-protocol';
42
+ import {
43
+ type MountSet,
44
+ type MountSetRequest,
45
+ deriveMountSet,
46
+ toBwrapArgs,
47
+ toSandboxProfile,
48
+ } from './mount-set';
49
+ import { JAIL_PROVIDED_PATHS } from './unjailed-lint';
40
50
 
41
- /** Which jail celilo can build here. `none` means the hook runs unjailed. */
42
- export type JailBackend = 'bubblewrap' | 'none';
51
+ /**
52
+ * Which jail celilo can build here. `none` means the hook runs unjailed.
53
+ *
54
+ * `sandbox-exec` is in the union ahead of its backend (task 4.8) because the
55
+ * POLICY for it is decided and landed first (ce-29z): `auto` defers on it
56
+ * until D14's declared-path mounts exist, and the planner below enforces
57
+ * that whether or not a platform reports the backend yet.
58
+ */
59
+ export type JailBackend = 'bubblewrap' | 'sandbox-exec' | 'none';
60
+
61
+ /**
62
+ * Whether `auto` may jail through `sandbox-exec`.
63
+ *
64
+ * False on purpose, and the load-bearing fact of ce-29z. Measured there,
65
+ * same hardware, one variable (`CELILO_HOOK_JAIL=off` equals main's
66
+ * behaviour): under bubblewrap a fixture hook's write to an undeclared host
67
+ * path lands in the private tmpfs (D13b) and succeeds silently, while under
68
+ * `sandbox-exec` the same write is `EPERM` — the hook exits non-zero and the
69
+ * full `apps/celilo` suite goes red on macOS where it is green on Linux CI.
70
+ * Sandbox-exec DENIES rather than masks, so the macOS jail is stricter than
71
+ * the Linux one for exactly the paths nobody declared.
72
+ *
73
+ * The mechanism that declares them is D14 (`type: path` on a manifest
74
+ * variable), and D14 is not built speculatively — task 4.2a measured the
75
+ * population and found no consumer that survives D9b. So `auto` defers on
76
+ * this backend until that mechanism lands; flip this to `true` as part of
77
+ * that landing, never before. `CELILO_HOOK_JAIL=required` bypasses the
78
+ * deferral: it is the operator's explicit act (D8), and an operator who sets
79
+ * it on macOS has opted into the strictness.
80
+ */
81
+ const SANDBOX_EXEC_AUTO_JAIL_ENABLED = false;
82
+
83
+ /**
84
+ * Why an `auto` sandbox-exec spawn was deferred. Operator-facing: it names
85
+ * the decision and the one way past it. Exported because `system doctor`
86
+ * renders the same deferral from (policy, availability) alone, where no plan
87
+ * — and so no plan reason — exists yet.
88
+ */
89
+ export const SANDBOX_EXEC_AUTO_DEFERRED_REASON =
90
+ 'macOS hooks run unjailed: sandbox-exec denies (EPERM) a hook write to any host path the mount set does not declare, where bubblewrap masks it into its private tmpfs — undeclared config paths would break module hooks on this host. The declaration mechanism (D14, manifest `type: path`) is not built, so auto defers on this backend (ce-29z). Set CELILO_HOOK_JAIL=required to jail anyway.';
91
+
92
+ /**
93
+ * Does `auto` defer on this backend? Shared by the planner and by
94
+ * `system doctor`, so the doctor cannot report a jail the executor will not
95
+ * build.
96
+ */
97
+ export function autoJailDefers(availability: JailAvailability): boolean {
98
+ return availability.backend === 'sandbox-exec' && !SANDBOX_EXEC_AUTO_JAIL_ENABLED;
99
+ }
43
100
 
44
101
  /**
45
102
  * What the operator asked for. `CELILO_HOOK_JAIL`, and the default is `auto`.
@@ -75,13 +132,25 @@ export interface JailPlan {
75
132
  *
76
133
  * bubblewrap fails the whole jail on a bind whose SOURCE is missing, and
77
134
  * several rows are legitimately absent: `/lib64` does not exist on arm64,
78
- * `<module>/generated` only appears once celilo has generated something, and
79
- * `~/.ssh` need not exist at all. Dropping them is the caller's job rather
135
+ * and `<module>/generated` only appears once celilo has generated
136
+ * something. Dropping them is the caller's job rather
80
137
  * than the derivation's, which is why they are reported rather than silently
81
138
  * filtered — a contract input landing here is a real defect and this is
82
139
  * where it becomes visible.
83
140
  */
84
141
  readonly skipped: readonly string[];
142
+ /**
143
+ * The directory to spawn the child in, when the backend cannot set it itself.
144
+ *
145
+ * bubblewrap takes `--chdir`, so its plans leave this absent and the child
146
+ * inherits celilo's cwd — which the jail has replaced anyway.
147
+ * `sandbox-exec` has no equivalent, so the child inherits the OPERATOR's cwd
148
+ * and the profile does not name it. Measured 2026-08-27: `bun` reads its
149
+ * working directory before running anything, so a cwd outside the profile
150
+ * kills it with `error: An unknown error occurred (Unexpected)` and no
151
+ * further diagnostic. Naming the mount set's `chdir` here is the fix.
152
+ */
153
+ readonly cwd?: string;
85
154
  }
86
155
 
87
156
  /**
@@ -104,7 +173,17 @@ export interface JailPlan {
104
173
  * `--new-session` is NOT here. It defends against TIOCSTI injection into a
105
174
  * controlling terminal, and the child is spawned with `stdout: 'pipe'` and no
106
175
  * tty, so there is nothing to inject into.
176
+ *
177
+ * The `--proc`/`--dev` pairs are BUILT from `JAIL_PROVIDED_PATHS`
178
+ * (unjailed-lint.ts), the same list the advisory lint reads, so the spawn and
179
+ * the lint cannot disagree about what the namespace itself provides. Adding a
180
+ * path to that constant without a flag here is a compile error.
107
181
  */
182
+ const JAIL_PROVIDED_FLAGS: Record<(typeof JAIL_PROVIDED_PATHS)[number], string> = {
183
+ '/proc': '--proc',
184
+ '/dev': '--dev',
185
+ };
186
+
108
187
  const JAIL_NAMESPACE_ARGS = [
109
188
  '--unshare-user',
110
189
  '--unshare-ipc',
@@ -113,10 +192,7 @@ const JAIL_NAMESPACE_ARGS = [
113
192
  // The one unshare that legitimately may be unavailable on an older kernel.
114
193
  '--unshare-cgroup-try',
115
194
  '--die-with-parent',
116
- '--proc',
117
- '/proc',
118
- '--dev',
119
- '/dev',
195
+ ...JAIL_PROVIDED_PATHS.flatMap((path) => [JAIL_PROVIDED_FLAGS[path], path]),
120
196
  ] as const;
121
197
 
122
198
  /**
@@ -131,6 +207,24 @@ const JAIL_NAMESPACE_ARGS = [
131
207
  */
132
208
  const BWRAP = 'bwrap';
133
209
 
210
+ /**
211
+ * The macOS jail builder.
212
+ *
213
+ * Pinned absolute, unlike `BWRAP`. There is no AppArmor-style profile to miss
214
+ * here, so `PATH` buys nothing and costs the one thing that matters: a
215
+ * `sandbox-exec` earlier on `PATH` would be handed every hook's profile and
216
+ * could report a jail it never built. The probe below would catch that, and
217
+ * pinning means it never has to.
218
+ */
219
+ const SANDBOX_EXEC = '/usr/bin/sandbox-exec';
220
+
221
+ /**
222
+ * The runner shim, named here as well as in the executor because the probe has
223
+ * to spawn the REAL one. A probe that starts a smaller program answers a
224
+ * question about a smaller program.
225
+ */
226
+ const HOOK_RUNNER_PATH = join(import.meta.dir, 'hook-runner.ts');
227
+
134
228
  /** How long to wait for the probe before calling the backend unavailable. */
135
229
  const PROBE_TIMEOUT_MS = 10_000;
136
230
 
@@ -158,17 +252,7 @@ export function detectJailBackend(): JailAvailability {
158
252
  }
159
253
 
160
254
  function probeJailBackend(): JailAvailability {
161
- if (process.platform === 'darwin') {
162
- // `sandbox-exec` is measured working (D8) but the backend that generates
163
- // its profile is task 4.8 and is not built. Claiming macOS is jailed
164
- // because the tool exists would be the fail-open jail D8 calls worse than
165
- // no jail at all.
166
- return {
167
- backend: 'none',
168
- reason:
169
- 'macOS has no hook jail yet: the sandbox-exec backend is designed but not built (hook-process-boundary task 4.8). Hooks run unjailed on this host.',
170
- };
171
- }
255
+ if (process.platform === 'darwin') return probeSandboxExec();
172
256
  if (process.platform !== 'linux') {
173
257
  return {
174
258
  backend: 'none',
@@ -188,6 +272,118 @@ function probeJailBackend(): JailAvailability {
188
272
  }
189
273
  }
190
274
 
275
+ /**
276
+ * What the probe plants and then tries to read. Its ABSENCE from the output is
277
+ * the evidence, so it is a string nothing else would print.
278
+ */
279
+ const SANDBOX_PROBE_MARKER = 'celilo-jail-probe-reached-the-planted-file';
280
+
281
+ /**
282
+ * Can this Mac jail? Measured by proving a DENIAL, never by finding the binary.
283
+ *
284
+ * This probe is shaped differently from bubblewrap's and the difference is the
285
+ * point. bubblewrap FAILS when it cannot build a namespace, loudly, with one of
286
+ * four distinct denials — so running it at all is the measurement.
287
+ * `sandbox-exec` does not fail. A profile it cannot apply to a path is simply
288
+ * not applied: the access succeeds and nothing anywhere reports an error (D8,
289
+ * measured, and re-measured 2026-08-27 in both directions — a rule naming
290
+ * `/tmp/x` where the kernel sees `/private/tmp/x` neither denies nor grants).
291
+ *
292
+ * So "sandbox-exec exists" is not evidence of anything, and neither is "the
293
+ * profile parsed". The only honest question is whether a file this probe
294
+ * planted, outside the profile, is actually unreachable from inside it. That is
295
+ * CLAUDE.md's reach probe applied to the jail itself: plant a marker, run the
296
+ * REAL machinery, and read which markers fired.
297
+ *
298
+ * Three outcomes, and the middle one is the one worth having:
299
+ *
300
+ * - the read was refused → `sandbox-exec`, the jail is real
301
+ * - the read SUCCEEDED → `none`, and the reason says fail-open.
302
+ * A jail that is believed and absent is worse than no jail (D8).
303
+ * - `sandbox-exec` never ran → `none`, with its own message
304
+ */
305
+ function probeSandboxExec(): JailAvailability {
306
+ let scratch: string | undefined;
307
+ try {
308
+ scratch = mkdtempSync(join(realpathOrSelf(tmpdir()), 'celilo-jail-probe-'));
309
+ const allowed = join(scratch, 'allowed');
310
+ const planted = join(scratch, 'planted');
311
+ mkdirSync(join(allowed, 'state'), { recursive: true });
312
+ writeFileSync(planted, SANDBOX_PROBE_MARKER);
313
+
314
+ // The REAL derivation, the REAL renderer, and the REAL shim path, so this
315
+ // clears the same bar the spawn does. Every shortcut here has already been
316
+ // measured to matter: a mount set built around `bun -e` omits the shim's
317
+ // `node_modules` rows, and the runtime then starts under the probe's
318
+ // profile and dies under the executor's.
319
+ const profile = toSandboxProfile(
320
+ deriveMountSet({
321
+ modulePath: allowed,
322
+ stateDir: join(allowed, 'state'),
323
+ socketDir: allowed,
324
+ runtimePath: process.execPath,
325
+ runnerPath: HOOK_RUNNER_PATH,
326
+ runtimeModulePaths: runtimeModulePathsFor(HOOK_RUNNER_PATH),
327
+ pathInputs: [],
328
+ }),
329
+ );
330
+ const run = (cmd: readonly string[]) =>
331
+ spawnSync(SANDBOX_EXEC, ['-p', profile, '--', ...cmd], {
332
+ cwd: allowed,
333
+ encoding: 'utf-8',
334
+ timeout: PROBE_TIMEOUT_MS,
335
+ });
336
+
337
+ // 1. Can the RUNTIME start? Spawn the actual shim with no socket in its
338
+ // environment: it refuses immediately with a known message, having
339
+ // already loaded itself, `@celilo/capabilities` and Zod. That is the
340
+ // whole of the startup path, and it is where a jail that denies the
341
+ // runtime something it needs shows up.
342
+ const started = run([process.execPath, HOOK_RUNNER_PATH]);
343
+ if (started.error) throw started.error;
344
+ if (!started.stderr?.includes(HOOK_SOCKET_ENV)) {
345
+ return {
346
+ backend: 'none',
347
+ reason: `sandbox-exec built a jail the hook runner cannot start in, so hooks run unjailed rather than failing one at a time. The runner exited ${started.status} saying: ${(started.stderr || started.stdout || '').trim().split('\n')[0] || '(nothing)'}`,
348
+ };
349
+ }
350
+
351
+ // 2. Does a DENIAL actually apply? `sandbox-exec` never fails on a rule it
352
+ // cannot match — the access simply succeeds and nothing reports an
353
+ // error (D8, measured). So the profile is not evidence; a file this
354
+ // probe planted outside the mount set, and could not read, is.
355
+ const denied = run([
356
+ process.execPath,
357
+ '-e',
358
+ `try{require('node:fs').readFileSync(${JSON.stringify(planted)});console.log('${SANDBOX_PROBE_MARKER}')}catch{console.log('refused')}`,
359
+ ]);
360
+ if (denied.error) throw denied.error;
361
+ if ((denied.stdout ?? '').includes(SANDBOX_PROBE_MARKER)) {
362
+ return {
363
+ backend: 'none',
364
+ reason:
365
+ 'sandbox-exec ran but did not enforce the profile: a file outside the mount set was still readable. Hooks run unjailed rather than appearing jailed and not being, which is the one outcome worse than no jail. This is usually an unresolved path in a rule — every path must be realpath()d before it is written.',
366
+ };
367
+ }
368
+
369
+ return { backend: 'sandbox-exec' };
370
+ } catch (error) {
371
+ const spawnFailure = (error as { code?: string } | null)?.code;
372
+ if (spawnFailure === 'ENOENT') {
373
+ return {
374
+ backend: 'none',
375
+ reason: `${SANDBOX_EXEC} is not on this macOS, so hooks run unjailed. It has carried a deprecation notice since 10.8; if Apple has removed it, this host has no hook jail backend.`,
376
+ };
377
+ }
378
+ return {
379
+ backend: 'none',
380
+ reason: `sandbox-exec could not run the probe, so hooks run unjailed: ${error instanceof Error ? error.message : String(error)}`,
381
+ };
382
+ } finally {
383
+ if (scratch) rmSync(scratch, { recursive: true, force: true });
384
+ }
385
+ }
386
+
191
387
  /**
192
388
  * Turn the probe's failure into something an operator can act on.
193
389
  *
@@ -258,7 +454,6 @@ export function realpathRequest(request: MountSetRequest): MountSetRequest {
258
454
  ...input,
259
455
  value: realpathOrSelf(input.value),
260
456
  })),
261
- sshDir: request.sshDir ? realpathOrSelf(request.sshDir) : undefined,
262
457
  };
263
458
  }
264
459
 
@@ -362,6 +557,14 @@ function realpathOrSelf(path: string): string {
362
557
  * @param set - The derived mount set, or `undefined` when the caller has no
363
558
  * module tree to jail (the executor's own fixtures, and the bus handler path).
364
559
  */
560
+ /**
561
+ * Why an `off` policy runs unjailed, shared with `celilo system doctor` (task
562
+ * 4.6) so the sentence the operator reads there is the sentence the record
563
+ * carries.
564
+ */
565
+ export const JAIL_OFF_REASON =
566
+ 'CELILO_HOOK_JAIL=off: the operator switched the hook jail off on this host.';
567
+
365
568
  export function planJailedSpawn(
366
569
  cmd: readonly string[],
367
570
  set: MountSet | undefined,
@@ -374,7 +577,7 @@ export function planJailedSpawn(
374
577
  cmd,
375
578
  mode: 'unjailed',
376
579
  backend: availability.backend,
377
- reason: 'CELILO_HOOK_JAIL=off: the operator switched the hook jail off on this host.',
580
+ reason: JAIL_OFF_REASON,
378
581
  skipped: [],
379
582
  };
380
583
  }
@@ -391,18 +594,49 @@ export function planJailedSpawn(
391
594
  return { cmd, mode: 'unjailed', backend: availability.backend, reason, skipped: [] };
392
595
  }
393
596
 
597
+ // ce-29z: auto defers on sandbox-exec until D14 exists. Not an availability
598
+ // question — the backend is there; it is a policy one, which is why this
599
+ // sits in the planner rather than the probe.
600
+ if (policy === 'auto' && autoJailDefers(availability)) {
601
+ return {
602
+ cmd,
603
+ mode: 'unjailed',
604
+ backend: availability.backend,
605
+ reason: SANDBOX_EXEC_AUTO_DEFERRED_REASON,
606
+ skipped: [],
607
+ };
608
+ }
609
+
394
610
  // A tmpfs needs no source — bubblewrap creates it — so it is never dropped.
611
+ // The note below (ce-29z, 2026-09) is why 'required' is the only policy that
612
+ // reaches the sandbox-exec arm while SANDBOX_EXEC_AUTO_JAIL_ENABLED is
613
+ // false: the flag flip is D14's landing, never 4.8's.
395
614
  const present = set.entries.filter((e) => e.mode === 'tmpfs' || exists(e.path));
396
615
  const skipped = set.entries.filter((e) => !present.includes(e)).map((e) => e.path);
616
+ const applied: MountSet = { ...set, entries: present };
617
+
618
+ // Dropping an absent path is a bubblewrap NEED — it fails the whole jail on a
619
+ // bind with no source — and is merely tidy for `sandbox-exec`, which happily
620
+ // names a path that is not there. Both backends drop the same rows anyway,
621
+ // because a mount set that means two things on two platforms is exactly the
622
+ // drift task 4.7 exists to prevent.
623
+ if (availability.backend === 'sandbox-exec') {
624
+ // `-p` rather than a profile FILE: nothing to create, nothing to clean
625
+ // up, and nothing whose own readability has to be reasoned about.
626
+ // `sandbox-exec` reads the profile before it applies the sandbox, so a
627
+ // file would not have needed to be in the mount set — but it would have
628
+ // needed a lifetime, and this has none.
629
+ return {
630
+ cmd: [SANDBOX_EXEC, '-p', toSandboxProfile(applied), '--', ...cmd],
631
+ mode: 'jailed',
632
+ backend: availability.backend,
633
+ skipped,
634
+ cwd: set.chdir,
635
+ };
636
+ }
397
637
 
398
638
  return {
399
- cmd: [
400
- BWRAP,
401
- ...JAIL_NAMESPACE_ARGS,
402
- ...toBwrapArgs({ ...set, entries: present }),
403
- '--',
404
- ...cmd,
405
- ],
639
+ cmd: [BWRAP, ...JAIL_NAMESPACE_ARGS, ...toBwrapArgs(applied), '--', ...cmd],
406
640
  mode: 'jailed',
407
641
  backend: availability.backend,
408
642
  skipped,
@@ -423,6 +657,20 @@ export interface JailModeRecord {
423
657
  */
424
658
  readonly host: string;
425
659
  readonly recordedAt: string;
660
+ /**
661
+ * The jailed record this host most recently held, present only on an
662
+ * `unjailed` record that replaced one on the SAME host.
663
+ *
664
+ * This is what makes the jailed-to-unjailed transition durable. The write
665
+ * below overwrites the previous record, so without this field the file for
666
+ * a host that used to jail and has stopped reads identically to a Mac that
667
+ * never jailed — and the self-monitor (task 4.4) runs on a sweep, long
668
+ * after `recordJailMode`'s return value is gone.
669
+ */
670
+ readonly lastJailed?: {
671
+ readonly backend: JailBackend;
672
+ readonly recordedAt: string;
673
+ };
426
674
  }
427
675
 
428
676
  /**
@@ -457,12 +705,14 @@ export function readJailMode(): JailModeRecord | undefined {
457
705
  */
458
706
  export function recordJailMode(plan: JailPlan): { previous?: JailModeRecord } {
459
707
  const previous = readJailMode();
708
+ const host = hostname();
460
709
  const record: JailModeRecord = {
461
710
  mode: plan.mode,
462
711
  backend: plan.backend,
463
712
  ...(plan.reason ? { reason: plan.reason } : {}),
464
- host: hostname(),
713
+ host,
465
714
  recordedAt: new Date().toISOString(),
715
+ ...(lastJailedFor(plan.mode, previous, host) ?? {}),
466
716
  };
467
717
 
468
718
  if (
@@ -489,3 +739,25 @@ export function recordJailMode(plan: JailPlan): { previous?: JailModeRecord } {
489
739
  }
490
740
  return { previous };
491
741
  }
742
+
743
+ /**
744
+ * What an unjailed record remembers about the last time this host jailed.
745
+ *
746
+ * A record from a DIFFERENT host contributes nothing: that is the
747
+ * copied-between-boxes case the `host` field exists for, and reading it as a
748
+ * transition would alert on a move that regressed nothing. A jailed record
749
+ * remembers nothing either — re-jailing is the recovery, and a later
750
+ * regression should date from the NEW jailed record, not the first ever.
751
+ */
752
+ function lastJailedFor(
753
+ mode: JailMode,
754
+ previous: JailModeRecord | undefined,
755
+ host: string,
756
+ ): Pick<JailModeRecord, 'lastJailed'> | undefined {
757
+ if (mode !== 'unjailed' || !previous || previous.host !== host) return undefined;
758
+ if (previous.mode === 'jailed') {
759
+ return { lastJailed: { backend: previous.backend, recordedAt: previous.recordedAt } };
760
+ }
761
+ // unjailed → unjailed (a backend or reason change): the memory rides along.
762
+ return previous.lastJailed ? { lastJailed: previous.lastJailed } : undefined;
763
+ }
@@ -7,7 +7,14 @@
7
7
  */
8
8
 
9
9
  import { describe, expect, test } from 'bun:test';
10
- import { deriveMountSet, forbiddenPaths, isForbidden, toBwrapArgs } from './mount-set';
10
+ import { BROWSER_ROOT } from '@celilo/capabilities';
11
+ import {
12
+ deriveMountSet,
13
+ forbiddenPaths,
14
+ isForbidden,
15
+ toBwrapArgs,
16
+ toSandboxProfile,
17
+ } from './mount-set';
11
18
 
12
19
  const BASE = {
13
20
  modulePath: '/var/celilo/modules/caddy',
@@ -136,13 +143,115 @@ describe('paths are identical inside and outside', () => {
136
143
  });
137
144
  });
138
145
 
139
- describe('~/.ssh is stage 2 only', () => {
140
- test('bound read-only when supplied', () => {
141
- const set = deriveMountSet({ ...BASE, sshDir: '/var/celilo/.ssh' });
142
- expect(set.entries.find((e) => e.path === '/var/celilo/.ssh')?.mode).toBe('ro');
146
+ describe('~/.ssh is never in the mount set (stage 3, D12)', () => {
147
+ test('no derivation produces an .ssh row', () => {
148
+ // Withholding the key is what turns the remote-ops broker's target check
149
+ // from a convention into a boundary: with no credential in the jail, a
150
+ // hand-built `ssh` cannot authenticate anywhere.
151
+ expect(pathsOf(BASE).some((p) => p.endsWith('/.ssh'))).toBe(false);
143
152
  });
153
+ });
144
154
 
145
- test('absent when not supplied, which is what stage 3 does', () => {
146
- expect(pathsOf(BASE).some((p) => p.endsWith('/.ssh'))).toBe(false);
155
+ describe('the fleet browser is reachable, and only read-only (task 4.10)', () => {
156
+ test('BROWSER_ROOT is bound', () => {
157
+ // Task 4.10 names `~/.cache/ms-playwright`. That path is stale:
158
+ // `managed-browser-runtime` moved the browser into a celilo-owned tree,
159
+ // and `resolveBrowser()` — the thing a hook actually asks — returns
160
+ // `BROWSER_EXECUTABLE_PATH` under this root. Asserted against the exported
161
+ // constant rather than a literal, so a future move cannot leave this test
162
+ // passing about a directory nothing launches from.
163
+ expect(pathsOf(BASE)).toContain(BROWSER_ROOT);
164
+ });
165
+
166
+ test('read-only, because a hook has no business writing the shared install', () => {
167
+ expect(deriveMountSet(BASE).entries.find((e) => e.path === BROWSER_ROOT)?.mode).toBe('ro');
168
+ });
169
+
170
+ test('the data directory beside it stays out', () => {
171
+ // The row is one named subdirectory, not `/var/lib/celilo`. Binding the
172
+ // parent would put `celilo.db` and `master.key` back inside the jail —
173
+ // the exact acceptance criterion D9 satisfies by absence.
174
+ expect(pathsOf(BASE)).not.toContain('/var/lib/celilo');
175
+ });
176
+ });
177
+
178
+ describe('the sandbox-exec profile renders the same set (task 4.8)', () => {
179
+ const profileOf = (r: Parameters<typeof deriveMountSet>[0] = BASE) =>
180
+ toSandboxProfile(deriveMountSet(r));
181
+
182
+ test('a read-only mount is denied write and allowed read, in that order', () => {
183
+ const lines = profileOf().split('\n');
184
+ const deny = lines.indexOf(`(deny file-write* (subpath "${BASE.modulePath}"))`);
185
+ const allow = lines.indexOf(`(allow file-read* (subpath "${BASE.modulePath}"))`);
186
+ expect(deny).toBeGreaterThan(-1);
187
+ // SBPL is last-match-wins, so the deny must come FIRST or it would revoke
188
+ // the read it is paired with.
189
+ expect(allow).toBeGreaterThan(deny);
190
+ });
191
+
192
+ test('a writable directory nested in the read-only tree comes after it', () => {
193
+ const lines = profileOf().split('\n');
194
+ expect(
195
+ lines.indexOf(`(allow file-read* file-write* (subpath "${BASE.stateDir}"))`),
196
+ ).toBeGreaterThan(lines.indexOf(`(allow file-read* (subpath "${BASE.modulePath}"))`));
197
+ });
198
+
199
+ test('the tmpfs row grants nothing: macOS has no tmpfs and deny-default covers it', () => {
200
+ // The row still appears, as a comment, so a reader of the profile can see
201
+ // that the derivation asked for something this backend cannot give.
202
+ const profile = profileOf();
203
+ expect(profile).toContain('; /tmp: no tmpfs backend');
204
+ expect(profile).not.toContain('(subpath "/tmp")');
205
+ });
206
+
207
+ test('every ancestor is a literal, never a subpath', () => {
208
+ // A `subpath` grant on an ancestor would expose everything beneath it —
209
+ // `/var/celilo` holds master.key. `literal` permits stat and readdir of the
210
+ // directory node alone. This is the difference between D9's acceptance
211
+ // criterion holding and not.
212
+ const profile = profileOf();
213
+ expect(profile).toContain('(allow file-read* (literal "/var/celilo"))');
214
+ expect(profile).not.toContain('(allow file-read* (subpath "/var/celilo"))');
215
+ expect(profile).not.toContain('(subpath "/"))');
216
+ });
217
+
218
+ test('a forbidden path never reaches the profile', () => {
219
+ const profile = profileOf({
220
+ ...BASE,
221
+ pathInputs: [{ name: 'evil', value: '/usr/bin/bwrap', access: 'write' as const }],
222
+ });
223
+ expect(profile).not.toContain('(subpath "/usr/bin/bwrap")');
224
+ });
225
+
226
+ test('a quote in a path cannot end the rule early', () => {
227
+ const profile = profileOf({ ...BASE, modulePath: '/var/celilo/modules/od"d' });
228
+ expect(profile).toContain('(subpath "/var/celilo/modules/od\\"d")');
229
+ });
230
+
231
+ test('the network is allowed, because D9 does not namespace it', () => {
232
+ expect(profileOf()).toContain('(allow network*)');
233
+ });
234
+ });
235
+
236
+ describe('name resolution inside the jail (celilo#1225)', () => {
237
+ test('the resolver config is bound read-only', () => {
238
+ // Without these a hook resolves no NAME. getaddrinfo finds no nameserver
239
+ // and the call dies as ETIMEOUT, which reads as the remote endpoint being
240
+ // down. Measured on namecheap's validate_config against an endpoint that
241
+ // was up.
242
+ const set = deriveMountSet(BASE);
243
+ for (const path of ['/etc/resolv.conf', '/etc/nsswitch.conf', '/etc/hosts']) {
244
+ const row = set.entries.find((e) => e.path === path);
245
+ expect(row).toBeDefined();
246
+ expect(row?.mode).toBe('ro');
247
+ }
248
+ });
249
+
250
+ test('binding the resolver does not bind the rest of /etc', () => {
251
+ // The acceptance criterion for this jail is absence. Naming three files
252
+ // must not become naming a directory.
253
+ const set = deriveMountSet(BASE);
254
+ expect(set.entries.some((e) => e.path === '/etc')).toBe(false);
255
+ expect(set.entries.some((e) => e.path === '/etc/shadow')).toBe(false);
147
256
  });
148
257
  });