/** * The hook jail's caller (openspec/changes/hook-process-boundary, D8 and D9). * * `mount-set.ts` computes WHAT a hook may see. This file decides whether a jail * is available at all, turns that computation into a command line, and records * which of the two happened. It is the half that touches the machine, kept out * of the derivation so the derivation stays hermetic. * * Three things live here and they answer three different questions: * * - `detectJailBackend()` — CAN this host jail? Measured by running * bubblewrap, never by looking for the binary. D8's table records four * distinct denials that all leave `bwrap` sitting on disk. * - `planJailedSpawn()` — pure. Given a backend, a policy and a mount set, * what command does celilo spawn? (Rule 10.4.) * - `recordJailMode()` — WHICH happened, written down. D8 is explicit that * the mode is state and not a log line: a per-invocation warning on a fleet * that deploys often is noise, noise gets filtered, and filtered is * indistinguishable from absent. The row that matters is a host that used * to jail and has stopped, and you cannot see a transition in a log nobody * reads. */ import { execFileSync, spawnSync } from 'node:child_process'; import type { Dirent } from 'node:fs'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs'; import { hostname, tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { getDataDir } from '../config/paths'; import { HOOK_SOCKET_ENV } from './hook-protocol'; import { type MountEntry, type MountSet, type MountSetRequest, deriveMountSet, toBwrapArgs, toSandboxProfile, } from './mount-set'; import { JAIL_PROVIDED_PATHS } from './unjailed-lint'; /** * Which jail celilo can build here. `none` means the hook runs unjailed. * * `sandbox-exec` is in the union ahead of its backend (task 4.8) because the * POLICY for it is decided and landed first (ce-29z): `auto` defers on it * until D14's declared-path mounts exist, and the planner below enforces * that whether or not a platform reports the backend yet. */ export type JailBackend = 'bubblewrap' | 'sandbox-exec' | 'none'; /** * Whether `auto` may jail through `sandbox-exec`. * * False on purpose, and the load-bearing fact of ce-29z. Measured there, * same hardware, one variable (`CELILO_HOOK_JAIL=off` equals main's * behaviour): under bubblewrap a fixture hook's write to an undeclared host * path lands in the private tmpfs (D13b) and succeeds silently, while under * `sandbox-exec` the same write is `EPERM` — the hook exits non-zero and the * full `apps/celilo` suite goes red on macOS where it is green on Linux CI. * Sandbox-exec DENIES rather than masks, so the macOS jail is stricter than * the Linux one for exactly the paths nobody declared. * * The mechanism that declares them is D14 (`type: path` on a manifest * variable), and D14 is not built speculatively — task 4.2a measured the * population and found no consumer that survives D9b. So `auto` defers on * this backend until that mechanism lands; flip this to `true` as part of * that landing, never before. `CELILO_HOOK_JAIL=required` bypasses the * deferral: it is the operator's explicit act (D8), and an operator who sets * it on macOS has opted into the strictness. */ const SANDBOX_EXEC_AUTO_JAIL_ENABLED = false; /** * Why an `auto` sandbox-exec spawn was deferred. Operator-facing: it names * the decision and the one way past it. Exported because `system doctor` * renders the same deferral from (policy, availability) alone, where no plan * — and so no plan reason — exists yet. */ export const SANDBOX_EXEC_AUTO_DEFERRED_REASON = 'macOS hooks run unjailed: sandbox-exec denies (EPERM) a hook write to any host path the mount set does not declare, where bubblewrap masks it into its private tmpfs — undeclared config paths would break module hooks on this host. The declaration mechanism (D14, manifest `type: path`) is not built, so auto defers on this backend (ce-29z). Set CELILO_HOOK_JAIL=required to jail anyway.'; /** * Does `auto` defer on this backend? Shared by the planner and by * `system doctor`, so the doctor cannot report a jail the executor will not * build. */ export function autoJailDefers(availability: JailAvailability): boolean { return availability.backend === 'sandbox-exec' && !SANDBOX_EXEC_AUTO_JAIL_ENABLED; } /** * What the operator asked for. `CELILO_HOOK_JAIL`, and the default is `off` * (peba's ruling on ce-rez7; `resolveJailPolicy`'s doc comment below has the * reversal trigger). * * `required` is D8's end state, reached as an explicit act once stage 2 has * been proven on a host: an unavailable jail becomes a hard failure rather * than a recorded fact. `off` is the escape hatch for the reverse case — a * hook that has not yet been walked against the jail (task 4.13) and needs to * run today. */ export type JailPolicy = 'auto' | 'off' | 'required'; /** Whether the hook that just ran was jailed. The recorded state (D8). */ export type JailMode = 'jailed' | 'unjailed'; export interface JailAvailability { readonly backend: JailBackend; /** * Why there is no backend, in one sentence an operator can act on. * Absent when there is one. `celilo system doctor` surfaces it (task 4.6). */ readonly reason?: string; } export interface JailPlan { /** The command celilo spawns, jail wrapper included. */ readonly cmd: readonly string[]; readonly mode: JailMode; readonly backend: JailBackend; readonly reason?: string; /** * Mount rows the derivation asked for that do not exist on this host. * * bubblewrap fails the whole jail on a bind whose SOURCE is missing, and * several rows are legitimately absent: `/lib64` does not exist on arm64, * and `/generated` only appears once celilo has generated something. * Dropping them is the caller's job rather than the derivation's, which is * why they are reported rather than silently filtered. * * @psbanka - 2026-09: these carry the whole ROW now, not just the path. They * used to be `string[]`, built by `.map((e) => e.path)`, which threw away the * `reason` and left the caller to classify an absence by reading a path * string. It could not, so it warned about every one of them: 96 identical * lines in a single `cele2e run --all`, with the one case that means silent * data loss hidden among them. A signal that repeats unchanged is one every * reader learns to skip. */ readonly skipped: readonly MountEntry[]; /** * The directory to spawn the child in, when the backend cannot set it itself. * * bubblewrap takes `--chdir`, so its plans leave this absent and the child * inherits celilo's cwd — which the jail has replaced anyway. * `sandbox-exec` has no equivalent, so the child inherits the OPERATOR's cwd * and the profile does not name it. Measured 2026-08-27: `bun` reads its * working directory before running anything, so a cwd outside the profile * kills it with `error: An unknown error occurred (Unexpected)` and no * further diagnostic. Naming the mount set's `chdir` here is the fix. */ readonly cwd?: string; } /** * Namespace flags, shared by the probe and the real spawn so they cannot * drift. * * A probe that clears a weaker bar than the spawn is the failure CLAUDE.md * names: a correct assertion about the wrong subject. If any flag here is * denied, the probe fails and celilo records `unjailed` instead of discovering * it one hook at a time. * * **`--unshare-net` is deliberately absent.** D9 says the network is not * namespaced; D12 scopes reachability by withholding the credential instead. * * `--unshare-pid` needs a fresh `/proc` or the jail shows the host's process * table, and `/proc/1/root` is a well-worn way to read out of one. It also * makes the kill in D7 total: `bwrap` is pid 1 inside the namespace, and the * kernel reaps every process in a pid namespace whose init dies. * * `--new-session` is NOT here. It defends against TIOCSTI injection into a * controlling terminal, and the child is spawned with `stdout: 'pipe'` and no * tty, so there is nothing to inject into. * * The `--proc`/`--dev` pairs are BUILT from `JAIL_PROVIDED_PATHS` * (unjailed-lint.ts), the same list the advisory lint reads, so the spawn and * the lint cannot disagree about what the namespace itself provides. Adding a * path to that constant without a flag here is a compile error. */ const JAIL_PROVIDED_FLAGS: Record<(typeof JAIL_PROVIDED_PATHS)[number], string> = { '/proc': '--proc', '/dev': '--dev', }; const JAIL_NAMESPACE_ARGS = [ '--unshare-user', '--unshare-ipc', '--unshare-pid', '--unshare-uts', // The one unshare that legitimately may be unavailable on an older kernel. '--unshare-cgroup-try', '--die-with-parent', ...JAIL_PROVIDED_PATHS.flatMap((path) => [JAIL_PROVIDED_FLAGS[path], path]), ] as const; /** * The jail builder. * * Resolved from `PATH` rather than pinned to `/usr/bin/bwrap`. The AppArmor * profile D8 ships attaches BY PATH, so a `bwrap` found somewhere else carries * no profile — but that case fails the probe below rather than passing * silently, and celilo then records `unjailed` with the parser's own message. * A visible wrong answer is worth more than a pinned path that is wrong on a * distribution nobody tested. */ const BWRAP = 'bwrap'; /** * The macOS jail builder. * * Pinned absolute, unlike `BWRAP`. There is no AppArmor-style profile to miss * here, so `PATH` buys nothing and costs the one thing that matters: a * `sandbox-exec` earlier on `PATH` would be handed every hook's profile and * could report a jail it never built. The probe below would catch that, and * pinning means it never has to. */ const SANDBOX_EXEC = '/usr/bin/sandbox-exec'; /** * The runner shim, named here as well as in the executor because the probe has * to spawn the REAL one. A probe that starts a smaller program answers a * question about a smaller program. */ const HOOK_RUNNER_PATH = join(import.meta.dir, 'hook-runner.ts'); /** How long to wait for the probe before calling the backend unavailable. */ const PROBE_TIMEOUT_MS = 10_000; const JAIL_MODE_FILE = 'hook-jail-mode.json'; /** Cleared by nothing: a host does not gain a jail mid-process. */ let probed: JailAvailability | undefined; /** * Can this host build a jail? Measured, cached for the process. * * The probe RUNS bubblewrap, with the same namespace flags the real spawn * uses, against a whole-filesystem bind. Presence is not the question: D8 * measured four distinct denials — a missing `CAP_SYS_ADMIN`, a seccomp * filter, an AppArmor policy, and Docker's masked `/proc` — and every one of * them leaves the binary exactly where it was. * * `bun` is the command because it is the one binary guaranteed to be here: we * are running in it. A probe that execs `/bin/true` fails for a missing * `/bin/true` and reads exactly like a denied namespace. */ export function detectJailBackend(): JailAvailability { if (!probed) probed = probeJailBackend(); return probed; } function probeJailBackend(): JailAvailability { if (process.platform === 'darwin') return probeSandboxExec(); if (process.platform !== 'linux') { return { backend: 'none', reason: `No hook jail backend exists for platform '${process.platform}'. Hooks run unjailed on this host.`, }; } try { execFileSync( BWRAP, [...JAIL_NAMESPACE_ARGS, '--ro-bind', '/', '/', '--', process.execPath, '--version'], { stdio: 'ignore', timeout: PROBE_TIMEOUT_MS }, ); return { backend: 'bubblewrap' }; } catch (error) { return { backend: 'none', reason: unavailableReason(error) }; } } /** * What the probe plants and then tries to read. Its ABSENCE from the output is * the evidence, so it is a string nothing else would print. */ const SANDBOX_PROBE_MARKER = 'celilo-jail-probe-reached-the-planted-file'; /** * Can this Mac jail? Measured by proving a DENIAL, never by finding the binary. * * This probe is shaped differently from bubblewrap's and the difference is the * point. bubblewrap FAILS when it cannot build a namespace, loudly, with one of * four distinct denials — so running it at all is the measurement. * `sandbox-exec` does not fail. A profile it cannot apply to a path is simply * not applied: the access succeeds and nothing anywhere reports an error (D8, * measured, and re-measured 2026-08-27 in both directions — a rule naming * `/tmp/x` where the kernel sees `/private/tmp/x` neither denies nor grants). * * So "sandbox-exec exists" is not evidence of anything, and neither is "the * profile parsed". The only honest question is whether a file this probe * planted, outside the profile, is actually unreachable from inside it. That is * CLAUDE.md's reach probe applied to the jail itself: plant a marker, run the * REAL machinery, and read which markers fired. * * Three outcomes, and the middle one is the one worth having: * * - the read was refused → `sandbox-exec`, the jail is real * - the read SUCCEEDED → `none`, and the reason says fail-open. * A jail that is believed and absent is worse than no jail (D8). * - `sandbox-exec` never ran → `none`, with its own message */ function probeSandboxExec(): JailAvailability { let scratch: string | undefined; try { scratch = mkdtempSync(join(realpathOrSelf(tmpdir()), 'celilo-jail-probe-')); const allowed = join(scratch, 'allowed'); const planted = join(scratch, 'planted'); mkdirSync(join(allowed, 'state'), { recursive: true }); writeFileSync(planted, SANDBOX_PROBE_MARKER); // The REAL derivation, the REAL renderer, and the REAL shim path, so this // clears the same bar the spawn does. Every shortcut here has already been // measured to matter: a mount set built around `bun -e` omits the shim's // `node_modules` rows, and the runtime then starts under the probe's // profile and dies under the executor's. const profile = toSandboxProfile( deriveMountSet({ modulePath: allowed, stateDir: join(allowed, 'state'), socketDir: allowed, runtimePath: process.execPath, runnerPath: HOOK_RUNNER_PATH, runtimeModulePaths: runtimeModulePathsFor(HOOK_RUNNER_PATH), pathInputs: [], }), ); const run = (cmd: readonly string[]) => spawnSync(SANDBOX_EXEC, ['-p', profile, '--', ...cmd], { cwd: allowed, encoding: 'utf-8', timeout: PROBE_TIMEOUT_MS, }); // 1. Can the RUNTIME start? Spawn the actual shim with no socket in its // environment: it refuses immediately with a known message, having // already loaded itself, `@celilo/capabilities` and Zod. That is the // whole of the startup path, and it is where a jail that denies the // runtime something it needs shows up. const started = run([process.execPath, HOOK_RUNNER_PATH]); if (started.error) throw started.error; if (!started.stderr?.includes(HOOK_SOCKET_ENV)) { return { backend: 'none', 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)'}`, }; } // 2. Does a DENIAL actually apply? `sandbox-exec` never fails on a rule it // cannot match — the access simply succeeds and nothing reports an // error (D8, measured). So the profile is not evidence; a file this // probe planted outside the mount set, and could not read, is. const denied = run([ process.execPath, '-e', `try{require('node:fs').readFileSync(${JSON.stringify(planted)});console.log('${SANDBOX_PROBE_MARKER}')}catch{console.log('refused')}`, ]); if (denied.error) throw denied.error; if ((denied.stdout ?? '').includes(SANDBOX_PROBE_MARKER)) { return { backend: 'none', reason: '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.', }; } return { backend: 'sandbox-exec' }; } catch (error) { const spawnFailure = (error as { code?: string } | null)?.code; if (spawnFailure === 'ENOENT') { return { backend: 'none', 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.`, }; } return { backend: 'none', reason: `sandbox-exec could not run the probe, so hooks run unjailed: ${error instanceof Error ? error.message : String(error)}`, }; } finally { if (scratch) rmSync(scratch, { recursive: true, force: true }); } } /** * Turn the probe's failure into something an operator can act on. * * Deliberately reads the FAILURE and not a message: D8 records one string * (`setting up uid map: Permission denied`) arriving from two unrelated * causes, which is why the guidance names both rather than guessing. */ function unavailableReason(error: unknown): string { const spawnFailure = (error as { code?: string } | null)?.code; if (spawnFailure === 'ENOENT') { return 'bubblewrap is not installed, so hooks run unjailed. Install it (`apt install bubblewrap`) and re-run.'; } return [ 'bubblewrap is installed but could not build a namespace, so hooks run unjailed.', 'On Ubuntu 24.04 this is kernel.apparmor_restrict_unprivileged_userns=1 refusing a user namespace to an unprofiled binary;', 'celilo ships /etc/apparmor.d/celilo-hook-jail to grant it, so check that the profile loaded (`apparmor_parser -Q --skip-cache /etc/apparmor.d/celilo-hook-jail`).', 'Inside a container it is more likely a dropped capability or a masked /proc.', ].join(' '); } /** * What the operator asked for, from `CELILO_HOOK_JAIL`. * * Fails fast on a value it does not know (Rule 4.2). A typo'd * `CELILO_HOOK_JAIL=requried` that silently meant `auto` would read as the * jail being enforced when it is not, which is the one mistake this variable * exists to prevent. * * The unset/empty default is `off` (peba's ruling on ce-rez7): jailing must * begin because someone set a switch, not because a package upgrade installed * a backend. Turning the jail ON is a deliberate act. */ export function jailPolicy(): JailPolicy { const raw = process.env.CELILO_HOOK_JAIL; if (raw === undefined || raw === '') return 'off'; if (raw === 'auto' || raw === 'off' || raw === 'required') return raw; throw new Error( `CELILO_HOOK_JAIL='${raw}' is not a hook jail policy. Use 'auto' (jail when a backend is available), 'required' (an unavailable jail is a hard failure), or 'off'.`, ); } /** Where the effective jail policy came from. Rendered by `system doctor` (D4). */ export type JailPolicySource = 'env' | 'module' | 'config' | 'default'; /** How much jailing a policy does. Lower is weaker (per-module-jail-policy). */ export const POLICY_STRENGTH: Record = { off: 0, auto: 1, required: 2 }; /** * Whether the module's own policy jails LESS than the system's would — the * definition of an exemption (per-module-jail-policy task 3.1-3.3). * * Shared by `module jail`'s weakening interview, `module list`'s marker, * `system doctor`'s count and `system audit`'s category, so the four * surfaces cannot disagree about what an exemption is. */ export function isWeakerJailPolicy(modulePolicy: JailPolicy, systemPolicy: JailPolicy): boolean { return POLICY_STRENGTH[modulePolicy] < POLICY_STRENGTH[systemPolicy]; } /** * Apply the precedence to the four sources (per-module-jail-policy task 1.2): * env wins, then the module's own recorded policy, then stored system * config, then the default, which is `off` (peba's rulings on ce-rez7 and * ce-8832; see openspec/changes/hook-jail-config-surface/design.md and * openspec/changes/per-module-jail-policy/ for the fixed chain). * * Pure, so both consumers (executor.ts and system-doctor.ts) can fetch the * stored rows themselves and stay testable without a database. * * The empty-string env value is treated as unset, matching `jailPolicy()`'s * own handling — one rule for "what counts as set", not a second one here. * * The stored paths are deliberately stricter than the env path: a stored * empty string THROWS (peba's ruling on ce-8832). The set-time pattern * `^(auto|off|required)$` rejects `''`, so a stored one can only arrive via * a restore from file or a hand edit to the DB — exactly the untrusted * paths D3 exists to catch. Resolving it to a silent `auto` would let * foreign state pick the jailing default with nobody told, and failing * closed is the whole point. An empty CELILO_HOOK_JAIL stays "not set", * because `jailPolicy()` relies on that and slice 1's safety rests on the * two resolvers agreeing. * * The module row follows the same throw rule as the system key (task 1.3). * `undefined` is the everyday case — no row, follow the system — and a row * keyed to a module that was removed is impossible by schema: the FK * cascades the delete. */ export function resolveJailPolicy( envValue: string | undefined, moduleValue: string | undefined, configValue: string | undefined, ): { policy: JailPolicy; source: JailPolicySource } { if (envValue !== undefined && envValue !== '') { if (envValue === 'auto' || envValue === 'off' || envValue === 'required') { return { policy: envValue, source: 'env' }; } throw new Error( `CELILO_HOOK_JAIL='${envValue}' is not a hook jail policy. Use 'auto' (jail when a backend is available), 'required' (an unavailable jail is a hard failure), or 'off'.`, ); } if (moduleValue !== undefined) { if (moduleValue === 'auto' || moduleValue === 'off' || moduleValue === 'required') { return { policy: moduleValue, source: 'module' }; } throw new Error( `the module's jail policy='${moduleValue}' is not a hook jail policy. Use 'auto' (jail when a backend is available), 'required' (an unavailable jail is a hard failure), or 'off'.`, ); } if (configValue !== undefined) { if (configValue === 'auto' || configValue === 'off' || configValue === 'required') { return { policy: configValue, source: 'config' }; } throw new Error( `hooks.jail_policy='${configValue}' is not a hook jail policy. Use 'auto' (jail when a backend is available), 'required' (an unavailable jail is a hard failure), or 'off'.`, ); } return { policy: 'off', source: 'default' }; } /** * Resolve every path in a mount-set request through the filesystem (task 4.2k). * * `deriveMountSet` is pure and says so: it resolves lexically, which cannot * follow a symlink. Two reasons the caller has to. * * `modulePath` is `mod.sourcePath` out of the database, and a database * restored from another box carries that box's absolute paths (ISS-0052, * `restore-from-file.ts`). * * And on macOS the whole jail turns on it: `/tmp` is a symlink to * `/private/tmp`, a `sandbox-exec` rule naming the unresolved path is silently * not applied, the access succeeds, and nothing reports an error (D8, * measured). That backend is task 4.8, but the resolution belongs here now so * it is not a thing 4.8 has to remember. * * A path that does not exist keeps its lexical form. It cannot be resolved and * it will not survive `planJailedSpawn`'s existence filter either. */ export function realpathRequest(request: MountSetRequest): MountSetRequest { return { ...request, modulePath: realpathOrSelf(request.modulePath), stateDir: realpathOrSelf(request.stateDir), screenshotDir: request.screenshotDir ? realpathOrSelf(request.screenshotDir) : undefined, socketDir: realpathOrSelf(request.socketDir), runtimePath: realpathOrSelf(request.runtimePath), runnerPath: realpathOrSelf(request.runnerPath), runtimeModulePaths: request.runtimeModulePaths?.map(realpathOrSelf), pathInputs: request.pathInputs.map((input) => ({ ...input, value: realpathOrSelf(input.value), })), }; } /** * The `node_modules` directories the runner shim resolves its own imports * through, nearest first. * * The shim is not self-contained: it imports `isCompiledHook` from * `@celilo/capabilities` and Zod through `hook-protocol.ts`. Node resolution * walks up from the importing file looking for `node_modules` at each * ancestor, so this walks the same ladder and keeps whichever rungs exist. * * **It collects only directories literally named `node_modules`, and that is * the safety property.** Under an npm install the shim sits at * `/var/celilo/node_modules/@celilo/cli/src/hooks/`, so the ancestor holding * its dependencies is `/var/celilo` — and binding THAT would put `master.key` * and `celilo.db` inside the jail, which is the one outcome the whole change * exists to prevent. Appending `node_modules` before testing for existence is * what makes the difference, so do not "simplify" this into binding the * ancestor itself. */ export function runtimeModulePathsFor( runnerPath: string, exists: (path: string) => boolean = existsSync, ): string[] { const found: string[] = []; let dir = dirname(runnerPath); for (;;) { const candidate = join(dir, 'node_modules'); if (exists(candidate)) { found.push(candidate); found.push(...linkedPackageDirs(candidate)); } const parent = dirname(dir); if (parent === dir) return [...new Set(found)]; dir = parent; } } /** * Package directories inside `dir` that are reachable ONLY through a symlink. * * A workspace install — bun's, pnpm's — puts a link in `node_modules` pointing * sideways at `packages/`. Binding `node_modules` binds the link and not * what it points at, and the shim then dies on * `ENOENT reading ".../node_modules/@celilo/capabilities"`. That is the second * of the two failures this walk exists to prevent, and like the first it was * found by running the jail rather than by reading it. * * An npm install has real directories here, so this finds nothing and costs * one `readdir`. That is why it is written as the general case rather than as * a development special case: the two shapes are the same rule, and a * dev-only branch here would be a jail nobody tests until production. */ function linkedPackageDirs(dir: string): string[] { const linked: string[] = []; let entries: Dirent[]; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return linked; } for (const entry of entries) { // `.bin` holds links to executables, not to packages. Following them binds // single files and buys nothing. if (entry.name === '.bin') continue; const path = join(dir, entry.name); // A scope is a real directory whose MEMBERS are the links. if (entry.isDirectory() && entry.name.startsWith('@')) { linked.push(...linkedPackageDirs(path)); continue; } if (!entry.isSymbolicLink()) continue; try { const target = realpathSync(path); if (statSync(target).isDirectory()) linked.push(target); } catch { // A dangling link resolves to nothing and binds nothing. } } return linked; } function realpathOrSelf(path: string): string { try { return realpathSync(path); } catch { return path; } } /** * Decide the command celilo spawns. * * Planning function (Rule 10.4) — pure, so every branch below is testable * without a jail, which matters because the host running the tests usually has * no jail at all. * * @param cmd - The unjailed command: the runtime and the runner shim. * @param set - The derived mount set, or `undefined` when the caller has no * module tree to jail (the executor's own fixtures, and the bus handler path). */ /** * Why an `off` policy runs unjailed, shared with `celilo system doctor` (task * 4.6) so the sentence the operator reads there is the sentence the record * carries. * * It names no source. Since hook-jail-config-surface there are three (the * environment variable, the stored `hooks.jail_policy` row, and the `off` * default), and this sentence is rendered for all of them. The doctor prints * the source on its own line above this one; the record carries it as state. */ export const JAIL_OFF_REASON = "The hook jail policy is 'off', so hooks run unjailed on this host. Set hooks.jail_policy to 'auto' or 'required' to jail them."; export function planJailedSpawn( cmd: readonly string[], set: MountSet | undefined, availability: JailAvailability, policy: JailPolicy, exists: (path: string) => boolean = existsSync, ): JailPlan { if (policy === 'off') { return { cmd, mode: 'unjailed', backend: availability.backend, reason: JAIL_OFF_REASON, skipped: [], }; } if (availability.backend === 'none' || !set) { const reason = set ? availability.reason : 'This invocation has no module tree to jail, so there is no mount set to enforce.'; if (policy === 'required') { throw new Error( `The hook jail policy is 'required' and no hook jail is available. ${reason ?? ''}`.trim(), ); } return { cmd, mode: 'unjailed', backend: availability.backend, reason, skipped: [] }; } // ce-29z: auto defers on sandbox-exec until D14 exists. Not an availability // question — the backend is there; it is a policy one, which is why this // sits in the planner rather than the probe. if (policy === 'auto' && autoJailDefers(availability)) { return { cmd, mode: 'unjailed', backend: availability.backend, reason: SANDBOX_EXEC_AUTO_DEFERRED_REASON, skipped: [], }; } // A tmpfs needs no source — bubblewrap creates it — so it is never dropped. // The note below (ce-29z, 2026-09) is why 'required' is the only policy that // reaches the sandbox-exec arm while SANDBOX_EXEC_AUTO_JAIL_ENABLED is // false: the flag flip is D14's landing, never 4.8's. const present = set.entries.filter((e) => e.mode === 'tmpfs' || exists(e.path)); const skipped = set.entries.filter((e) => !present.includes(e)); const applied: MountSet = { ...set, entries: present }; // Dropping an absent path is a bubblewrap NEED — it fails the whole jail on a // bind with no source — and is merely tidy for `sandbox-exec`, which happily // names a path that is not there. Both backends drop the same rows anyway, // because a mount set that means two things on two platforms is exactly the // drift task 4.7 exists to prevent. if (availability.backend === 'sandbox-exec') { // `-p` rather than a profile FILE: nothing to create, nothing to clean // up, and nothing whose own readability has to be reasoned about. // `sandbox-exec` reads the profile before it applies the sandbox, so a // file would not have needed to be in the mount set — but it would have // needed a lifetime, and this has none. return { cmd: [SANDBOX_EXEC, '-p', toSandboxProfile(applied), '--', ...cmd], mode: 'jailed', backend: availability.backend, skipped, cwd: set.chdir, }; } return { cmd: [BWRAP, ...JAIL_NAMESPACE_ARGS, ...toBwrapArgs(applied), '--', ...cmd], mode: 'jailed', backend: availability.backend, skipped, }; } export interface JailModeRecord { readonly mode: JailMode; readonly backend: JailBackend; readonly reason?: string; /** * The host this was measured on. * * D8's third state — "unjailed, was jailed yesterday" — is the only one that * raises an alert, and it is defined per host. Without this the same file * copied between boxes, or a database restored onto a new one, reads as a * transition that never happened. */ readonly host: string; readonly recordedAt: string; /** * The jailed record this host most recently held, present only on an * `unjailed` record that replaced one on the SAME host. * * This is what makes the jailed-to-unjailed transition durable. The write * below overwrites the previous record, so without this field the file for * a host that used to jail and has stopped reads identically to a Mac that * never jailed — and the self-monitor (task 4.4) runs on a sweep, long * after `recordJailMode`'s return value is gone. */ readonly lastJailed?: { readonly backend: JailBackend; readonly recordedAt: string; }; } /** * Where the mode is written. `CELILO_HOOK_JAIL_MODE_PATH` overrides it, the * same way `subscriber-store.ts` takes an override for the same reason. */ export function jailModeStorePath(): string { return process.env.CELILO_HOOK_JAIL_MODE_PATH ?? join(getDataDir(), JAIL_MODE_FILE); } /** The last recorded mode, or `undefined` if nothing has recorded one yet. */ export function readJailMode(): JailModeRecord | undefined { const path = jailModeStorePath(); if (!existsSync(path)) return undefined; try { return JSON.parse(readFileSync(path, 'utf-8')) as JailModeRecord; } catch { // A corrupt file is the same as no file for every consumer: the next run // overwrites it. Throwing here would fail a hook over a state write. return undefined; } } /** * Write down which mode this run used, and return what it replaced. * * The previous record is returned rather than acted on. A jailed-to-unjailed * transition on the same host is what raises the self-monitor alert, and that * monitor is task 4.4 — this is the state it will read. * * Best effort by design: a read-only data directory must not fail a hook. */ export function recordJailMode(plan: JailPlan): { previous?: JailModeRecord } { const previous = readJailMode(); const host = hostname(); const record: JailModeRecord = { mode: plan.mode, backend: plan.backend, ...(plan.reason ? { reason: plan.reason } : {}), host, recordedAt: new Date().toISOString(), ...(lastJailedFor(plan.mode, previous, host) ?? {}), }; if ( previous && previous.host === record.host && previous.mode === record.mode && previous.backend === record.backend ) { // Unchanged. Rewriting it every hook would churn the file and lose the // one timestamp worth having: when the mode last CHANGED. return { previous }; } const path = jailModeStorePath(); try { mkdirSync(dirname(path), { recursive: true }); const tmp = `${path}.tmp`; writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`); renameSync(tmp, path); } catch { // Deliberately swallowed, and the only place in this file that is. The // mode is diagnostic state; a hook must not fail because celilo could not // write it down. } return { previous }; } /** * What an unjailed record remembers about the last time this host jailed. * * A record from a DIFFERENT host contributes nothing: that is the * copied-between-boxes case the `host` field exists for, and reading it as a * transition would alert on a move that regressed nothing. A jailed record * remembers nothing either — re-jailing is the recovery, and a later * regression should date from the NEW jailed record, not the first ever. */ function lastJailedFor( mode: JailMode, previous: JailModeRecord | undefined, host: string, ): Pick | undefined { if (mode !== 'unjailed' || !previous || previous.host !== host) return undefined; if (previous.mode === 'jailed') { return { lastJailed: { backend: previous.backend, recordedAt: previous.recordedAt } }; } // unjailed → unjailed (a backend or reason change): the memory rides along. return previous.lastJailed ? { lastJailed: previous.lastJailed } : undefined; }