Verify complete native ACP distributions under existing lifetime guardian

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-28 11:14:34 -05:00
1 parent c810a25db2
commit af61365ea5
3 files changed
+332

No files matched your search

@@ -1,4 +1,6 @@
import { MAX_ACPX_RUNTIME_EXECUTABLE_BYTES, ACPX_PRIVATE_SNAPSHOT_ENV, createAcpxPrivateSnapshot, type AcpxPrivateSnapshot } from "./private-snapshot.js";
import { createNativeAcpxDistributionSnapshot, NATIVE_ACPX_BOOTSTRAP_NAME, readNativeAcpxDistributionEntries, type NativeAcpxDistributionInput } from "./native-distribution-integrity.js";
export type { NativeAcpxDistributionInput } from "./native-distribution-integrity.js";
import { createHash } from "node:crypto";
import {
spawn as spawnChildProcess,
@@ -406,6 +408,43 @@ export interface VerifiedAcpxInstallation {
openCommand(): Promise<VerifiedAcpxCommandLease>;
}
/**
* Native candidate distribution primitive. This verifies bytes and lifetime
* ownership; it does not qualify or enable a provider profile. The caller must
* source every expected value from its trusted, versioned profile declaration.
*/
export async function verifyNativeAcpxInstallation(
input: NativeAcpxDistributionInput,
): Promise<VerifiedAcpxInstallation> {
const declaration: NativeAcpxDistributionInput = Object.freeze({
...input, fixedArguments: Object.freeze([...input.fixedArguments]),
});
const entries = await readNativeAcpxDistributionEntries(declaration);
return Object.freeze({
commandDigest: `sha256:${declaration.expectedClosureSha256}`,
agentServerPackageJsonPath: declaration.manifestPath,
agentRuntimePackageJsonPath: null,
async openCommand(): Promise<VerifiedAcpxCommandLease> {
const native = await createNativeAcpxDistributionSnapshot(declaration, entries);
const lease = commandLease(
native.snapshot.roots[0]!, NATIVE_ACPX_BOOTSTRAP_NAME, "commonjs",
native.bootstrap, native.commandDirectory, [], 0, "commonjs", [],
null, null, native.snapshot,
);
return {
spawn(args = [], options = {}, lifetime) {
if (args.length !== 0) throw new Error("Native ACPX distribution accepts only its fixed profile arguments");
return lease.spawn([], { ...options, env: {
...(options.env ?? process.env),
PAPERCLIP_ACPX_NATIVE_GUARDED: lifetime === undefined ? "0" : "1",
} }, lifetime);
},
close: () => lease.close(),
};
},
});
}
export interface VerifiedAcpxCommandLease {
/**
* Launch with a Linux descriptor-backed entry identity. Callers must not
@@ -0,0 +1,115 @@
import { createHash } from "node:crypto";
import { once } from "node:events";
import { chmod, copyFile, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
import { createServer, type Server } from "node:net";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import type { ChildProcess } from "node:child_process";
import { afterEach, describe, expect, it } from "vitest";
import { awaitVerifiedAcpxProviderExit, awaitVerifiedAcpxProviderOwnership, verifyNativeAcpxInstallation } from "./installation-integrity.js";
import { parseNativeAcpxDistributionEntries, type NativeAcpxDistributionInput, type NativeAcpxDistributionEntry } from "./native-distribution-integrity.js";
const roots: string[] = [];
const hash = (value: string | Buffer) => createHash("sha256").update(value).digest("hex");
afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); });
async function fixture(options: { node?: boolean; script?: string } = {}): Promise<NativeAcpxDistributionInput> {
const root = await mkdtemp(join(tmpdir(), "native-acpx-test-")); roots.push(root);
const entries: NativeAcpxDistributionEntry[] = [];
if (options.node) {
await copyFile(process.execPath, join(root, "runtime")); await chmod(join(root, "runtime"), 0o700);
const bytes = await readFile(join(root, "runtime")); entries.push({ path: "runtime", sha256: hash(bytes), size: bytes.length, executable: true });
// Homebrew's test-host Node uses an @rpath libnode dylib. Include that
// dependency in this fixture's closure instead of relying on its old path.
const libraryRoot = join(dirname(process.execPath), "..", "lib");
for (const name of (await readdir(libraryRoot).catch(() => [] as string[])).filter(name => /^libnode\..*\.dylib$/.test(name))) {
await copyFile(join(libraryRoot, name), join(root, name)); await chmod(join(root, name), 0o600);
const library = await readFile(join(root, name)); entries.push({ path: name, sha256: hash(library), size: library.length, executable: false });
}
const script = options.script ?? 'console.log("qualified-entry");';
await writeFile(join(root, "entry.cjs"), script, { mode: 0o600 });
entries.push({ path: "entry.cjs", sha256: hash(script), size: Buffer.byteLength(script), executable: false });
} else {
const script = options.script ?? '#!/bin/sh\nprintf "native:%s:%s" "$1" "${COPILOT_PKG_CACHE_HOME:-none}"\n';
await writeFile(join(root, "runtime"), script, { mode: 0o700 });
entries.push({ path: "runtime", sha256: hash(script), size: Buffer.byteLength(script), executable: true });
}
entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
const manifestPath = join(root, "closure.json");
await writeFile(manifestPath, JSON.stringify({ entries }));
return { distributionRoot: root, manifestPath, expectedClosureSha256: hash(JSON.stringify(entries)), executable: "runtime", ...(options.node ? { entrypoint: "entry.cjs" } : {}), fixedArguments: options.node ? [] : ["fixed"] };
}
async function output(child: ChildProcess): Promise<{ text: string; error: string; code: number | null }> {
let text = ""; let error = "";
child.stdout!.on("data", value => { text += String(value); });
child.stderr!.on("data", value => { error += String(value); });
const [code] = await once(child, "close") as [number | null];
return { text, error, code };
}
describe("native ACPX execution closure", () => {
it("rejects paths, ordering, oversized files and altered manifest pins", () => {
const entry = { path: "runtime", sha256: "a".repeat(64), size: 10, executable: true };
for (const entries of [[{ ...entry, path: "../escape" }], [{ ...entry, path: "/absolute" }], [entry, entry], [{ ...entry, size: 2 ** 40 }], [{ ...entry, unknown: 1 }]]) {
expect(() => parseNativeAcpxDistributionEntries({ entries }, hash(JSON.stringify(entries)))).toThrow();
}
expect(() => parseNativeAcpxDistributionEntries({ entries: [entry] }, "b".repeat(64))).toThrow("manifest digest mismatch");
});
it("checks every file digest and refuses links at admission", async () => {
const declaration = await fixture(); const installation = await verifyNativeAcpxInstallation(declaration);
await writeFile(join(declaration.distributionRoot, "runtime"), "altered");
await expect(installation.openCommand()).rejects.toThrow();
await rm(join(declaration.distributionRoot, "runtime"));
await symlink("closure.json", join(declaration.distributionRoot, "runtime"));
await expect(installation.openCommand()).rejects.toThrow("symbolic link");
});
it("launches only fixed arguments from a frozen snapshot after installed files change", async () => {
const declaration = await fixture(); const lease = await (await verifyNativeAcpxInstallation(declaration)).openCommand();
expect(() => lease.spawn(["--untrusted-override"])).toThrow("fixed profile arguments");
await writeFile(join(declaration.distributionRoot, "runtime"), "changed after lease");
const result = await output(lease.spawn());
expect(result).toEqual({ text: "native:fixed:none", error: "", code: 0 });
expect(() => lease.spawn()).toThrow("closed");
await lease.close();
});
it("gives packaged executables a fresh private extraction cache each launch", async () => {
const declaration = { ...await fixture(), isolatedCacheEnvironmentName: "COPILOT_PKG_CACHE_HOME" as const };
const install = await verifyNativeAcpxInstallation(declaration);
const first = await output((await install.openCommand()).spawn([], { env: { COPILOT_PKG_CACHE_HOME: "/ambient/cache" } }));
const second = await output((await install.openCommand()).spawn());
expect(first.text).toMatch(/^native:fixed:.*paperclip-acpx-native-.*\/state$/);
expect(first.text).not.toBe(second.text);
});
it("loads a pinned Node entrypoint while rejecting unqualified external modules", async () => {
const declaration = await fixture({ node: true });
const result = await output((await (await verifyNativeAcpxInstallation(declaration)).openCommand()).spawn());
expect(result.code, result.error).toBe(0); expect(result.text).toBe("qualified-entry\n");
const outside = join(declaration.distributionRoot, "outside.cjs"); await writeFile(outside, "module.exports='ambient';");
const evil = await fixture({ node: true, script: `require(${JSON.stringify(outside)});` });
const denied = await output((await (await verifyNativeAcpxInstallation(evil)).openCommand()).spawn());
expect(denied.code).not.toBe(0); expect(denied.error).toContain("escaped its closed distribution");
}, 30_000);
it("uses the existing guardian ownership and provider-exit proof for native children", async () => {
const declaration = await fixture({ script: '#!/bin/sh\nprintf "ready"\nwhile :; do sleep 1; done\n' });
const fences = await Promise.all([listen(), listen()]);
const fds = fences.map(server => (server as Server & { _handle?: { fd?: number } })._handle!.fd!);
const owners: number[] = [];
const child = (await (await verifyNativeAcpxInstallation(declaration)).openCommand()).spawn([], {}, {
credentialFenceFds: [fds[0]!, fds[1]!], activateCredentialFenceOwner: async pid => { owners.push(pid); },
});
let stderr = ""; child.stderr!.on("data", value => { stderr += String(value); });
try {
await awaitVerifiedAcpxProviderOwnership(child);
expect(owners).toEqual([child.pid]);
const [chunk] = await once(child.stdout!, "data"); expect(String(chunk), stderr).toBe("ready");
const exited = once(child, "exit"); child.kill(); await exited;
await awaitVerifiedAcpxProviderExit(child);
} finally {
child.kill(); await Promise.all(fences.map(server => new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))));
}
}, 15_000);
});
async function listen(): Promise<Server> {
const server = createServer(); server.listen(0, "127.0.0.1"); await once(server, "listening"); return server;
}
@@ -0,0 +1,178 @@
import { createHash } from "node:crypto";
import { constants } from "node:fs";
import { chmod, lstat, mkdir, mkdtemp, open, realpath, rm, writeFile, type FileHandle } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
import type { AcpxPrivateSnapshot } from "./private-snapshot.js";
export interface NativeAcpxDistributionEntry { path: string; sha256: string; size: number; executable: boolean }
export interface NativeAcpxDistributionInput {
distributionRoot: string;
manifestPath: string;
/** Trusted profile pin, never taken from the installed manifest itself. */
expectedClosureSha256: string;
executable: string;
/** When present, execute this JS entry with the distribution's bundled Node. */
entrypoint?: string;
fixedArguments: readonly string[];
isolatedCacheEnvironmentName?: "COPILOT_PKG_CACHE_HOME";
}
export interface NativeAcpxDistributionSnapshot {
snapshot: AcpxPrivateSnapshot;
commandDirectory: FileHandle;
bootstrap: Buffer;
}
const MAX_NATIVE_TREE_BYTES = 1024 * 1024 * 1024;
const MAX_NATIVE_FILE_BYTES = 384 * 1024 * 1024;
const MAX_NATIVE_MANIFEST_BYTES = 4 * 1024 * 1024;
const BOOTSTRAP = ".paperclip-native-entry.cjs";
const GUARD = ".paperclip-native-module-guard.cjs";
export const NATIVE_ACPX_BOOTSTRAP_NAME = BOOTSTRAP;
const sha256 = (value: string | Buffer) => createHash("sha256").update(value).digest("hex");
const validPath = (value: unknown): value is string => typeof value === "string" && value.length > 0 && value.length <= 4_096 && !isAbsolute(value) && !/[\u0000-\u001f\u007f\\]/.test(value) && value.split("/").every(part => part.length > 0 && part !== "." && part !== "..");
/** Parse a closed, canonical execution inventory before opening any file in it. */
export function parseNativeAcpxDistributionEntries(value: unknown, expectedSha256: string): NativeAcpxDistributionEntry[] {
if (!/^[a-f0-9]{64}$/.test(expectedSha256)) throw new Error("Native ACPX closure pin is invalid");
const entries = value !== null && typeof value === "object" ? (value as { entries?: unknown }).entries : undefined;
if (!Array.isArray(entries) || entries.length === 0 || entries.length > 10_000) throw new Error("Native ACPX closure inventory is invalid");
let total = 0;
let previous = "";
const parsed = entries.map(raw => {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) throw new Error("Native ACPX closure entry is invalid");
const entry = raw as Record<string, unknown>;
if (Object.keys(entry).some(key => !["path", "sha256", "size", "executable"].includes(key)) || !validPath(entry.path) || entry.path <= previous || entry.path === BOOTSTRAP || entry.path === GUARD || entry.path === "manifest.json" || entry.path.startsWith(".paperclip-")) throw new Error("Native ACPX closure paths must be safe, unique and sorted");
if (typeof entry.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(entry.sha256) || !Number.isSafeInteger(entry.size) || Number(entry.size) < 0 || Number(entry.size) > MAX_NATIVE_FILE_BYTES || typeof entry.executable !== "boolean") throw new Error("Native ACPX closure file metadata is invalid");
previous = entry.path;
total += Number(entry.size);
if (total > MAX_NATIVE_TREE_BYTES) throw new Error("Native ACPX closure exceeds its byte bound");
return { path: entry.path, sha256: entry.sha256, size: Number(entry.size), executable: entry.executable };
});
if (sha256(JSON.stringify(parsed)) !== expectedSha256) throw new Error("Native ACPX closure manifest digest mismatch");
return parsed;
}
export async function readNativeAcpxDistributionEntries(input: NativeAcpxDistributionInput): Promise<NativeAcpxDistributionEntry[]> {
if (!validPath(input.executable) || (input.entrypoint !== undefined && !validPath(input.entrypoint)) || input.fixedArguments.length > 128 || input.fixedArguments.some(arg => typeof arg !== "string" || arg.length > 16_384 || arg.includes("\0")) || (input.isolatedCacheEnvironmentName !== undefined && input.isolatedCacheEnvironmentName !== "COPILOT_PKG_CACHE_HOME")) throw new Error("Native ACPX launch declaration is invalid");
const file = await open(input.manifestPath, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
const before = await file.stat({ bigint: true });
if (!before.isFile() || before.size < 1n || before.size > BigInt(MAX_NATIVE_MANIFEST_BYTES)) throw new Error("Native ACPX manifest must be a bounded regular file");
const bytes = await file.readFile();
if (!same(before, await file.stat({ bigint: true })) || bytes.length !== Number(before.size)) throw new Error("Native ACPX manifest changed while read");
const entries = parseNativeAcpxDistributionEntries(JSON.parse(bytes.toString("utf8")), input.expectedClosureSha256);
if (!entries.some(entry => entry.path === input.executable && entry.executable && entry.size > 0) || (input.entrypoint !== undefined && !entries.some(entry => entry.path === input.entrypoint && entry.size > 0))) throw new Error("Native ACPX executable or entrypoint is absent from its closure");
return entries;
} finally { await file.close(); }
}
/**
* Freeze only manifest-admitted bytes. Native trees deliberately have a separate
* bound; the tighter JavaScript/npm snapshot limits remain unchanged.
*/
export async function createNativeAcpxDistributionSnapshot(input: NativeAcpxDistributionInput, entries: NativeAcpxDistributionEntry[]): Promise<NativeAcpxDistributionSnapshot> {
// Callers cannot substitute entries between manifest validation and copying.
entries = parseNativeAcpxDistributionEntries({ entries }, input.expectedClosureSha256);
if (process.platform !== "linux" && process.platform !== "darwin") throw new Error("Native ACPX snapshots require Linux or macOS");
const source = await realpath(input.distributionRoot);
const rootBefore = await lstat(source, { bigint: true });
if (!rootBefore.isDirectory() || rootBefore.isSymbolicLink()) throw new Error("Native ACPX distribution must be a directory");
const heldRoot = await open(source, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_DIRECTORY);
const privateRoot = await realpath(await mkdtemp(join(tmpdir(), "paperclip-acpx-native-")));
const packageRoot = join(privateRoot, "distribution");
const cacheRoot = join(privateRoot, "state");
const directories = new Set([privateRoot, packageRoot]);
const digests: Record<string, string> = {};
const close = async () => {
for (const path of directories) await chmod(path, 0o700).catch(() => undefined);
await rm(privateRoot, { recursive: true, force: true });
};
let commandDirectory: FileHandle | undefined;
try {
if (!same(rootBefore, await heldRoot.stat({ bigint: true }))) throw new Error("Native ACPX distribution root changed before snapshot");
await mkdir(packageRoot, { mode: 0o700 });
if (input.isolatedCacheEnvironmentName) await mkdir(cacheRoot, { mode: 0o700 });
for (const entry of entries) {
const path = join(source, ...entry.path.split("/"));
if (await realpath(path) !== path) throw new Error("Native ACPX closure contains a symbolic link");
const before = await lstat(path, { bigint: true });
if (!before.isFile() || before.nlink !== 1n || before.size !== BigInt(entry.size) || Boolean(before.mode & 0o111n) !== entry.executable) throw new Error("Native ACPX closure file identity is invalid");
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
if (!same(before, await file.stat({ bigint: true }))) throw new Error("Native ACPX closure file changed before read");
const bytes = Buffer.alloc(entry.size);
let offset = 0;
while (offset < bytes.length) {
const read = await file.read(bytes, offset, bytes.length - offset, offset);
if (read.bytesRead === 0) throw new Error("Native ACPX closure file ended during snapshot");
offset += read.bytesRead;
}
if (!same(before, await file.stat({ bigint: true })) || !same(before, await lstat(path, { bigint: true })) || await realpath(path) !== path) throw new Error("Native ACPX closure file changed while read");
if (sha256(bytes) !== entry.sha256) throw new Error(`Native ACPX closure file digest mismatch: ${entry.path}`);
const target = join(packageRoot, ...entry.path.split("/"));
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
let directory = dirname(target);
while (directory !== privateRoot) { directories.add(directory); directory = dirname(directory); }
await writeFile(target, bytes, { mode: entry.executable ? 0o500 : 0o400, flag: "wx" });
digests[target] = entry.sha256;
} finally { await file.close(); }
}
if (!same(rootBefore, await heldRoot.stat({ bigint: true })) || !same(rootBefore, await lstat(source, { bigint: true }))) throw new Error("Native ACPX distribution root changed during snapshot");
const executable = join(packageRoot, ...input.executable.split("/"));
const args = [...(input.entrypoint === undefined ? [] : ["--require", join(packageRoot, GUARD), join(packageRoot, ...input.entrypoint.split("/"))]), ...input.fixedArguments];
if (input.entrypoint !== undefined) {
const guard = Buffer.from(nativeModuleGuard(digests));
await writeFile(join(packageRoot, GUARD), guard, { mode: 0o400, flag: "wx" });
digests[join(packageRoot, GUARD)] = sha256(guard);
}
const bootstrap = Buffer.from(nativeBootstrap(executable, digests[executable]!, args, input.isolatedCacheEnvironmentName ? { name: input.isolatedCacheEnvironmentName, value: cacheRoot } : undefined));
await writeFile(join(packageRoot, BOOTSTRAP), bootstrap, { mode: 0o400, flag: "wx" });
digests[join(packageRoot, BOOTSTRAP)] = sha256(bootstrap);
// executable is null: commandLease's separately-qualified provider runtime
// slot is unused; this distribution is spawned by the trusted bootstrap.
const manifest = Buffer.from(JSON.stringify({ roots: [packageRoot], executable: null, digests }));
const manifestPath = join(privateRoot, "manifest.json");
await writeFile(manifestPath, manifest, { mode: 0o400, flag: "wx" });
for (const directory of directories) await chmod(directory, 0o500);
commandDirectory = await open(packageRoot, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_DIRECTORY);
return { commandDirectory, bootstrap, snapshot: { roots: [packageRoot], executable: null, digests, handoff: { path: manifestPath, digest: sha256(manifest) }, close } };
} catch (error) {
await commandDirectory?.close(); await close(); throw error;
} finally { await heldRoot.close(); }
}
function nativeBootstrap(executable: string, executableDigest: string, args: string[], cache?: { name: string; value: string }): string {
return [
'const {spawn}=require("node:child_process");',
'const fs=require("node:fs");',
// The commandLease's guarded bootstrap owns FDs 5 (guardian), 6 (exit),
// 7/8 (credential fences). Keep them open in the native process too.
'const guarded=process.env.PAPERCLIP_ACPX_NATIVE_GUARDED==="1";',
'const env={...process.env}; delete env.PAPERCLIP_ACPX_NATIVE_GUARDED; delete env.PAPERCLIP_ACPX_PRIVATE_SNAPSHOT; delete env.PAPERCLIP_VERIFIED_RUNTIME_EXECUTABLE;',
'env.NODE_DISABLE_COMPILE_CACHE="1"; delete env.NODE_COMPILE_CACHE;',
...(cache ? [`env[${JSON.stringify(cache.name)}]=${JSON.stringify(cache.value)};`] : []),
'if(guarded)for(const fd of [5,6,7,8])fs.fstatSync(fd);',
`const executable=${JSON.stringify(executable)};const fd=fs.openSync(executable,fs.constants.O_RDONLY|fs.constants.O_NOFOLLOW);`,
`if(require("node:crypto").createHash("sha256").update(fs.readFileSync(fd)).digest("hex")!==${JSON.stringify(executableDigest)})throw new Error("Native ACPX executable changed before spawn");`,
'const stdio=guarded?[0,1,2,5,6,7,8]:[0,1,2]; const childExecutable=process.platform==="linux"?"/proc/self/fd/"+stdio.length:executable;stdio.push(fd);',
`const child=spawn(childExecutable,${JSON.stringify(args)},{env,cwd:process.cwd(),shell:false,detached:false,stdio});fs.closeSync(fd);`,
'child.once("error",()=>process.exit(1)); child.once("exit",(code,signal)=>process.exit(signal?1:code??1));',
'for(const signal of ["SIGTERM","SIGINT","SIGHUP"])process.on(signal,()=>child.kill(signal));',
].join("\n");
}
function nativeModuleGuard(digests: Record<string, string>): string {
return [
'const {registerHooks,isBuiltin}=require("node:module");',
'const {readFileSync,realpathSync}=require("node:fs");',
'const {fileURLToPath}=require("node:url"); const {createHash}=require("node:crypto");',
`const digests=${JSON.stringify(digests)};`,
'const verify=url=>{if(!url.startsWith("file:"))throw new Error("Native ACPX module must be a qualified file or builtin");const path=fileURLToPath(url);if(realpathSync(path)!==path||!Object.hasOwn(digests,path))throw new Error("Native ACPX module escaped its closed distribution");const bytes=readFileSync(path);if(createHash("sha256").update(bytes).digest("hex")!==digests[path])throw new Error("Native ACPX module digest mismatch");return bytes;};',
'registerHooks({resolve(specifier,context,next){const result=next(specifier,context);if(!isBuiltin(specifier)&&!result.url.startsWith("node:"))verify(result.url);return result;},load(url,context,next){if(url.startsWith("node:"))return next(url,context);const bytes=verify(url);if(["module","commonjs","json"].includes(context.format))return {format:context.format,source:bytes,shortCircuit:true};return next(url,context);}});',
].join("\n");
}
function same(a: { dev: bigint; ino: bigint; size: bigint; mtimeNs: bigint; ctimeNs: bigint }, b: typeof a): boolean {
return a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs;
}