From 683696352dd58e3d2f3568163499ded9dd40f960 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 11 Oct 2026 04:31:53 -0500 Subject: [PATCH] fix: verify dependency read continuity after ctime hydration --- .../acpx/installation-integrity.test.ts | 105 ++++++++++++++++++ .../drivers/acpx/installation-integrity.ts | 65 +++++++++-- 2 files changed, 161 insertions(+), 9 deletions(-) diff --git a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts index 93466eed151..6b94ce1315d 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts @@ -37,6 +37,7 @@ import { awaitVerifiedAcpxProviderOwnership, createAcpxPackageJsonResolver, guardSnapshotModuleLookup, + readStableSnapshotModuleSource, guardSnapshotModuleResolution, reapCurrentProviderProcessGroup, sanitizedNodeEnvironment, @@ -1709,6 +1710,110 @@ describe("ACPX installation integrity", () => { await expectFailure(child, "requires Linux descriptor-pinned paths"); } }); + describe("descriptor-pinned dependency read continuity", () => { + it.each(["stable", "once", "recurring", "changed_bytes", "dev", "ino", "mode", "uid", "gid", "nlink", "size", "mtimeNs", "not_regular", "oversized", "snapshot_first", "snapshot_second"] as const)( + "requires bounded complete read continuity (%s)", (transition) => { + const original = Buffer.from("qualified fixture bytes"); + const changed = Buffer.from("different fixture bytes"); + expect(changed.length).toBe(original.length); + const initial = { dev: 1n, ino: 2n, mode: 0o100444n, uid: 1000n, gid: 1000n, nlink: 1n, size: BigInt(original.length), + mtimeNs: 10n, ctimeNs: 20n, isFile: () => true }; + let pass = 0; + const verify = vi.fn((bytes: Buffer) => { + if (transition.startsWith("snapshot_")) { + const expected = createHash("sha256").update(original).digest("hex"); + if (createHash("sha256").update(bytes).digest("hex") !== expected) throw new Error("snapshot digest mismatch"); + } + }); + const read = () => readStableSnapshotModuleSource({ + before: { ...initial, ...(transition === "oversized" ? { size: 17n } : {}) }, + maxBytes: transition === "oversized" ? 16 : 64, + stat: () => { + const metadata = { ...initial, ctimeNs: transition === "stable" ? 20n : 21n }; + if (transition === "recurring" && pass === 2) metadata.ctimeNs = 22n; + if (["dev", "ino", "mode", "uid", "gid", "nlink", "size", "mtimeNs"].includes(transition)) { + const field = transition as "dev" | "ino" | "mode" | "uid" | "gid" | "nlink" | "size" | "mtimeNs"; + metadata[field] += 1n; + } + if (transition === "not_regular") metadata.isFile = () => false; + return metadata; + }, + read: (buffer, offset, length, position) => { + if (position === 0) pass += 1; + const source = (transition === "snapshot_first" + || ((transition === "changed_bytes" || transition === "snapshot_second") && pass === 2)) ? changed : original; + return source.copy(buffer, offset, position, position + length); + }, + verify, + }); + if (transition === "stable" || transition === "once") { + expect(read()).toEqual(original); + expect(pass).toBe(transition === "stable" ? 1 : 2); + expect(verify).toHaveBeenCalledTimes(pass); + } else { + expect(read).toThrow(); + expect(pass).toBeLessThanOrEqual(2); + if (transition === "snapshot_second") expect(verify).toHaveBeenCalledTimes(2); + } + }, + ); + }); + + it.runIf(process.platform !== "win32").each(["once", "recurring", "bytes", "mode", "rename", "replacement", "symlink"] as const)("loads dependency bytes only after stable ctime-only continuity (%s)", async (transition) => { + const fixture = await installationFixture(); + await writeFile(join(fixture.commandDirectory, "dependency.mjs"), 'export const value = "dependency-success";'); + const command = `import fs from 'node:fs'; + const read = fs.readSync.bind(fs), stat = fs.fstatSync.bind(fs); + let moduleFd, passes = 0; + fs.readSync = (fd, buffer, offset, length, position) => { + const count = read(fd, buffer, offset, length, position); + if (buffer.subarray(offset, offset + count).includes(Buffer.from('dependency-success'))) { + moduleFd = fd; passes += 1; + if (${JSON.stringify(transition)} === 'bytes' && passes === 2) buffer[offset] = 0; + if (passes === 1 && ['rename','replacement','symlink'].includes(${JSON.stringify(transition)})) { + const named = new URL('./dependency.mjs', import.meta.url); + fs.renameSync(named, new URL('./moved.mjs', import.meta.url)); + if (${JSON.stringify(transition)} === 'replacement') fs.writeFileSync(named, buffer.subarray(offset, offset + count)); + if (${JSON.stringify(transition)} === 'symlink') fs.symlinkSync('./moved.mjs', named); + } + } + return count; + }; + fs.fstatSync = (fd, options) => { + const metadata = stat(fd, options); + if (fd === moduleFd && options?.bigint) { + metadata.ctimeNs += ${JSON.stringify(transition)} === 'recurring' ? BigInt(passes) : 1n; + if (${JSON.stringify(transition)} === 'mode') metadata.mode ^= 0o100n; + } + return metadata; + }; + const dependency = await import('./dependency.mjs'); + process.stdout.write(dependency.value);`; + await writeFile(fixture.commandPath, command); + await writeFile(fixture.serverPackageJsonPath, JSON.stringify({ version: "0.0.33", bin: "bin/server.js", type: "module" })); + const installation = await verifyQualifiedAcpxInstallation({ ...fixture.profile, + commandDigest: `sha256:${createHash("sha256").update(command).digest("hex")}`, + }, fixture.resolve); + const lease = await installation.openCommand(); + const child = lease.spawn(); + const closed = once(child, "close"); + let stdout = ""; + child.stdout?.on("data", bytes => { stdout += String(bytes); }); + // Drain private stderr while keeping it out of test/report output. + child.stderr?.resume(); + const timeout = setTimeout(() => child.kill("SIGKILL"), 4_000); + try { + const [code] = await closed; + expect(code).toBe(transition === "once" ? 0 : 1); + expect(stdout).toBe(transition === "once" ? "dependency-success" : ""); + } finally { + clearTimeout(timeout); + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + await closed; + await lease.close(); + } + }); + it.runIf(process.platform !== "win32").each(["OTHER", "UNVERIFIED_MODULE"] as const)( "settles a guarded ESM import failure without guardian retirement (%s)", async (category) => { diff --git a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts index edf2c897bc7..136c37ee604 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts @@ -7,7 +7,7 @@ import { type ChildProcess, type SpawnOptionsWithoutStdio, } from "node:child_process"; -import { closeSync, constants, existsSync, fstatSync, openSync, readFileSync, realpathSync } from "node:fs"; +import { type BigIntStats, closeSync, constants, existsSync, fstatSync, openSync, readFileSync, realpathSync } from "node:fs"; import { lstat, open, @@ -1848,6 +1848,55 @@ export function sanitizedNodeEnvironment( return sanitized; } +/** + * Read continuity for descriptor-pinned dependency modules. Linux has no separate + * per-module expected digest here; equal reads do not create new provenance. + * Darwin's existing private-snapshot digest verifier still runs on every pass. + * This function is serialized into the child, so it must remain self-contained. + */ +export function readStableSnapshotModuleSource(input: { + before: Pick; + stat: () => Pick; + read: (buffer: Buffer, offset: number, length: number, position: number) => number; + verify: (bytes: Buffer) => void; + maxBytes: number; +}): Buffer { + const reject = (): never => { + throw Object.assign(new Error("ACPX provider module changed while it was read"), { + code: "ERR_ACPX_UNVERIFIED_MODULE", + }); + }; + let before = input.before; + let first: Buffer | null = null; + for (let attempt = 0; attempt < 2; attempt += 1) { + if (!before.isFile() || before.size < 0n || before.size > BigInt(input.maxBytes)) reject(); + const size = Number(before.size); + const buffer = Buffer.alloc(size + 1); + let offset = 0; + while (offset < buffer.length) { + const count = input.read(buffer, offset, buffer.length - offset, offset); + if (!Number.isSafeInteger(count) || count < 0 || count > buffer.length - offset) reject(); + if (count === 0) break; + offset += count; + } + const bytes = buffer.subarray(0, offset); + const after = input.stat(); + input.verify(bytes); + if (!after.isFile() || bytes.length !== size || BigInt(bytes.length) !== after.size + || before.dev !== after.dev || before.ino !== after.ino || before.mode !== after.mode + || before.uid !== after.uid || before.gid !== after.gid || before.nlink !== after.nlink + || before.size !== after.size || before.mtimeNs !== after.mtimeNs) reject(); + if (first !== null && !first.equals(bytes)) reject(); + if (before.ctimeNs === after.ctimeNs) return bytes; + if (attempt !== 0) reject(); + first = bytes; + // Continue from the full final metadata, including ctime. A further drift + // between passes is therefore rejected, not granted another transition. + before = after; + } + return reject(); +} + function snapshotBootstrap(format: AcpxCommandFormat, guarded = false): string { return [ 'const fs = require("node:fs");', @@ -1916,6 +1965,7 @@ function snapshotBootstrap(format: AcpxCommandFormat, guarded = false): string { `const source = fs.readFileSync(${COMMAND_SOURCE_FD});`, "let resolvingDescriptorBare = false;", "const resolveBareFromDescriptor = (specifier, dependencyDirectoryUrl) => { resolvingDescriptorBare = true; try { return require.resolve(specifier, { paths: [fileURLToPath(dependencyDirectoryUrl)] }); } finally { resolvingDescriptorBare = false; } };", + `const readStableSnapshotModuleSource = ${readStableSnapshotModuleSource.toString()};`, "registerHooks({ resolve(specifier, context, nextResolve) {", "if (resolvingDescriptorBare) return nextResolve(specifier, context);", "if (specifier === target) return { url: target, shortCircuit: true };", @@ -1978,14 +2028,11 @@ function snapshotBootstrap(format: AcpxCommandFormat, guarded = false): string { "const extension = extname(fileURLToPath(url));", 'const moduleFormat = extension === ".mjs" ? "module" : extension === ".cjs" ? "commonjs" : extension === ".json" ? "json" : extension === ".node" ? "addon" : extension === ".js" ? (hintedFormat === "module" || hintedFormat === "commonjs" ? hintedFormat : packageFormat) : hintedFormat;', 'if (moduleFormat !== "module" && moduleFormat !== "commonjs" && moduleFormat !== "json") { const error = new Error("ACPX provider module format is not supported by descriptor-pinned loading"); error.code = "ERR_ACPX_UNVERIFIED_MODULE"; throw error; }', - "const admittedModuleBytes = Number(metadataBefore.size);", - "const moduleBuffer = Buffer.alloc(admittedModuleBytes + 1);", - "let moduleBytesRead = 0;", - "while (moduleBytesRead < moduleBuffer.length) { const bytesRead = fs.readSync(moduleFd, moduleBuffer, moduleBytesRead, moduleBuffer.length - moduleBytesRead, moduleBytesRead); if (bytesRead === 0) break; moduleBytesRead += bytesRead; }", - "const moduleSource = moduleBuffer.subarray(0, moduleBytesRead);", - "const metadataAfter = fs.fstatSync(moduleFd, { bigint: true });", - "verifySnapshotBytes(fileURLToPath(url), moduleSource);", - `if (moduleSource.length > ${MAX_AGENT_COMMAND_BYTES} || moduleSource.length !== admittedModuleBytes || BigInt(moduleSource.length) !== metadataAfter.size || metadataBefore.dev !== metadataAfter.dev || metadataBefore.ino !== metadataAfter.ino || metadataBefore.size !== metadataAfter.size || metadataBefore.mtimeNs !== metadataAfter.mtimeNs || metadataBefore.ctimeNs !== metadataAfter.ctimeNs) { const error = new Error("ACPX provider module changed while it was read"); error.code = "ERR_ACPX_UNVERIFIED_MODULE"; throw error; }`, + // Re-check both the retained descriptor's canonical path and its named + // inode after each pass; a rename is not harmless hydration just because + // its already-open descriptor still returns identical bytes. + 'const statModule = () => { const currentUrl = pathToFileURL(fs.realpathSync(privateSnapshot ? fileURLToPath(url) : "/proc/self/fd/" + moduleFd)).href; const named = fs.lstatSync(fileURLToPath(url), { bigint: true }); const metadata = fs.fstatSync(moduleFd, { bigint: true }); if (currentUrl !== openedUrl || !named.isFile() || named.dev !== metadata.dev || named.ino !== metadata.ino) { const error = new Error("ACPX provider module path changed while it was read"); error.code = "ERR_ACPX_UNVERIFIED_MODULE"; throw error; } return metadata; };', + `const moduleSource = readStableSnapshotModuleSource({ before: metadataBefore, stat: statModule, read: (buffer, offset, length, position) => fs.readSync(moduleFd, buffer, offset, length, position), verify: (bytes) => verifySnapshotBytes(fileURLToPath(url), bytes), maxBytes: ${MAX_AGENT_COMMAND_BYTES} });`, "return { format: moduleFormat, source: moduleSource, shortCircuit: true };", "} finally { fs.closeSync(moduleFd); }", "});",