mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 22:56:58 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - The chat evaluation lane runs each chat case in a paid runner and
keeps the private evidence of each attempt in a bounded private archive
> - A failed attempt keeps its Playwright trace and video, and the
bundle has a 256 MB allowance
> - Playwright's HTML reporter copies each attachment into
`html-report/data/<sha1>.<ext>`, and the same bytes also stay in
`playwright-output/`
> - The bundle holds the original tree and the packaged tree, so a 30 MB
trace used about 125 MB of the allowance before the blob report copies
> - Large failed attempts went over the allowance, and then all of their
private evidence was lost
> - This pull request removes the `playwright-output/` copy only when
the HTML report holds the identical bytes
> - A second gap lost the whole merged campaign report of failed cells:
the report copied trace viewer font, SVG and manifest files to a place
where the trusted seal refuses them
> - This pull request stops that copy, and it names a refused report
carrier with a closed category
> - The benefit is that large failed attempts keep their evidence, the
merged report is kept, and the HTML report and its trace viewer still
open the trace
## Linked Issues or Issue Description
**What happened**
Failed chat evaluation attempts with large Playwright traces lost all of
their private evidence. The bundle went over the 256 MB private evidence
allowance. For example, the failed `tasks.organize-open-work` cell made
a 254 MB bundle. That bundle held the same 30.7 MB trace four times
(`playwright-output/.../trace.zip` and `html-report/data/<sha1>.zip`, in
both the original tree and the packaged tree), plus two blob report
copies. Larger traces went over the limit, and then the transfer refused
the whole bundle.
Also, the merged campaign report of a failed cell was refused with
`refusal: unsupported-packaged-evidence-carrier` (for example
`runs-adapters.inspect-failed-run`, run 42431162923). The cell's
evidence manifest lists its trace viewer files
`html-report/trace/codicon.*.ttf`, `playwright-logo.svg` and
`manifest.webmanifest`. `report.ts` copied them to
`normalized/evidence/<id>/attempt-1/html-report/trace/`. The
generated-report seal accepts trace viewer files only under
`html/trace/`, so it refused these three files and lost the whole
report. I reproduced this from the retained cell archive: a merge of its
blob report passes the seal rule, and the three normalized copies are
the only files that fail it.
**Expected behavior**
A failed attempt keeps its trace, and the trace uses as little of the
allowance as possible. The HTML report and the trace viewer still open
the trace for a person who downloads the bundle. The merged report of a
failed cell is kept. If the seal refuses a carrier, the refusal names
its category, not its path. No privacy, integrity or size check changes.
**Steps to reproduce**
1. Dispatch `runner-full-stack-e2e.yml` with `cadence=manual` for a
private chat case that fails with a large trace, for example
`chat-features.runner-opencode.local.tasks.organize-open-work`.
2. Download `manual-cell-<sha256(executionId)>.zip` from the private
history bucket.
3. List the archive. The trace bytes are in `playwright-output/` and in
`html-report/data/` in both `originals/` and `sealed-results/`.
4. For the report refusal: look at the "Retain complete manual merged
evidence only in restricted storage" step of the same run. It prints
`refusal: unsupported-packaged-evidence-carrier` and no
`manual-report.zip` is retained.
## What Changed
- Add `tests/runner-e2e/private-report-copies.ts` with
`dropDuplicatePlaywrightOutput`. It removes a `playwright-output/` file
only when `html-report/data/<sha1 of content><ext>` is a regular file
(not a symlink) with the same size and the same SHA-256. It writes the
removed paths, their kept paths and digests to
`snapshots/playwright-output-copies.json`.
- The function does nothing when the cell has a framework JSON report
(`framework-report.json` or `snapshots/playwright-framework.json`),
because that report registers `playwright-output/` attachments by path.
It also does nothing when `playwright-output`, `html-report` or
`html-report/data` is missing or is a symlink.
- Call it in the private manual branch of `tests/runner-e2e/launch.ts`,
before packaging and before both seals. Both evidence trees then keep
one HTML report copy of each trace and video.
- The HTML report, its trace viewer assets and the blob report do not
change.
- `tests/runner-e2e/report.ts` does not stage a cell file into
`normalized/evidence/` when its type is one the generated-report seal
refuses (the same `isStandardEvidenceFile("html-report/...")` rule). The
merged report has its own authenticated trace viewer under
`html/trace/`. The cell archive still keeps the files.
- `tests/runner-e2e/manual-private-transfer.ts` adds
`assertGeneratedReportCarriers`, which runs before the report seal. It
throws a fixed message per closed category (`font`, `svg`,
`webmanifest`, `image`, `source map`, `no extension`, `other`, `trace
viewer asset`, `trace viewer bytes`), so the refusal line names, for
example, `unsupported-generated-report-carrier-font`. It never names a
path, and the seal stays the authority.
- No change to the target-checked trusted files
(`manual-private-handoff.ts`, `manual-private-retention.ts`,
`manual-private-report-assets.ts`), so candidate branches do not need an
update. No change to the limits or the secret scans.
## Verification
- `vitest run --config tests/runner-e2e/vitest.config.ts
tests/runner-e2e/private-report-copies.test.ts
tests/runner-e2e/manual-private-cell-report-assets.test.ts
tests/runner-e2e/private-framework-report.test.ts
tests/runner-e2e/manual-private-handoff.test.ts`: 79 passed.
- The new tests use a real failed Playwright run with the HTML, JUnit
and blob reporters and a real retained trace. They show these things:
- Only the duplicate `playwright-output/` files are removed. The HTML
report and blob report bytes do not change.
- The deduplicated cell passes `packageEvidence`,
`sealStandardOriginalEvidence`, `sealManualResultEvidence`, the trusted
`validateSealedOriginals` and `validateSealedInventory`, the strict
archive pack and unpack, and `stageSealedPrivateCell`.
- A tampered or symlinked HTML report copy keeps the original file.
- Framework JSON cells and symlinked data directories are not changed.
- `vitest run --config tests/runner-e2e/vitest.config.ts manual-private
private-report private-refusal private-framework generated-report
report`: 25 files, 311 passed.
- `generated-report-carriers.test.ts` shows each refused carrier gets
its closed category code, the code has no path, and the real seal
refuses the same tree. `report.test.ts` shows the font, SVG and manifest
are not staged and the other files are.
- A new test runs the real launcher private branch from `launch.ts`. It
shows that the sealed originals keep one trace copy, in
`html-report/data/`.
- Live run: I applied the commit to a branch from
`chat-eval/master-candidate` (`chat-eval/case-evidence-dedupe`) and
dispatched
`chat-features.runner-opencode.local.tasks.organize-open-work` (run
42432263920). I will add the bundle size result in a comment.
## Risks
- Low risk. The change runs only in the private manual evidence branch.
It removes a file only after a full content comparison with the kept
copy. If a check fails, the file stays.
- A person who wants the trace must open `html-report/data/<sha1>.zip`
(the HTML report links to it), not `playwright-output/<test>/trace.zip`.
The receipt in `snapshots/playwright-output-copies.json` names the new
location.
- `report.ts` and `manual-private-transfer.ts` run from the master
checkout in the report job, so the report fix takes effect only after
merge. The normalized per-cell HTML report copy no longer has the viewer
font, logo and manifest. The merged report under `html/` and the cell
archive keep a complete viewer.
- The blob report still has a compressed copy of the trace, and the
bundle still holds the original and the packaged tree. A trace over
about 60 MB can still go over the allowance. This change does not fix
that case.
> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.
## Model Used
- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), run in Claude Code with
tool use (shell, file editing, GitHub CLI and AWS CLI).
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
274 lines
23 KiB
TypeScript
274 lines
23 KiB
TypeScript
import { PRIVATE_FRAMEWORK_REPORT, validatePrivateFrameworkReport, materializePrivateFrameworkAttachments } from "./private-framework-report.js";
|
|
import { createHash } from "node:crypto";
|
|
import { appendFile, cp, lstat, mkdir, mkdtemp, readFile, readdir, realpath, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { canonicalGeneratedReportAsset, isTrustedGeneratedReportAsset, inspectCellReportProducer, privateCellEvidenceOwner, cellTraceAsset, readPrivateCellTraceAsset } from "./manual-private-report-assets.js";
|
|
import { sealManualResultEvidence, type ManualPrivateHandoff } from "./manual-private-handoff.js";
|
|
import { normalizedSecrets } from "./redaction.js";
|
|
import { CREDENTIAL_NAMES } from "./types.js";
|
|
import { isStandardEvidenceFile } from "./evidence.js";
|
|
import { BoundedHistoryPublisher } from "./history-budget.js";
|
|
import { privateArtifactKey, retainPrivateTransfer, transferPrivateArtifact, verifyPrivateHistory, type PrivateEvidenceCompleteness } from "./private-history.js";
|
|
import { authorizeManualPrivateEvidence, MANUAL_PRIVATE_PROTOCOL, manualCellKind, manualPrivateSessionPolicy } from "./manual-private-retention.js";
|
|
import { shippedChatEvidenceAuthority } from "./testops-chat-admission.js";
|
|
import { manualDispatchEvidenceAuthority } from "./manual-dispatch-authority.js";
|
|
import { privateRefusalCode } from "./private-refusal-code.js";
|
|
|
|
const hash = (bytes: string | Buffer) => createHash("sha256").update(bytes).digest("hex");
|
|
function request(env: NodeJS.ProcessEnv) {
|
|
const ids = JSON.parse(env.MANUAL_PRIVATE_IDS ?? "null") as string[];
|
|
const approval: { workflowSha: string; targetSha: string; runId: string; attempt: string; executionIds: readonly string[]; approvalSha256: string;
|
|
launchSha256?: string; launchBinding?: "dispatcher" } =
|
|
env.PAPERCLIP_TESTOPS_CHAT_MAIN_ADMISSION !== undefined ? shippedChatEvidenceAuthority(env)
|
|
: env.PAPERCLIP_MANUAL_DISPATCH_AUTHORITY !== undefined ? manualDispatchEvidenceAuthority(env)
|
|
: authorizeManualPrivateEvidence({ workflowSha: env.GITHUB_SHA ?? "", targetSha: env.TARGET_SHA ?? "",
|
|
runId: env.GITHUB_RUN_ID ?? "", attempt: env.GITHUB_RUN_ATTEMPT ?? "", executionIds: ids }, env.MANUAL_PRIVATE_APPROVALS_JSON ?? "");
|
|
return { ids, approval, bucket: env.RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET ?? "" };
|
|
}
|
|
async function output(name: string, value: string) {
|
|
if (/[\r\n]/.test(value)) throw new Error("Invalid private evidence step output");
|
|
await appendFile(process.env.GITHUB_OUTPUT!, `${name}=${value}\n`);
|
|
}
|
|
async function prepare(env: NodeJS.ProcessEnv) {
|
|
const { ids, approval, bucket } = request(env), id = env.SELECTED_EXECUTION_ID;
|
|
if (!id || !ids.includes(id)) throw new Error("Private evidence cell is not approved");
|
|
const target = await realpath(env.TARGET_ROOT ?? ""), trusted = path.resolve(import.meta.dirname, "../..");
|
|
if (target === trusted) throw new Error("Trusted transfer code must be separate from target checkout");
|
|
const launch = await readFile(path.join(target, "tests/runner-e2e/launch.ts"));
|
|
const targetLaunchSha256 = hash(launch);
|
|
if (approval.launchBinding !== "dispatcher" && targetLaunchSha256 !== approval.launchSha256)
|
|
throw new Error("Target evidence launcher differs from protected approval");
|
|
for (const file of ["manual-private-handoff.ts", "manual-private-retention.ts", "manual-private-report-assets.ts"]) {
|
|
if (!(await readFile(path.join(target, "tests/runner-e2e", file))).equals(await readFile(path.join(trusted, "tests/runner-e2e", file))))
|
|
throw new Error("Target private evidence protocol differs from trusted implementation");
|
|
}
|
|
const temporary = await realpath(env.RUNNER_TEMP ?? ""), root = await mkdtemp(path.join(temporary, "manual-private-evidence-"));
|
|
await writeFile(path.join(root, "identity.json"), JSON.stringify({ ...approval, launchSha256: targetLaunchSha256, executionIds: [id], root, status: "pending" }), { mode: 0o600, flag: "wx" });
|
|
await output("root", root); await output("session_policy", JSON.stringify(manualPrivateSessionPolicy({ bucket, runId: approval.runId, executionIds: [id], mode: "cell-write" })));
|
|
}
|
|
function requireSession(env: NodeJS.ProcessEnv) {
|
|
if (![env.AWS_ACCESS_KEY_ID, env.AWS_SECRET_ACCESS_KEY, env.AWS_SESSION_TOKEN].every(value => typeof value === "string" && value.length > 0))
|
|
throw new Error("Explicit private archive session is required; no credential fallback");
|
|
}
|
|
async function verify(env: NodeJS.ProcessEnv) {
|
|
requireSession(env); const { approval, bucket } = request(env), root = await realpath(env.PRIVATE_EVIDENCE_ROOT ?? "");
|
|
const identity = JSON.parse(await readFile(path.join(root, "identity.json"), "utf8"));
|
|
if (identity.approvalSha256 !== approval.approvalSha256 || identity.targetSha !== approval.targetSha || identity.workflowSha !== approval.workflowSha
|
|
|| identity.runId !== approval.runId || identity.attempt !== "1" || identity.root !== root) throw new Error("Private preflight identity changed");
|
|
const publisher = new BoundedHistoryPublisher(path.join(root, "preflight-requests.json"));
|
|
await verifyPrivateHistory(publisher, bucket);
|
|
const preflight = JSON.stringify({ ...identity, status: "verified", cellReportProducer: await inspectCellReportProducer() });
|
|
if (Buffer.byteLength(preflight) > 65536) throw new Error("Private preflight exceeds its byte allowance");
|
|
await writeFile(path.join(root, "preflight.json"), preflight, { mode: 0o600, flag: "wx" });
|
|
}
|
|
export async function validateSealedInventory(directory: string, inventory: string, id?: string, treeKind: "standard" | "generated-report" = "standard", context?: ManualPrivateHandoff) {
|
|
const cellOwner = treeKind === "standard" && context?.cellReportProducer
|
|
? await privateCellEvidenceOwner(context, id ?? (context.executionIds.length === 1 ? context.executionIds[0]! : "")) : undefined;
|
|
const receipt = JSON.parse(await readFile(path.join(directory, inventory), "utf8"));
|
|
if (receipt.protocol !== MANUAL_PRIVATE_PROTOCOL || id !== undefined && receipt.executionId !== id || receipt.complete !== true
|
|
|| !Array.isArray(receipt.files) || !receipt.files.length || receipt.files.length > 4096
|
|
|| hash(JSON.stringify(receipt.files)) !== receipt.inventorySha256) throw new Error("Original evidence inventory is incomplete");
|
|
if (id === undefined && receipt.treeKind !== treeKind) throw new Error("Private evidence tree kind differs from its inventory");
|
|
const expected = new Set<string>(inventory.startsWith("../") ? [] : [inventory]); let bytes = 0;
|
|
for (const row of receipt.files) {
|
|
if (!row || Object.keys(row).sort().join() !== "bytes,path,sha256" || typeof row.path !== "string"
|
|
|| row.path.split("/").some((part: string) => !part || part === "." || part === "..") || row.path.includes("\\")
|
|
|| expected.has(row.path) || !Number.isSafeInteger(row.bytes) || row.bytes < 0 || !/^[0-9a-f]{64}$/.test(row.sha256)) throw new Error("Invalid original evidence inventory row");
|
|
const cellAsset = cellOwner ? cellTraceAsset(row.path, cellOwner, id === undefined ? "final" : "original") : undefined;
|
|
if (cellAsset?.reserved && !cellAsset.canonical) throw new Error("Invalid private cell trace owner");
|
|
const standard = isStandardEvidenceFile(id === undefined ? `html-report/${row.path}` : row.path);
|
|
const canonical = id === undefined && treeKind === "generated-report" ? canonicalGeneratedReportAsset(row.path) : undefined;
|
|
const staticReportOwner = id === undefined && treeKind === "generated-report" && (row.path.startsWith("html/trace/") || canonical !== undefined);
|
|
if (cellAsset?.reserved ? !cellAsset.canonical : staticReportOwner ? !canonical : !standard) throw new Error("Invalid original evidence inventory row");
|
|
if (row.bytes > 256 * 1024 * 1024 - bytes) throw new Error("Original evidence byte accounting changed");
|
|
expected.add(row.path); bytes += row.bytes;
|
|
const file = path.join(directory, row.path), metadata = await lstat(file);
|
|
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size !== row.bytes) throw new Error("Original evidence changed after its scan");
|
|
const data = cellAsset?.reserved
|
|
? (await readPrivateCellTraceAsset({ owner: cellOwner!, layout: id === undefined ? "final" : "original", relative: row.path, file, remaining: row.bytes, secrets: [] }))!
|
|
: await readFile(file);
|
|
if (hash(data) !== row.sha256) throw new Error("Original evidence changed after its scan");
|
|
if (staticReportOwner && !await isTrustedGeneratedReportAsset(row.path, data)) throw new Error("Invalid original evidence inventory row");
|
|
}
|
|
if (bytes !== receipt.sourceBytes || bytes > 256 * 1024 * 1024) throw new Error("Original evidence byte accounting changed");
|
|
const actual = new Set<string>();
|
|
async function visit(relative: string) {
|
|
for (const entry of await readdir(path.join(directory, relative), { withFileTypes: true })) {
|
|
const name = relative ? `${relative}/${entry.name}` : entry.name;
|
|
if (entry.isSymbolicLink() || !entry.isFile() && !entry.isDirectory() || name.split("/").length > 24) throw new Error("Invalid sealed evidence entry");
|
|
if (entry.isDirectory()) { await visit(name); continue; }
|
|
if (!expected.has(name)) throw new Error("Unscanned file added to sealed evidence");
|
|
actual.add(name);
|
|
}
|
|
}
|
|
await visit("");
|
|
if (actual.size !== expected.size) throw new Error("Sealed evidence is incomplete");
|
|
if (treeKind === "standard") for (const row of receipt.files) {
|
|
if (row.path === PRIVATE_FRAMEWORK_REPORT || row.path.endsWith(`/${PRIVATE_FRAMEWORK_REPORT}`)) {
|
|
const cell = path.join(directory, row.path.slice(0, -PRIVATE_FRAMEWORK_REPORT.length));
|
|
if (id !== undefined) await validatePrivateFrameworkReport(cell);
|
|
else {
|
|
const metadata = JSON.parse(await readFile(path.join(directory, row.path), "utf8"));
|
|
const owner = metadata.canonicalExecutionId;
|
|
if (!context || typeof owner !== "string" || !context.executionIds.includes(owner)) throw new Error("Unbound canonical framework evidence");
|
|
await validatePrivateFrameworkReport(cell, path.join(context.root, "originals", manualCellKind(owner)), owner);
|
|
}
|
|
}
|
|
}
|
|
return receipt;
|
|
}
|
|
export async function validateSealedOriginals(root: string, id: string, context?: ManualPrivateHandoff) {
|
|
const receipt = await validateSealedInventory(path.join(root, "originals", manualCellKind(id)), "original-evidence-inventory.json", id, "standard", context);
|
|
const missing = receipt.missingRequiredEvidence;
|
|
const allowed = new Set(["final-state.png", "result.json", "junit.xml", "html-report/index.html", "snapshots/fixtures.json", "snapshots/api-state.json", "blob-report/*.zip"]);
|
|
if (![true, false, null].includes(receipt.requiredEvidenceComplete) || !Array.isArray(missing) || new Set(missing).size !== missing.length
|
|
|| missing.some((name: unknown) => typeof name !== "string" || !allowed.has(name))
|
|
|| (receipt.requiredEvidenceComplete === false) !== (missing.length > 0)) throw new Error("Required evidence completeness is unknown");
|
|
return receipt;
|
|
}
|
|
function cellCompleteness(id: string, receipt: { requiredEvidenceComplete: boolean | null; missingRequiredEvidence: string[] }): PrivateEvidenceCompleteness {
|
|
return { meaning: "available-files-and-transfer-only", doesNotAttestFeatureOutcome: true,
|
|
cells: [{ executionId: id, availableFileInventoryComplete: true, requiredPassEvidenceComplete: receipt.requiredEvidenceComplete, missingRequiredEvidence: receipt.missingRequiredEvidence }] };
|
|
}
|
|
async function uploadCell(env: NodeJS.ProcessEnv) {
|
|
requireSession(env); const { approval, bucket } = request(env), id = env.SELECTED_EXECUTION_ID!;
|
|
if (!approval.executionIds.includes(id)) throw new Error("Private evidence cell is not approved");
|
|
const root = await realpath(env.PRIVATE_EVIDENCE_ROOT ?? ""), context = JSON.parse(await readFile(path.join(root, "preflight.json"), "utf8"));
|
|
if (context.status !== "verified" || context.approvalSha256 !== approval.approvalSha256 || context.targetSha !== approval.targetSha
|
|
|| context.runId !== approval.runId || context.executionIds.join() !== id || context.root !== root) throw new Error("Private evidence preflight identity changed");
|
|
if (await lstat(path.join(root, "contamination.json")).then(() => true, error => { if (error.code === "ENOENT") return false; throw error; }))
|
|
throw new Error("Contaminated private evidence cannot be uploaded");
|
|
const originals = await validateSealedOriginals(root, id, context);
|
|
const evidenceCompleteness = cellCompleteness(id, originals);
|
|
const bundle = path.join(root, "sealed-bundle"); await mkdir(bundle, { mode: 0o700 });
|
|
const results = path.join(root, "sealed-results");
|
|
await validateSealedInventory(path.join(results, "html-report"), "../result-evidence-inventory.json", undefined, "standard", context);
|
|
await mkdir(path.join(bundle, "originals"), { mode: 0o700 });
|
|
await cp(path.join(root, "originals", manualCellKind(id)), path.join(bundle, "originals", manualCellKind(id)), { recursive: true, errorOnExist: true, force: false });
|
|
await cp(results, path.join(bundle, "sealed-results"), { recursive: true, errorOnExist: true, force: false });
|
|
await writeFile(path.join(bundle, "identity.json"), JSON.stringify({ ...context, evidenceCompleteness }), { mode: 0o600, flag: "wx" });
|
|
const receipt = path.join(root, "cell-transfer.json"), publisher = new BoundedHistoryPublisher(receipt);
|
|
await retainPrivateTransfer({ operation: "upload", kind: manualCellKind(id), runId: approval.runId, directory: bundle, bucket, publisher, evidenceCompleteness }, receipt,
|
|
() => new BoundedHistoryPublisher(`${receipt}.retention.json`));
|
|
}
|
|
async function reportSession(env: NodeJS.ProcessEnv) {
|
|
const { approval, bucket } = request(env);
|
|
await output("session_policy", JSON.stringify(manualPrivateSessionPolicy({ bucket, runId: approval.runId, executionIds: [...approval.executionIds], mode: "report-write" })));
|
|
await output("cell_count", String(approval.executionIds.length));
|
|
for (const [index, id] of approval.executionIds.entries()) {
|
|
await output(`read_policy_${index}`, JSON.stringify(manualPrivateSessionPolicy({ bucket, runId: approval.runId, executionIds: [id], mode: "cell-read" })));
|
|
}
|
|
}
|
|
async function downloadCell(env: NodeJS.ProcessEnv) {
|
|
requireSession(env); const { approval, bucket } = request(env), root = await realpath(env.PRIVATE_REPORT_ROOT ?? "");
|
|
const index = Number(env.SELECTED_CELL_INDEX);
|
|
if (!/^[0-7]$/.test(env.SELECTED_CELL_INDEX ?? "") || !approval.executionIds[index]) throw new Error("Invalid exact private cell index");
|
|
const selected = path.join(root, "selected-runner-e2e"); await mkdir(selected, { recursive: true, mode: 0o700 });
|
|
const id = approval.executionIds[index]!;
|
|
{
|
|
const directory = path.join(root, manualCellKind(id)), publisher = new BoundedHistoryPublisher(path.join(root, `${manualCellKind(id)}-download.json`));
|
|
const receipt = await publisher.downloadJson<{ evidenceCompleteness?: PrivateEvidenceCompleteness; transfer?: { status?: string; sha256?: string } }>(bucket,
|
|
`${privateArtifactKey(manualCellKind(id), approval.runId)}.upload-receipt.json`, path.join(root, `${manualCellKind(id)}-receipt.json`));
|
|
if (receipt?.transfer?.status !== "complete" || !/^[0-9a-f]{64}$/.test(receipt.transfer.sha256 ?? "")) throw new Error("Private cell transfer receipt is unavailable");
|
|
const downloaded = await transferPrivateArtifact({ operation: "download", kind: manualCellKind(id), runId: approval.runId, directory, bucket, publisher });
|
|
if (downloaded.sha256 !== receipt.transfer.sha256) throw new Error("Private cell archive differs from retained transfer receipt");
|
|
const identity = JSON.parse(await readFile(path.join(directory, "identity.json"), "utf8"));
|
|
if (identity.targetSha !== approval.targetSha || identity.workflowSha !== approval.workflowSha || identity.runId !== approval.runId
|
|
|| identity.executionIds.join() !== id || identity.protocol !== MANUAL_PRIVATE_PROTOCOL) throw new Error("Private cell archive has a foreign identity");
|
|
const originals = await validateSealedOriginals(directory, id, { ...identity, root: directory }), summary = cellCompleteness(id, originals);
|
|
if (hash(JSON.stringify(identity.evidenceCompleteness)) !== hash(JSON.stringify(summary))
|
|
|| hash(JSON.stringify(receipt.evidenceCompleteness)) !== hash(JSON.stringify(summary))) throw new Error("Private evidence completeness receipt differs from archive");
|
|
await stageSealedPrivateCell(directory, selected, id, { ...identity, root: directory });
|
|
await writeFile(path.join(root, `manual-cell-completeness-${index}.json`), JSON.stringify(summary), { mode: 0o600, flag: "wx" });
|
|
}
|
|
}
|
|
export async function stageSealedPrivateCell(directory: string, selected: string, id: string, context: ManualPrivateHandoff) {
|
|
await validateSealedOriginals(directory, id, context);
|
|
const inventory = await validateSealedInventory(path.join(directory, "sealed-results", "html-report"), "../result-evidence-inventory.json", undefined, "standard", context);
|
|
const destination = path.join(selected, manualCellKind(id));
|
|
await cp(path.join(directory, "sealed-results", "html-report"), destination, { recursive: true, errorOnExist: true, force: false });
|
|
for (const row of inventory.files) {
|
|
if (row.path === PRIVATE_FRAMEWORK_REPORT || row.path.endsWith(`/${PRIVATE_FRAMEWORK_REPORT}`)) {
|
|
const cell = path.join(destination, row.path.slice(0, -PRIVATE_FRAMEWORK_REPORT.length));
|
|
await materializePrivateFrameworkAttachments(cell, path.join(directory, "originals", manualCellKind(id)), id);
|
|
}
|
|
}
|
|
}
|
|
async function verifyDownloads(env: NodeJS.ProcessEnv) {
|
|
const { approval } = request(env), root = await realpath(env.PRIVATE_REPORT_ROOT ?? "");
|
|
const cells: PrivateEvidenceCompleteness["cells"] = [];
|
|
for (const [index, id] of approval.executionIds.entries()) {
|
|
const summary = JSON.parse(await readFile(path.join(root, `manual-cell-completeness-${index}.json`), "utf8")) as PrivateEvidenceCompleteness;
|
|
if (summary.meaning !== "available-files-and-transfer-only" || summary.doesNotAttestFeatureOutcome !== true
|
|
|| summary.cells.length !== 1 || summary.cells[0]!.executionId !== id) throw new Error("Private report lacks an exact cell transfer");
|
|
cells.push(...summary.cells);
|
|
}
|
|
await writeFile(path.join(root, "manual-report-input-completeness.json"), JSON.stringify({ meaning: "available-files-and-transfer-only", doesNotAttestFeatureOutcome: true, cells }), { mode: 0o600, flag: "wx" });
|
|
}
|
|
/**
|
|
* Names the first carrier the generated-report seal would refuse with a closed
|
|
* category, so a refused report is diagnosable without its paths. The seal stays
|
|
* authoritative; this only runs before it and never admits anything.
|
|
*/
|
|
export async function assertGeneratedReportCarriers(source: string) {
|
|
let visited = 0;
|
|
async function visit(relative: string): Promise<void> {
|
|
for (const entry of await readdir(path.join(source, relative), { withFileTypes: true })) {
|
|
if (++visited > 4096) return;
|
|
const name = relative ? `${relative}/${entry.name}` : entry.name;
|
|
if (entry.isDirectory() && !entry.isSymbolicLink()) { if (name.split("/").length < 24) await visit(name); continue; }
|
|
if (!entry.isFile() || entry.isSymbolicLink()) continue;
|
|
const canonical = canonicalGeneratedReportAsset(name);
|
|
if (name.startsWith("html/trace/") || canonical !== undefined) {
|
|
if (!canonical) throw new Error("Unsupported generated report carrier (trace viewer asset)");
|
|
if (!await isTrustedGeneratedReportAsset(name, await readFile(path.join(source, name))))
|
|
throw new Error("Unsupported generated report carrier (trace viewer bytes)");
|
|
continue;
|
|
}
|
|
if (isStandardEvidenceFile(`html-report/${name}`)) continue;
|
|
const extension = path.extname(name).toLowerCase();
|
|
if ([".ttf", ".otf", ".woff", ".woff2"].includes(extension)) throw new Error("Unsupported generated report carrier (font)");
|
|
if (extension === ".svg") throw new Error("Unsupported generated report carrier (svg)");
|
|
if (extension === ".webmanifest") throw new Error("Unsupported generated report carrier (webmanifest)");
|
|
if ([".jpg", ".jpeg", ".gif", ".webp", ".avif", ".bmp", ".ico"].includes(extension)) throw new Error("Unsupported generated report carrier (image)");
|
|
if (extension === ".map") throw new Error("Unsupported generated report carrier (source map)");
|
|
if (!extension) throw new Error("Unsupported generated report carrier (no extension)");
|
|
throw new Error("Unsupported generated report carrier (other)");
|
|
}
|
|
}
|
|
await visit("");
|
|
}
|
|
export async function uploadReport(env: NodeJS.ProcessEnv) {
|
|
requireSession(env); const { approval, bucket } = request(env), source = await realpath(env.PRIVATE_REPORT_SOURCE ?? ""), root = await realpath(env.PRIVATE_REPORT_ROOT ?? "");
|
|
if (!source.startsWith(`${root}${path.sep}`)) throw new Error("Private report escaped owned staging");
|
|
const evidenceCompleteness = JSON.parse(await readFile(path.join(root, "manual-report-input-completeness.json"), "utf8")) as PrivateEvidenceCompleteness;
|
|
if (evidenceCompleteness.meaning !== "available-files-and-transfer-only" || evidenceCompleteness.doesNotAttestFeatureOutcome !== true
|
|
|| evidenceCompleteness.cells.map(cell => cell.executionId).sort().join() !== [...approval.executionIds].sort().join()) throw new Error("Private report lacks exact cell completeness receipts");
|
|
const secrets = normalizedSecrets([...CREDENTIAL_NAMES.map(name => env[name]), env.AWS_ACCESS_KEY_ID, env.AWS_SECRET_ACCESS_KEY, env.AWS_SESSION_TOKEN]);
|
|
await assertGeneratedReportCarriers(source);
|
|
await sealManualResultEvidence({ root, protocol: MANUAL_PRIVATE_PROTOCOL, private: true, workflowSha: approval.workflowSha,
|
|
targetSha: approval.targetSha, runId: approval.runId, attempt: approval.attempt, executionIds: [...approval.executionIds] }, secrets, source, "generated-report");
|
|
const sealed = path.join(root, "sealed-results");
|
|
await validateSealedInventory(path.join(sealed, "html-report"), "../result-evidence-inventory.json", undefined, "generated-report");
|
|
const receipt = path.join(root, "report-transfer.json"), publisher = new BoundedHistoryPublisher(receipt);
|
|
await retainPrivateTransfer({ operation: "upload", kind: "manual-report", runId: approval.runId, directory: sealed, bucket, publisher, evidenceCompleteness }, receipt,
|
|
() => new BoundedHistoryPublisher(`${receipt}.retention.json`));
|
|
}
|
|
async function main() {
|
|
const action = process.argv[2];
|
|
if (process.argv.length !== 3) throw new Error("Invalid private evidence command");
|
|
if (action === "prepare") await prepare(process.env);
|
|
else if (action === "verify") await verify(process.env);
|
|
else if (action === "upload-cell") await uploadCell(process.env);
|
|
else if (action === "report-session") await reportSession(process.env);
|
|
else if (action === "download-cell") await downloadCell(process.env);
|
|
else if (action === "verify-downloads") await verifyDownloads(process.env);
|
|
else if (action === "upload-report") await uploadReport(process.env);
|
|
else throw new Error("Invalid private evidence command");
|
|
}
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
// Name only a closed refusal code (never message text) so a refused attempt is diagnosable.
|
|
main().catch(error => { process.stderr.write(`Restricted manual evidence operation failed (refusal: ${privateRefusalCode(error)}); no public fallback\n`); process.exitCode = 1; });
|
|
}
|