import { spawn } from "node:child_process"; import { mkdir, readFile, readdir, rm, writeFile, copyFile, lstat, } from "node:fs/promises"; import path from "node:path"; import { assertSecretFree, findSecretLeak, findSecretLeakInJsonValues, redactText, sanitizeJson, normalizedSecrets, } from "./redaction.js"; import { assertPrivateCellDirectories, cellTraceAsset, readPrivateCellTraceAsset, type PrivateCellEvidenceOwner } from "./manual-private-report-assets.js"; const TEXT_EXTENSIONS = new Set([ ".json", ".xml", ".html", ".css", ".js", ".txt", ".log", ".md", ]); const BINARY_EXTENSIONS = new Set([".png", ".webm"]); const ACTIVE_CONTENT_EXTENSIONS = new Set([".svg"]); const ALLOWED_DIRECTORIES = new Set([ "snapshots", "playwright-output", "blob-report", "html-report", ]); const ALLOWED_ROOT_FILES = new Set([ "result.json", "final-state.png", "failure.png", "tool-review-pending.png", "decision-pending.png", "first-task-response.png", "chat-plan-draft.png", "chat-plan-revised.png", "server.log", "playwright.log", "junit.xml", ]); const REQUIRED_PASS_FILES = [ "final-state.png", "result.json", "junit.xml", path.join("html-report", "index.html"), path.join("snapshots", "fixtures.json"), path.join("snapshots", "api-state.json"), ] as const; export function isStandardEvidenceFile(relative: string) { const extension = path.extname(relative).toLowerCase(); return isStandardEvidencePath(relative) && (TEXT_EXTENSIONS.has(extension) || BINARY_EXTENSIONS.has(extension) || extension === ".zip"); } export interface EvidencePackageResult { files: string[]; leaks: Array<{ file: string; reason: string }>; missing: string[]; } async function walk(root: string, relative = "", privateStandardOnly = false): Promise { const directory = path.join(root, relative); const entries = await readdir(directory, { withFileTypes: true }).catch( () => [], ); const files: string[] = []; for (const entry of entries) { const next = path.join(relative, entry.name); // Installed preparation may leave a large, deliberately excluded staging // tree. Never enumerate it for private standard-evidence packaging. if (privateStandardOnly && !relative && !isStandardEvidencePath(entry.isDirectory() ? path.join(next, "placeholder") : next)) continue; if (entry.isDirectory()) files.push(...(await walk(root, next, privateStandardOnly))); else if (entry.isFile()) files.push(next); } return files; } export function isStandardEvidencePath(relative: string) { const segments = relative.split(path.sep); if (segments.length === 1) return ( ALLOWED_ROOT_FILES.has(relative) || /^(?:plan|question)-[a-z0-9-]+\.png$/.test(relative) ); return ALLOWED_DIRECTORIES.has(segments[0]); } async function inspectZip(source: string, secrets: readonly string[]) { // Failure traces can exceed Node's child-process output buffer. Stream the // expanded archive through the exact-value scanner with enough overlap to // detect a credential split across stdout chunks, without retaining the // archive in memory or weakening fail-closed evidence publication. const overlap = Math.max( 256, ...secrets.map((secret) => Buffer.byteLength(secret, "utf8") + 16), ); return new Promise((resolve) => { const unzip = spawn("unzip", ["-p", source], { stdio: ["ignore", "pipe", "pipe"], }); let carry = Buffer.alloc(0); let leak: string | null = null; let spawnError: Error | null = null; unzip.stdout.on("data", (chunk: Buffer) => { if (leak) return; const data = Buffer.concat([carry, chunk]); leak = findSecretLeak(data, secrets, { includeShapes: false }); carry = data.subarray(Math.max(0, data.length - overlap)); if (leak) unzip.kill(); }); // Drain diagnostics so a noisy unzip cannot block. Error text is withheld // because archive paths and contents belong to private attempt evidence. unzip.stderr.resume(); unzip.on("error", (error) => { spawnError = error; }); unzip.on("close", (code) => { if (leak) return resolve(leak); if (spawnError) { return resolve(`zip could not be inspected: ${spawnError.message}`); } return resolve( code === 0 ? null : `zip could not be inspected: unzip exited with code ${String(code)}`, ); }); }); } export async function packageEvidence(input: { privateDir: string; uploadDir: string; secrets: readonly string[]; expectPassScreenshot: boolean; privateCell?: { owner: PrivateCellEvidenceOwner; layout: "original" | "final-wrapper" }; }): Promise { if (input.privateCell && !["original", "final-wrapper"].includes(input.privateCell.layout)) throw new Error("Invalid private cell packaging layout"); if (input.privateCell) await assertPrivateCellDirectories(input.privateCell.owner, input.privateDir, input.uploadDir); await rm(input.uploadDir, { recursive: true, force: true }); await mkdir(input.uploadDir, { recursive: true }); const files: string[] = []; const leaks: EvidencePackageResult["leaks"] = []; const available = await walk(input.privateDir, "", Boolean(input.privateCell)); const standard = available.filter(isStandardEvidencePath); if (input.privateCell && standard.length > 4096) throw new Error("Private cell evidence exceeds entry allowance"); const privateSecrets = input.privateCell ? normalizedSecrets(input.secrets).flatMap(value => [value, encodeURIComponent(value), Buffer.from(value).toString("base64"), Buffer.from(value).toString("base64url")]) : []; let privateBytes = 0; for (const relative of standard) { const source = path.join(input.privateDir, relative); const extension = path.extname(relative).toLowerCase(); const destination = path.join(input.uploadDir, relative); if (input.privateCell) { assertSecretFree(relative, privateSecrets, "private cell evidence path"); const metadata = await lstat(source); if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > 256 * 1024 * 1024 - privateBytes) throw new Error("Private cell evidence exceeds byte allowance"); privateBytes += metadata.size; if (!Number.isSafeInteger(privateBytes)) throw new Error("Invalid private cell evidence byte accounting"); const asset = cellTraceAsset(relative, input.privateCell.owner, input.privateCell.layout); if (asset.reserved) { const bytes = await readPrivateCellTraceAsset({ ...input.privateCell, relative, file: source, remaining: metadata.size, secrets: input.secrets }); await mkdir(path.dirname(destination), { recursive: true, mode: 0o700 }); await writeFile(destination, bytes!, { mode: 0o600 }); files.push(relative); continue; } if (!isStandardEvidenceFile(relative)) throw new Error("Unsupported private cell evidence carrier"); } await mkdir(path.dirname(destination), { recursive: true }); if (ACTIVE_CONTENT_EXTENSIONS.has(extension)) { // SVG can execute script when opened directly from an artifact. Keep the // source in the disposable private attempt directory, but never admit it // to the sanitized CI artifact. continue; } else if (TEXT_EXTENSIONS.has(extension)) { const raw = await readFile(source, "utf8"); const parsed = extension === ".json" ? JSON.parse(raw) : null; const leak = extension === ".json" ? findSecretLeakInJsonValues(parsed, input.secrets) : findSecretLeak(raw, input.secrets); if (leak) leaks.push({ file: relative, reason: leak }); // Redacting an already serialized JSON string can change escape // boundaries around shell commands. Sanitize parsed values instead so // uploaded snapshots stay valid JSON. const safe = extension === ".json" ? `${JSON.stringify(sanitizeJson(parsed, input.secrets), null, 2)}\n` : redactText(raw, input.secrets); if (extension === ".json") { const safeLeak = findSecretLeakInJsonValues( JSON.parse(safe), input.secrets, ); if (safeLeak) throw new Error(`Secret leak in ${relative}: ${safeLeak}`); } else { assertSecretFree(safe, input.secrets, relative); } await writeFile(destination, safe, "utf8"); files.push(relative); } else if (extension === ".zip") { const leak = await inspectZip(source, input.secrets); if (leak) { leaks.push({ file: relative, reason: leak }); continue; } await copyFile(source, destination); files.push(relative); } else if (BINARY_EXTENSIONS.has(extension)) { // This raw-byte scan catches embedded plaintext credentials, but cannot // inspect rendered pixels. Provider/UI raster and video files remain in // the access-controlled CI artifact. The public publisher creates its // own synthetic summary image from fixed labels and numeric/status data. const raw = await readFile(source); const leak = findSecretLeak(raw, input.secrets); if (leak) { leaks.push({ file: relative, reason: leak }); continue; } await copyFile(source, destination); files.push(relative); } } const missing = input.expectPassScreenshot ? [ ...REQUIRED_PASS_FILES.filter((required) => !files.includes(required)), ...(files.some( (file) => file.startsWith(`blob-report${path.sep}`) && file.endsWith(".zip"), ) ? [] : [path.join("blob-report", "*.zip")]), ] : []; const manifest = { schema: "paperclip.runner-e2e.evidence/v1", files: [...files].sort(), leaks, missing, }; const manifestText = `${JSON.stringify(sanitizeJson(manifest, input.secrets), null, 2)}\n`; const manifestLeak = findSecretLeakInJsonValues( JSON.parse(manifestText), input.secrets, ); if (manifestLeak) throw new Error(`Secret leak in evidence-manifest.json: ${manifestLeak}`); await writeFile( path.join(input.uploadDir, "evidence-manifest.json"), manifestText, "utf8", ); files.push("evidence-manifest.json"); return { files, leaks, missing }; }