/** * The hook jail's mount set (openspec/changes/hook-process-boundary, D9). * * A jailed hook sees exactly the paths listed here and nothing else. Not * "denied" — ABSENT. A hook reaching for celilo's master key gets `ENOENT`, * because inside the jail there is no such file. That is the acceptance * criterion, and it is satisfied by absence rather than by a check. * * The set is DERIVED, never declared by the module. A module cannot ask for * more, which is the whole point. * * This file is pure. It computes a description of a filesystem view and * touches nothing. That is what lets `bwrap` is never in the mount set be a * hermetic test rather than an integration one, and what lets the unjailed * advisory lint (task 4.7) consume the same computation instead of a second * one that can drift from it. */ import { dirname, isAbsolute, join, resolve } from 'node:path'; import { BROWSER_ROOT, type PathAccess } from '@celilo/capabilities'; /** * `tmpfs` is not an access level, it is "put a fresh empty filesystem here". * It has to be its own mode because ORDER matters and a tmpfs erases whatever * the jail would otherwise see at that path. */ export type MountMode = 'ro' | 'rw' | 'tmpfs'; export interface MountEntry { /** * The path, IDENTICAL inside and outside the jail. Never remapped. * * Paths cross the capability boundary as strings: a hook hands the broker a * path it wrote to, and the broker has to be able to read it. A remapped * mount would make those two names disagree and the failure would look like * a missing file rather than a translation bug. */ readonly path: string; readonly mode: MountMode; /** Why this row exists. Surfaced by the unjailed lint and by `system doctor`. */ readonly reason: string; /** What this row's absence from the host means. See `MountAbsence`. */ readonly absence: MountAbsence; } export interface MountSet { /** * ORDER IS SEMANTIC. bubblewrap applies these in sequence and a later entry * wins over an earlier one, which is what lets a read-write directory sit * inside a read-only tree. Do not sort this list. */ readonly entries: readonly MountEntry[]; /** * The jail's working directory. * * The spawn does not set `cwd`, so a hook child inherits celilo's — whatever * directory the operator's shell happened to be in. Inside the jail that * directory usually does not exist, and bubblewrap fails on a path nobody * chose. So the jail names one explicitly. */ readonly chdir: string; } /** A path input the contract declared, paired with the value the framework resolved. */ export interface DeclaredPathInput { readonly name: string; readonly value: string; readonly access: PathAccess; } export interface MountSetRequest { /** * The module's own tree. Comes from the DB as `module.sourcePath`, which is * NOT guaranteed to sit under the module store — a restored database carries * the absolute paths of the box it was taken from (ISS-0052). Resolved below * before it becomes a bind-mount argument. */ readonly modulePath: string; /** `/state`, celilo#1000's sanctioned writable directory. */ readonly stateDir: string; /** `/screenshots/`, this run only. Absent when the hook takes none. */ readonly screenshotDir?: string; /** The directory holding the broker's unix socket. */ readonly socketDir: string; /** The interpreter celilo spawns (`process.execPath`). */ readonly runtimePath: string; /** The runner shim, which lives in celilo's tree rather than the module's. */ readonly runnerPath: string; /** * The `node_modules` directories the shim resolves its OWN imports through, * nearest first. Empty means the shim is self-contained, which it is not. * * This row exists because the jail was run rather than read. `bwrap` built * the namespace correctly, applied every mount below, and the shim then died * on `Cannot find module '@celilo/capabilities'` — it imports `isCompiledHook` * from there and Zod through `hook-protocol.ts`, and both resolve ABOVE * `dirname(runnerPath)`. Under npm that is `/var/celilo/node_modules`; in the * repo it is the workspace root's. Neither is inside the shim's directory, * so without this the jail cannot start a hook at all, anywhere. * * The caller walks the filesystem for these (`runtimeModulePathsFor`), which * is why they arrive as an argument rather than being computed here. Only * directories literally NAMED `node_modules` are ever collected, and that is * what keeps `/var/celilo` — `master.key`, `celilo.db` — out of the jail * while `/var/celilo/node_modules` goes into it. */ readonly runtimeModulePaths?: readonly string[]; /** Contract-declared path inputs, already resolved to values. */ readonly pathInputs: readonly DeclaredPathInput[]; } /** Directories whose contents the runtime needs in order to start at all. */ const RUNTIME_SUPPORT_DIRS = ['/usr/lib', '/lib', '/lib64', '/etc/ssl'] as const; /** * What resolving a hostname needs. Read-only, and absent ones are dropped. * * Without these a jailed hook cannot resolve a NAME. `getaddrinfo` finds no * nameserver, falls back to a loopback that answers nothing, and the call dies * as `ETIMEOUT` — which reads as the remote endpoint being down rather than as * the jail having no resolver. Measured on `namecheap`'s `validate_config`: * `getaddrinfo ETIMEOUT dynamicdns.park-your-domain.com`, against an endpoint * that was up and one the e2e topology answers for. * * This is not a widening of what a hook may reach. Design D12 already records * as a residual that "a hook can still `fetch()` any HTTP endpoint directly; * only `probeHttp` consults the target check" — so the network is already * open, and a hook could always dial a literal IP. Withholding the resolver * config did not close that door; it only made the door work for addresses and * not for names, which is an accident rather than a policy. * * It sits beside `/etc/ssl` for the same reason that does: an outbound call * needs a trust store AND a way to turn a name into an address, and binding * one without the other leaves half a capability. */ const RESOLVER_FILES = ['/etc/resolv.conf', '/etc/nsswitch.conf', '/etc/hosts'] as const; /** * Paths that must NEVER appear in a mount set, whatever asks for them. * * `bwrap` is the load-bearing entry and the reason this list exists rather * than being a comment. celilo ships an AppArmor profile granting `userns` to * `/usr/bin/bwrap` so the jail can be built at all, and that grant applies to * anyone on the box who runs it. A jailed hook that could exec `bwrap` would * get a namespace of its own, be uid 0 inside it with `CAP_SYS_ADMIN`, and * reach kernel surface an unprivileged user cannot otherwise touch — which is * the entire reason Ubuntu restricts unprivileged user namespaces. * * `bwrap` runs OUTSIDE the jail because it is what creates the jail. It does * not need to exist inside one, so leaving it out costs nothing. * * Task 4.10 already predicts how this gets undone: a browser hook fails with * `bwrap: command not found`, a developer reads a missing binary and adds it, * the suite goes green, and the escape path is open with nothing to read. The * test on this constant is that missing thing to read. */ const NEVER_MOUNT = ['/usr/bin/bwrap', '/usr/local/bin/bwrap', '/bin/bwrap'] as const; /** * What a row's ABSENCE from the host means. Required, never defaulted: a default * lets the next mount be added without deciding which kind it is, and that is * exactly how the undifferentiated `skipped` list came to exist. * * bubblewrap fails the whole jail on a bind whose source is missing, so an * absent row must be dropped. The question this answers is whether dropping it * is fine, fatal, or nobody's business. */ export type MountAbsence = /** Fatal. The hook cannot do what it was asked without this. */ | 'required' /** Fatal only for a module that declared it needs the facility. */ | 'declared-only' /** Not reported. A genuinely needed one fails the runtime, which is louder. */ | 'runtime' /** Expected at some lifecycle points. Debug at most. */ | 'conditional'; function entry(path: string, mode: MountMode, reason: string, absence: MountAbsence): MountEntry { return { path, mode, reason, absence }; } /** * Compute the filesystem view a hook gets. * * Pure: resolves paths lexically and reads nothing from disk. `resolve` is not * `realpath` — it cannot follow a symlink, because following one is I/O. The * caller supplies already-real paths; on macOS that distinction is the * difference between a rule that applies and one that silently does not * (task 4.8), so the caller's `realpath` is not optional. */ export function deriveMountSet(request: MountSetRequest): MountSet { const modulePath = resolve(request.modulePath); const entries: MountEntry[] = []; // 1. A private /tmp FIRST, because it erases everything beneath it. // // This has to lead. The broker's socket directory is an `mkdtemp` under // `os.tmpdir()`, and every staged contract input (backup_dir, restore_dir, // the cross-module roots) comes from `stagingDirFor`, also under // `os.tmpdir()`. Bind those first and the tmpfs wipes them. // // The failure that causes is not a crash. A hook whose `backup_dir` is // silently an empty tmpfs directory writes into it, returns success, and // produces a backup containing NOTHING. It is found at restore. So the gate // on this asserts the artifact is non-empty, never that the hook exited zero. entries.push(entry('/tmp', 'tmpfs', 'private scratch, per run', 'runtime')); // 2. The runtime. Without it nothing runs, so it is not really a policy row. entries.push(entry(request.runtimePath, 'ro', 'the interpreter', 'required')); entries.push( entry(dirname(request.runnerPath), 'ro', 'the runner shim celilo spawns', 'required'), ); // See MountSetRequest.runtimeModulePaths. Without these the shim starts and // immediately dies on `Cannot find module`. for (const dir of request.runtimeModulePaths ?? []) { entries.push( entry(resolve(dir), 'ro', "celilo's own dependencies, which the shim imports", 'required'), ); } for (const dir of RUNTIME_SUPPORT_DIRS) { entries.push(entry(dir, 'ro', 'shared libraries and trust store', 'runtime')); } for (const file of RESOLVER_FILES) { entries.push(entry(file, 'ro', 'name resolution — see RESOLVER_FILES', 'runtime')); } // The fleet browser, read-only (task 4.10). // // `BROWSER_ROOT` rather than `~/.cache/ms-playwright`, which is what task 4.10 // says to bind and is now the wrong path: `managed-browser-runtime` moved the // browser into a celilo-owned tree and `resolveBrowser()` is what a hook asks // for it. Binding the cache would bind a directory nothing launches from. // // Read-only because a hook has no business writing to the shared browser // install, and unconditional because it costs nothing when absent — // `planJailedSpawn` drops a row whose source does not exist, so a host with no // provisioned browser gets no row and no error. Gating it on some // "this module uses a browser" signal would need a declaration that does not // exist, and would fail closed in the one case that matters. // // Note what this does NOT bind: `/var/lib/celilo` itself stays out, so the // data directory beside it is as absent as it was before. This is one // subdirectory, named explicitly. // // ⚠️ The bind is necessary and not sufficient. Measured 2026-08-28 under a // real bubblewrap jail (`hook-jail-toolchain-reach.test.ts`): Chromium with // its own sandbox does NOT start inside the jail, and the same binary with // `--no-sandbox` renders. Chromium's sandbox forks a helper into a new user // namespace and nesting that inside bubblewrap's unprivileged one fails. So a // browser hook also needs `--no-sandbox`, which belongs to whatever launches // the browser rather than here. That interim landed 2026-08-31 (celilo#1215): // the launch path in `test-fixtures/jail-toolchain-hook.ts` carries the flag, // and `jail-browser-launch-flags.test.ts` pins it there. entries.push( entry(BROWSER_ROOT, 'ro', 'the fleet browser, when one is provisioned', 'declared-only'), ); // 3. The module's own tree, read-only, then its writable directories carved // on top. bubblewrap resolves that in the right order, which is why the // order here is not cosmetic. // // D9 says "the module's own tree is bound read-only". These are the // carved exceptions to that sentence, and there are three of them rather // than the one D9's prose implies. entries.push(entry(modulePath, 'ro', "the module's own tree", 'required')); entries.push( entry( resolve(request.stateDir), 'rw', 'ctx.stateDir, the sanctioned writable directory', 'required', ), ); entries.push( entry( join(modulePath, 'generated'), 'rw', "celilo's generated output the hook may amend", 'conditional', ), ); if (request.screenshotDir) { entries.push( entry( resolve(request.screenshotDir), 'rw', 'ctx.screenshotDir, this run only', 'conditional', ), ); } // 4. The broker channel. Bound AFTER the tmpfs, per the note above. entries.push(entry(resolve(request.socketDir), 'rw', 'the capability broker socket', 'required')); // 5. Contract-declared path inputs, at the access the contract declares. // Never inferred from the name — see ContractField.path. for (const input of request.pathInputs) { entries.push( entry( resolve(input.value), input.access === 'write' ? 'rw' : 'ro', `contract input '${input.name}' (${input.access})`, // Fatal, always. A hook whose declared WRITE path is dropped writes into // the run's private tmpfs and reports success over a directory that is // discarded when it exits. celilo#1248-adjacent; see jail.ts's note. 'required', ), ); } // There is deliberately NO `~/.ssh` row (stage 3, design D12). With no key // in the jail, a hand-built `ssh` cannot authenticate anywhere; the remote // primitives cross to the remote-ops broker instead, and withholding the // key is what makes that broker's target check a boundary rather than a // convention. return { entries: entries.filter((e) => !isForbidden(e.path)), // The module's own tree is the only directory guaranteed to exist inside // the jail and to mean something to the hook. chdir: modulePath, }; } /** Is this path one nothing may ever mount? See NEVER_MOUNT. */ export function isForbidden(path: string): boolean { const resolved = isAbsolute(path) ? resolve(path) : path; return NEVER_MOUNT.some((forbidden) => resolved === forbidden); } /** The forbidden list, for the test that asserts it is honoured. */ export function forbiddenPaths(): readonly string[] { return NEVER_MOUNT; } /** Render a mount set as bubblewrap arguments, in order. */ export function toBwrapArgs(set: MountSet): string[] { const args: string[] = []; for (const e of set.entries) { if (e.mode === 'tmpfs') args.push('--tmpfs', e.path); else if (e.mode === 'rw') args.push('--bind', e.path, e.path); else args.push('--ro-bind', e.path, e.path); } args.push('--chdir', set.chdir); return args; } /** * Render a mount set as a `sandbox-exec` profile, in order (task 4.8). * * The SECOND renderer of the same derivation, alongside `toBwrapArgs`. That is * the property task 4.7 asks for and the reason both live here: one * computation, several consumers, so a macOS jail and a Linux jail cannot come * to different conclusions about what a hook may see. * * **Order is semantic here for the same reason it is in `toBwrapArgs`, by a * different mechanism.** SBPL is last-match-wins, so a read-write directory * nested inside a read-only tree works exactly as bubblewrap's later-`--bind`- * wins does. Measured 2026-08-27: with `state/` emitted after the module tree, * a write to the tree gives `EPERM` and a write to `state/` succeeds. * * Three rules are not derived from the mount set, and each is a parity * statement rather than a convenience: * * - `(import bsd.sb)` supplies what any process needs to start at all — * the dyld shared cache, `file-read-metadata` for symlink traversal, the * `logd`/`cfprefsd` lookups. Without it `bun` dies before `main` with no * diagnostic (`SIGABRT`, no stderr, because stderr is denied too). * - `(allow process*)` matches bubblewrap, which does not restrict `exec` * either. A hook can run whatever it can READ, and what it can read is the * mount set. Withholding the path is the boundary in both backends. * - `(allow network*)` is D9: the network is not namespaced. D12 scopes * reachability by withholding the credential, never by filtering packets. * * Everything else this profile does NOT say is deliberate. `/etc` is absent * because it is absent from D9's table, so on macOS a hook that resolves a * hostname is not stopped by THIS profile: `(allow network*)` lets it reach * the system resolver, which answers out of process in mDNSResponder * (measured 2026-08-30, task 4.13's suite). Withholding `/etc/resolv.conf` * does withhold resolution on Linux, where the file is the resolver's * configuration. Adding `/etc` here alone is the drift task 4.7 exists to * prevent. * * @param set - Paths already resolved through `realpath`. Not optional: a rule * naming an unresolved path does not match, and the failure is silent in * both directions (D8). Measured: with the module tree named as `/tmp/…` * rather than `/private/tmp/…` the rule does not apply, and bun cannot read * the cwd it was handed. */ export function toSandboxProfile(set: MountSet): string { const lines = [ '(version 1)', '(import "/System/Library/Sandbox/Profiles/bsd.sb")', '(deny default)', '(allow process*)', '(allow network*)', ]; // Every ANCESTOR of every mount, as a directory node and nothing more. // // This row has no bubblewrap counterpart and that is exactly why it exists. // bubblewrap builds a new filesystem: to bind `/a/b/c` it must CREATE `/a/b` // inside the namespace, so the parents come for free. `sandbox-exec` filters // the tree that is already there and grants nothing implicitly, so every // parent stays denied. // // What breaks is module resolution, and it breaks in a way that names none of // this. Bun resolves a bare import by walking UP from the importing file // testing each `/node_modules`. The shim's own `node_modules` is // bound, but the directories BETWEEN are not, so the walk dies early, bun // falls back to auto-install, and it tries to create `node_modules` in the // read-only module tree. The message is `bun is unable to write files: // PermissionDenied` — a write error for what is really a read denial three // steps earlier. Measured 2026-08-27 by A/B on one variable: with a writable // working directory the shim starts, with a read-only one it does not. // // `literal`, never `subpath`. A literal grant on a directory permits `stat` // and `readdir` of that directory ALONE and confers nothing on the files in // it. So `/var/celilo` becomes listable, which reveals that a file named // `master.key` exists, and reading its bytes stays denied. That is the // difference between D9's criterion holding and not, so do not "simplify" // this to a subpath. for (const path of ancestorsOf(set.entries)) { lines.push(`(allow file-read* (literal ${sbplString(path)}))`); } for (const e of set.entries) { // No tmpfs on macOS, and none is needed: `deny default` already makes the // path unreadable, which is the privacy half of the row. The usability // half — a working scratch directory — is what macOS does not get, so a // hook writing to /tmp gets EPERM here and a discarded success on Linux. // Louder than Linux rather than weaker, and named so nobody has to guess. if (e.mode === 'tmpfs') { lines.push(`; ${e.path}: no tmpfs backend; denied by default (${e.reason})`); continue; } lines.push(`; ${e.reason}`); if (e.mode === 'rw') { lines.push(`(allow file-read* file-write* (subpath ${sbplString(e.path)}))`); continue; } // The explicit deny makes `ro` mean read-only whatever preceded it, rather // than relying on nothing earlier having granted write to a parent. That // is true of today's derivation order and is not a property anyone should // have to re-verify after editing it. lines.push(`(deny file-write* (subpath ${sbplString(e.path)}))`); lines.push(`(allow file-read* (subpath ${sbplString(e.path)}))`); } return `${lines.join('\n')}\n`; } /** * Every directory strictly above one of these mounts, nearest-first order * irrelevant, deduplicated. Excludes the mount paths themselves, which carry * their own rules. */ function ancestorsOf(entries: readonly MountEntry[]): string[] { const own = new Set(entries.map((e) => e.path)); const found = new Set(); for (const e of entries) { let dir = dirname(e.path); while (dir !== dirname(dir)) { if (!own.has(dir)) found.add(dir); dir = dirname(dir); } found.add('/'); } return [...found]; } /** A path as an SBPL string literal. */ function sbplString(path: string): string { return `"${path.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; }