/** * The live half of the hook jail: a hook that genuinely cannot reach a path * (design D9, tasks 4.9 and 4.2j). * * **This suite needs a real jail and SKIPS without one, loudly.** A Mac has no * backend until task 4.8, and neither does a runner with no bubblewrap, so the * skip is the common case on a development box. It prints the reason the probe * gave rather than passing quietly — CLAUDE.md's whole point about a check that * cannot reach its subject is that silence and success look identical. * * Two properties, and they fail in opposite directions: * * - **Unreachability, asserted as unreachability.** bubblewrap removes the * path (`ENOENT`), `sandbox-exec` denies it (`EPERM`), and both satisfy * D9. So the assertion is "the read did not succeed", never a match on an * errno — a test pinned to `ENOENT` would go red on macOS for a jail that * was working perfectly. * * - **The staged input survived the tmpfs.** `/tmp` is a fresh tmpfs mounted * FIRST, and every staged contract input lives under `os.tmpdir()`. Mount * the tmpfs after them and they vanish — and 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 this * asserts the bytes are on disk in the PARENT afterwards, never that the * hook exited zero (task 4.2j). */ import { describe, expect, test } from 'bun:test'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { homedir, tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; /** Uniquely named so it never collides with a real key on a jailed dev box. */ const PLANTED_KEY_NAME = 'id_celilo_jail_probe'; import { executeHookScript } from './executor'; import { detectJailBackend } from './jail'; import { createCapturingLogger } from './logger'; import { configStore, secretStore } from './test-fixtures/store-backed'; import type { HookContext } from './types'; const PROBE_HOOK = resolve(__dirname, 'test-fixtures/jail-probe-hook.ts'); interface Probe { succeeded: boolean; detail: string; } const availability = detectJailBackend(); // Gated on the BACKEND alone, not on `jailPolicy()`. The ambient policy is an // operator switch (`CELILO_HOOK_JAIL=off` is the default), and reading it here // made the suite skip on every host with the default — measuring nothing, on // any platform, forever. The suite does not need the operator's blessing: it // sets the policy to `required` inside `runProbe()` and restores it after, so // the jail under test is this suite's own act, deterministic wherever a // backend exists. (celilo#1359: since the default became `off`, all three // jailed suites skipped everywhere.) const jailed = availability.backend !== 'none'; interface Rig { outputs: { planted_secret: Probe; sibling_write: Probe; state_write: Probe; staged_write: Probe; ssh_key: Probe; }; stagedInput: string; siblingFile: string; cleanup: () => void; } /** Where the probe fixture hands its report to the parent (hook-owned-state D5). */ const REPORT_PATH = 'report.json'; /** * Lay out a module store the way celilo does, run the probe hook against it, * and hand back both the hook's report and the paths so the parent can check * the disk rather than the report. */ async function runProbe(): Promise { const scratch = mkdtempSync(join(tmpdir(), 'celilo-jail-live-')); const store = join(scratch, 'modules'); const modulePath = join(store, 'jail-probe'); const stateDir = join(modulePath, 'state'); const screenshotDir = join(modulePath, 'screenshots', 'run'); mkdirSync(join(modulePath, 'scripts'), { recursive: true }); mkdirSync(stateDir, { recursive: true }); mkdirSync(screenshotDir, { recursive: true }); // A sibling module, one `..` away from the hook's own tree. const sibling = join(store, 'technitium'); mkdirSync(sibling, { recursive: true }); const siblingFile = join(sibling, 'trespassed'); // celilo's master key, outside the store entirely. Planted rather than real: // the operator's key is never read (CLAUDE.md, never test against live data). const plantedSecret = join(scratch, 'master.key'); writeFileSync(plantedSecret, 'not-the-real-key'); // A staged contract input, under os.tmpdir() exactly as `stagingDirFor` // produces. This is the path the tmpfs would erase. const stagedInput = mkdtempSync(join(tmpdir(), 'celilo-jail-staged-')); // A throwaway SSH key at the exact path the pre-stage-3 jail bound — // `homedir()/.ssh` — so the flip (task 5.5) is real: the red baseline // binds this directory and the probe reads the key, this branch binds no // `.ssh` at all and the read gives ENOENT. Guarded to jailed hosts only // (this whole suite is `skipIf(!jailed)`), which on a Mac is never — so // the operator's real key is never touched on a dev box; on a jailed CI // box homedir is an ephemeral container root. Uniquely named and removed // in cleanup regardless. const sshDir = join(homedir(), '.ssh'); const plantedKey = join(sshDir, PLANTED_KEY_NAME); const createdSshDir = !existsSync(sshDir); mkdirSync(sshDir, { recursive: true }); writeFileSync(plantedKey, 'not-a-real-key\n'); const { logger } = createCapturingLogger(); const context: HookContext = { config: configStore({ planted_secret: plantedSecret, sibling_file: siblingFile, staged_input: stagedInput, }), secrets: secretStore(), systems: [], logger, debug: false, screenshotDir, stateDir, capabilities: {}, }; // The jailed run jails under `required`: ce-29z made `auto` defer on // sandbox-exec until D14 exists, so this suite's jail is the operator's // explicit act — the bypass the deferral deliberately leaves open. const savedPolicy = process.env.CELILO_HOOK_JAIL; process.env.CELILO_HOOK_JAIL = 'required'; try { await executeHookScript(PROBE_HOOK, context, { timeoutMs: 60_000, idleTimeoutMs: 60_000, jail: { modulePath, pathInputs: [{ name: 'staged_input', value: stagedInput, access: 'write' }], }, }); // The fixture hands its report through the state directory: hook return // values are no longer carried anywhere (hook-owned-state D5). const outputs = JSON.parse( readFileSync(join(stateDir, REPORT_PATH), 'utf-8'), ) as unknown as Rig['outputs']; return { outputs, stagedInput, siblingFile, cleanup: () => { if (savedPolicy === undefined) delete process.env.CELILO_HOOK_JAIL; else process.env.CELILO_HOOK_JAIL = savedPolicy; rmSync(scratch, { recursive: true, force: true }); rmSync(stagedInput, { recursive: true, force: true }); rmSync(plantedKey, { force: true }); if (createdSshDir) rmSync(sshDir, { recursive: true, force: true }); }, }; } catch (error) { if (savedPolicy === undefined) delete process.env.CELILO_HOOK_JAIL; else process.env.CELILO_HOOK_JAIL = savedPolicy; throw error; } } describe.skipIf(!jailed)(`the jail is real (backend: ${availability.backend})`, () => { test('a hook cannot reach celilo’s master key, or a sibling module', async () => { const rig = await runProbe(); try { const { planted_secret, sibling_write, state_write, staged_write, ssh_key } = rig.outputs; console.log(['', 'jail probe:', JSON.stringify(rig.outputs, null, 2)].join('\n')); // Unreachability, not an errno. Under bubblewrap the message is ENOENT // and under sandbox-exec it is EPERM; asserting either one would make // this suite red on a platform whose jail works. expect(planted_secret.succeeded).toBe(false); expect(sibling_write.succeeded).toBe(false); // And the parent's own view: the write did not land by another route. expect(existsSync(rig.siblingFile)).toBe(false); // Stage 3 (design D12, task 5.5): the SSH credential is not bound, so // a jailed hook cannot authenticate anywhere by hand. This is the // recurrence-gate row that FLIPPED when the `~/.ssh` mount was removed // — it read `succeeded: true` for the whole of stage 2. expect(ssh_key.succeeded).toBe(false); // The carve-out. `state/` is inside the read-only module tree and is // bound read-write on top of it; if the ordering were wrong this is the // assertion that would catch it. expect(state_write.succeeded).toBe(true); expect(staged_write.succeeded).toBe(true); } finally { rig.cleanup(); } }, 90_000); test('a staged contract input survives the /tmp tmpfs (task 4.2j)', async () => { const rig = await runProbe(); try { // The hook reported success above. That is exactly the signal that is // worthless here: a write into a private tmpfs succeeds and is gone. // Read the bytes from the parent. const produced = join(rig.stagedInput, 'produced'); expect(existsSync(produced)).toBe(true); expect(readFileSync(produced, 'utf-8').length).toBeGreaterThan(0); } finally { rig.cleanup(); } }, 90_000); }); describe.skipIf(jailed)('this run is not jailed', () => { test('says so, rather than reporting a pass it did not earn', () => { // Not an assertion about the product. It is the line that stops a green // run on a backend-less host reading as "the jail was proven". The only // way here is `backend === 'none'` — an operator who set the policy off // does NOT land in this companion, because the live suite above now pins // the policy itself and runs anyway (celilo#1359). const why = availability.reason ?? 'no backend'; console.log( `\nhook jail: SKIPPED the live suite — ${why}\nThese properties are proven wherever a jail backend is available. The hermetic half runs everywhere (jail.test.ts).`, ); expect(availability.backend === 'none').toBe(true); }); });