/** * Hermetic guards on the hook jail's mount set (design D9). * * `deriveMountSet` is pure, so every property D9 asserts is checkable here * without spawning anything. The two that matter most are absence properties: * what the jail does NOT contain is the acceptance criterion. */ import { describe, expect, test } from 'bun:test'; import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { BROWSER_ROOT } from '@celilo/capabilities'; import { planJailedSpawn, realpathRequest, runtimeModulePathsFor } from './jail'; import { deriveMountSet, forbiddenPaths, isForbidden, toBwrapArgs, toSandboxProfile, } from './mount-set'; const BASE = { modulePath: '/var/celilo/modules/caddy', stateDir: '/var/celilo/modules/caddy/state', socketDir: '/tmp/celilo-hook-a1b2c3', runtimePath: '/usr/local/bin/bun', runnerPath: '/opt/celilo/src/hooks/hook-runner.ts', pathInputs: [], }; const pathsOf = (r: Parameters[0]) => deriveMountSet(r).entries.map((e) => e.path); describe('bwrap is never in the mount set', () => { // The AppArmor profile grants userns to /usr/bin/bwrap for anyone on the // box. A jailed hook that could exec it would be uid 0 with CAP_SYS_ADMIN in // its own namespace. bwrap runs OUTSIDE the jail because it creates the // jail, so withholding it costs nothing. test('is absent from an ordinary derivation', () => { expect(pathsOf(BASE).filter(isForbidden)).toEqual([]); }); test('is dropped even when a contract input names it', () => { // The realistic route back in is a developer debugging `bwrap: command not // found` and adding it. This asserts the derivation refuses, rather than // trusting nobody will ask. const set = deriveMountSet({ ...BASE, pathInputs: [{ name: 'evil', value: '/usr/bin/bwrap', access: 'read' }], }); expect(set.entries.map((e) => e.path)).not.toContain('/usr/bin/bwrap'); }); test('every forbidden path is actually recognised', () => { for (const p of forbiddenPaths()) expect(isForbidden(p)).toBe(true); }); }); describe('what the jail must not contain', () => { test("celilo's data directory and its secrets are absent", () => { const paths = pathsOf(BASE); // Absence, not a check. These simply do not exist inside the jail. expect(paths).not.toContain('/var/celilo'); expect(paths).not.toContain('/var/celilo/master.key'); expect(paths).not.toContain('/var/celilo/celilo.db'); }); test("a sibling module's tree is absent", () => { const paths = pathsOf(BASE); expect(paths).not.toContain('/var/celilo/modules'); expect(paths.some((p) => p.includes('/modules/technitium'))).toBe(false); }); test('the celilo CLI is absent, so a hook cannot re-enter it (celilo#1121)', () => { expect(pathsOf(BASE)).not.toContain('/usr/local/bin/celilo'); }); }); describe('ordering is semantic', () => { test('the tmpfs leads, so it cannot erase the socket or a staged input', () => { // Every staged contract input and the broker socket live under os.tmpdir(). // Bind them before the tmpfs and they vanish — and a hook whose backup_dir // is silently empty SUCCEEDS and produces a backup containing nothing. const set = deriveMountSet({ ...BASE, pathInputs: [ { name: 'backup_dir', value: '/tmp/celilo-backup-9f/envelope/data', access: 'write' }, ], }); const tmpfsAt = set.entries.findIndex((e) => e.mode === 'tmpfs' && e.path === '/tmp'); const socketAt = set.entries.findIndex((e) => e.path === BASE.socketDir); const inputAt = set.entries.findIndex((e) => e.path.startsWith('/tmp/celilo-backup-')); expect(tmpfsAt).toBeGreaterThanOrEqual(0); expect(socketAt).toBeGreaterThan(tmpfsAt); expect(inputAt).toBeGreaterThan(tmpfsAt); }); test('writable directories come after the read-only module tree', () => { const set = deriveMountSet({ ...BASE, screenshotDir: `${BASE.modulePath}/screenshots/run1` }); const treeAt = set.entries.findIndex((e) => e.path === BASE.modulePath && e.mode === 'ro'); for (const carved of ['state', 'generated', 'screenshots/run1']) { const at = set.entries.findIndex((e) => e.path === `${BASE.modulePath}/${carved}`); expect(at).toBeGreaterThan(treeAt); expect(set.entries[at]?.mode).toBe('rw'); } }); }); describe('a hook write to the module tree is refused at mount time (celilo#1265)', () => { // The recurrence gate for celilo#1265: hello-private-foo failed at deploy // because its hook wrote ca.crt into site/dist, which the jail binds only // through the module tree's read-only row. The sanctioned writable directory // is `/state` (celilo#1000). This pins the DERIVED mount set, with // no docker and no bubblewrap: no row may make the built web root writable, // and the state dir must stay writable. If a carve-out for site/dist ever // appears here, this fails before a fixture can fail at deploy again. test('no entry makes site/dist writable; state stays writable', () => { const set = deriveMountSet(BASE); const webRoot = `${BASE.modulePath}/site/dist`; const writableCovering = set.entries.filter( (e) => e.mode === 'rw' && (e.path === webRoot || webRoot.startsWith(`${e.path}/`)), ); expect(writableCovering).toEqual([]); const state = set.entries.find((e) => e.path === BASE.stateDir); expect(state?.mode).toBe('rw'); }); }); describe('contract inputs are bound at their declared access', () => { test("'write' is read-write and 'read' is read-only", () => { const set = deriveMountSet({ ...BASE, pathInputs: [ { name: 'backup_dir', value: '/tmp/stage/data', access: 'write' }, { name: 'artifact_path', value: '/tmp/stage/db.sqlite', access: 'read' }, ], }); expect(set.entries.find((e) => e.path === '/tmp/stage/data')?.mode).toBe('rw'); expect(set.entries.find((e) => e.path === '/tmp/stage/db.sqlite')?.mode).toBe('ro'); }); test('an undeclared input contributes nothing', () => { // db_path was passed for months without being declared. A derivation that // walks declarations cannot see it, which is the correct outcome here and // the reason the contract has to declare what it passes (celilo#1118). expect(pathsOf(BASE)).not.toContain('/var/celilo/celilo.db'); }); }); describe('paths are identical inside and outside', () => { test('no entry is remapped', () => { const args = toBwrapArgs(deriveMountSet(BASE)); for (let i = 0; i < args.length; i++) { if (args[i] === '--bind' || args[i] === '--ro-bind') { expect(args[i + 1]).toBe(args[i + 2] as string); } } }); test('the jail names its own working directory', () => { // The spawn sets no cwd, so the child inherits celilo's — a directory that // usually does not exist inside the jail. expect(deriveMountSet(BASE).chdir).toBe(BASE.modulePath); expect(toBwrapArgs(deriveMountSet(BASE))).toContain('--chdir'); }); }); describe('~/.ssh is never in the mount set (stage 3, D12)', () => { test('no derivation produces an .ssh row', () => { // Withholding the key is what turns the remote-ops broker's target check // from a convention into a boundary: with no credential in the jail, a // hand-built `ssh` cannot authenticate anywhere. expect(pathsOf(BASE).some((p) => p.endsWith('/.ssh'))).toBe(false); }); }); describe('the fleet browser is reachable, and only read-only (task 4.10)', () => { test('BROWSER_ROOT is bound', () => { // Task 4.10 names `~/.cache/ms-playwright`. That path is stale: // `managed-browser-runtime` moved the browser into a celilo-owned tree, // and `resolveBrowser()` — the thing a hook actually asks — returns // `BROWSER_EXECUTABLE_PATH` under this root. Asserted against the exported // constant rather than a literal, so a future move cannot leave this test // passing about a directory nothing launches from. expect(pathsOf(BASE)).toContain(BROWSER_ROOT); }); test('read-only, because a hook has no business writing the shared install', () => { expect(deriveMountSet(BASE).entries.find((e) => e.path === BROWSER_ROOT)?.mode).toBe('ro'); }); test('the data directory beside it stays out', () => { // The row is one named subdirectory, not `/var/lib/celilo`. Binding the // parent would put `celilo.db` and `master.key` back inside the jail — // the exact acceptance criterion D9 satisfies by absence. expect(pathsOf(BASE)).not.toContain('/var/lib/celilo'); }); }); describe('the sandbox-exec profile renders the same set (task 4.8)', () => { const profileOf = (r: Parameters[0] = BASE) => toSandboxProfile(deriveMountSet(r)); test('a read-only mount is denied write and allowed read, in that order', () => { const lines = profileOf().split('\n'); const deny = lines.indexOf(`(deny file-write* (subpath "${BASE.modulePath}"))`); const allow = lines.indexOf(`(allow file-read* (subpath "${BASE.modulePath}"))`); expect(deny).toBeGreaterThan(-1); // SBPL is last-match-wins, so the deny must come FIRST or it would revoke // the read it is paired with. expect(allow).toBeGreaterThan(deny); }); test('a writable directory nested in the read-only tree comes after it', () => { const lines = profileOf().split('\n'); expect( lines.indexOf(`(allow file-read* file-write* (subpath "${BASE.stateDir}"))`), ).toBeGreaterThan(lines.indexOf(`(allow file-read* (subpath "${BASE.modulePath}"))`)); }); test('the tmpfs row grants nothing: macOS has no tmpfs and deny-default covers it', () => { // The row still appears, as a comment, so a reader of the profile can see // that the derivation asked for something this backend cannot give. const profile = profileOf(); expect(profile).toContain('; /tmp: no tmpfs backend'); expect(profile).not.toContain('(subpath "/tmp")'); }); test('every ancestor is a literal, never a subpath', () => { // A `subpath` grant on an ancestor would expose everything beneath it — // `/var/celilo` holds master.key. `literal` permits stat and readdir of the // directory node alone. This is the difference between D9's acceptance // criterion holding and not. const profile = profileOf(); expect(profile).toContain('(allow file-read* (literal "/var/celilo"))'); expect(profile).not.toContain('(allow file-read* (subpath "/var/celilo"))'); expect(profile).not.toContain('(subpath "/"))'); }); test('a forbidden path never reaches the profile', () => { const profile = profileOf({ ...BASE, pathInputs: [{ name: 'evil', value: '/usr/bin/bwrap', access: 'write' as const }], }); expect(profile).not.toContain('(subpath "/usr/bin/bwrap")'); }); test('a quote in a path cannot end the rule early', () => { const profile = profileOf({ ...BASE, modulePath: '/var/celilo/modules/od"d' }); expect(profile).toContain('(subpath "/var/celilo/modules/od\\"d")'); }); test('the network is allowed, because D9 does not namespace it', () => { expect(profileOf()).toContain('(allow network*)'); }); }); describe('name resolution inside the jail (celilo#1225)', () => { test('the resolver config is bound read-only', () => { // Without these a hook resolves no NAME. getaddrinfo finds no nameserver // and the call dies as ETIMEOUT, which reads as the remote endpoint being // down. Measured on namecheap's validate_config against an endpoint that // was up. const set = deriveMountSet(BASE); for (const path of ['/etc/resolv.conf', '/etc/nsswitch.conf', '/etc/hosts']) { const row = set.entries.find((e) => e.path === path); expect(row).toBeDefined(); expect(row?.mode).toBe('ro'); } }); test('binding the resolver does not bind the rest of /etc', () => { // The acceptance criterion for this jail is absence. Naming three files // must not become naming a directory. const set = deriveMountSet(BASE); expect(set.entries.some((e) => e.path === '/etc')).toBe(false); expect(set.entries.some((e) => e.path === '/etc/shadow')).toBe(false); }); }); describe('every row states what its own absence means (celilo#1244 family)', () => { const rowFor = (set: ReturnType, path: string) => set.entries.find((e) => e.path === path); test('a contract path input is REQUIRED, so dropping it can never be a warning', () => { // The case the old undifferentiated warning buried. A hook whose declared // write path is dropped writes into the run's private tmpfs and reports // success over a directory discarded when it exits. const set = deriveMountSet({ ...BASE, pathInputs: [{ name: 'backup_dir', value: '/srv/staged', access: 'write' }], }); expect(rowFor(set, '/srv/staged')?.absence).toBe('required'); }); test('a runtime support directory is RUNTIME, so its absence is not reported', () => { // /lib64 does not exist on arm64 and never will. A genuinely needed one // fails the runtime, which is a louder and more specific signal than a // warning that fired 96 times in one `cele2e run --all`. const set = deriveMountSet(BASE); expect(rowFor(set, '/lib64')?.absence).toBe('runtime'); expect(rowFor(set, '/usr/lib')?.absence).toBe('runtime'); }); test("the module's own tree and its state dir are REQUIRED", () => { // Neither is ever legitimately absent: executor.ts mkdirSyncs the state dir // before deriving the set, so an absent one means something is wrong that a // dropped mount would only make more confusing later. const set = deriveMountSet(BASE); expect(rowFor(set, BASE.modulePath)?.absence).toBe('required'); expect(rowFor(set, BASE.stateDir)?.absence).toBe('required'); }); test('generated output is CONDITIONAL, because it appears only after generation', () => { const set = deriveMountSet(BASE); expect(rowFor(set, `${BASE.modulePath}/generated`)?.absence).toBe('conditional'); }); test('every row carries a policy, so a new mount cannot inherit a default', () => { const set = deriveMountSet({ ...BASE, pathInputs: [{ name: 'cert', value: '/srv/certs', access: 'read' }], }); for (const e of set.entries) { expect(e.absence).toBeDefined(); } }); }); describe('a jailed hook cannot spawn /bin/sh, by derivation (e2e-recovery lane B)', () => { // The full derivation lives in openspec/changes/e2e-suite-recovery/ // jail-shell-derivation.md. The short form: the derivation names NO system // executable directory. It binds the interpreter as one file, the runner // shim's directory, celilo's own node_modules, the module tree, the broker // socket, the declared inputs, and library/resolver support dirs // (/usr/lib, /lib, /lib64, /etc/ssl, the three resolver files). /bin and // /usr/bin are in none of those families, so a shell is ABSENT — bubblewrap // builds a namespace where the lookup dies with ENOENT, and sandbox-exec // denies the read under deny-default (EPERM) — which is the measured // `ENOENT: no such file or directory, posix_spawn '/bin/sh'` from // e2e-suite-recovery's proposal.md. // // The assertion is absence of COVERAGE, never a specific errno, for the same // reason hook-jail-unreachability.test.ts refuses to match on one: ENOENT // and EPERM are both the jail working, and a test pinned to one goes red on // the other platform for a jail that was working perfectly. // // These are tests over the DERIVED set, computed against a real mirrored // layout with the real code path (realpathRequest -> runtimeModulePathsFor // -> deriveMountSet -> planJailedSpawn), not against hand-built arguments: // a hand-built set proves what the author believed, not what the jail does. const EXECUTABLE_DIRS = ['/bin', '/usr/bin', '/sbin', '/usr/sbin', '/usr/local/bin']; const SHELL = '/bin/sh'; /** Mirror a real module layout into a temp dir and derive through the real path. */ function derivedForMirroredModule() { const scratch = mkdtempSync(join(tmpdir(), 'celilo-laneb-')); const modulePath = join(scratch, 'modules', 'caddy'); const stateDir = join(modulePath, 'state'); const screenshotDir = join(modulePath, 'screenshots', 'run1'); mkdirSync(join(modulePath, 'generated'), { recursive: true }); mkdirSync(stateDir, { recursive: true }); mkdirSync(screenshotDir, { recursive: true }); const socketDir = mkdtempSync(join(tmpdir(), 'celilo-laneb-sock-')); const stagedInput = mkdtempSync(join(tmpdir(), 'celilo-laneb-stage-')); const runnerPath = resolve(__dirname, 'hook-runner.ts'); const request = realpathRequest({ modulePath, stateDir, screenshotDir, socketDir, runtimePath: process.execPath, runnerPath, runtimeModulePaths: runtimeModulePathsFor(runnerPath), pathInputs: [{ name: 'backup_dir', value: join(stagedInput, 'data'), access: 'write' }], }); const set = deriveMountSet(request); return { set, cleanup: () => { rmSync(scratch, { recursive: true, force: true }); rmSync(socketDir, { recursive: true, force: true }); rmSync(stagedInput, { recursive: true, force: true }); }, }; } test('nothing in the jail covers /bin/sh, on any backend', () => { const { set, cleanup } = derivedForMirroredModule(); try { // A row covers /bin/sh only if it IS the shell or an ancestor of it. // No entry is: nothing mounts /bin, and / is never a row (its grant, on // macOS, is a literal on the node alone, which confers nothing on // contents — pinned separately below). const covering = set.entries.filter( (e) => e.path === SHELL || SHELL.startsWith(`${e.path}/`), ); expect(covering).toEqual([]); } finally { cleanup(); } }); test('no system executable directory is a row, and the interpreter is the one exception', () => { const { set, cleanup } = derivedForMirroredModule(); try { const paths = set.entries.map((e) => e.path); expect(paths.filter((p) => EXECUTABLE_DIRS.includes(p))).toEqual([]); // A file bind under an executable directory (the interpreter can live in // /usr/local/bin) is legitimate ONLY as the single runtime row. Any // wider row there would drag the directory's other contents in. for (const e of set.entries) { const dir = EXECUTABLE_DIRS.find((d) => e.path.startsWith(`${d}/`)); if (dir) expect(e.path).toBe(process.execPath); } } finally { cleanup(); } }); test('the rendered bubblewrap command exposes no shell path either', () => { // Measure reach on the artifact the kernel actually receives, not on the // intermediate set: planJailedSpawn filters absent sources and renders the // argv, and that argv is the last thing a future edit could corrupt. const { set, cleanup } = derivedForMirroredModule(); try { const plan = planJailedSpawn( [process.execPath, resolve(__dirname, 'hook-runner.ts')], set, { backend: 'bubblewrap' }, 'required', ); expect(plan.mode).toBe('jailed'); expect(plan.cmd).not.toContain(SHELL); expect(plan.cmd).not.toContain('/bin'); } finally { cleanup(); } }); test('the sandbox profile grants no read on /bin, as subpath or as literal', () => { const { set, cleanup } = derivedForMirroredModule(); try { const profile = toSandboxProfile(set); // A subpath grant on /bin (or any executable directory) would make the // shell readable, and (allow process*) does the rest — exec is not // restricted; what the hook can READ is the boundary in both backends. for (const dir of EXECUTABLE_DIRS) { expect(profile).not.toContain(`(subpath "${dir}")`); } // /bin must not even appear as an ancestor literal: a literal permits // stat and readdir of the node alone, but its presence would still mean // something began mounting beneath /bin. expect(profile).not.toContain('(literal "/bin")'); } finally { cleanup(); } }); });