/** * Test fixture: reports what a hook can and cannot reach from inside the jail. * * Every probe reports an OUTCOME rather than throwing, because the assertion * that matters is unreachability and not any particular errno (task 4.9). * bubblewrap removes the path and gives `ENOENT`; `sandbox-exec` denies it and * gives `EPERM`. Both satisfy the requirement, so this fixture records only * whether the access worked and hands the message back for the post-mortem. * * The report is written to a JSON file in the state directory: hook return * values are no longer carried anywhere (hook-owned-state D5), and `state/` * is the one writable path inside the jail. */ import { readFileSync, readdirSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { defineHook } from '@celilo/capabilities'; function probe(fn: () => void): { succeeded: boolean; detail: string } { try { fn(); return { succeeded: true, detail: 'ok' }; } catch (error) { return { succeeded: false, detail: error instanceof Error ? error.message : String(error) }; } } export default defineHook({ hook: 'container_created', requires: [], handler: async (ctx) => { const config = ctx.config as unknown as { planted_secret: string; sibling_file: string; staged_input: string; }; const report = { // Outside the module tree entirely, and the whole acceptance criterion: // celilo's data directory is not bound, so the key is not merely denied // but absent. planted_secret: probe(() => { readFileSync(config.planted_secret, 'utf-8'); }), // A sibling module's tree. `` itself is never bound, so one `..` // reaches nothing. sibling_write: probe(() => { writeFileSync(config.sibling_file, 'trespassed'); }), // The carve-out: `state/` sits INSIDE the read-only module tree and is // bound read-write on top of it. state_write: probe(() => { writeFileSync(`${ctx.stateDir}/jail-probe`, 'state is writable'); }), // A declared path input under os.tmpdir(), which D9 also makes a fresh // tmpfs. If the tmpfs did not lead, this write lands in a private // filesystem that vanishes when the hook exits — and the hook SUCCEEDS. // The parent asserts the bytes survived, which is the only way to tell. staged_write: probe(() => { writeFileSync(`${config.staged_input}/produced`, 'staged input survived the tmpfs'); }), // Stage 3's observable (design D12, task 5.5): the SSH credential is // not bound, so a hook cannot authenticate anywhere by hand. Reads // `homedir()/.ssh` — the exact path the pre-stage-3 jail bound (via // `join(homedir(), '.ssh')`), so the red baseline reaches a planted key // and this branch does not. `homedir()` rather than `process.env.HOME` // because that is what the mount decision used, and the two can differ. ssh_key: probe(() => { const sshDir = join(homedir(), '.ssh'); const names = readdirSync(sshDir); for (const name of names) readFileSync(join(sshDir, name)); }), }; // Hand the report to the parent through the state directory — the one // channel a jailed hook still has (D5 removed the return channel). writeFileSync(`${ctx.stateDir}/report.json`, JSON.stringify(report)); }, });