diff --git a/Dockerfile b/Dockerfile index 034ac98683..4a334f1004 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/docker/daytona-runner/Dockerfile b/docker/daytona-runner/Dockerfile index 1d938956be..d0e0e92406 100644 --- a/docker/daytona-runner/Dockerfile +++ b/docker/daytona-runner/Dockerfile @@ -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 diff --git a/packages/paperclip-runner/scripts/assemble-provider-pack.mjs b/packages/paperclip-runner/scripts/assemble-provider-pack.mjs index 9168ba6ce4..082833c5c4 100644 --- a/packages/paperclip-runner/scripts/assemble-provider-pack.mjs +++ b/packages/paperclip-runner/scripts/assemble-provider-pack.mjs @@ -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`); diff --git a/packages/paperclip-runner/scripts/build-provider-pack.test.mjs b/packages/paperclip-runner/scripts/build-provider-pack.test.mjs index 5468bf9df5..2b64ee2858 100644 --- a/packages/paperclip-runner/scripts/build-provider-pack.test.mjs +++ b/packages/paperclip-runner/scripts/build-provider-pack.test.mjs @@ -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"); + } +}); diff --git a/packages/paperclip-runner/scripts/provider-pack-integrity.mjs b/packages/paperclip-runner/scripts/provider-pack-integrity.mjs index 99fc745263..bd40d7cb08 100644 --- a/packages/paperclip-runner/scripts/provider-pack-integrity.mjs +++ b/packages/paperclip-runner/scripts/provider-pack-integrity.mjs @@ -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"); +}