Verify complete canonical provider exports offline

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-09 16:18:01 -05:00
parent 4cd6a81e4a
commit 3497dec88b
5 changed files with 179 additions and 12 deletions

View File

@ -284,7 +284,8 @@ RUN test -n "${PAPERCLIP_BUILD_COMMIT}" \
&& PAPERCLIP_RUNNER_SOURCE_REVISION="${PAPERCLIP_BUILD_COMMIT}" \
node packages/paperclip-runner/scripts/assemble-provider-pack.mjs /provider-pack \
&& node packages/paperclip-runner/scripts/verify-pi-provider-launch.mjs /provider-pack \
&& chmod -R a+rX /provider-pack
&& PATH=/provider-pack/node_modules/.bin:$PATH sh -ec 'for cli in node acpx claude-agent-acp codex-acp pi-acp pi claude codex opencode; do test -x "/provider-pack/node_modules/.bin/$cli"; done; test "$(acpx --version)" = "0.13.1"; test "$(claude-agent-acp --version)" = "0.70.0"; test "$(codex-acp --version)" = "@agentclientprotocol/codex-acp 1.6.2"' \
&& node --input-type=module -e "import {verifyProviderPack} from './packages/paperclip-runner/scripts/provider-pack-integrity.mjs'; verifyProviderPack('/provider-pack', {revision: '${PAPERCLIP_BUILD_COMMIT}', lockSha256: '${PAPERCLIP_RUNNER_LOCK_SHA256}'});"
FROM production AS cloud
COPY --from=cloud-provider-pack /provider-pack /opt/paperclip-runner/provider-pack

View File

@ -39,7 +39,8 @@ RUN pnpm --filter @paperclipai/paperclip-runner build:typescript \
&& PAPERCLIP_RUNNER_SOURCE_REVISION="${PAPERCLIP_RUNNER_SOURCE_REVISION}" \
node packages/paperclip-runner/scripts/assemble-provider-pack.mjs /provider-pack \
&& node packages/paperclip-runner/scripts/verify-pi-provider-launch.mjs /provider-pack \
&& chmod -R a+rX /provider-pack
&& PATH=/provider-pack/node_modules/.bin:$PATH sh -ec 'for cli in node acpx claude-agent-acp codex-acp pi-acp pi claude codex opencode; do test -x "/provider-pack/node_modules/.bin/$cli"; done; test "$(acpx --version)" = "0.13.1"; test "$(claude-agent-acp --version)" = "0.70.0"; test "$(codex-acp --version)" = "@agentclientprotocol/codex-acp 1.6.2"' \
&& node --input-type=module -e "import {verifyProviderPack} from './packages/paperclip-runner/scripts/provider-pack-integrity.mjs'; verifyProviderPack('/provider-pack', {revision: '${PAPERCLIP_RUNNER_SOURCE_REVISION}', lockSha256: '${PAPERCLIP_RUNNER_LOCK_SHA256}'});"
# The trusted qualification entry exports the identical canonical provider stage.
FROM scratch AS provider-pack-export

View File

