From 0485678d17aee0b0a57f277cd7e376b05879d34c Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 3 Sep 2026 00:59:12 -0500 Subject: [PATCH] fix(runner): close provider launch trust gaps --- .../acpx/installation-integrity.test.ts | 57 +++++++++++++++- .../drivers/acpx/installation-integrity.ts | 65 ++++++++++--------- .../acpx/verified-runtime-executable.test.ts | 25 +++++++ .../acpx/verified-runtime-executable.ts | 12 +++- .../src/live/runnerd-codex-transport.ts | 7 +- 5 files changed, 130 insertions(+), 36 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 87e56f64ed..c7b80a22dd 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts @@ -50,8 +50,11 @@ afterEach(async () => { describe("ACPX installation integrity", () => { it("anchors dynamic provider package resolution at an explicit root", async () => { - const root = await mkdtemp(join(tmpdir(), "paperclip-acpx-package-root-")); - temporaryDirectories.push(root); + const parent = await mkdtemp( + join(tmpdir(), "paperclip-acpx-package-parent-"), + ); + temporaryDirectories.push(parent); + const root = join(parent, "provider-pack"); const providerDirectory = join(root, "node_modules", "qualified-provider"); const providerPackageJson = join(providerDirectory, "package.json"); await mkdir(providerDirectory, { recursive: true }); @@ -72,6 +75,56 @@ describe("ACPX installation integrity", () => { expect(() => createAcpxPackageJsonResolver(undefined)).toThrow( "explicit normalized absolute path", ); + + const ancestorProviderDirectory = join( + parent, + "node_modules", + "ancestor-provider", + ); + await mkdir(ancestorProviderDirectory, { recursive: true }); + await writeFile( + join(ancestorProviderDirectory, "package.json"), + JSON.stringify({ name: "ancestor-provider", version: "1.0.0" }), + ); + expect(() => + createAcpxPackageJsonResolver(root)("ancestor-provider"), + ).toThrow("outside the selected provider root"); + + const outsideProviderDirectory = join(parent, "outside-provider"); + await mkdir(outsideProviderDirectory); + await writeFile( + join(outsideProviderDirectory, "package.json"), + JSON.stringify({ name: "linked-provider", version: "1.0.0" }), + ); + await symlink( + outsideProviderDirectory, + join(root, "node_modules", "linked-provider"), + ); + expect(() => + createAcpxPackageJsonResolver(root)("linked-provider"), + ).toThrow("outside the selected provider root"); + }); + + it("does not fall back through the server package for a missing rooted dependency", async () => { + const fixture = await installationFixture(); + const nestedRuntimeDirectory = join( + fixture.serverDirectory, + "node_modules", + "@earendil-works", + "pi-coding-agent", + ); + await mkdir(nestedRuntimeDirectory, { recursive: true }); + await writeFile( + join(nestedRuntimeDirectory, "package.json"), + JSON.stringify({ version: "0.84.2" }), + ); + + await expect( + verifyQualifiedAcpxInstallation(fixture.profile, (packageName) => { + if (packageName === "pi-acp") return fixture.serverPackageJsonPath; + throw new Error("rooted package is absent"); + }), + ).rejects.toThrow("rooted package is absent"); }); it("rejects an unregistered provider exit proof", async () => { diff --git a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts index aef1e5a449..84fa23d325 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts @@ -4,7 +4,7 @@ import { type ChildProcess, type SpawnOptionsWithoutStdio, } from "node:child_process"; -import { constants } from "node:fs"; +import { constants, realpathSync } from "node:fs"; import { lstat, open, @@ -21,6 +21,7 @@ import { isAbsolute, relative, resolve, + sep, } from "node:path"; import type { Readable, Writable } from "node:stream"; @@ -229,9 +230,37 @@ export function createAcpxPackageJsonResolver( "ACPX provider package root must be an explicit normalized absolute path", ); } + const canonicalRoot = realpathSync(root); + const canonicalNodeModules = realpathSync( + resolve(canonicalRoot, "node_modules"), + ); + if (!pathIsInside(canonicalRoot, canonicalNodeModules)) { + throw new Error( + "ACPX provider node_modules resolves outside the selected provider root", + ); + } const providerRequire = createRequire(resolve(root, "package.json")); - return (packageName) => - providerRequire.resolve(`${packageName}/package.json`); + return (packageName) => { + const packageJsonPath = realpathSync( + providerRequire.resolve(`${packageName}/package.json`), + ); + if (!pathIsInside(canonicalNodeModules, packageJsonPath)) { + throw new Error( + `ACPX provider package ${packageName} resolves outside the selected provider root`, + ); + } + return packageJsonPath; + }; +} + +function pathIsInside(root: string, candidate: string): boolean { + const candidateRelativePath = relative(root, candidate); + return ( + candidateRelativePath !== "" && + candidateRelativePath !== ".." && + !candidateRelativePath.startsWith(`..${sep}`) && + !isAbsolute(candidateRelativePath) + ); } export interface VerifiedAcpxInstallation { @@ -409,11 +438,7 @@ export async function verifyQualifiedAcpxInstallation( throw new Error("Qualified ACPX runtime package omitted its version"); } runtimePackageJsonPath = await realpath( - resolvePackageJsonFrom( - profile.agentRuntimePackage, - serverPackageJsonPath, - resolvePackageJson, - ), + resolvePackageJson(profile.agentRuntimePackage), ); runtimePackage = await readPackageJson( runtimePackageJsonPath, @@ -558,24 +583,6 @@ function defaultPackageJsonResolver(packageName: string): string { return createRequire(import.meta.url).resolve(`${packageName}/package.json`); } -function resolvePackageJsonFrom( - packageName: string, - parentPackageJsonPath: string, - resolvePackageJson: AcpxPackageJsonResolver, -): string { - try { - return resolvePackageJson(packageName); - } catch (primaryError) { - try { - return createRequire(parentPackageJsonPath).resolve( - `${packageName}/package.json`, - ); - } catch { - throw primaryError; - } - } -} - async function readPackageJson( packageJsonPath: string, packageName: string, @@ -631,11 +638,7 @@ async function verifyQualifiedRuntimeExecutable(input: { } const executablePackageJsonPath = await realpath( - resolvePackageJsonFrom( - QUALIFIED_CODEX_LINUX_X64_RUNTIME.packageName, - input.runtimePackageJsonPath, - input.resolvePackageJson, - ), + input.resolvePackageJson(QUALIFIED_CODEX_LINUX_X64_RUNTIME.packageName), ); const executablePackage = await readPackageJson( executablePackageJsonPath, diff --git a/packages/paperclip-runner/src/drivers/acpx/verified-runtime-executable.test.ts b/packages/paperclip-runner/src/drivers/acpx/verified-runtime-executable.test.ts index a8d9e15509..13d351e933 100644 --- a/packages/paperclip-runner/src/drivers/acpx/verified-runtime-executable.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/verified-runtime-executable.test.ts @@ -55,4 +55,29 @@ describe("verified runtime executable", () => { "/usr/bin/node", ); }); + + it("accepts only the authenticated live process image on macOS", () => { + expect( + verifiedRuntimeExecutable( + { + [VERIFIED_RUNTIME_EXECUTABLE_ENV]: + "/private/tmp/.paperclip-verified-executable/launch", + }, + "darwin", + 4321, + "/private/tmp/.paperclip-verified-executable/launch", + ), + ).toBe("/private/tmp/.paperclip-verified-executable/launch"); + }); + + it("rejects a different environment-supplied executable on macOS", () => { + expect(() => + verifiedRuntimeExecutable( + { [VERIFIED_RUNTIME_EXECUTABLE_ENV]: "/tmp/attacker/node" }, + "darwin", + 4321, + "/private/tmp/.paperclip-verified-executable/launch", + ), + ).toThrow("path is invalid"); + }); }); diff --git a/packages/paperclip-runner/src/drivers/acpx/verified-runtime-executable.ts b/packages/paperclip-runner/src/drivers/acpx/verified-runtime-executable.ts index 1e528c8808..e51fe7625d 100644 --- a/packages/paperclip-runner/src/drivers/acpx/verified-runtime-executable.ts +++ b/packages/paperclip-runner/src/drivers/acpx/verified-runtime-executable.ts @@ -27,10 +27,18 @@ export function verifiedRuntimeExecutable( } if (platform === "darwin") { - if (!isAbsolute(configured) || resolve(configured) !== configured) { + if ( + !isAbsolute(fallback) || + resolve(fallback) !== fallback || + configured !== fallback + ) { throw new Error("Verified runtime executable path is invalid"); } - return configured; + // The Rust supervisor materializes the authenticated runtime as a private, + // read-only executable and starts this process from that exact pathname. + // Descendants may inherit the handoff variable, but they cannot nominate a + // different absolute path and have it treated as verified. + return fallback; } throw new Error( diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index e36e9a65c1..e64d005d63 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -1122,7 +1122,12 @@ function acpxProviderPackageRoot(sidecarScript: string): string { "runner_provider_package_root_incompatible: ACPX sidecar must use the provider package dist/cli layout", ); } - return resolve(cliDirectory, "../.."); + const sidecarPackageRoot = resolve(cliDirectory, "../.."); + // A local source build consumes pnpm's workspace-owned node_modules tree. + // A deployed provider pack owns a closed node_modules tree at its own root. + return sidecarPackageRoot === packageRoot + ? resolve(packageRoot, "../..") + : sidecarPackageRoot; } function acpxRunnerLaunchProfile(