/** * What TOOLING the jail leaves a hook (task 4.13). * * `hook-jail-unreachability.test.ts` asserts D9's filesystem claims and they * hold. This suite asks the question D9's table never answers, and the answer * turns out to be the reason task 4.13 exists: **the fleet's real hooks shell * out, and the jail as derived today has no shell.** * * `execSync` spawns `/bin/sh -c`. `remote.ts` — the single seam every remote * primitive routes through — builds an `ssh @ ` string and * hands it to exactly that. The derivation binds `/usr/lib`, `/lib`, `/lib64` * and `/etc/ssl`, and nothing else outside the module's own tree. No `/bin`, * no `/usr/bin`, no `/etc/resolv.conf`. * * That is measured here rather than argued, because reading the derivation is * what produced the belief that stage 2 was complete. The mount set is correct * about every path it names. It is silent about the ones a hook needs to run * another program at all, and silence and correctness look identical from the * outside. * * ⚠️ **These tests assert the CURRENT reach, including the failures.** They are * not a wish list. When stage 2 gains a shell — or stage 3 brokers the remote * calls and hooks stop needing one — the expectations here change with it, and * the diff that changes them is where somebody states which of those happened. * A suite that asserted the desired end state would be red for the whole of * stage 2 and would teach nobody anything. * * **Needs a real jail and skips without one, loudly**, for the reason its * neighbour gives: a check that cannot reach its subject returns a confident * answer about nothing. */ import { describe, expect, test } from 'bun:test'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { executeHookScript } from './executor'; import { detectJailBackend } from './jail'; import { createCapturingLogger } from './logger'; import { configStore, secretStore } from './test-fixtures/store-backed'; import type { HookContext } from './types'; const PROBE_HOOK = resolve(__dirname, 'test-fixtures/jail-toolchain-hook.ts'); interface Probe { succeeded: boolean; detail: string; } interface Reach { shell: Probe; exec_without_shell: Probe; ssh: Probe; ansible: Probe; resolv_conf: Probe; dns_lookup: Probe; nsswitch: Probe; hosts_file: Probe; playwright_cache: Probe; celilo_browser_root: Probe; data_dir_sibling: Probe; dynamic_loader: Probe; browser_binary_present: Probe; browser_sandboxed: Probe; browser_no_sandbox: Probe; bwrap_present: Probe; } /** * A real browser to launch inside the jail, or nothing. * * Task 4.10's question cannot be answered without one, and most hosts have * none. `CELILO_PROBE_BROWSER` names it for the run that does — the Docker * image this suite's finding was measured in. Absent, the two browser probes * report "no browser supplied" and the run says so out loud rather than * reporting an ABSENT that means "not tested". */ const PROBE_BROWSER = process.env.CELILO_PROBE_BROWSER ?? ''; /** * The browser's directory, refused if handing it over would defeat this * suite's own `bwrap` guard. * * Binding the browser's directory as a path input is how a browser reaches the * jail here. Point `CELILO_PROBE_BROWSER` at `/usr/bin/chromium` and that * directory is `/usr/bin`, which carries `bwrap` in with it — the guard below * then fails for a reason that is the harness's fault, not the derivation's. * Measured: it happened on the first run of this file. * * Debian's `/usr/bin/chromium` is a `#!/bin/sh` wrapper anyway and cannot exec * in a jail with no shell. The real ELF is under `/usr/lib`, which the * derivation already binds, so the honest browser to name is that one. */ function probeBrowserDir(): string { const dir = dirname(PROBE_BROWSER); if (existsSync(join(dir, 'bwrap'))) { throw new Error( `CELILO_PROBE_BROWSER=${PROBE_BROWSER} would bind ${dir}, which holds bwrap and would defeat the guard this suite exists to keep. Name the browser's real ELF (Debian: /usr/lib/chromium/chromium), not the /usr/bin wrapper.`, ); } return dir; } const availability = detectJailBackend(); const jailed = availability.backend !== 'none'; /** Run the probe hook against a module tree laid out the way celilo lays one out. */ async function measureReach(): Promise<{ reach: Reach; cleanup: () => void }> { const scratch = mkdtempSync(join(tmpdir(), 'celilo-jail-reach-')); const modulePath = join(scratch, 'modules', 'jail-reach'); const stateDir = join(modulePath, 'state'); const screenshotDir = join(modulePath, 'screenshots', 'run'); mkdirSync(join(modulePath, 'scripts'), { recursive: true }); mkdirSync(stateDir, { recursive: true }); mkdirSync(screenshotDir, { recursive: true }); const { logger } = createCapturingLogger(); const context: HookContext = { config: PROBE_BROWSER ? configStore({ browser_executable: PROBE_BROWSER }) : configStore(), secrets: secretStore(), systems: [], logger, debug: false, screenshotDir, stateDir, capabilities: {}, }; // The jailed run jails under `required`: ce-29z made `auto` defer on // sandbox-exec until D14 exists, so this suite's jail is the operator's // explicit act — the bypass the deferral deliberately leaves open. const savedPolicy = process.env.CELILO_HOOK_JAIL; process.env.CELILO_HOOK_JAIL = 'required'; try { await executeHookScript(PROBE_HOOK, context, { timeoutMs: 60_000, idleTimeoutMs: 60_000, jail: { modulePath, // The browser reaches the jail as a DECLARED PATH INPUT, which is the // mechanism 4.10's fix would use. Binding it by editing the derivation // and then asking the derivation whether it is bound would prove // nothing. pathInputs: PROBE_BROWSER ? [{ name: 'browser_executable', value: probeBrowserDir(), access: 'read' as const }] : [], }, }); // The fixture hands its report through the state directory: hook return // values are no longer carried anywhere (hook-owned-state D5). const reach = JSON.parse( readFileSync(join(stateDir, 'report.json'), 'utf-8'), ) as unknown as Reach; return { reach, cleanup: restorePolicy }; } catch (error) { restorePolicy(); throw error; } function restorePolicy() { if (savedPolicy === undefined) delete process.env.CELILO_HOOK_JAIL; else process.env.CELILO_HOOK_JAIL = savedPolicy; rmSync(scratch, { recursive: true, force: true }); } } describe.skipIf(!jailed)(`what a jailed hook can run (backend: ${availability.backend})`, () => { // 30s, not bun's default 5s: the DNS probe deliberately waits for an answer // that never comes, and a runner timeout would report as a suite failure // rather than as the measurement it is. test('measures the reach', async () => { const { reach, cleanup } = await measureReach(); try { // Printed unconditionally, and to stderr because bun swallows a passing // test's stdout. This suite's value is the table, and a run that only // says "1 pass" has thrown the finding away. for (const [name, probe] of Object.entries(reach)) { console.error(` ${probe.succeeded ? 'REACHED' : 'ABSENT '} ${name}: ${probe.detail}`); } // ── The finding, per backend ──────────────────────────────────────── // bubblewrap BUILDS a namespace, so reach is filesystem absence: no // `/bin` is bound, so no shell, so `execSync` cannot run at all. 22 // module script files import `node:child_process`. // // `sandbox-exec` FILTERS the tree that is already there, and its parity // statements change what exec means. `(allow process*)` lets the kernel // map and run an image the jail cannot READ — exec is not file-read in // SBPL — so `/bin/sh` runs, `/bin/echo` runs, and `ssh` spawns. Measured // 2026-08-30, first run of this suite under the second backend. The // boundary that DOES hold on macOS is the filesystem and the credential: // writes outside the mount set are EPERM, secrets are unreadable, and // the `~/.ssh` key stage 3 withheld is not in the jail for ssh to use — // asserted by hook-trespass.test.ts and hook-jail-unreachability.test.ts, // which pass under both backends. if (availability.backend === 'sandbox-exec') { expect(reach.shell.succeeded).toBe(true); expect(reach.exec_without_shell.succeeded).toBe(true); // It spawns; it cannot authenticate. That is stage 3's boundary, not // this suite's. expect(reach.ssh.succeeded).toBe(true); // `ansible-playbook` is not installed on the probe host, so the row // measures the HOST, not the jail: posix_spawn of anything present is // allowed. No assertion either way. // `dns_lookup` also stays a printed row rather than an assertion: // measured REACHED — `(allow network*)` plus macOS resolving // out-of-process in mDNSResponder — but gating on it needs a live // resolver, which a unit suite should not require. } else { expect(reach.shell.succeeded).toBe(false); expect(reach.exec_without_shell.succeeded).toBe(false); // `~/.ssh` is bound read-only in stage 2 specifically so `remote.ts` // keeps working. There is no `ssh` to hand that key to. expect(reach.ssh.succeeded).toBe(false); expect(reach.ansible.succeeded).toBe(false); } // Nothing binds the resolver's configuration, on either platform: // bubblewrap by absence, sandbox-exec by `deny default` (EPERM). expect(reach.resolv_conf.succeeded).toBe(false); // ── Task 4.10 ────────────────────────────────────────────────────── // `~/.cache/ms-playwright` is the path task 4.10 names and it is bound // by nothing, deliberately: `managed-browser-runtime` moved the browser // into a celilo-owned tree, so binding the cache would bind a directory // nothing launches from. expect(reach.playwright_cache.succeeded).toBe(false); // Binding BROWSER_ROOT must not expose what sits beside it. This one IS // asserted unconditionally: `master.key` is unreadable whether or not // the browser directory exists on this host, so unlike the row below // there is no configuration in which this passes vacuously. expect(reach.data_dir_sibling.succeeded).toBe(false); // `BROWSER_ROOT` is NOT asserted here, and the reason is worth stating // because asserting it would be the trap this file exists to avoid. // `deriveMountSet` now emits the row, but `planJailedSpawn` drops any // row whose source is missing — so on a host with no provisioned // browser this probe reports ABSENT for a reason that has nothing to do // with the mount set, and an assertion either way would be measuring // the host rather than the derivation. The row itself is asserted // hermetically in `mount-set.test.ts`, where it can be. // The nesting question, asserted only on a run that supplied a browser. // Measured 2026-08-28 in `oven/bun:latest` on aarch64 with Debian's // chromium: with its own sandbox Chromium will not start inside the // jail, and `--no-sandbox` renders. That is task 4.10's prediction // confirmed, and it says what the fix has to include. if (PROBE_BROWSER) { expect(reach.browser_binary_present.succeeded).toBe(true); expect(reach.browser_sandboxed.succeeded).toBe(false); expect(reach.browser_no_sandbox.succeeded).toBe(true); } // ── The guard ────────────────────────────────────────────────────── // Opposite direction, and it must never flip. D9 predicts the exact // edit that would flip it: someone reads the failures above, binds // `/usr/bin` to fix them, and brings `bwrap` in with it. `isForbidden` // compares whole paths, so a bind of the DIRECTORY passes that filter. // Asserted under bubblewrap only: there is no bubblewrap jail on macOS, // so the row would measure whether the OPERATOR has bwrap installed, // and posix_spawn of it is allowed anyway. if (availability.backend !== 'sandbox-exec') { expect(reach.bwrap_present.succeeded).toBe(false); } } finally { cleanup(); } }, 30_000); }); describe.skipIf(jailed)('no jail on this host', () => { test('says so rather than passing quietly', () => { console.log(`hook jail unavailable, so 4.13's reach was NOT measured: ${availability.reason}`); expect(availability.backend).toBe('none'); }); });