/** * The unjailed advisory lint (hook-process-boundary task 4.7, design D8). * * The integration tests below run REAL hook scripts through * `executeHookScript` — the spawned shim, the real env hand-off, the real * `node:fs` wrappers — because the lint's whole subject is what happens * inside the child process, and a test that called `installUnjailedLint` in * the test process would wrap `node:fs` for every other test in the run. * * `CELILO_HOOK_JAIL=off` forces the unjailed path on every platform, so these * assertions hold on a Linux CI box with bubblewrap installed as well as on a * Mac with none. That is also the honest reach: the lint's input is the same * mount set the jail would have used, and the only difference between this * run and a jailed one is that nothing enforces it. * * Rule 7.6, watched both ways while landing: with the shim's install call * removed, the warning assertion goes red; with it restored, the negative * assertion (in-set access is silent) stays green. */ import { afterAll, afterEach, describe, expect, test } from 'bun:test'; import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { executeHookScript, hookChildEnv } from './executor'; import type { MountSetWire } from './hook-protocol'; import { createCapturingLogger } from './logger'; import { deriveMountSet } from './mount-set'; import { configStore, secretStore } from './test-fixtures/store-backed'; import type { HookContext } from './types'; import { classifyAccess, mountSetEnvValue, parseLintMountSet } from './unjailed-lint'; /** The workspace copy of the package every module bundles. */ const CAPABILITIES_PACKAGE = resolve(import.meta.dir, '../../../../packages/capabilities'); const scratchRoots: string[] = []; afterAll(() => { for (const root of scratchRoots) rmSync(root, { recursive: true, force: true }); }); /** * A minimal module fixture: a hook script and its own bundled * `@celilo/capabilities` (celilo#173), so the shim's brand check sees a real * module and the derivation has a real tree to bind. */ function scratchModule(script: string): string { const root = mkdtempSync(join(tmpdir(), 'celilo-lint-')); scratchRoots.push(root); writeFileSync(join(root, 'hook.ts'), script); mkdirSync(join(root, 'node_modules', '@celilo'), { recursive: true }); symlinkSync(CAPABILITIES_PACKAGE, join(root, 'node_modules', '@celilo', 'capabilities')); return root; } const describeSet = (overrides: Partial): MountSetWire => ({ entries: [ { path: '/tmp', mode: 'tmpfs', reason: 'private scratch, per run', absence: 'runtime' }, { path: '/srv/celilo/mod', mode: 'ro', reason: "the module's own tree", absence: 'required', }, { path: '/srv/celilo/mod/state', mode: 'rw', reason: 'the sanctioned writable directory', absence: 'required', }, { path: '/srv/staged', mode: 'rw', reason: "contract input 'backup_dir' (write)", absence: 'required', }, ], chdir: '/srv/celilo/mod', ...overrides, }); describe('classifyAccess', () => { const set = describeSet({}); test('a path under no row is absent', () => { expect(classifyAccess(set, '/etc/hosts', false)).toBe('absent'); expect(classifyAccess(set, '/srv/other-mod', true)).toBe('absent'); }); test('a read inside the module tree is allowed; a write is read-only', () => { expect(classifyAccess(set, '/srv/celilo/mod/hook.ts', false)).toBe('allowed'); expect(classifyAccess(set, '/srv/celilo/mod/hook.ts', true)).toBe('read-only'); }); test('a later row wins, which is how a writable directory sits inside a read-only tree', () => { expect(classifyAccess(set, '/srv/celilo/mod/state/cursor', true)).toBe('allowed'); expect(classifyAccess(set, '/srv/celilo/mod/state/cursor', false)).toBe('allowed'); }); test('a tmpfs row allows both', () => { expect(classifyAccess(set, '/tmp/x', false)).toBe('allowed'); expect(classifyAccess(set, '/tmp/x', true)).toBe('allowed'); }); test('the paths the namespace itself provides are allowed', () => { expect(classifyAccess(set, '/proc/self/status', false)).toBe('allowed'); expect(classifyAccess(set, '/dev/null', true)).toBe('allowed'); }); test('a relative path resolves against chdir, the jailed hook working directory', () => { expect(classifyAccess(set, 'hook.ts', false)).toBe('allowed'); expect(classifyAccess(set, 'hook.ts', true)).toBe('read-only'); expect(classifyAccess(set, '../outside', false)).toBe('absent'); }); }); describe('the environment hand-off', () => { test('the serializer and the parser agree, starting from the real derivation', () => { const modulePath = mkdtempSync(join(tmpdir(), 'celilo-lint-req-')); scratchRoots.push(modulePath); const set = deriveMountSet({ modulePath, stateDir: join(modulePath, 'state'), socketDir: '/tmp/celilo-hook-abc', runtimePath: '/usr/local/bin/bun', runnerPath: '/srv/celilo/src/hooks/hook-runner.ts', runtimeModulePaths: ['/srv/celilo/node_modules'], pathInputs: [{ name: 'backup_dir', value: '/tmp/staged', access: 'write' }], }); const parsed = parseLintMountSet(mountSetEnvValue(set)); expect(parsed).toEqual(JSON.parse(JSON.stringify(set))); }); test('absent or invalid input disables the lint rather than failing the hook', () => { expect(parseLintMountSet(undefined)).toBeUndefined(); expect(parseLintMountSet('')).toBeUndefined(); expect(parseLintMountSet('not json')).toBeUndefined(); expect(parseLintMountSet(JSON.stringify({ entries: [{ path: 1 }] }))).toBeUndefined(); }); test('hookChildEnv carries the set only when one is given', () => { const set = describeSet({}); const env = hookChildEnv('/tmp/s', '/tmp/r', set); expect(parseLintMountSet(env.CELILO_HOOK_MOUNT_SET)).toEqual(set); expect('CELILO_HOOK_MOUNT_SET' in hookChildEnv('/tmp/s', '/tmp/r', undefined)).toBe(false); }); }); describe('the lint inside a real hook run', () => { const savedJail = process.env.CELILO_HOOK_JAIL; const savedModePath = process.env.CELILO_HOOK_JAIL_MODE_PATH; let modeStore: string; const startUnjailed = (): void => { // `off` forces the unjailed path on every platform, mount set and all — // which is exactly the configuration the lint exists for. process.env.CELILO_HOOK_JAIL = 'off'; modeStore = join(mkdtempSync(join(tmpdir(), 'celilo-lint-mode-')), 'mode.json'); scratchRoots.push(modeStore); process.env.CELILO_HOOK_JAIL_MODE_PATH = modeStore; }; afterEach(() => { if (savedJail === undefined) delete process.env.CELILO_HOOK_JAIL; else process.env.CELILO_HOOK_JAIL = savedJail; if (savedModePath === undefined) delete process.env.CELILO_HOOK_JAIL_MODE_PATH; else process.env.CELILO_HOOK_JAIL_MODE_PATH = savedModePath; }); const run = async ( script: string, ): Promise<{ messages: Array<{ level: string; message: string }> }> => { startUnjailed(); const root = scratchModule(script); mkdirSync(join(root, 'state'), { recursive: true }); const { logger, messages } = createCapturingLogger(); const context: HookContext = { config: configStore(), secrets: secretStore(), systems: [], logger, debug: false, screenshotDir: join(root, 'artifacts'), stateDir: join(root, 'state'), capabilities: {}, }; await executeHookScript(join(root, 'hook.ts'), context, { timeoutMs: 60_000, idleTimeoutMs: 60_000, jail: { modulePath: root, pathInputs: [] }, }); return { messages }; }; const advisories = (messages: Array<{ level: string; message: string }>): string[] => messages .filter((m) => m.level === 'warn' && m.message.startsWith('Hook advisory:')) .map((m) => m.message); test('an access outside the mount set warns, and says it is advisory', async () => { const { messages } = await run(` import { defineHook } from '@celilo/capabilities'; import { readFileSync } from 'node:fs'; export default defineHook({ requires: [] as const, handler: async () => { // A path outside the mount set. Not /etc/hosts — the resolver // binding (celilo#1225) mounts that read-only on purpose, and the // lint is right to stay silent about it. try { readFileSync('/etc/passwd', 'utf-8'); } catch { /* the read is the point, not the bytes */ } return {}; }, }); `); const warnings = advisories(messages); expect(warnings.length).toBe(1); expect(warnings[0]).toContain('/etc/passwd'); expect(warnings[0]).toContain('on a jailed host'); // The one sentence the task forbids losing: what it is, and what it is not. expect(warnings[0]).toContain('Advisory lint, not a security boundary'); }); test('access inside the mount set — including the writable carve-outs — is silent', async () => { const { messages } = await run(` import { defineHook } from '@celilo/capabilities'; import { readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; export default defineHook({ requires: [] as const, handler: async (ctx) => { // Its own tree, read-only in the mount set: reads are fine. readFileSync(join(import.meta.dir, 'hook.ts'), 'utf-8'); // state/ sits INSIDE that tree and is carved read-write; both // assertions ride the same run, which is the last-wins ordering. writeFileSync(join(ctx.stateDir as string, 'cursor'), 'run-1'); readFileSync(join(ctx.stateDir as string, 'cursor'), 'utf-8'); return {}; }, }); `); expect(advisories(messages)).toEqual([]); }); test('a write outside the mount set warns as absent, per path, and the cap holds', async () => { const { messages } = await run(` import { defineHook } from '@celilo/capabilities'; import { readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; export default defineHook({ requires: [] as const, handler: async () => { for (let i = 0; i < 30; i++) { try { readFileSync(join(homedir(), 'celilo-lint-nope-' + i)); } catch { /* expected */ } } return {}; }, }); `); const warnings = advisories(messages); const listed = warnings.filter((w) => !w.includes('suppressed')); const capNotice = warnings.filter((w) => w.includes('will be suppressed')); expect(listed.length).toBe(25); expect(capNotice.length).toBe(1); expect(listed[0]).toContain('absent'); }); });