@ -1,4 +1,4 @@
import { canonicalJson, sha256File, sha256Tree } from "./provider-pack-integrity.mjs";
import { canonicalJson, sha256File, sha256Tree, prepareProviderTree, writeProviderTreeSidecar } from "./provider-pack-integrity.mjs";
import { normalizeProviderPackLayout } from "./provider-pack-layout.mjs";
import { portableProviderShim } from "./portable-provider-shim.mjs";
import { createHash } from "node:crypto";
@ -232,7 +232,9 @@ try {
: spawnSync("git", ["diff", "--quiet", "--", "packages/paperclip-runner"], {
cwd: workspaceRoot,
}).status !== 0;
const exportTree = prepareProviderTree(temporaryRoot);
const payload = {
exportTreeDigest: exportTree.digest,
pins: {
nodeMinimum: minimumNodeVersion.join("."),
codex: "0.153.4",
@ -297,6 +299,8 @@ try {
{ mode: 0o600 },
);
writeProviderTreeSidecar(temporaryRoot, exportTree);
rmSync(outputRoot, { recursive: true, force: true });
renameSync(temporaryRoot, outputRoot);
process.stdout.write(`${outputRoot}\n`);

View File

@ -2,11 +2,11 @@ import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { test } from "node:test";
import { createHash } from "node:crypto";
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync } from "node:fs";
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync, chmodSync, symlinkSync, unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { buildProviderPack } from "./build-provider-pack.mjs";
import { canonicalJson, sha256File, sha256Tree } from "./provider-pack-integrity.mjs";
import { canonicalJson, sha256File, sha256Tree, prepareProviderTree, writeProviderTreeSidecar, verifyProviderTree } from "./provider-pack-integrity.mjs";
const revision = "a".repeat(40);
function fixture(runTest) {
const parent = mkdtempSync(join(tmpdir(), "canonical-provider-test-"));
@ -32,14 +32,21 @@ function fixture(runTest) {
writeFileSync(file, name === "productionLock" ? lock : name);
artifacts[name] = { path, sha256: sha256File(file) };
}
for (const name of ["node_modules/@agentclientprotocol/codex-acp/dist/index.js", "node_modules/pi/vendor/pi", "node_modules/.bin/pi"]) {
mkdirSync(dirname(join(destination, name)), { recursive: true });
writeFileSync(join(destination, name), "additional runtime bytes", { mode: 0o755 });
}
symlinkSync("pi/vendor/pi", join(destination, "node_modules/pi-link"));
const distDigest = sha256Tree(join(destination, "dist"));
const payload = { target: { platform: "linux", architecture: "x64" },
const tree = prepareProviderTree(destination);
const payload = { exportTreeDigest: tree.digest, target: { platform: "linux", architecture: "x64" },
runnerSourceRevision: args.find((value) => value.startsWith("PAPERCLIP_RUNNER_SOURCE_REVISION=")).split("=")[1],
artifacts, distDigest, bridgeDigest: `sha256:${createHash("sha256").update(artifacts.opencodeProxy.sha256).update("\n")
.update(artifacts.acpxSidecar.sha256).update("\n").update(distDigest).digest("hex")}` };
const manifest = { schema: "paperclip-runner/remote-provider-pack/v1", payload,
digest: `sha256:${createHash("sha256").update(canonicalJson(payload)).digest("hex")}` };
writeFileSync(join(destination, "provider-pack.json"), JSON.stringify(manifest));
writeProviderTreeSidecar(destination, tree);
tamper?.(destination);
}
const run = (command, args, options) => { calls.push({ command, args, options }); exported(args); return { status: 0 }; };
@ -109,3 +116,71 @@ test("implicit HEAD rejects dirty canonical inputs while explicit trusted revisi
else process.env.PAPERCLIP_RUNNER_SOURCE_REVISION = previous;
}
}));
for (const [name, tamper] of [
["ACP dependency", root => writeFileSync(join(root, "node_modules/@agentclientprotocol/codex-acp/dist/index.js"), "changed")],
["Pi binary", root => writeFileSync(join(root, "node_modules/pi/vendor/pi"), "changed")],
["additional binary shim", root => writeFileSync(join(root, "node_modules/.bin/pi"), "changed")],
["missing dependency", root => rmSync(join(root, "node_modules/@agentclientprotocol/codex-acp/dist/index.js"))],
["extra dependency", root => writeFileSync(join(root, "node_modules/unexpected"), "extra")],
["lost executable mode", root => chmodSync(join(root, "node_modules/pi/vendor/pi"), 0o644)],
["directory mode", root => chmodSync(join(root, "node_modules/pi/vendor"), 0o700)],
["changed internal symlink", root => { unlinkSync(join(root, "node_modules/pi-link")); symlinkSync(".bin/pi", join(root, "node_modules/pi-link")); }],
["escaping symlink", root => { unlinkSync(join(root, "node_modules/pi-link")); symlinkSync("../../", join(root, "node_modules/pi-link")); }],
["absolute symlink", root => { unlinkSync(join(root, "node_modules/pi-link")); symlinkSync(join(root, "node_modules/pi/vendor/pi"), join(root, "node_modules/pi-link")); }],
["missing sidecar", root => rmSync(join(root, "provider-pack-integrity.json"))],
["sidecar mode", root => chmodSync(join(root, "provider-pack-integrity.json"), 0o755)],
]) test("complete inventory rejects " + name + " and retains prior published pack", () => fixture(f => {
mkdirSync(f.outputRoot); writeFileSync(join(f.outputRoot, "old"), "retain");
const run = (_command, args) => { f.exported(args, tamper); return { status: 0 }; };
assert.throws(() => buildProviderPack({ ...f, revision, run }));
assert.equal(readFileSync(join(f.outputRoot, "old"), "utf8"), "retain");
assert.deepEqual(readdirSync(f.parent).sort(), ["output", "source"]);
}));
test("inventory digest is bound in the manifest and cannot be replaced independently", () => fixture(f => {
const run = (_command, args) => { f.exported(args, root => {
const p = join(root, "provider-pack-integrity.json"), sidecar = JSON.parse(readFileSync(p, "utf8"));
sidecar.entries = sidecar.entries.filter(e => e.path !== "node_modules/pi/vendor/pi");
sidecar.digest = "sha256:" + createHash("sha256").update(canonicalJson(sidecar.entries)).digest("hex");
writeFileSync(p, JSON.stringify(sidecar));
}); return { status: 0 }; };
assert.throws(() => buildProviderPack({ ...f, revision, run }), /inventory digest mismatch/);
}));
test("sidecar covers final manifest and excludes only its own bytes", () => fixture(f => {
buildProviderPack({ ...f, revision });
const manifest = JSON.parse(readFileSync(join(f.outputRoot, "provider-pack.json"), "utf8"));
const sidecar = JSON.parse(readFileSync(join(f.outputRoot, "provider-pack-integrity.json"), "utf8"));
assert(sidecar.entries.some(e => e.path === "provider-pack.json"));
assert(!sidecar.entries.some(e => e.path === "provider-pack-integrity.json"));
assert(sidecar.entries.some(e => e.path === "node_modules/pi-link" && e.target === "pi/vendor/pi"));
assert.doesNotThrow(() => verifyProviderTree(f.outputRoot, manifest.payload.exportTreeDigest));
writeFileSync(join(f.outputRoot, "provider-pack.json"), "different manifest");
assert.throws(() => verifyProviderTree(f.outputRoot, manifest.payload.exportTreeDigest), /contents/);
}));
test("large binary hashing matches byte digest and inventory normalizes existing a+rX contract", () => fixture(f => {
const file = join(f.parent, "large"), data = Buffer.alloc(8 * 1024 * 1024 + 31, 0x7d);
writeFileSync(file, data);
assert.equal(sha256File(file), "sha256:" + createHash("sha256").update(data).digest("hex"));
const dir = join(f.parent, "tree");mkdirSync(dir, { mode: 0o700 });writeFileSync(join(dir, "tool"), "tool", { mode: 0o700 });
const prepared = prepareProviderTree(dir);
assert.equal(prepared.entries.find(e => e.path === "").mode, 0o755);
assert.equal(prepared.entries.find(e => e.path === "tool").mode, 0o755);
}));
test("source-revision-only changes preserve content tree identity", () => fixture(f => {
const first = buildProviderPack({ ...f, revision });
const second = buildProviderPack({ ...f, revision: "b".repeat(40) });
assert.equal(first.manifest.payload.exportTreeDigest, second.manifest.payload.exportTreeDigest);
assert.notEqual(first.manifest.digest, second.manifest.digest);
}));
test("both canonical stages exercise pinned CLI versions and verify full tree after launch smoke", () => {
for (const [file, stageName] of [["../../../Dockerfile", "cloud-provider-pack"], ["../../../docker/daytona-runner/Dockerfile", "provider-pack-build"]]) {
const body = readFileSync(new URL(file, import.meta.url), "utf8").split(/^FROM /m).find(s => s.split("\n", 1)[0].endsWith(" AS " + stageName));
assert(body);
const pi = body.indexOf("verify-pi-provider-launch.mjs /provider-pack"), versions = body.indexOf('test "$(acpx --version)" = "0.13.1"'), verification = body.indexOf("verifyProviderPack('/provider-pack'");
assert(pi >= 0 && versions > pi && verification > versions);
assert(body.includes('test "$(claude-agent-acp --version)" = "0.70.0"'));
assert(body.includes('test "$(codex-acp --version)" = "@agentclientprotocol/codex-acp 1.6.2"'));
assert(!body.includes("chmod -R a+rX /provider-pack"), "Modes must be normalized before binding the inventory");
}
});

View File

@ -1,6 +1,6 @@
import { createHash } from "node:crypto";
import { readdirSync, readFileSync, readlinkSync, realpathSync } from "node:fs";
import { join, resolve, sep } from "node:path";
import { readdirSync, readFileSync, readlinkSync, realpathSync, openSync, closeSync, readSync, fstatSync, lstatSync, chmodSync, writeFileSync } from "node:fs";
import { join, resolve, sep, isAbsolute } from "node:path";
export function canonicalJson(value) {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
@ -13,10 +13,23 @@ export function canonicalJson(value) {
return JSON.stringify(value);
}
// Build/export-only hashing reads a bounded buffer even for large provider binaries.
export function sha256File(path) {
return `sha256:${createHash("sha256")
.update(readFileSync(path))
.digest("hex")}`;
const hash = createHash("sha256"), buffer = Buffer.allocUnsafe(64 * 1024);
const fd = openSync(path, "r");
try {
const before = fstatSync(fd);
if (!before.isFile()) throw new Error("Provider artifact must be a regular file");
let total = 0, bytes;
while ((bytes = readSync(fd, buffer, 0, buffer.length, null)) > 0) {
hash.update(buffer.subarray(0, bytes)); total += bytes;
}
const after = fstatSync(fd);
if (total !== before.size || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) {
throw new Error("Provider artifact changed while hashing");
}
return `sha256:${hash.digest("hex")}`;
} finally { closeSync(fd); }
}
export function sha256Tree(root) {
@ -51,7 +64,9 @@ export function sha256Tree(root) {
/** Verify exported bytes before replacing any prior working provider pack. */
export function verifyProviderPack(root, { revision, lockSha256 }) {
const manifest = JSON.parse(readFileSync(join(root, "provider-pack.json"), "utf8"));
const manifestPath = join(root, "provider-pack.json");
if (!lstatSync(manifestPath).isFile()) throw new Error("Provider manifest must be a regular file");
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
const payload = manifest.payload;
if (manifest.schema !== "paperclip-runner/remote-provider-pack/v1"
|| payload?.runnerSourceRevision !== revision
@ -83,5 +98,76 @@ export function verifyProviderPack(root, { revision, lockSha256 }) {
.update(payload.artifacts.acpxSidecar.sha256).update("\n")
.update(payload.distDigest).digest("hex")}`;
if (payload.bridgeDigest !== bridgeDigest) throw new Error("Canonical provider-pack bridge integrity mismatch");
verifyProviderTree(root, payload.exportTreeDigest);
return manifest;
}
// This inventory is created and checked OFFLINE, never by the sandbox startup verifier.
export const providerTreeSidecar = "provider-pack-integrity.json";
const manifestName = "provider-pack.json";
const treeSchema = "paperclip-runner/export-tree/v1";
const maxEntries = 1_000_000;
function treeDigest(entries) {
return `sha256:${createHash("sha256").update(canonicalJson(entries)).digest("hex")}`;
}
function sortEntries(entries) { return entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0); }
export function collectProviderTree(root, { normalizeModes = false, omitManifest = false } = {}) {
const canonicalRoot = realpathSync(root), entries = [];
function visit(file, relativePath) {
if (relativePath === providerTreeSidecar || (omitManifest && relativePath === manifestName)) return;
if (entries.length >= maxEntries) throw new Error("Provider tree inventory exceeds bounded entry limit");
let stat = lstatSync(file);
if (stat.isSymbolicLink()) {
const target = readlinkSync(file), actual = realpathSync(file);
if (isAbsolute(target) || (actual !== canonicalRoot && !actual.startsWith(canonicalRoot + sep))) {
throw new Error("Provider tree symlink escapes export root");
}
entries.push({ path: relativePath, type: "symlink", target });
return;
}
if (!stat.isFile() && !stat.isDirectory()) throw new Error("Provider tree contains unsupported entry type");
if (stat.mode & 0o7000) throw new Error("Provider tree contains unsafe special permissions");
if (normalizeModes) {
chmodSync(file, (stat.mode & 0o777) | 0o444 | (stat.isDirectory() || (stat.mode & 0o111) ? 0o111 : 0));
stat = lstatSync(file);
}
const common = { path: relativePath, mode: stat.mode & 0o777 };
if (stat.isDirectory()) {
entries.push({ ...common, type: "directory" });
for (const name of readdirSync(file).sort()) visit(join(file, name), relativePath ? relativePath + "/" + name : name);
} else {
entries.push({ ...common, type: "file", bytes: stat.size, sha256: sha256File(file) });
}
}
visit(canonicalRoot, "");
return sortEntries(entries);
}
export function prepareProviderTree(root) {
const entries = collectProviderTree(root, { normalizeModes: true, omitManifest: true });
return { entries, digest: treeDigest(entries) };
}
export function writeProviderTreeSidecar(root, prepared) {
// The manifest binds all other entries. Its own full content digest is already
// verified separately, avoiding a circular sidecar/manifest digest dependency.
const file = join(root, manifestName), stat = lstatSync(file);
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Provider manifest must be a regular file");
chmodSync(file, (stat.mode & 0o777) | 0o444);
const entries = sortEntries([...prepared.entries, { path: manifestName, type: "file", mode: lstatSync(file).mode & 0o777, bytes: stat.size, sha256: sha256File(file) }]);
if (treeDigest(entries.filter(e => e.path !== manifestName)) !== prepared.digest) throw new Error("Provider tree preparation mismatch");
writeFileSync(join(root, providerTreeSidecar), JSON.stringify({ schema: treeSchema, digest: treeDigest(entries), entries }) + "\n", { mode: 0o644, flag: "wx" });
}
export function verifyProviderTree(root, expectedDigest) {
if (!/^sha256:[a-f0-9]{64}$/.test(expectedDigest ?? "")) throw new Error("Provider manifest is missing the full export tree digest");
const file = join(root, providerTreeSidecar), stat = lstatSync(file);
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 64 * 1024 * 1024 || (stat.mode & 0o777) !== 0o644) {
throw new Error("Provider tree sidecar is invalid");
}
const sidecar = JSON.parse(readFileSync(file, "utf8"));
if (sidecar.schema !== treeSchema || !Array.isArray(sidecar.entries) || sidecar.entries.length > maxEntries
|| sidecar.digest !== treeDigest(sidecar.entries)
|| expectedDigest !== treeDigest(sidecar.entries.filter(e => e.path !== manifestName))) {
throw new Error("Provider tree inventory digest mismatch");
}
const actual = collectProviderTree(root);
if (canonicalJson(actual) !== canonicalJson(sidecar.entries)) throw new Error("Provider tree contents, permissions, or links differ from the trusted export");
}