fix(runner): close provider launch trust gaps

This commit is contained in:
Dotta 2026-09-03 00:59:12 -05:00
parent 1cbd8ae541
commit 0485678d17
5 changed files with 130 additions and 36 deletions

View File

@ -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 () => {

View File

@ -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,

View File

@ -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");
});
});

View File

@ -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(

View File

@ -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(