fix(runner): close provider launch trust gaps
This commit is contained in:
parent
1cbd8ae541
commit
0485678d17
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue