/** * Hermetic guards on the hook jail's CALLER (design D8, D9). * * `mount-set.test.ts` covers what the derivation computes. This covers what * celilo does with it, and it is deliberately hermetic: the host running these * tests usually has no jail at all (a Mac has none until task 4.8), so a suite * that needed one would skip everywhere and prove nothing. `planJailedSpawn` * is pure and takes the backend and the existence check as arguments for * exactly that reason. * * The live half — a hook that actually cannot reach a path — is * `hook-jail-unreachability.test.ts`, which skips loudly when there is no * backend rather than passing quietly. */ import { describe, expect, test } from 'bun:test'; import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { JAIL_OFF_REASON, type JailAvailability, type JailPlan, jailModeStorePath, jailPolicy, planJailedSpawn, readJailMode, realpathRequest, recordJailMode, resolveJailPolicy, runtimeModulePathsFor, } from './jail'; import { deriveMountSet } from './mount-set'; const AVAILABLE: JailAvailability = { backend: 'bubblewrap' }; const UNAVAILABLE: JailAvailability = { backend: 'none', reason: 'bubblewrap is not installed, so hooks run unjailed.', }; const MODULE = '/var/celilo/modules/caddy'; const CMD = ['/usr/local/bin/bun', '/opt/celilo/src/hooks/hook-runner.ts']; const REQUEST = { modulePath: MODULE, stateDir: `${MODULE}/state`, socketDir: '/tmp/celilo-hook-a1b2c3', runtimePath: CMD[0] as string, runnerPath: CMD[1] as string, pathInputs: [], }; /** Every path exists, so nothing is dropped and the argv is the full set. */ const allPresent = () => true; function plan(): JailPlan { return planJailedSpawn(CMD, deriveMountSet(REQUEST), AVAILABLE, 'auto', allPresent); } describe('the shim is spawned under bwrap', () => { test('bwrap leads, and the unjailed command follows the -- terminator', () => { const { cmd, mode } = plan(); expect(mode).toBe('jailed'); expect(cmd[0]).toBe('bwrap'); expect(cmd.slice(cmd.indexOf('--') + 1)).toEqual(CMD); }); test('the derived mount set reaches the argv, at the mode it derived', () => { const { cmd } = plan(); const args = cmd.join(' '); // The module's own tree read-only, then its writable directory carved on // top of it. Order is what makes the carve-out work, so assert the order // and not merely the presence. expect(args).toContain(`--ro-bind ${MODULE} ${MODULE}`); expect(args).toContain(`--bind ${MODULE}/state ${MODULE}/state`); expect(args.indexOf(`--ro-bind ${MODULE} `)).toBeLessThan( args.indexOf(`--bind ${MODULE}/state`), ); }); test('the jail names its own working directory (task 4.2i)', () => { // The spawn sets no `cwd`, so without this the child inherits celilo's — // whatever directory the operator's shell was in, which need not exist // inside the namespace. bubblewrap then fails on a path nobody chose. const { cmd } = plan(); expect(cmd[cmd.indexOf('--chdir') + 1]).toBe(MODULE); }); test('the network is NOT namespaced, which D9 says on purpose', () => { // Reachability is scoped by withholding the credential (D12), not by // filtering packets. An --unshare-net here would break every hook that // reaches its own systems and would look like a DNS bug. expect(plan().cmd).not.toContain('--unshare-net'); }); test('a fresh /proc comes with the pid namespace', () => { // --unshare-pid without a fresh /proc leaves the host's process table // visible, and /proc/1/root is a well-worn way to read out of a jail. const { cmd } = plan(); expect(cmd).toContain('--unshare-pid'); expect(cmd[cmd.indexOf('--proc') + 1]).toBe('/proc'); }); }); describe('what the jailed argv must never name', () => { // These are the acceptance criterion of the whole change, asserted at the // caller rather than in the derivation: whatever else the wrapping does, it // must not smuggle a path back in. test("celilo's data directory, its master key and a sibling module are absent", () => { const args = plan().cmd.join(' '); expect(args).not.toContain('/var/celilo/master.key'); expect(args).not.toContain('/var/celilo/celilo.db'); expect(args).not.toContain('/var/celilo/modules '); expect(args).not.toContain('/modules/technitium'); }); test('bwrap binds no bwrap, so the jail cannot build a jail', () => { // 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 // inside a namespace of its own. bwrap is the FIRST word of this command // and must not be an argument of it. expect(plan().cmd.slice(1).join(' ')).not.toContain('/bwrap'); }); }); describe('a mount whose source is missing is dropped, not fatal', () => { // bubblewrap fails the whole jail on a bind with no source, and several rows // are legitimately absent: /lib64 does not exist on arm64, and // /generated appears only once celilo has generated something. const missing = `${MODULE}/generated`; const exists = (path: string) => path !== missing; test('it is filtered out of the argv and named in `skipped`', () => { const { cmd, skipped } = planJailedSpawn( CMD, deriveMountSet(REQUEST), AVAILABLE, 'auto', exists, ); expect(skipped.map((m) => m.path)).toContain(missing); expect(cmd.join(' ')).not.toContain(missing); }); test('a tmpfs is never dropped, because it has no source to be missing', () => { const { cmd, skipped } = planJailedSpawn( CMD, deriveMountSet(REQUEST), AVAILABLE, 'auto', () => false, ); expect(skipped).not.toContain('/tmp'); expect(cmd.join(' ')).toContain('--tmpfs /tmp'); }); }); describe("celilo's own dependencies reach the jail, and nothing else does", () => { // The shim is not self-contained: it imports `isCompiledHook` from // @celilo/capabilities and Zod through hook-protocol.ts, and both resolve // ABOVE its own directory. Measured — the jail built correctly and the shim // died on `Cannot find module '@celilo/capabilities'`. const NPM_SHIM = '/var/celilo/node_modules/@celilo/cli/src/hooks/hook-runner.ts'; test('an npm install finds its node_modules, nearest first', () => { const exists = (p: string) => p === '/var/celilo/node_modules'; expect(runtimeModulePathsFor(NPM_SHIM, exists)).toEqual(['/var/celilo/node_modules']); }); test('the data directory holding master.key is NEVER collected', () => { // This is the whole safety property. `/var/celilo` is the ancestor that // holds the shim's dependencies, and it also holds master.key and // celilo.db. Only a path literally named `node_modules` is ever kept, so // the ancestor itself cannot be bound however the walk is refactored. const everythingExists = () => true; const found = runtimeModulePathsFor(NPM_SHIM, everythingExists); expect(found).not.toContain('/var/celilo'); expect(found.every((p) => p.endsWith('/node_modules'))).toBe(true); }); test('the walk stops at the filesystem root rather than looping', () => { expect(runtimeModulePathsFor('/hook-runner.ts', () => false)).toEqual([]); }); test('they are bound read-only, and the derivation puts them in the argv', () => { const { cmd } = planJailedSpawn( CMD, deriveMountSet({ ...REQUEST, runtimeModulePaths: ['/var/celilo/node_modules'] }), AVAILABLE, 'auto', allPresent, ); expect(cmd.join(' ')).toContain('--ro-bind /var/celilo/node_modules /var/celilo/node_modules'); }); }); describe('the three policies', () => { test("'auto' with no backend runs the hook and records why", () => { const p = planJailedSpawn(CMD, deriveMountSet(REQUEST), UNAVAILABLE, 'auto', allPresent); expect(p.mode).toBe('unjailed'); expect(p.cmd).toEqual(CMD); expect(p.reason).toBe(UNAVAILABLE.reason); }); test("'off' declines a jail that IS available", () => { const p = planJailedSpawn(CMD, deriveMountSet(REQUEST), AVAILABLE, 'off', allPresent); expect(p.mode).toBe('unjailed'); expect(p.cmd).toEqual(CMD); }); test("'required' turns an unavailable jail into a hard failure (task 4.5)", () => { expect( () => planJailedSpawn(CMD, deriveMountSet(REQUEST), UNAVAILABLE, 'required', allPresent), // Not /CELILO_HOOK_JAIL=required/. Since hook-jail-config-surface the // policy can come from the stored `hooks.jail_policy` row or the // default, so a message naming the variable names a source that may be // unset. ).toThrow(/hook jail policy is 'required'/); }); test("'required' names the reason, so the operator can act on it", () => { expect(() => planJailedSpawn(CMD, deriveMountSet(REQUEST), UNAVAILABLE, 'required', allPresent), ).toThrow(/not installed/); }); test('an invocation with no mount set runs unjailed rather than in an empty jail', () => { // An empty mount set is a hook that cannot read its own script. Absent // means "do not jail this one", never "jail it with nothing". const p = planJailedSpawn(CMD, undefined, AVAILABLE, 'auto', allPresent); expect(p.mode).toBe('unjailed'); expect(p.cmd).toEqual(CMD); }); }); describe('auto defers on sandbox-exec until D14 exists (ce-29z)', () => { // The backend hand-built here is one no probe yields yet — task 4.8 owns // the platform probe that reports it. The policy, though, is decided NOW: // sandbox-exec denies (EPERM) a hook write to an undeclared host path where // bubblewrap masks it into its private tmpfs, so a macOS auto jail reddens // the full suite for fixtures main's Linux CI passes. Measured, ce-29z. const SANDBOX_EXEC: JailAvailability = { backend: 'sandbox-exec' }; test("'auto' runs the hook unjailed and says why", () => { const p = planJailedSpawn(CMD, deriveMountSet(REQUEST), SANDBOX_EXEC, 'auto', allPresent); expect(p.mode).toBe('unjailed'); expect(p.cmd).toEqual(CMD); expect(p.backend).toBe('sandbox-exec'); expect(p.reason).toMatch(/D14/); expect(p.reason).toMatch(/CELILO_HOOK_JAIL=required/); }); test("'required' is the operator's explicit act and is NOT deferred", () => { // Mode only, not argv shape: the sandbox-exec spawn arm is task 4.8's. // This assertion survives that landing; an argv assertion would not. const p = planJailedSpawn(CMD, deriveMountSet(REQUEST), SANDBOX_EXEC, 'required', allPresent); expect(p.mode).toBe('jailed'); }); test("'off' still declines a jail that IS available", () => { const p = planJailedSpawn(CMD, deriveMountSet(REQUEST), SANDBOX_EXEC, 'off', allPresent); expect(p.mode).toBe('unjailed'); expect(p.reason).toBe(JAIL_OFF_REASON); }); }); describe('jailPolicy reads the operator’s switch', () => { const withEnv = (value: string | undefined, fn: () => T): T => { const saved = process.env.CELILO_HOOK_JAIL; if (value === undefined) delete process.env.CELILO_HOOK_JAIL; else process.env.CELILO_HOOK_JAIL = value; try { return fn(); } finally { if (saved === undefined) delete process.env.CELILO_HOOK_JAIL; else process.env.CELILO_HOOK_JAIL = saved; } }; test('unset is off (peba, ce-rez7)', () => { expect(withEnv(undefined, jailPolicy)).toBe('off'); }); test('an explicit auto still means auto', () => { expect(withEnv('auto', jailPolicy)).toBe('auto'); }); test('a typo is refused rather than silently meaning auto', () => { // `CELILO_HOOK_JAIL=requried` reading as `auto` would look exactly like // the jail being enforced when it is not — the one mistake this variable // exists to prevent (Rule 4.2). expect(() => withEnv('requried', jailPolicy)).toThrow(/is not a hook jail policy/); }); }); describe('resolveJailPolicy applies D2 precedence (hook-jail-config-surface)', () => { test('an env value set and non-empty wins over stored config', () => { expect(resolveJailPolicy('required', undefined, 'off')).toEqual({ policy: 'required', source: 'env', }); }); test('an EMPTY STRING env value counts as unset and falls through to config', () => { // jailPolicy() already treats '' as unset, so the resolver agrees rather // than inventing a second rule for what counts as set. expect(resolveJailPolicy('', undefined, 'off')).toEqual({ policy: 'off', source: 'config' }); }); test('stored config wins when the env var is absent', () => { expect(resolveJailPolicy(undefined, undefined, 'off')).toEqual({ policy: 'off', source: 'config', }); expect(resolveJailPolicy(undefined, undefined, 'required')).toEqual({ policy: 'required', source: 'config', }); }); test('all absent resolves to off from the default (peba, ce-rez7)', () => { expect(resolveJailPolicy(undefined, undefined, undefined)).toEqual({ policy: 'off', source: 'default', }); expect(resolveJailPolicy('', undefined, undefined)).toEqual({ policy: 'off', source: 'default', }); }); test('an explicit env auto still wins (peba, ce-rez7)', () => { expect(resolveJailPolicy('auto', undefined, 'off')).toEqual({ policy: 'auto', source: 'env' }); }); test('a stored empty jail_policy THROWS, it does not resolve to auto (peba, 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 — the // untrusted paths D3 exists to catch. It is outside the enum and fails // closed like any other bad stored value. expect(() => resolveJailPolicy(undefined, undefined, '')).toThrow(/is not a hook jail policy/); expect(() => resolveJailPolicy(undefined, undefined, '')).toThrow(/hooks\.jail_policy=''/); }); test('a typo in the STORED row is refused, not silently treated as auto (D3)', () => { // The DB row is a trust boundary like any other (Rule 3.7): restores carry // foreign state, and a typo that silently meant `auto` reads as an armed // jail when nothing is armed. The config path is no laxer than the env // path. expect(() => resolveJailPolicy(undefined, undefined, 'alwayssafe')).toThrow( /is not a hook jail policy/, ); expect(() => resolveJailPolicy(undefined, undefined, 'alwayssafe')).toThrow( /hooks\.jail_policy='alwayssafe'/, ); }); test('a typo in the env value is still refused (unchanged jailPolicy behaviour)', () => { expect(() => resolveJailPolicy('requried', undefined, undefined)).toThrow( /is not a hook jail policy/, ); expect(() => resolveJailPolicy('requried', undefined, 'off')).toThrow( /CELILO_HOOK_JAIL='requried'/, ); }); }); describe('resolveJailPolicy applies the four-step precedence (per-module-jail-policy task 1.3)', () => { // Each boundary gets its own assertion, not just the ends: the chain is // fixed and rendered, and a test that only pins (env, default) cannot tell // a reordering of module and config apart from a correct one. test('env beats the module row', () => { expect(resolveJailPolicy('required', 'off', 'auto')).toEqual({ policy: 'required', source: 'env', }); expect(resolveJailPolicy('off', 'auto', 'auto')).toEqual({ policy: 'off', source: 'env' }); }); test('the module row beats system config', () => { expect(resolveJailPolicy(undefined, 'off', 'auto')).toEqual({ policy: 'off', source: 'module', }); expect(resolveJailPolicy(undefined, 'required', 'off')).toEqual({ policy: 'required', source: 'module', }); }); test('system config beats the default', () => { expect(resolveJailPolicy(undefined, undefined, 'auto')).toEqual({ policy: 'auto', source: 'config', }); expect(resolveJailPolicy(undefined, undefined, 'required')).toEqual({ policy: 'required', source: 'config', }); }); test('the default is off (peba, ce-rez7)', () => { expect(resolveJailPolicy(undefined, undefined, undefined)).toEqual({ policy: 'off', source: 'default', }); }); test('an absent module row falls through to system config — absent means follow the system', () => { // The everyday case: a fleet with no per-module rows behaves exactly as // it does today, which is the spec's own operator guarantee. expect(resolveJailPolicy(undefined, undefined, 'auto')).toEqual({ policy: 'auto', source: 'config', }); }); test('a bad stored per-module value THROWS rather than coercing (task 1.3)', () => { // The same rule as the system key (peba, ce-8832): a restore or a hand // edit is a trust boundary, and a typo that silently meant `off` would // jail a module the operator exempted — or the reverse. The error names // its source so a reader can tell which stored row is junk. expect(() => resolveJailPolicy(undefined, 'alwayssafe', 'off')).toThrow( /is not a hook jail policy/, ); expect(() => resolveJailPolicy(undefined, 'alwayssafe', 'off')).toThrow( /module's jail policy='alwayssafe'/, ); expect(() => resolveJailPolicy(undefined, '', 'off')).toThrow(/is not a hook jail policy/); }); }); describe('realpath at the caller (task 4.2k)', () => { test('a symlinked module root is resolved before it becomes a bind', () => { // `mod.sourcePath` comes out of the database, and a database restored from // another box carries that box's absolute paths (ISS-0052). const scratch = mkdtempSync(join(tmpdir(), 'celilo-jail-realpath-')); try { const real = join(scratch, 'real-module'); mkdirSync(join(real, 'state'), { recursive: true }); const link = join(scratch, 'linked-module'); symlinkSync(real, link); const resolved = realpathRequest({ ...REQUEST, modulePath: link, stateDir: join(link, 'state'), }); expect(resolved.modulePath).not.toContain('linked-module'); expect(resolved.modulePath).toBe(resolved.stateDir.replace(/\/state$/, '')); } finally { rmSync(scratch, { recursive: true, force: true }); } }); test('a path that does not exist keeps its lexical form', () => { // It cannot be resolved, and it is dropped by the existence filter anyway. expect(realpathRequest(REQUEST).modulePath).toBe(MODULE); }); }); describe('the mode is recorded state, not a log line (design D8)', () => { const withStore = (fn: (path: string) => T): T => { const scratch = mkdtempSync(join(tmpdir(), 'celilo-jail-mode-')); const path = join(scratch, 'nested', 'hook-jail-mode.json'); const saved = process.env.CELILO_HOOK_JAIL_MODE_PATH; process.env.CELILO_HOOK_JAIL_MODE_PATH = path; try { return fn(path); } finally { if (saved === undefined) delete process.env.CELILO_HOOK_JAIL_MODE_PATH; else process.env.CELILO_HOOK_JAIL_MODE_PATH = saved; rmSync(scratch, { recursive: true, force: true }); } }; const jailed: JailPlan = { cmd: CMD, mode: 'jailed', backend: 'bubblewrap', skipped: [] }; const unjailed: JailPlan = { cmd: CMD, mode: 'unjailed', backend: 'none', reason: 'bubblewrap is not installed', skipped: [], }; test('the first run writes the mode and had nothing to replace', () => { withStore(() => { expect(recordJailMode(jailed).previous).toBeUndefined(); expect(readJailMode()?.mode).toBe('jailed'); }); }); test('a jailed-to-unjailed transition is readable from the record (task 4.4)', () => { withStore(() => { recordJailMode(jailed); const { previous } = recordJailMode(unjailed); // This is the third of D8's three states, the only one that is an event: // a host that used to jail and has stopped. The self-monitor is task // 4.4; this is the state it reads. expect(previous?.mode).toBe('jailed'); expect(readJailMode()?.mode).toBe('unjailed'); expect(readJailMode()?.reason).toContain('not installed'); }); }); test('an unchanged mode keeps the timestamp of the last CHANGE', () => { withStore(() => { recordJailMode(jailed); const first = readJailMode()?.recordedAt; recordJailMode(jailed); expect(readJailMode()?.recordedAt).toBe(first as string); }); }); test('the record names the host, because a transition is per host', () => { withStore(() => { recordJailMode(jailed); expect(readJailMode()?.host).toBeTruthy(); }); }); test('a corrupt file reads as absent rather than failing the hook', () => { withStore((path) => { mkdirSync(join(path, '..'), { recursive: true }); writeFileSync(path, 'not json'); expect(readJailMode()).toBeUndefined(); }); }); test('an unwritable store does not fail the hook', () => { // Diagnostic state. celilo must not refuse to run a hook because it could // not write down how it ran it. withStore(() => { process.env.CELILO_HOOK_JAIL_MODE_PATH = '/proc/celilo-cannot-write-here/mode.json'; expect(() => recordJailMode(jailed)).not.toThrow(); }); }); test('the store sits beside the rest of celilo’s per-machine state', () => { const saved = process.env.CELILO_HOOK_JAIL_MODE_PATH; delete process.env.CELILO_HOOK_JAIL_MODE_PATH; try { expect(jailModeStorePath()).toMatch(/hook-jail-mode\.json$/); } finally { if (saved !== undefined) process.env.CELILO_HOOK_JAIL_MODE_PATH = saved; } }); // The write overwrites the previous record, and the self-monitor (task 4.4) // reads the file on a sweep long after `recordJailMode`'s return value is // gone. `lastJailed` is what keeps the transition readable. test('an unjailed record remembers the jailed record it replaced (task 4.4)', () => { withStore(() => { recordJailMode(jailed); const jailedAt = readJailMode()?.recordedAt; recordJailMode(unjailed); const record = readJailMode(); expect(record?.lastJailed?.backend).toBe('bubblewrap'); expect(record?.lastJailed?.recordedAt).toBe(jailedAt as string); }); }); test('re-jailing clears the memory, so a later regression dates from the new jailed record', () => { withStore(() => { recordJailMode(jailed); recordJailMode(unjailed); recordJailMode(jailed); expect(readJailMode()?.lastJailed).toBeUndefined(); }); }); test('an unjailed-to-unjailed rewrite carries the memory along', () => { withStore(() => { // CELILO_HOOK_JAIL=off on a host whose backend still works: mode // unjailed, backend bubblewrap. The regression memory must survive the // operator then losing the backend too. const off: JailPlan = { cmd: CMD, mode: 'unjailed', backend: 'bubblewrap', reason: 'CELILO_HOOK_JAIL=off', skipped: [], }; recordJailMode(jailed); const jailedAt = readJailMode()?.recordedAt; recordJailMode(off); recordJailMode(unjailed); expect(readJailMode()?.lastJailed?.recordedAt).toBe(jailedAt as string); }); }); test("another host's jailed record is a move, not a transition", () => { withStore((path) => { mkdirSync(join(path, '..'), { recursive: true }); writeFileSync( path, JSON.stringify({ mode: 'jailed', backend: 'bubblewrap', host: 'some-other-box', recordedAt: '2026-01-01T00:00:00.000Z', }), ); recordJailMode(unjailed); expect(readJailMode()?.lastJailed).toBeUndefined(); }); }); });