mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 21:07:40 +01:00
Verify complete native ACP distributions under existing lifetime guardian
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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;
|
||||
}
|
||||
Reference in new issue
Block a user