Verify complete canonical provider exports offline
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
4cd6a81e4a
commit
3497dec88b
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue