/** * The unjailed advisory lint (hook-process-boundary task 4.7, design D8). * * On a host with no jail backend the hook runs with ambient filesystem * access, so a hook that reads outside its tree works on that host and fails * on the fleet — a divergence otherwise found in production one deploy at a * time. This file closes the gap by making the runner shim check each * filesystem access against the SAME mount set the jail would have enforced. * It is the derivation's second consumer, which is the point: one computation * and two consumers cannot drift the way a consumer and a hand-maintained * list can. * * **This is a lint, not a security boundary, and neither the code nor its * output may describe it as one.** It observes `node:fs` calls from inside * the hook's own process, and module code bypasses it trivially — a `Bun.file` * call, a `dlopen`, or a subprocess never crosses a wrapper. What it claims, * and all it claims, is: on a jailed host, this access would have failed. * * **Channel.** The mount set arrives in the child's environment * (`HOOK_MOUNT_SET_ENV`), set only when the run is unjailed — presence is the * signal to install, so a jailed run carries nothing and installs nothing. * * **WHY THIS MODULE SELF-INSTALLS AT EVALUATION, AND WHY IT MUST STAY A * LEAF.** Measured on Bun 1.3: the first ESM import of `node:fs` anywhere in * a process resolves the builtin's named exports and never revisits them, so * a wrapper planted on `require('node:fs')` AFTER that first import is * invisible to every later `import { readFileSync } from 'node:fs'`. The * shim's other imports (`jail.ts`, `@celilo/capabilities`) ESM-import * `node:fs`, so the wrappers must be in place before any of them load. Hence * `hook-runner-entry.ts`: the executor spawns IT, its first statement forces * this module's evaluation and the install, and only then does the real * runner load. Import order inside the runner is therefore irrelevant — but * this module still imports nothing but `node:path`, `zod` (through * `hook-protocol.ts`, which pulls no other builtins), and type-only imports, * because the entry's guarantee is only as good as this file's graph is * shallow. Importing `jail.ts` from here would silently disarm the lint; the * integration tests in `unjailed-lint.test.ts` go red if that happens (they * did, while this was being landed). * * The self-install is guarded on the runner shim's own environment variable, * so a parent celilo process that imports this module for * `mountSetEnvValue` never wraps its own filesystem. */ import { basename, dirname, isAbsolute, resolve } from 'node:path'; import { HOOK_MOUNT_SET_ENV, HOOK_SOCKET_ENV, type MountSetWire, MountSetWireSchema, } from './hook-protocol'; import type { MountSet } from './mount-set'; /** * The two paths the jail's namespace itself provides — bubblewrap's * `--proc /proc` and `--dev /dev` (`jail.ts`'s JAIL_NAMESPACE_ARGS is built * from this constant, so the two cannot drift) — rather than derivation * rows. Counting them absent would be a false warning on every hook that * touched either. */ export const JAIL_PROVIDED_PATHS = ['/proc', '/dev'] as const; /** * `realpathSync` captured at module load, before the wrappers go in. * `classifyAccess` runs from INSIDE a wrapped call (a wrapper reports, report * classifies), so reaching for the module property here would re-enter the * wrapper and classify forever. */ const PRISTINE_REALPATH = (require('node:fs') as typeof import('node:fs')).realpathSync; /** The verdict for one access, against one mount set. */ export type AccessVerdict = 'allowed' | 'absent' | 'read-only'; /** * Distinct paths warned about before the lint stops listing and summarises. * A hook walking a directory outside the set would otherwise emit one line * per file; after this many distinct paths the reader has the point. */ const WARN_CAP = 25; /** * Serialise a derived mount set for `HOOK_MOUNT_SET_ENV`. * * The executor calls this with the set it just derived; `parseLintMountSet` * is the only thing on the reading side. */ export function mountSetEnvValue(set: MountSet): string { return JSON.stringify(set); } /** * Read and validate the mount set out of the child's environment. * * `undefined` when the variable is absent — the jailed case, and every * invocation with no module tree. A value that fails validation is reported * to stderr (the executor forwards it through the logger) and the run * proceeds WITHOUT the lint rather than failing the hook over a diagnostic: * the lint must never be the reason a deploy breaks. A silent skip would be * the one thing worse than that, hence the stderr line. */ export function parseLintMountSet(value: string | undefined): MountSetWire | undefined { if (value === undefined || value === '') return undefined; let json: unknown; try { json = JSON.parse(value); } catch (error) { process.stderr.write( `hook runner: ${HOOK_MOUNT_SET_ENV} is not valid JSON (${error instanceof Error ? error.message : String(error)}); the unjailed advisory lint is off this run.\n`, ); return undefined; } const parsed = MountSetWireSchema.safeParse(json); if (parsed.success) return parsed.data; process.stderr.write( `hook runner: ${HOOK_MOUNT_SET_ENV} failed validation; the unjailed advisory lint is off this run.\n`, ); return undefined; } /** * Warnings emitted before the hook's logger exists are buffered here and * flushed by `forwardLintWarnings`. In practice there are none — the shim * does no filesystem work between load and hook start — but the lint must * never assume that. */ const buffered: string[] = []; let emit: ((message: string) => void) | undefined; let installed = false; /** * Install the wrappers now, if this process is an unjailed hook runner. * * Idempotent, and called from TWO places by design: this module's own * evaluation, and `hook-runner-entry.ts`'s first statement. Which one lands * first depends on Bun's import evaluation order, which is not a thing to * reason about twice — the guard makes both orders correct, and the entry * makes one of them certain before any other module in the child process can * ESM-load `node:fs` (see the docblock at the top). No-op everywhere else: no * runner socket in the environment means this is not a hook runner process. */ export function installUnjailedLintIfUnjailed(): void { if (installed) return; if (process.env[HOOK_SOCKET_ENV] === undefined) return; const set = parseLintMountSet(process.env[HOOK_MOUNT_SET_ENV]); if (!set) return; installed = true; install(set, (message) => (emit ? emit(message) : buffered.push(message))); } /** * Hand the lint's output to the hook's logger, draining anything buffered. * Call once the shim's logger exists, before the hook script is imported. */ export function forwardLintWarnings(to: (message: string) => void): void { emit = to; for (const message of buffered.splice(0)) to(message); } /** * Classify one access against the mount set. * * Last matching row wins, in `entries` order — that is bubblewrap's own rule * (`--ro-bind` then a later `--bind` overrides), and the derivation emits its * rows in exactly that order. A path under no row is absent, which is what * the jail actually produces (mount-set.ts: not "denied", ABSENT). * * Relative paths resolve against `set.chdir`, because that is the working * directory the jailed hook runs in (`--chdir`, task 4.2i) — the child's real * cwd is celilo's, which is a directory the jail does not contain. */ export function classifyAccess(set: MountSetWire, path: string, write: boolean): AccessVerdict { const absolute = isAbsolute(path) ? path : resolve(set.chdir, path); const candidates = realpathCandidates(absolute); let verdict: AccessVerdict = 'absent'; for (const candidate of candidates) { verdict = strongest(verdict, classifyOne(set, candidate, write)); } return verdict; } /** * The lexical path plus, when it can be resolved, its realpath. * * The mount set the lint compares against is the REALPATHED one * (`realpathRequest`, task 4.2k — the jail binds real paths). A dev checkout * on macOS reaches the hook through symlinked prefixes (`/tmp` → * `/private/tmp`, `/var` → `/private/var`), so a hook writing the path celilo * handed it and the same path as bound can disagree lexically. The lint * accepts either form as "inside": the lint runs only where there is no jail * to match byte-for-byte, and the alternative is a warning on every state * write on a Mac. * * The realpath of the FULL path usually does not exist — the check fires * before a create, and `realpathSync` fails on the file being created. So * this resolves the longest existing ancestor and reattaches whatever is * left: for `/cursor`, where only `` exists, the second * candidate is the realpath of `` plus `/cursor`. */ function realpathCandidates(path: string): string[] { const candidates = [path]; let suffix = ''; let current = path; for (;;) { try { const real = PRISTINE_REALPATH(current) + suffix; if (!candidates.includes(real)) candidates.push(real); return candidates; } catch { const parent = dirname(current); if (parent === current) return candidates; suffix = `/${basename(current)}${suffix}`; current = parent; } } } function classifyOne(set: MountSetWire, absolute: string, write: boolean): AccessVerdict { let verdict: AccessVerdict = 'absent'; for (const entry of set.entries) { if (!covers(entry.path, absolute)) continue; verdict = entry.mode === 'ro' && write ? 'read-only' : 'allowed'; } for (const provided of JAIL_PROVIDED_PATHS) { if (covers(provided, absolute)) verdict = 'allowed'; } return verdict; } function covers(root: string, path: string): boolean { const prefix = root.endsWith('/') ? root : `${root}/`; return path === root || path.startsWith(prefix); } /** The more informative of two verdicts, for the realpath candidate pair. */ function strongest(a: AccessVerdict, b: AccessVerdict): AccessVerdict { if (a === 'allowed' || b === 'allowed') return 'allowed'; if (a === 'read-only' || b === 'read-only') return 'read-only'; return 'absent'; } /** * Wrap every path-taking function this file knows about on both `node:fs` * and `node:fs/promises`. The wrap fires BEFORE the underlying call, so it * observes attempts — including attempts that succeed locally only because * there is no jail, which is exactly the divergence it exists to surface. * * Warnings dedupe per (verdict, path) and are capped at WARN_CAP distinct * paths, with one notice when the cap bites. */ function install(set: MountSetWire, warn: (message: string) => void): void { const warned = new Set(); let suppressed = 0; const report = (rawPath: string, write: boolean): void => { if (typeof rawPath !== 'string' || rawPath === '') return; const absolute = isAbsolute(rawPath) ? rawPath : resolve(set.chdir, rawPath); const verdict = classifyAccess(set, absolute, write); if (verdict === 'allowed') return; const key = `${verdict}:${absolute}`; if (warned.has(key)) return; if (warned.size >= WARN_CAP) { suppressed += 1; if (suppressed === 1) { warn( `Hook advisory: further path(s) outside the hook's mount set will be suppressed after ${WARN_CAP}.`, ); } return; } warned.add(key); warn(advisoryMessage(verdict, absolute)); }; for (const moduleId of ['node:fs', 'node:fs/promises'] as const) { const mod = require(moduleId) as Record; for (const [name, pathArgsFor] of Object.entries(PATH_ARGS)) { const original = mod[name]; if (typeof original !== 'function') continue; mod[name] = function (this: unknown, ...call: unknown[]) { const pathArgs = typeof pathArgsFor === 'function' ? pathArgsFor(call) : pathArgsFor; for (const [index, write] of pathArgs) { const value = call[index]; if (typeof value === 'string') report(value, write); else if (value instanceof URL && value.protocol === 'file:') { report(value.pathname, write); } } return (original as (...args: unknown[]) => unknown).apply(this, call); }; } } } function advisoryMessage(verdict: AccessVerdict, path: string): string { if (verdict === 'read-only') { return [ `Hook advisory: '${path}' is read-only in the hook's mount set,`, 'so on a jailed host this write would fail.', 'Advisory lint, not a security boundary; module code bypasses it trivially.', ].join(' '); } return [ `Hook advisory: '${path}' is outside the hook's mount set,`, 'so on a jailed host this access would fail (the path is absent there, ENOENT).', 'Advisory lint, not a security boundary; module code bypasses it trivially.', ].join(' '); } /** * Which arguments of which functions carry a path, and whether the access is * a write. Read and write variants share the table; a name missing from a * given module is simply skipped, so one table covers `node:fs`, its `Sync` * variants and `node:fs/promises`. * * Deliberately a table of the common calls rather than an exhaustive census: * a function missed here is a warning not emitted, never a behaviour change. * The lint is advisory and module code bypasses it trivially (design D8); * exhaustive coverage would buy a boundary-shaped guarantee it cannot keep. */ const READ0: readonly (readonly [number, boolean])[] = [[0, false]]; const WRITE0: readonly (readonly [number, boolean])[] = [[0, true]]; const TWO_PATH: readonly (readonly [number, boolean])[] = [ [0, false], [1, true], ]; const PATH_ARGS: Record< string, | readonly (readonly [number, boolean])[] | ((call: unknown[]) => readonly (readonly [number, boolean])[]) > = { // Reads. readFile: READ0, readFileSync: READ0, readdir: READ0, readdirSync: READ0, stat: READ0, statSync: READ0, lstat: READ0, lstatSync: READ0, access: READ0, accessSync: READ0, existsSync: READ0, realpath: READ0, realpathSync: READ0, readlink: READ0, readlinkSync: READ0, createReadStream: READ0, opendir: READ0, opendirSync: READ0, // Writes. writeFile: WRITE0, writeFileSync: WRITE0, appendFile: WRITE0, appendFileSync: WRITE0, mkdir: WRITE0, mkdirSync: WRITE0, mkdtemp: WRITE0, mkdtempSync: WRITE0, rm: WRITE0, rmSync: WRITE0, rmdir: WRITE0, rmdirSync: WRITE0, unlink: WRITE0, unlinkSync: WRITE0, chmod: WRITE0, chmodSync: WRITE0, chown: WRITE0, chownSync: WRITE0, truncate: WRITE0, truncateSync: WRITE0, utimes: WRITE0, utimesSync: WRITE0, createWriteStream: WRITE0, symlink: [[1, true]], // Both ends are paths. rename: TWO_PATH, renameSync: TWO_PATH, copyFile: TWO_PATH, copyFileSync: TWO_PATH, cp: TWO_PATH, cpSync: TWO_PATH, // Flag-dependent: 'r' reads, everything else writes. open: OPEN_FLAGS, openSync: OPEN_FLAGS, }; /** `open`'s flags argument decides read from write. */ function OPEN_FLAGS(call: unknown[]): readonly (readonly [number, boolean])[] { const flags = call[1]; const flag = typeof flags === 'string' ? flags : 'r'; return [[0, flag !== 'r']]; } installUnjailedLintIfUnjailed();