diff --git a/packages/paperclip-runner/docs/runner-protocol-live-evals.md b/packages/paperclip-runner/docs/runner-protocol-live-evals.md index 4192fcdee6d..c6e3334eaeb 100644 --- a/packages/paperclip-runner/docs/runner-protocol-live-evals.md +++ b/packages/paperclip-runner/docs/runner-protocol-live-evals.md @@ -348,7 +348,8 @@ lifecycle scripts. `runtime.json` records the caller-supplied source revision, source input hashes, installed package manifest hashes, and every output file's size and SHA-256. The caller must build from the intended trusted checkout; the revision label alone is not source attestation. The artifact is limited to -32 MiB and 4,096 files. It contains no campaign credentials or allowance. +32 MiB and 4,096 files, including a receipt limited to 2 MiB by both the builder +and verifier. It contains no campaign credentials or allowance. Save the printed `receiptSha256` outside the artifact, such as a trusted build job output. Before a worker loads any copied code, invoke the verifier from its diff --git a/packages/paperclip-runner/scripts/controlled-evals/build-runtime.mjs b/packages/paperclip-runner/scripts/controlled-evals/build-runtime.mjs index 18619a59b4b..b946c0ec044 100644 --- a/packages/paperclip-runner/scripts/controlled-evals/build-runtime.mjs +++ b/packages/paperclip-runner/scripts/controlled-evals/build-runtime.mjs @@ -4,7 +4,7 @@ import { createRequire } from 'node:module'; import { cp, mkdir, readFile, readdir, realpath, writeFile } from 'node:fs/promises'; import { basename, dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { RUNTIME_MAX_BYTES, RUNTIME_MAX_FILES } from './verify-runtime.mjs'; +import { encodeRuntimeReceipt, RUNTIME_MAX_BYTES, RUNTIME_MAX_FILES } from './verify-runtime.mjs'; const source = dirname(fileURLToPath(import.meta.url)); const repository = resolve(source, '../../../..'); @@ -82,7 +82,7 @@ export async function buildControllerRuntime({ outputDirectory, sourceRevision } } await inventory(output); const receipt = { schema: 'paperclip.runner-eval.controller-runtime/v1', sourceRevision, nodeMajor: 24, inputs, packageInputs, payloadBytes, files }; - const receiptBytes = JSON.stringify(receipt, null, 2) + '\n'; + const receiptBytes = encodeRuntimeReceipt(receipt); if (payloadBytes + Buffer.byteLength(receiptBytes) > RUNTIME_MAX_BYTES) throw new Error('Controller runtime receipt exceeds its artifact allowance'); await writeFile(join(output, 'runtime.json'), receiptBytes, { flag: 'wx', mode: 0o600 }); return { ...receipt, totalBytes: payloadBytes + Buffer.byteLength(receiptBytes), receiptSha256: sha256(receiptBytes) }; diff --git a/packages/paperclip-runner/scripts/controlled-evals/portable.test.mjs b/packages/paperclip-runner/scripts/controlled-evals/portable.test.mjs index 321afc88eb0..6191860bb64 100644 --- a/packages/paperclip-runner/scripts/controlled-evals/portable.test.mjs +++ b/packages/paperclip-runner/scripts/controlled-evals/portable.test.mjs @@ -7,7 +7,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import { promisify } from 'node:util'; import { test } from 'node:test'; import { buildControllerRuntime } from './build-runtime.mjs'; -import { verifyControllerRuntime } from './verify-runtime.mjs'; +import { encodeRuntimeReceipt, RUNTIME_MAX_RECEIPT_BYTES, verifyControllerRuntime } from './verify-runtime.mjs'; const exec = promisify(execFile); const source = dirname(fileURLToPath(import.meta.url)); @@ -56,6 +56,10 @@ test('copied controller runs without source dependencies, retains native evidenc await rename(built, directory); const input = { directory, sourceRevision, receiptSha256: receipt.receiptSha256 }; assert.equal((await verifyControllerRuntime(input)).totalBytes, receipt.totalBytes); + const rawReceipt = JSON.parse(await readFile(join(directory, 'runtime.json'), 'utf8')); + assert(encodeRuntimeReceipt(rawReceipt).length < RUNTIME_MAX_RECEIPT_BYTES); + const manyInputs = Object.fromEntries(Array.from({ length: 25_000 }, (_, index) => [`src/part-${index}.ts`, 'a'.repeat(64)])); + assert.throws(() => encodeRuntimeReceipt({ ...rawReceipt, inputs: manyInputs }), /receipt exceeds its byte allowance/); const environment = { PATH: process.env.PATH, ...(process.env.LANG ? { LANG: process.env.LANG } : {}) }; const probe = join(root, 'probe.mjs'); await writeFile(probe, isolatedProbe); const isolated = await exec(process.execPath, [probe, directory], { cwd: root, env: environment, timeout: 20_000 }); diff --git a/packages/paperclip-runner/scripts/controlled-evals/verify-runtime.mjs b/packages/paperclip-runner/scripts/controlled-evals/verify-runtime.mjs index 1bf3821acda..15d08a0a912 100644 --- a/packages/paperclip-runner/scripts/controlled-evals/verify-runtime.mjs +++ b/packages/paperclip-runner/scripts/controlled-evals/verify-runtime.mjs @@ -6,6 +6,12 @@ import { join, resolve } from 'node:path'; export const RUNTIME_MAX_BYTES = 32 * 1_048_576; export const RUNTIME_MAX_FILES = 4096; +export const RUNTIME_MAX_RECEIPT_BYTES = 2 * 1_048_576; +export function encodeRuntimeReceipt(receipt) { + const bytes = Buffer.from(JSON.stringify(receipt, null, 2) + '\n'); + if (bytes.length > RUNTIME_MAX_RECEIPT_BYTES) throw new Error('Controller runtime receipt exceeds its byte allowance'); + return bytes; +} const sha256 = bytes => createHash('sha256').update(bytes).digest('hex'); async function readRegular(path, limit) { const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); @@ -28,7 +34,7 @@ export async function verifyControllerRuntime({ directory, sourceRevision, recei if (!/^[a-f0-9]{40}$/.test(sourceRevision ?? '') || !/^[a-f0-9]{64}$/.test(receiptSha256 ?? '')) throw new Error('Runtime verification requires trusted revision and receipt digest'); const root = resolve(directory); if (!(await lstat(root)).isDirectory()) throw new Error('Runtime root must be a real directory'); - const receiptBytes = await readRegular(join(root, 'runtime.json'), 2 * 1_048_576); + const receiptBytes = await readRegular(join(root, 'runtime.json'), RUNTIME_MAX_RECEIPT_BYTES); if (sha256(receiptBytes) !== receiptSha256) throw new Error('Runtime receipt digest mismatch'); const receipt = JSON.parse(receiptBytes.toString('utf8')); if (receipt.schema !== 'paperclip.runner-eval.controller-runtime/v1' || receipt.sourceRevision !== sourceRevision diff --git a/server/src/__tests__/issue-list-assignee-filter-routes.test.ts b/server/src/__tests__/issue-list-assignee-filter-routes.test.ts index 3c685dcfa3d..ae3501f9d0f 100644 --- a/server/src/__tests__/issue-list-assignee-filter-routes.test.ts +++ b/server/src/__tests__/issue-list-assignee-filter-routes.test.ts @@ -800,6 +800,7 @@ describeEmbeddedPostgres("issue list routes assigneeAgentId filter", () => { "descendantOf", "labelId", "participantAgentId", + "createdFromIssueId", ])("returns 422 instead of a DB error for malformed %s filters", async (param) => { const companyId = randomUUID(); await db.insert(companies).values({ @@ -828,6 +829,7 @@ describeEmbeddedPostgres("issue list routes assigneeAgentId filter", () => { "descendantOf", "labelId", "participantAgentId", + "createdFromIssueId", ])("returns 422 from issues/count for malformed %s filters", async (param) => { const companyId = randomUUID(); await db.insert(companies).values({