/** * Test fixture: what TOOLING can a hook reach from inside the jail (task 4.13)? * * `jail-probe-hook.ts` next door asks what a hook can reach on the FILESYSTEM, * and D9's table is written in those terms. This one asks the question D9's * table does not answer: the fleet's real hooks shell out. `remote.ts` builds an * `ssh @ ` string and hands it to `execSync`, which spawns * `/bin/sh -c`. Neither `/bin/sh` nor `/usr/bin/ssh` appears anywhere in the * derivation, and 22 module script files import `node:child_process`. * * So every probe here reports an outcome rather than throwing, the same way its * neighbour does. The point is to MEASURE the reach rather than reason about it: * reading the derivation is what produced the belief that stage 2 was fine, and * reading it again would produce the same belief. * * `bwrap` is probed too, and its expected answer is the opposite of every other * one here. It must NOT be reachable. D9 predicts exactly how that gets undone — * a hook fails on a missing binary, somebody binds the directory it lives in, * and the escape path reopens with a green suite — so the guard belongs in the * same run as the failures that would tempt someone into it. */ import { execFileSync, execSync } from 'node:child_process'; import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; import { BROWSER_ROOT, defineHook } from '@celilo/capabilities'; interface Probe { succeeded: boolean; detail: string; } function probe(fn: () => string): Probe { try { return { succeeded: true, detail: fn().slice(0, 200) }; } catch (error) { return { succeeded: false, detail: error instanceof Error ? error.message : String(error) }; } } async function probeAsync(fn: () => Promise): Promise { try { return { succeeded: true, detail: (await fn()).slice(0, 200) }; } catch (error) { return { succeeded: false, detail: error instanceof Error ? error.message : String(error) }; } } /** * Launch args for the two arms of task 4.10's controlled experiment, exported * so the unit test that pins the flag on the launch path asserts the SAME * definition the probes launch with rather than a copy of it. * * `SANDBOXED_LAUNCH_ARGS` is the experiment arm: Chromium's own sandbox on, * which does not start inside the jail (its namespace sandbox cannot nest in * the unprivileged user namespace bwrap creates). It must stay sandboxed or * the comparison stops being one that isolates a variable. * * `JAILED_LAUNCH_ARGS` is the launch path itself, and the interim celilo#1215 * approved: a Chromium launched by a jailed hook starts with `--no-sandbox`, * because the jail is then the only sandbox around the browser. The flag * belongs here, in the launcher — never in the mount set or any other * contract-shaped place. Brokered rendering outside the jail (v2, celilo#1215) * is the preferred end state and removes the need for this flag. */ export const SANDBOXED_LAUNCH_ARGS: readonly string[] = ['--headless', '--dump-dom', 'about:blank']; export const JAILED_LAUNCH_ARGS: readonly string[] = [ '--headless', '--no-sandbox', '--dump-dom', 'about:blank', ]; export default defineHook({ hook: 'container_created', requires: [], handler: async (ctx) => { // Through `ctx.config`, not an env var. Stage 1 stopped forwarding the // process environment into hooks (celilo#1158), so a probe keyed on // `process.env` would read empty and report "no browser supplied" for a // run that supplied one — a false ABSENT indistinguishable from a real one. const browser = (ctx.config as { browser_executable?: string }).browser_executable ?? ''; const reach = { // The one every other shell-out depends on. `execSync` spawns // `/bin/sh -c`, so a jail without it cannot run ANY hook that uses // `node:child_process`, whatever binary that hook was reaching for. shell: probe(() => execSync('echo alive', { encoding: 'utf-8' })), // Bypasses the shell to separate two failures that look identical // through `execSync`: no `/bin/sh`, versus no target binary. exec_without_shell: probe(() => execFileSync('/bin/echo', ['alive'], { encoding: 'utf-8' })), // What `remote.ts` needs. Stage 2 binds `~/.ssh` read-only ON PURPOSE so // that remote.ts keeps working, which makes the absence of the binary it // feeds that key to the interesting result rather than a detail. ssh: probe(() => execFileSync('/usr/bin/ssh', ['-V'], { encoding: 'utf-8', stdio: 'pipe' })), // The deploy path. `ansible-playbook` is a Python program, so a bind of // the binary alone would not be enough even if one existed. ansible: probe(() => execFileSync('/usr/bin/ansible-playbook', ['--version'], { encoding: 'utf-8' }), ), // Name resolution, in two halves, because they fail for different // reasons and only one of them is about the mount set. resolv_conf: probe(() => readFileSync('/etc/resolv.conf', 'utf-8')), dns_lookup: await probeAsync(async () => { // Bun's own resolver, so this measures name resolution rather than the // absence of some binary that happens to do it. A jail with no // `/etc/resolv.conf` has no nameserver to ask. // Bounded, because the interesting failure is a HANG. With no // `/etc/resolv.conf` there is no nameserver to ask and the resolver // waits out its own retry schedule, so an unbounded probe reports the // test runner's timeout instead of the jail's behaviour. const answer = await Promise.race([ Bun.dns.lookup('example.com', { family: 4 }), new Promise((_, reject) => setTimeout(() => reject(new Error('no answer within 3s')), 3_000), ), ]); return answer[0]?.address ?? 'resolved, no address'; }), // Files a resolver reads before it ever touches the network. nsswitch: probe(() => readFileSync('/etc/nsswitch.conf', 'utf-8')), hosts_file: probe(() => readFileSync('/etc/hosts', 'utf-8')), // ── Task 4.10, the browser case ────────────────────────────────── // // The task says to bind `~/.cache/ms-playwright`. That path is stale: // `managed-browser-runtime` has since landed `BROWSER_ROOT` in // `@celilo/capabilities`, so a provisioned browser lives under it and // `resolveBrowser()` is what a hook asks for one. Both are probed, and // only the playwright cache is ASSERTED — see the suite for why. playwright_cache: probe(() => { const home = process.env.HOME ?? '/root'; if (existsSync(`${home}/.cache/ms-playwright`)) return 'present'; throw new Error(`no ${home}/.cache/ms-playwright`); }), celilo_browser_root: probe(() => { if (existsSync(BROWSER_ROOT)) return `present at ${BROWSER_ROOT}`; throw new Error(`no ${BROWSER_ROOT} on this host`); }), // ⚠️ BROWSER_ROOT is a SUBDIRECTORY of celilo's Linux data directory // (`/var/lib/celilo`), which also holds `celilo.db` and `master.key`. // Binding it must not expose its siblings. bubblewrap creates the parent // as an empty directory in the jail's namespace, so `..` reaches // nothing — but that is the kind of claim worth a probe rather than a // sentence, because it is the whole acceptance criterion D9 satisfies // by absence. data_dir_sibling: probe(() => { const parent = dirname(BROWSER_ROOT); return readFileSync(`${parent}/master.key`, 'utf-8'); }), // Why a present binary can still fail to exec. `posix_spawn` reports a // missing ELF interpreter as ENOENT on the PROGRAM, which is // indistinguishable from the program being absent. Probed rather than // inferred, and worth it: the loader turned out to be present and the // first hypothesis was wrong. dynamic_loader: probe(() => { const found: string[] = []; for (const dir of ['/lib', '/lib64', '/usr/lib']) { try { for (const name of readdirSync(dir)) { if (name.startsWith('ld-linux') || name.startsWith('ld.so')) { found.push(`${dir}/${name}`); } } } catch { // A directory the derivation legitimately skipped: `/lib64` does // not exist on arm64 and `planJailedSpawn` drops a missing source. } } if (found.length === 0) { throw new Error('no ld-linux loader under /lib, /lib64 or /usr/lib'); } return found.join(', '); }), // Did the bind happen at all? Separates "no browser reached the jail" // from "one did and will not run", which the two launches below cannot // tell apart on their own. browser_binary_present: probe(() => { if (!browser) throw new Error('no browser supplied to this run'); if (existsSync(browser)) return `present at ${browser}`; throw new Error(`absent at ${browser}`); }), // The nesting question, and the only pair here needing a real browser. // // Two launches differing in ONE flag, so the result is a controlled // experiment rather than an observation. Chromium's own sandbox forks a // helper into a NEW user namespace, and nesting that inside // bubblewrap's unprivileged one is what D9 and task 4.10 both predict // will fail. `--no-sandbox` skips the fork, so the launch that must // succeed carries it (celilo#1215 interim). The args come from the // exported constants above, which the flag-pinning unit test shares. browser_sandboxed: probe(() => { if (!browser) throw new Error('no browser supplied to this run'); return execFileSync(browser, [...SANDBOXED_LAUNCH_ARGS], { encoding: 'utf-8', stdio: 'pipe', timeout: 20_000, }); }), browser_no_sandbox: probe(() => { if (!browser) throw new Error('no browser supplied to this run'); return execFileSync(browser, [...JAILED_LAUNCH_ARGS], { encoding: 'utf-8', stdio: 'pipe', timeout: 20_000, }); }), // MUST be false. See the docblock. bwrap_present: probe(() => { for (const path of ['/usr/bin/bwrap', '/usr/local/bin/bwrap', '/bin/bwrap']) { if (existsSync(path)) return `present at ${path}`; } throw new Error('no bwrap on any known path'); }), }; // Hand the report to the parent through the state directory — the one // channel a jailed hook still has (hook-owned-state D5 removed the // return channel). writeFileSync(`${ctx.stateDir}/report.json`, JSON.stringify(reach)); }, });