/** * The recurrence gate for openspec/changes/hook-process-boundary (celilo#1001). * * Runs `modules/hello-trespass`'s hook through `executeHookScript` and asserts * exactly what the stage that has landed claims, and nothing more. * * **Stage 1 claims the environment. It does not claim the filesystem and it * does not claim the network.** So this file asserts the sensitive environment * is empty, and asserts the other three trespasses STILL SUCCEED. A gate that * claimed more than its stage delivers would go green for the wrong reason and * would have to be rewritten — quietly weakening it — the first time somebody * noticed. Stage 2 flips the two filesystem rows and stage 3 flips the SSH row; * each stage edits the assertion it earns. * * `HOME` is redirected to a scratch directory holding a planted master key and * a planted SSH key, so the trespasses are deterministic and the operator's * real key is never read (CLAUDE.md: never test against live data). `HOME` is * on design D5's allow-list, so the child resolves the same planted paths the * parent planted — which is the point of D5's own caveat: removing * `CELILO_DATA_DIR` does not hide a path a hook can compute. */ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; import { execFileSync } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { applyFallbacksToPackageJson, isPublished, listUnpublishedWorkspacePackages, planFallbacks, } from '../../../../scripts/workspace-fallback'; import { CLI_BIN_DIR, childPath, executeHookScript, hookChildEnv } from './executor'; import { HOOK_PROTOCOL_VERSION } from './hook-protocol'; import { detectJailBackend } from './jail'; import { createCapturingLogger } from './logger'; import { configStore, secretStore } from './test-fixtures/store-backed'; import type { HookContext } from './types'; const TRESPASS_SCRIPT = resolve( __dirname, '../../../../modules/hello-trespass/scripts/trespass.ts', ); interface TrespassOutcome { attempted: boolean; succeeded: boolean; target: string; detail: string; } interface TrespassReport { master_key: TrespassOutcome; sibling_write: TrespassOutcome; ssh_key: TrespassOutcome; remote_exec: TrespassOutcome; sensitive_env: string[]; } let scratchHome: string; let realHome: string | undefined; /** * Install the fixture module's own dependencies if they are not there. * * A `modules//scripts` directory is a standalone package, deliberately * outside the root workspace globs so that a module resolves the PUBLISHED * `@celilo/capabilities` it will actually run on the fleet rather than the * workspace copy. bun links workspace dependencies into each package's own * `node_modules` and never into the repo root, so there is nothing here for * the fixture to walk up to: with no install of its own the hook dies with * `Cannot find module '@celilo/capabilities'`. * * `bun run setup` does this for every module, and CI does it in its * check-modules step — which runs LAST on purpose, because twenty-two installs * is the slowest thing in that job. This gate is the first test to reach into * a module's script tree, so it prepares the one fixture it needs instead of * making every PR pay for all of them up front. * * It installs rather than skipping. A gate that quietly does not run is the * exact failure this whole change exists to make impossible. */ function ensureFixtureInstalled(): void { const scriptsDir = dirname(TRESPASS_SCRIPT); if (existsSync(join(scriptsDir, 'node_modules', '@celilo', 'capabilities'))) return; // On a version-packages PR the pin names a version that CANNOT be on the // registry yet, because only merging that PR lets it publish. So a plain // install here fails with `No version matching "^3.3.0" found`, and the // version PR can never go green on its own. // // This is celilo#642 exactly, and check-modules.sh already solves it. Reuse // that rather than resolving from the workspace unconditionally: doing it // unconditionally would check the fixture against a copy it does not bundle, // which is the celilo#173 property this module's scripts/ dir is kept out of // the root workspace globs to preserve. The fallback fires only when the pin // names a version the registry lacks AND the workspace holds exactly that // version — where the workspace IS the artifact about to be published, same // bytes by definition. // // The probe is scoped to this module's own dependencies: packages it does not // depend on are reported published without asking the network, so this costs // one registry call rather than one per publishable workspace package. const pkgPath = join(scriptsDir, 'package.json'); const original = readFileSync(pkgPath, 'utf-8'); const dependencies = (JSON.parse(original) as { dependencies?: Record }).dependencies ?? {}; const unpublished = listUnpublishedWorkspacePackages((name, version) => name in dependencies ? isPublished(name, version) : true, ); const fallbacks = planFallbacks(dependencies, unpublished); if (fallbacks.length > 0) writeFileSync(pkgPath, applyFallbacksToPackageJson(original, fallbacks)); try { // `process.execPath`, not `bun` on PATH: this is the bun already running the // suite, so it works wherever the suite does. execFileSync(process.execPath, ['install'], { cwd: scriptsDir, stdio: 'pipe' }); } finally { // Restore even when the install failed. A `file:` pin left in the tree // points the fixture at one machine's checkout and would be swept up by the // next `git add -A` — the same reason check-modules.sh restores from a trap. if (fallbacks.length > 0) writeFileSync(pkgPath, original); } } beforeAll(() => { ensureFixtureInstalled(); scratchHome = mkdtempSync(join(tmpdir(), 'celilo-trespass-home-')); // The master key at getMasterKeyPath()'s DEFAULT location for this platform. // Both branches are planted rather than the current one only, so the fixture // reads a planted key whichever host runs the suite. for (const dir of [ join(scratchHome, 'Library', 'Application Support', 'celilo'), join(scratchHome, '.local', 'share', 'celilo'), ]) { mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, 'master.key'), 'not-the-real-key\n'); } mkdirSync(join(scratchHome, '.ssh'), { recursive: true }); writeFileSync(join(scratchHome, '.ssh', 'id_ed25519'), 'not-a-real-key\n'); realHome = process.env.HOME; process.env.HOME = scratchHome; }); afterAll(() => { if (realHome === undefined) delete process.env.HOME; else process.env.HOME = realHome; rmSync(scratchHome, { recursive: true, force: true }); }); async function runTrespass(jail = false): Promise<{ report: TrespassReport; lines: string[] }> { const { logger, messages } = createCapturingLogger(); const screenshotDir = mkdtempSync(join(tmpdir(), 'celilo-trespass-artifacts-')); const stateDir = mkdtempSync(join(tmpdir(), 'celilo-trespass-artifacts-')); const context: HookContext = { config: configStore({ sibling_module_id: 'hello-foo', other_system_ip: '' }), secrets: secretStore(), systems: [], logger, debug: false, screenshotDir, stateDir, capabilities: {}, }; // The jailed run jails under `required`, not the `auto` default. ce-29z // made `auto` defer on sandbox-exec until D14 exists (undeclared host-path // writes would break module hooks), so the test's jail is the operator's // explicit act — exactly the bypass the deferral leaves open. const savedPolicy = process.env.CELILO_HOOK_JAIL; if (jail) process.env.CELILO_HOOK_JAIL = 'required'; try { await executeHookScript(TRESPASS_SCRIPT, context, { timeoutMs: 60_000, idleTimeoutMs: 60_000, // The module's own tree, two levels above the hook script. Absent means // "run unjailed", which is what the stage 1 assertions below need. ...(jail ? { jail: { modulePath: dirname(dirname(TRESPASS_SCRIPT)), pathInputs: [] } } : {}), }); return { // The fixture hands its report through the state directory the jail // binds read-write: the same channel works jailed and unjailed (D5 // removed the return channel). report: JSON.parse( readFileSync(join(stateDir, 'report.json'), 'utf-8'), ) as unknown as TrespassReport, lines: messages.map((m) => m.message), }; } finally { if (savedPolicy === undefined) delete process.env.CELILO_HOOK_JAIL; else process.env.CELILO_HOOK_JAIL = savedPolicy; rmSync(screenshotDir, { recursive: true, force: true }); rmSync(stateDir, { recursive: true, force: true }); } } describe('the child environment is an allow-list', () => { // The trespass gate below proves nothing sensitive gets through. This is the // complement: the six things that MUST get through, because a hook with no // `PATH` cannot spawn anything and one with no `HOME` breaks more than it // protects. test('forwards the allow-list and the channels, and nothing else', () => { process.env.CELILO_TEST_SECRET = 'must-not-cross'; try { const env = hookChildEnv('/tmp/celilo-hook-x/s', '/tmp/celilo-hook-x/r', undefined); expect(env.CELILO_HOOK_SOCKET).toBe('/tmp/celilo-hook-x/s'); expect(env.CELILO_HOOK_REMOTE_SOCKET).toBe('/tmp/celilo-hook-x/r'); // Pinned to the constant, not the literal: the version moves when the // wire does (2 added the hook-owned-state store frames), and this line // is part of the handshake's contract, not a second copy of the number. expect(env.CELILO_HOOK_PROTOCOL_VERSION).toBe(String(HOOK_PROTOCOL_VERSION)); expect(env.PATH).toBe(childPath(process.env.PATH)); expect(env.HOME).toBe(process.env.HOME as string); expect(env.CELILO_TEST_SECRET).toBeUndefined(); const allowed = new Set([ 'PATH', 'HOME', 'LANG', 'TZ', 'TMPDIR', 'CELILO_DEBUG', 'CELILO_HOOK_SOCKET', 'CELILO_HOOK_REMOTE_SOCKET', 'CELILO_HOOK_PROTOCOL_VERSION', 'CELILO_HOOK_MOUNT_SET', ]); expect(Object.keys(env).filter((name) => !allowed.has(name))).toEqual([]); // The unjailed lint's variable is present only when there is something // to lint (task 4.7). A jailed run carries nothing. expect(env.CELILO_HOOK_MOUNT_SET).toBeUndefined(); } finally { delete process.env.CELILO_TEST_SECRET; } }); test('omits an allow-listed variable the parent does not have', () => { const saved = process.env.TZ; delete process.env.TZ; try { // Absent, not the string "undefined" — which is what a naive copy // produces and what a shell then happily uses as a timezone. expect('TZ' in hookChildEnv('/tmp/s', '/tmp/r', undefined)).toBe(false); } finally { if (saved !== undefined) process.env.TZ = saved; } }); // celilo#1300: a hook that spawns `celilo` by name must find the same CLI // the parent runs, whatever the inherited PATH looks like. describe('childPath', () => { test('prepends the CLI bin and runtime directories so the child reaches the parent CLI', () => { const child = childPath('/usr/local/bin:/usr/bin'); expect(child.startsWith(`${CLI_BIN_DIR}:${dirname(process.execPath)}:`)).toBe(true); expect(child.endsWith('/usr/local/bin:/usr/bin')).toBe(true); }); test('leaves an inherited PATH that already carries the directories unchanged', () => { const already = `${CLI_BIN_DIR}:${dirname(process.execPath)}:/usr/bin`; expect(childPath(already)).toBe(already); }); test('substitutes both directories when the parent has no PATH at all', () => { expect(childPath(undefined)).toBe(`${CLI_BIN_DIR}:${dirname(process.execPath)}`); }); // The fleet gate (ce-y0fd): the .deb layout runs the CLI through // $CELILO_HOME/node_modules/@celilo/cli/bin/celilo, so the directory beside // process.execPath holds only bun and holds no celilo. A PATH that resolves // `celilo` must therefore come from the CLI package's own bin directory — // and that directory must really contain the bin, or every prepended PATH // entry is decoration over the same accident celilo#1300 recorded. test('prepends a CLI bin directory that actually contains the celilo bin', () => { expect(existsSync(join(CLI_BIN_DIR, 'celilo'))).toBe(true); }); }); }); describe('hook process boundary — hello-trespass gate', () => { test('stage 1: the environment is clean, and nothing else has changed', async () => { const { report, lines } = await runTrespass(); // The evidence, printed whether the assertions pass or fail. Without it a // red run says which assertion broke and not what the hook actually saw. console.log(['', 'hello-trespass:', ...lines.slice(1)].join('\n')); // What stage 1 claims. Empty because the child's environment is an // allow-list, not `...process.env` (design D5). expect(report.sensitive_env).toEqual([]); // What stage 1 deliberately does NOT claim. The master key is still // readable, because the hook computes the default path rather than // reading it out of the environment. Stage 2's mount set is what removes // it, by not binding the data directory at all. expect(report.master_key.succeeded).toBe(true); // Nor the sibling's tree: the module store is still one `..` away. expect(report.sibling_write.succeeded).toBe(true); // Nor the SSH key, which is what scopes reachability in stage 3 (D12). // `attempted: false` means the host has no private key at all — a bare CI // runner — which is an absent precondition, not a refusal. expect(report.ssh_key.attempted && !report.ssh_key.succeeded).toBe(false); }); }); 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 stage 2 skip on every host with the default — measuring nothing. The // jailed run pins the policy to `required` inside `runTrespass(true)` and // restores it after, so the jail under test is this suite's own act wherever a // backend exists. (celilo#1359: since the default became `off`, all three // jailed suites skipped everywhere.) const jailed = availability.backend !== 'none'; describe.skipIf(!jailed)( `stage 2: the same hook, jailed (backend: ${availability.backend})`, () => { // Task 4.12's platform half that a Mac can run. The e2e stage // (`e2e/tests/hook-jail-trespass.test.ts`) is the other one, because // bubblewrap needs a Linux kernel and a container to hold it. // // The SAME fixture, run twice, is the whole design of this gate: the stage 1 // block above asserts these two trespasses SUCCEED, and this one asserts the // jail refuses them. Neither reading is available from one run. test('trespasses 1 and 2 are refused, and the environment is still clean', async () => { const { report, lines } = await runTrespass(true); console.log(['', 'hello-trespass (jailed):', ...lines.slice(1)].join('\n')); // What stage 2 claims: celilo's data directory is not bound, so the master // key is unreachable. `` is not bound either, so a sibling is too. expect(report.master_key.succeeded).toBe(false); expect(report.sibling_write.succeeded).toBe(false); // Unreachability, never an errno. bubblewrap removes the path and reports // ENOENT; sandbox-exec denies it and reports EPERM. Pinning either one // would go red on a platform whose jail works perfectly (D9). expect(report.master_key.detail).toMatch(/ENOENT|EPERM/); // Stage 1's claim has not regressed on the way to stage 2. expect(report.sensitive_env).toEqual([]); }, 120_000); // Trespass 3 is deliberately NOT asserted here, and the reason is a property // of this fixture rather than of the jail. `jailRequest` binds `~/.ssh` from // `os.homedir()`, which on macOS reads the password database and ignores the // `HOME` this file redirects — so the hook looks in the scratch home and the // jail bound the real one, and the row reports a refusal that says nothing // about stage 3. The e2e stage asserts it, where the two agree. Filed as // celilo#1211. }, );