@celilo/cli 2.0.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.
package/src/hooks/jail.ts CHANGED
@@ -21,22 +21,31 @@
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';
40
49
  import { JAIL_PROVIDED_PATHS } from './unjailed-lint';
41
50
 
42
51
  /**
@@ -130,6 +139,18 @@ export interface JailPlan {
130
139
  * where it becomes visible.
131
140
  */
132
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;
133
154
  }
134
155
 
135
156
  /**
@@ -186,6 +207,24 @@ const JAIL_NAMESPACE_ARGS = [
186
207
  */
187
208
  const BWRAP = 'bwrap';
188
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
+
189
228
  /** How long to wait for the probe before calling the backend unavailable. */
190
229
  const PROBE_TIMEOUT_MS = 10_000;
191
230
 
@@ -213,17 +252,7 @@ export function detectJailBackend(): JailAvailability {
213
252
  }
214
253
 
215
254
  function probeJailBackend(): JailAvailability {
216
- if (process.platform === 'darwin') {
217
- // `sandbox-exec` is measured working (D8) but the backend that generates
218
- // its profile is task 4.8 and is not built. Claiming macOS is jailed
219
- // because the tool exists would be the fail-open jail D8 calls worse than
220
- // no jail at all.
221
- return {
222
- backend: 'none',
223
- reason:
224
- '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.',
225
- };
226
- }
255
+ if (process.platform === 'darwin') return probeSandboxExec();
227
256
  if (process.platform !== 'linux') {
228
257
  return {
229
258
  backend: 'none',
@@ -243,6 +272,118 @@ function probeJailBackend(): JailAvailability {
243
272
  }
244
273
  }
245
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
+
246
387
  /**
247
388
  * Turn the probe's failure into something an operator can act on.
248
389
  *
@@ -467,21 +608,35 @@ export function planJailedSpawn(
467
608
  }
468
609
 
469
610
  // A tmpfs needs no source — bubblewrap creates it — so it is never dropped.
470
- // @psbanka-agent note (ce-29z, 2026-09): with SANDBOX_EXEC_AUTO_JAIL_ENABLED
471
- // still false, the only way to reach this line with a sandbox-exec
472
- // availability is policy 'required', and task 4.8 owns the spawn arm that
473
- // makes that real. Until it lands, do not flip the flag.
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.
474
614
  const present = set.entries.filter((e) => e.mode === 'tmpfs' || exists(e.path));
475
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
+ }
476
637
 
477
638
  return {
478
- cmd: [
479
- BWRAP,
480
- ...JAIL_NAMESPACE_ARGS,
481
- ...toBwrapArgs({ ...set, entries: present }),
482
- '--',
483
- ...cmd,
484
- ],
639
+ cmd: [BWRAP, ...JAIL_NAMESPACE_ARGS, ...toBwrapArgs(applied), '--', ...cmd],
485
640
  mode: 'jailed',
486
641
  backend: availability.backend,
487
642
  skipped,
@@ -8,7 +8,13 @@
8
8
 
9
9
  import { describe, expect, test } from 'bun:test';
10
10
  import { BROWSER_ROOT } from '@celilo/capabilities';
11
- import { deriveMountSet, forbiddenPaths, isForbidden, toBwrapArgs } from './mount-set';
11
+ import {
12
+ deriveMountSet,
13
+ forbiddenPaths,
14
+ isForbidden,
15
+ toBwrapArgs,
16
+ toSandboxProfile,
17
+ } from './mount-set';
12
18
 
13
19
  const BASE = {
14
20
  modulePath: '/var/celilo/modules/caddy',
@@ -168,3 +174,84 @@ describe('the fleet browser is reachable, and only read-only (task 4.10)', () =>
168
174
  expect(pathsOf(BASE)).not.toContain('/var/lib/celilo');
169
175
  });
170
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);
256
+ });
257
+ });
@@ -110,6 +110,29 @@ export interface MountSetRequest {
110
110
  /** Directories whose contents the runtime needs in order to start at all. */
111
111
  const RUNTIME_SUPPORT_DIRS = ['/usr/lib', '/lib', '/lib64', '/etc/ssl'] as const;
112
112
 
113
+ /**
114
+ * What resolving a hostname needs. Read-only, and absent ones are dropped.
115
+ *
116
+ * Without these a jailed hook cannot resolve a NAME. `getaddrinfo` finds no
117
+ * nameserver, falls back to a loopback that answers nothing, and the call dies
118
+ * as `ETIMEOUT` — which reads as the remote endpoint being down rather than as
119
+ * the jail having no resolver. Measured on `namecheap`'s `validate_config`:
120
+ * `getaddrinfo ETIMEOUT dynamicdns.park-your-domain.com`, against an endpoint
121
+ * that was up and one the e2e topology answers for.
122
+ *
123
+ * This is not a widening of what a hook may reach. Design D12 already records
124
+ * as a residual that "a hook can still `fetch()` any HTTP endpoint directly;
125
+ * only `probeHttp` consults the target check" — so the network is already
126
+ * open, and a hook could always dial a literal IP. Withholding the resolver
127
+ * config did not close that door; it only made the door work for addresses and
128
+ * not for names, which is an accident rather than a policy.
129
+ *
130
+ * It sits beside `/etc/ssl` for the same reason that does: an outbound call
131
+ * needs a trust store AND a way to turn a name into an address, and binding
132
+ * one without the other leaves half a capability.
133
+ */
134
+ const RESOLVER_FILES = ['/etc/resolv.conf', '/etc/nsswitch.conf', '/etc/hosts'] as const;
135
+
113
136
  /**
114
137
  * Paths that must NEVER appear in a mount set, whatever asks for them.
115
138
  *
@@ -172,6 +195,9 @@ export function deriveMountSet(request: MountSetRequest): MountSet {
172
195
  for (const dir of RUNTIME_SUPPORT_DIRS) {
173
196
  entries.push(entry(dir, 'ro', 'shared libraries and trust store'));
174
197
  }
198
+ for (const file of RESOLVER_FILES) {
199
+ entries.push(entry(file, 'ro', 'name resolution — see RESOLVER_FILES'));
200
+ }
175
201
  // The fleet browser, read-only (task 4.10).
176
202
  //
177
203
  // `BROWSER_ROOT` rather than `~/.cache/ms-playwright`, which is what task 4.10
@@ -270,3 +296,132 @@ export function toBwrapArgs(set: MountSet): string[] {
270
296
  args.push('--chdir', set.chdir);
271
297
  return args;
272
298
  }
299
+
300
+ /**
301
+ * Render a mount set as a `sandbox-exec` profile, in order (task 4.8).
302
+ *
303
+ * The SECOND renderer of the same derivation, alongside `toBwrapArgs`. That is
304
+ * the property task 4.7 asks for and the reason both live here: one
305
+ * computation, several consumers, so a macOS jail and a Linux jail cannot come
306
+ * to different conclusions about what a hook may see.
307
+ *
308
+ * **Order is semantic here for the same reason it is in `toBwrapArgs`, by a
309
+ * different mechanism.** SBPL is last-match-wins, so a read-write directory
310
+ * nested inside a read-only tree works exactly as bubblewrap's later-`--bind`-
311
+ * wins does. Measured 2026-08-27: with `state/` emitted after the module tree,
312
+ * a write to the tree gives `EPERM` and a write to `state/` succeeds.
313
+ *
314
+ * Three rules are not derived from the mount set, and each is a parity
315
+ * statement rather than a convenience:
316
+ *
317
+ * - `(import bsd.sb)` supplies what any process needs to start at all —
318
+ * the dyld shared cache, `file-read-metadata` for symlink traversal, the
319
+ * `logd`/`cfprefsd` lookups. Without it `bun` dies before `main` with no
320
+ * diagnostic (`SIGABRT`, no stderr, because stderr is denied too).
321
+ * - `(allow process*)` matches bubblewrap, which does not restrict `exec`
322
+ * either. A hook can run whatever it can READ, and what it can read is the
323
+ * mount set. Withholding the path is the boundary in both backends.
324
+ * - `(allow network*)` is D9: the network is not namespaced. D12 scopes
325
+ * reachability by withholding the credential, never by filtering packets.
326
+ *
327
+ * Everything else this profile does NOT say is deliberate. `/etc` is absent
328
+ * because it is absent from D9's table, so on macOS a hook that resolves a
329
+ * hostname is not stopped by THIS profile: `(allow network*)` lets it reach
330
+ * the system resolver, which answers out of process in mDNSResponder
331
+ * (measured 2026-08-30, task 4.13's suite). Withholding `/etc/resolv.conf`
332
+ * does withhold resolution on Linux, where the file is the resolver's
333
+ * configuration. Adding `/etc` here alone is the drift task 4.7 exists to
334
+ * prevent.
335
+ *
336
+ * @param set - Paths already resolved through `realpath`. Not optional: a rule
337
+ * naming an unresolved path does not match, and the failure is silent in
338
+ * both directions (D8). Measured: with the module tree named as `/tmp/…`
339
+ * rather than `/private/tmp/…` the rule does not apply, and bun cannot read
340
+ * the cwd it was handed.
341
+ */
342
+ export function toSandboxProfile(set: MountSet): string {
343
+ const lines = [
344
+ '(version 1)',
345
+ '(import "/System/Library/Sandbox/Profiles/bsd.sb")',
346
+ '(deny default)',
347
+ '(allow process*)',
348
+ '(allow network*)',
349
+ ];
350
+
351
+ // Every ANCESTOR of every mount, as a directory node and nothing more.
352
+ //
353
+ // This row has no bubblewrap counterpart and that is exactly why it exists.
354
+ // bubblewrap builds a new filesystem: to bind `/a/b/c` it must CREATE `/a/b`
355
+ // inside the namespace, so the parents come for free. `sandbox-exec` filters
356
+ // the tree that is already there and grants nothing implicitly, so every
357
+ // parent stays denied.
358
+ //
359
+ // What breaks is module resolution, and it breaks in a way that names none of
360
+ // this. Bun resolves a bare import by walking UP from the importing file
361
+ // testing each `<ancestor>/node_modules`. The shim's own `node_modules` is
362
+ // bound, but the directories BETWEEN are not, so the walk dies early, bun
363
+ // falls back to auto-install, and it tries to create `node_modules` in the
364
+ // read-only module tree. The message is `bun is unable to write files:
365
+ // PermissionDenied` — a write error for what is really a read denial three
366
+ // steps earlier. Measured 2026-08-27 by A/B on one variable: with a writable
367
+ // working directory the shim starts, with a read-only one it does not.
368
+ //
369
+ // `literal`, never `subpath`. A literal grant on a directory permits `stat`
370
+ // and `readdir` of that directory ALONE and confers nothing on the files in
371
+ // it. So `/var/celilo` becomes listable, which reveals that a file named
372
+ // `master.key` exists, and reading its bytes stays denied. That is the
373
+ // difference between D9's criterion holding and not, so do not "simplify"
374
+ // this to a subpath.
375
+ for (const path of ancestorsOf(set.entries)) {
376
+ lines.push(`(allow file-read* (literal ${sbplString(path)}))`);
377
+ }
378
+
379
+ for (const e of set.entries) {
380
+ // No tmpfs on macOS, and none is needed: `deny default` already makes the
381
+ // path unreadable, which is the privacy half of the row. The usability
382
+ // half — a working scratch directory — is what macOS does not get, so a
383
+ // hook writing to /tmp gets EPERM here and a discarded success on Linux.
384
+ // Louder than Linux rather than weaker, and named so nobody has to guess.
385
+ if (e.mode === 'tmpfs') {
386
+ lines.push(`; ${e.path}: no tmpfs backend; denied by default (${e.reason})`);
387
+ continue;
388
+ }
389
+ lines.push(`; ${e.reason}`);
390
+ if (e.mode === 'rw') {
391
+ lines.push(`(allow file-read* file-write* (subpath ${sbplString(e.path)}))`);
392
+ continue;
393
+ }
394
+ // The explicit deny makes `ro` mean read-only whatever preceded it, rather
395
+ // than relying on nothing earlier having granted write to a parent. That
396
+ // is true of today's derivation order and is not a property anyone should
397
+ // have to re-verify after editing it.
398
+ lines.push(`(deny file-write* (subpath ${sbplString(e.path)}))`);
399
+ lines.push(`(allow file-read* (subpath ${sbplString(e.path)}))`);
400
+ }
401
+
402
+ return `${lines.join('\n')}\n`;
403
+ }
404
+
405
+ /**
406
+ * Every directory strictly above one of these mounts, nearest-first order
407
+ * irrelevant, deduplicated. Excludes the mount paths themselves, which carry
408
+ * their own rules.
409
+ */
410
+ function ancestorsOf(entries: readonly MountEntry[]): string[] {
411
+ const own = new Set(entries.map((e) => e.path));
412
+ const found = new Set<string>();
413
+ for (const e of entries) {
414
+ let dir = dirname(e.path);
415
+ while (dir !== dirname(dir)) {
416
+ if (!own.has(dir)) found.add(dir);
417
+ dir = dirname(dir);
418
+ }
419
+ found.add('/');
420
+ }
421
+ return [...found];
422
+ }
423
+
424
+ /** A path as an SBPL string literal. */
425
+ function sbplString(path: string): string {
426
+ return `"${path.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
427
+ }
@@ -188,7 +188,10 @@ describe('the lint inside a real hook run', () => {
188
188
  export default defineHook({
189
189
  requires: [] as const,
190
190
  handler: async () => {
191
- try { readFileSync('/etc/hosts', 'utf-8'); } catch { /* the read is the point, not the bytes */ }
191
+ // A path outside the mount set. Not /etc/hosts — the resolver
192
+ // binding (celilo#1225) mounts that read-only on purpose, and the
193
+ // lint is right to stay silent about it.
194
+ try { readFileSync('/etc/passwd', 'utf-8'); } catch { /* the read is the point, not the bytes */ }
192
195
  return {};
193
196
  },
194
197
  });
@@ -196,7 +199,7 @@ describe('the lint inside a real hook run', () => {
196
199
 
197
200
  const warnings = advisories(messages);
198
201
  expect(warnings.length).toBe(1);
199
- expect(warnings[0]).toContain('/etc/hosts');
202
+ expect(warnings[0]).toContain('/etc/passwd');
200
203
  expect(warnings[0]).toContain('on a jailed host');
201
204
  // The one sentence the task forbids losing: what it is, and what it is not.
202
205
  expect(warnings[0]).toContain('Advisory lint, not a security boundary');
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Health-coverage's view of the control plane (celilo#1225).
3
+ *
4
+ * `celilo-mgmt` declares no `health_check` hook, and that is deliberate: celilo
5
+ * runs `runFleetChecks` for it directly, which asks eight questions where the
6
+ * hook asked two. The hook was deleted because it could not answer them from
7
+ * inside the jail — it reported a present database missing and failed the
8
+ * deploy.
9
+ *
10
+ * Without the carve-out here, the module best observed by celilo would be the
11
+ * one celilo warns is unobservable. That warning would be both false and
12
+ * unfixable, since the fix it names — add a health_check hook — is the thing
13
+ * that was removed on purpose.
14
+ */
15
+
16
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
17
+ import { mkdtempSync, rmSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
19
+ import { join } from 'node:path';
20
+ import type { DbClient } from '../../db/client';
21
+ import { modules } from '../../db/schema';
22
+ import { setupTestDatabaseAt } from '../../test-utils/database';
23
+ import { CONTROL_PLANE_MODULE_ID } from '../deployed-systems';
24
+ import { loadModuleCoverage } from './coverage-source';
25
+ import { healthCoverageFailingKeys } from './health-coverage';
26
+
27
+ describe('health coverage and the control plane', () => {
28
+ let dir: string;
29
+ let db: DbClient;
30
+
31
+ beforeEach(async () => {
32
+ dir = mkdtempSync(join(tmpdir(), 'cov-'));
33
+ const dbPath = join(dir, 'celilo.db');
34
+ process.env.CELILO_DB_PATH = dbPath;
35
+ db = await setupTestDatabaseAt(dbPath);
36
+ });
37
+
38
+ afterEach(() => {
39
+ db.$client.close();
40
+ process.env.CELILO_DB_PATH = undefined;
41
+ try {
42
+ rmSync(dir, { recursive: true, force: true });
43
+ } catch {
44
+ /* ignore */
45
+ }
46
+ });
47
+
48
+ function installModule(id: string, manifestData: Record<string, unknown>): void {
49
+ db.insert(modules)
50
+ .values({
51
+ id,
52
+ name: id,
53
+ version: '1.0.0',
54
+ state: 'INSTALLED',
55
+ sourcePath: `/modules/${id}`,
56
+ manifestData,
57
+ })
58
+ .run();
59
+ }
60
+
61
+ test('the control plane counts as verifiable despite declaring no hook', () => {
62
+ installModule(CONTROL_PLANE_MODULE_ID, { id: CONTROL_PLANE_MODULE_ID, hooks: {} });
63
+
64
+ const coverage = loadModuleCoverage(db);
65
+ const controlPlane = coverage.find((m) => m.id === CONTROL_PLANE_MODULE_ID);
66
+
67
+ expect(controlPlane?.hasHealthCheckHook).toBe(true);
68
+ // The finding that must NOT be raised — the one whose remedy is to add
69
+ // back the hook this change deleted.
70
+ const failing = healthCoverageFailingKeys(coverage);
71
+ const neverVerified = failing.filter((f) => f.message.includes('can never be verified'));
72
+ expect(neverVerified).toHaveLength(0);
73
+ });
74
+
75
+ test('an ordinary module with no hook is still reported unobservable', () => {
76
+ // The carve-out is for the control plane alone. If it leaked to every
77
+ // module, the check would stop finding anything and look healthy.
78
+ installModule('homebridge', { id: 'homebridge', hooks: {} });
79
+
80
+ const failing = healthCoverageFailingKeys(loadModuleCoverage(db));
81
+ const messages = failing.map((f) => f.message);
82
+ expect(
83
+ messages.some((m) => m.includes('homebridge') && m.includes('can never be verified')),
84
+ ).toBe(true);
85
+ });
86
+ });
@@ -9,6 +9,7 @@
9
9
  import type { DbClient } from '../../db/client';
10
10
  import { modules } from '../../db/schema';
11
11
  import type { ModuleManifest } from '../../manifest/schema';
12
+ import { CONTROL_PLANE_MODULE_ID } from '../deployed-systems';
12
13
  import { loadModuleHealthCadences } from './health-cadence';
13
14
  import type { ModuleCoverageInput } from './health-coverage';
14
15
 
@@ -28,7 +29,16 @@ export function loadModuleCoverage(db: DbClient): ModuleCoverageInput[] {
28
29
  return {
29
30
  id: module.id,
30
31
  state: module.state,
31
- hasHealthCheckHook: Boolean(manifest.hooks?.health_check),
32
+ // The control plane is verifiable without declaring a hook, and that
33
+ // is the point rather than an exemption: celilo runs `runFleetChecks`
34
+ // for it directly (`controlPlaneHealthChecks`), which asks eight
35
+ // questions where its old hook asked two. The hook was deleted because
36
+ // it could not answer them from inside the jail — it reported a
37
+ // present database missing (celilo#1225). Reading only the manifest
38
+ // here would raise a coverage warning about the best-observed module
39
+ // in the fleet.
40
+ hasHealthCheckHook:
41
+ Boolean(manifest.hooks?.health_check) || module.id === CONTROL_PLANE_MODULE_ID,
32
42
  cadence: cadences.get(module.id)?.cadence ?? null,
33
43
  };
34
44
  });