From 6c2a0a2bd86ef411bbc0e273b179d5bc4f1a3733 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 11 Oct 2026 00:58:56 -0500 Subject: [PATCH] Reverify qualified runtime after ctime-only hydration --- .../acpx/installation-integrity.test.ts | 59 +++++++++ .../drivers/acpx/installation-integrity.ts | 125 +++++++++++------- 2 files changed, 134 insertions(+), 50 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 089aeec06bc..56fadfafa76 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts @@ -8,6 +8,8 @@ import { link, mkdir, mkdtemp, + open, + type FileHandle, readFile, realpath, rename, @@ -15,6 +17,7 @@ import { symlink, stat, writeFile, + utimes, } from "node:fs/promises"; import { createServer, type Server } from "node:net"; import { tmpdir } from "node:os"; @@ -25,6 +28,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js"; import { verifyProvisionedGrokExecutable, + openVerifiedRuntimeExecutable, builtinGrokLauncherPath, awaitVerifiedAcpxProviderExit, awaitVerifiedAcpxProviderOwnership, @@ -54,6 +58,61 @@ afterEach(async () => { }); describe("ACPX installation integrity", () => { + it.each(["once", "every_read", "wrong_digest", "mode", "mtime", "replacement", "symlink", "second_content"] as const)("requires stable full re-verification after a ctime-only transition (%s)", async (transition) => { + const directory = await mkdtemp(join(tmpdir(), "paperclip-runtime-hydration-")); + temporaryDirectories.push(directory); + const executable = join(directory, "runtime"); + const bytes = Buffer.from("qualified runtime fixture"); + await writeFile(executable, bytes, { mode: 0o755 }); + const digest = `sha256:${createHash("sha256").update(transition === "wrong_digest" ? "wrong" : bytes).digest("hex")}`; + const probe = await open(executable, "r"); + const prototype = Object.getPrototypeOf(probe) as FileHandle; + const originalRead = probe.read; + await probe.close(); + let reads = 0; + const readSpy = vi.spyOn(prototype, "read").mockImplementation(async function (this: FileHandle, ...args: Parameters) { + if (reads === 1 && transition === "second_content") { + await writeFile(executable, Buffer.alloc(bytes.length, 120)); + } + const result = await Reflect.apply(originalRead, this, args); + reads += 1; + if (reads === 1 || transition === "every_read") { + const before = await stat(executable, { bigint: true }); + // chmod to the existing mode models Boat hydration: ctime alone changes. + for (let attempt = 0; attempt < 20; attempt += 1) { + await chmod(executable, 0o755); + if ((await stat(executable, { bigint: true })).ctimeNs !== before.ctimeNs) break; + await new Promise((resolve) => setTimeout(resolve, 1)); + } + const after = await stat(executable, { bigint: true }); + expect(after.ctimeNs).not.toBe(before.ctimeNs); + expect([after.dev, after.ino, after.size, after.mode, after.mtimeNs]).toEqual([before.dev, before.ino, before.size, before.mode, before.mtimeNs]); + if (transition === "mode") await chmod(executable, 0o700); + if (transition === "mtime") await utimes(executable, new Date(0), new Date(0)); + if (transition === "replacement" || transition === "symlink") { + const moved = `${executable}.original`; + await rename(executable, moved); + if (transition === "symlink") await symlink(moved, executable); + else await writeFile(executable, bytes, { mode: 0o755 }); + } + } + return result; + }); + try { + if (transition === "once") { + const verified = await openVerifiedRuntimeExecutable(executable, digest, "fixture"); + try { + const stable = await verified.handle.stat({ bigint: true }); + expect(verified.identity.changedNanoseconds).toBe(stable.ctimeNs.toString()); + expect(verified.identity.modifiedNanoseconds).toBe(stable.mtimeNs.toString()); + } finally { await verified.handle.close(); } + } else { + await expect(openVerifiedRuntimeExecutable(executable, digest, "fixture")).rejects.toThrow(transition === "wrong_digest" ? "digest mismatch" : "changed while it was verified"); + } + expect(reads).toBe(["once", "every_read", "second_content"].includes(transition) ? 2 : 1); + } finally { readSpy.mockRestore(); } + }); + it.each([false, true])("admits only the declared qualified Codex platform in its npm-hoisted slot (published platform declaration: %s)", async (publishedDeclaration) => { const parent = await realpath(await mkdtemp(join(tmpdir(), "paperclip-codex-npm-hoist-"))); temporaryDirectories.push(parent); diff --git a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts index 05fd89fdc47..714118266d3 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts @@ -1166,7 +1166,7 @@ async function inspectCommand( } } -async function openVerifiedRuntimeExecutable( +export async function openVerifiedRuntimeExecutable( executablePath: string, expectedDigest: string, agent: string, @@ -1197,59 +1197,84 @@ async function openVerifiedRuntimeExecutable( } try { - const before = await handle.stat({ bigint: true }); - if ( - !before.isFile() || - before.size < 1n || - before.size > BigInt(MAX_ACPX_RUNTIME_EXECUTABLE_BYTES) || - (before.mode & 0o111n) === 0n - ) { - throw new Error( - `ACPX ${agent} runtime executable must be a bounded executable file`, - ); - } - const hash = createHash("sha256"); - const buffer = Buffer.alloc(1024 * 1024); - let position = 0; - try { - while (position < Number(before.size)) { - const { bytesRead } = await handle.read( - buffer, - 0, - Math.min(buffer.length, Number(before.size) - position), - position, + let lexicalBaseline = lexicalBefore; + for (let attempt = 0; attempt < 2; attempt += 1) { + const before = await handle.stat({ bigint: true }); + if ( + !before.isFile() || + before.size < 1n || + before.size > BigInt(MAX_ACPX_RUNTIME_EXECUTABLE_BYTES) || + (before.mode & 0o111n) === 0n + ) { + throw new Error( + `ACPX ${agent} runtime executable must be a bounded executable file`, ); - if (bytesRead === 0) break; - hash.update(buffer.subarray(0, bytesRead)); - position += bytesRead; } - } finally { - buffer.fill(0); - } - const after = await handle.stat({ bigint: true }); - const lexicalAfter = await lstat(executablePath, { bigint: true }).catch( - () => null, - ); - const beforeIdentity = fileIdentity(before); - const afterIdentity = fileIdentity(after); - if ( - position !== Number(before.size) || - lexicalAfter === null || - lexicalAfter.isSymbolicLink() || - !lexicalAfter.isFile() || - !sameIdentity(fileIdentity(lexicalBefore), fileIdentity(lexicalAfter)) || - !sameIdentity(fileIdentity(lexicalAfter), afterIdentity) || - !sameIdentity(beforeIdentity, afterIdentity) - ) { - throw new Error( - `ACPX ${agent} runtime executable changed while it was verified`, + const beforeIdentity = fileIdentity(before); + if ( + !sameIdentity(fileIdentity(lexicalBaseline), beforeIdentity) || + lexicalBaseline.mode !== before.mode + ) { + throw new Error( + `ACPX ${agent} runtime executable changed while it was verified`, + ); + } + const hash = createHash("sha256"); + const buffer = Buffer.alloc(1024 * 1024); + let position = 0; + try { + while (position < Number(before.size)) { + const { bytesRead } = await handle.read( + buffer, + 0, + Math.min(buffer.length, Number(before.size) - position), + position, + ); + if (bytesRead === 0) break; + hash.update(buffer.subarray(0, bytesRead)); + position += bytesRead; + } + } finally { + buffer.fill(0); + } + const after = await handle.stat({ bigint: true }); + const lexicalAfter = await lstat(executablePath, { bigint: true }).catch( + () => null, ); + const afterIdentity = fileIdentity(after); + if ( + position !== Number(before.size) || + lexicalAfter === null || + lexicalAfter.isSymbolicLink() || + !lexicalAfter.isFile() || + !sameIdentity(fileIdentity(lexicalAfter), afterIdentity) || + lexicalAfter.mode !== after.mode || + before.mode !== after.mode || + !sameIdentity( + { ...beforeIdentity, changedNanoseconds: afterIdentity.changedNanoseconds }, + afterIdentity, + ) + ) { + throw new Error( + `ACPX ${agent} runtime executable changed while it was verified`, + ); + } + const digest = `sha256:${hash.digest("hex")}`; + if (digest !== expectedDigest) { + throw new Error(`ACPX ${agent} runtime executable digest mismatch`); + } + if (sameIdentity(beforeIdentity, afterIdentity)) { + return { handle, identity: afterIdentity }; + } + // A lazy filesystem restore can change ctime on the first read. Only + // already-qualified bytes with otherwise identical metadata may receive + // one complete re-read on this same descriptor. The final pass must be + // fully stable; later launch identity comparisons remain strict. + lexicalBaseline = lexicalAfter; } - const digest = `sha256:${hash.digest("hex")}`; - if (digest !== expectedDigest) { - throw new Error(`ACPX ${agent} runtime executable digest mismatch`); - } - return { handle, identity: afterIdentity }; + throw new Error( + `ACPX ${agent} runtime executable changed while it was verified`, + ); } catch (error) { await handle.close(); throw error;