diff --git a/.github/workflows/runner-full-stack-e2e.yml b/.github/workflows/runner-full-stack-e2e.yml index 05610f75f75..55a08b6e689 100644 --- a/.github/workflows/runner-full-stack-e2e.yml +++ b/.github/workflows/runner-full-stack-e2e.yml @@ -1101,7 +1101,8 @@ jobs: working-directory: .manual-private-trusted env: MANUAL_PRIVATE_IDS: ${{ needs.authorize.outputs.manual_private_ids }} - MANUAL_PRIVATE_APPROVALS_JSON: ${{ vars.RUNNER_E2E_MANUAL_PRIVATE_APPROVALS_JSON }} + PAPERCLIP_MANUAL_DISPATCH_AUTHORITY: v1 + MANUAL_DISPATCH_ALLOWED_ACTOR_IDS: ${{ vars.RUNNER_E2E_ALLOWED_ACTOR_IDS }} TARGET_SHA: ${{ needs.authorize.outputs.target_sha }} TARGET_ROOT: ${{ github.workspace }} SELECTED_EXECUTION_ID: ${{ matrix.executionId }} @@ -1130,7 +1131,8 @@ jobs: working-directory: .manual-private-trusted env: MANUAL_PRIVATE_IDS: ${{ needs.authorize.outputs.manual_private_ids }} - MANUAL_PRIVATE_APPROVALS_JSON: ${{ vars.RUNNER_E2E_MANUAL_PRIVATE_APPROVALS_JSON }} + PAPERCLIP_MANUAL_DISPATCH_AUTHORITY: v1 + MANUAL_DISPATCH_ALLOWED_ACTOR_IDS: ${{ vars.RUNNER_E2E_ALLOWED_ACTOR_IDS }} TARGET_SHA: ${{ needs.authorize.outputs.target_sha }} TARGET_ROOT: ${{ github.workspace }} SELECTED_EXECUTION_ID: ${{ matrix.executionId }} @@ -1456,7 +1458,8 @@ jobs: working-directory: .manual-private-trusted env: MANUAL_PRIVATE_IDS: ${{ needs.authorize.outputs.manual_private_ids }} - MANUAL_PRIVATE_APPROVALS_JSON: ${{ vars.RUNNER_E2E_MANUAL_PRIVATE_APPROVALS_JSON }} + PAPERCLIP_MANUAL_DISPATCH_AUTHORITY: v1 + MANUAL_DISPATCH_ALLOWED_ACTOR_IDS: ${{ vars.RUNNER_E2E_ALLOWED_ACTOR_IDS }} TARGET_SHA: ${{ needs.authorize.outputs.target_sha }} TARGET_ROOT: ${{ github.workspace }} SELECTED_EXECUTION_ID: ${{ matrix.executionId }} @@ -1557,7 +1560,8 @@ jobs: if: needs.authorize.outputs.evidence_retention == 'private' env: MANUAL_PRIVATE_IDS: ${{ needs.authorize.outputs.manual_private_ids }} - MANUAL_PRIVATE_APPROVALS_JSON: ${{ vars.RUNNER_E2E_MANUAL_PRIVATE_APPROVALS_JSON }} + PAPERCLIP_MANUAL_DISPATCH_AUTHORITY: v1 + MANUAL_DISPATCH_ALLOWED_ACTOR_IDS: ${{ vars.RUNNER_E2E_ALLOWED_ACTOR_IDS }} TARGET_SHA: ${{ needs.authorize.outputs.target_sha }} RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET: ${{ vars.RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET }} NODE_OPTIONS: '' @@ -1595,7 +1599,8 @@ jobs: if: steps.manual_read_credentials_0.outcome == 'success' env: MANUAL_PRIVATE_IDS: ${{ needs.authorize.outputs.manual_private_ids }} - MANUAL_PRIVATE_APPROVALS_JSON: ${{ vars.RUNNER_E2E_MANUAL_PRIVATE_APPROVALS_JSON }} + PAPERCLIP_MANUAL_DISPATCH_AUTHORITY: v1 + MANUAL_DISPATCH_ALLOWED_ACTOR_IDS: ${{ vars.RUNNER_E2E_ALLOWED_ACTOR_IDS }} TARGET_SHA: ${{ needs.authorize.outputs.target_sha }} RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET: ${{ vars.RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET }} NODE_OPTIONS: '' @@ -1626,7 +1631,8 @@ jobs: if: steps.manual_read_credentials_1.outcome == 'success' env: MANUAL_PRIVATE_IDS: ${{ needs.authorize.outputs.manual_private_ids }} - MANUAL_PRIVATE_APPROVALS_JSON: ${{ vars.RUNNER_E2E_MANUAL_PRIVATE_APPROVALS_JSON }} + PAPERCLIP_MANUAL_DISPATCH_AUTHORITY: v1 + MANUAL_DISPATCH_ALLOWED_ACTOR_IDS: ${{ vars.RUNNER_E2E_ALLOWED_ACTOR_IDS }} TARGET_SHA: ${{ needs.authorize.outputs.target_sha }} RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET: ${{ vars.RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET }} NODE_OPTIONS: '' @@ -1657,7 +1663,8 @@ jobs: if: steps.manual_read_credentials_2.outcome == 'success' env: MANUAL_PRIVATE_IDS: ${{ needs.authorize.outputs.manual_private_ids }} - MANUAL_PRIVATE_APPROVALS_JSON: ${{ vars.RUNNER_E2E_MANUAL_PRIVATE_APPROVALS_JSON }} + PAPERCLIP_MANUAL_DISPATCH_AUTHORITY: v1 + MANUAL_DISPATCH_ALLOWED_ACTOR_IDS: ${{ vars.RUNNER_E2E_ALLOWED_ACTOR_IDS }} TARGET_SHA: ${{ needs.authorize.outputs.target_sha }} RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET: ${{ vars.RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET }} NODE_OPTIONS: '' @@ -1688,7 +1695,8 @@ jobs: if: steps.manual_read_credentials_3.outcome == 'success' env: MANUAL_PRIVATE_IDS: ${{ needs.authorize.outputs.manual_private_ids }} - MANUAL_PRIVATE_APPROVALS_JSON: ${{ vars.RUNNER_E2E_MANUAL_PRIVATE_APPROVALS_JSON }} + PAPERCLIP_MANUAL_DISPATCH_AUTHORITY: v1 + MANUAL_DISPATCH_ALLOWED_ACTOR_IDS: ${{ vars.RUNNER_E2E_ALLOWED_ACTOR_IDS }} TARGET_SHA: ${{ needs.authorize.outputs.target_sha }} RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET: ${{ vars.RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET }} NODE_OPTIONS: '' @@ -1719,7 +1727,8 @@ jobs: if: steps.manual_read_credentials_4.outcome == 'success' env: MANUAL_PRIVATE_IDS: ${{ needs.authorize.outputs.manual_private_ids }} - MANUAL_PRIVATE_APPROVALS_JSON: ${{ vars.RUNNER_E2E_MANUAL_PRIVATE_APPROVALS_JSON }} + PAPERCLIP_MANUAL_DISPATCH_AUTHORITY: v1 + MANUAL_DISPATCH_ALLOWED_ACTOR_IDS: ${{ vars.RUNNER_E2E_ALLOWED_ACTOR_IDS }} TARGET_SHA: ${{ needs.authorize.outputs.target_sha }} RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET: ${{ vars.RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET }} NODE_OPTIONS: '' @@ -1750,7 +1759,8 @@ jobs: if: steps.manual_read_credentials_5.outcome == 'success' env: MANUAL_PRIVATE_IDS: ${{ needs.authorize.outputs.manual_private_ids }} - MANUAL_PRIVATE_APPROVALS_JSON: ${{ vars.RUNNER_E2E_MANUAL_PRIVATE_APPROVALS_JSON }} + PAPERCLIP_MANUAL_DISPATCH_AUTHORITY: v1 + MANUAL_DISPATCH_ALLOWED_ACTOR_IDS: ${{ vars.RUNNER_E2E_ALLOWED_ACTOR_IDS }} TARGET_SHA: ${{ needs.authorize.outputs.target_sha }} RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET: ${{ vars.RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET }} NODE_OPTIONS: '' @@ -1781,7 +1791,8 @@ jobs: if: steps.manual_read_credentials_6.outcome == 'success' env: MANUAL_PRIVATE_IDS: ${{ needs.authorize.outputs.manual_private_ids }} - MANUAL_PRIVATE_APPROVALS_JSON: ${{ vars.RUNNER_E2E_MANUAL_PRIVATE_APPROVALS_JSON }} + PAPERCLIP_MANUAL_DISPATCH_AUTHORITY: v1 + MANUAL_DISPATCH_ALLOWED_ACTOR_IDS: ${{ vars.RUNNER_E2E_ALLOWED_ACTOR_IDS }} TARGET_SHA: ${{ needs.authorize.outputs.target_sha }} RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET: ${{ vars.RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET }} NODE_OPTIONS: '' @@ -1812,7 +1823,8 @@ jobs: if: steps.manual_read_credentials_7.outcome == 'success' env: MANUAL_PRIVATE_IDS: ${{ needs.authorize.outputs.manual_private_ids }} - MANUAL_PRIVATE_APPROVALS_JSON: ${{ vars.RUNNER_E2E_MANUAL_PRIVATE_APPROVALS_JSON }} + PAPERCLIP_MANUAL_DISPATCH_AUTHORITY: v1 + MANUAL_DISPATCH_ALLOWED_ACTOR_IDS: ${{ vars.RUNNER_E2E_ALLOWED_ACTOR_IDS }} TARGET_SHA: ${{ needs.authorize.outputs.target_sha }} RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET: ${{ vars.RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET }} NODE_OPTIONS: '' @@ -1831,7 +1843,8 @@ jobs: if: steps.manual_report.outcome == 'success' env: MANUAL_PRIVATE_IDS: ${{ needs.authorize.outputs.manual_private_ids }} - MANUAL_PRIVATE_APPROVALS_JSON: ${{ vars.RUNNER_E2E_MANUAL_PRIVATE_APPROVALS_JSON }} + PAPERCLIP_MANUAL_DISPATCH_AUTHORITY: v1 + MANUAL_DISPATCH_ALLOWED_ACTOR_IDS: ${{ vars.RUNNER_E2E_ALLOWED_ACTOR_IDS }} TARGET_SHA: ${{ needs.authorize.outputs.target_sha }} PRIVATE_REPORT_ROOT: ${{ github.workspace }} NODE_OPTIONS: '' @@ -2025,7 +2038,8 @@ jobs: if: always() && steps.manual_report_credentials.outcome == 'success' && steps.manual_cells_downloaded.outcome == 'success' env: MANUAL_PRIVATE_IDS: ${{ needs.authorize.outputs.manual_private_ids }} - MANUAL_PRIVATE_APPROVALS_JSON: ${{ vars.RUNNER_E2E_MANUAL_PRIVATE_APPROVALS_JSON }} + PAPERCLIP_MANUAL_DISPATCH_AUTHORITY: v1 + MANUAL_DISPATCH_ALLOWED_ACTOR_IDS: ${{ vars.RUNNER_E2E_ALLOWED_ACTOR_IDS }} TARGET_SHA: ${{ needs.authorize.outputs.target_sha }} RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET: ${{ vars.RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET }} NODE_OPTIONS: '' diff --git a/doc/manual-e2e-private-evidence.md b/doc/manual-e2e-private-evidence.md index 894d35c33c5..3e983ffdc62 100644 --- a/doc/manual-e2e-private-evidence.md +++ b/doc/manual-e2e-private-evidence.md @@ -24,7 +24,7 @@ aliases are not registered. The exact registry is in `tests/runner-e2e/manual-private-retention.ts`. These IDs require the reviewed AWS Fleet route. Broad chat suites, mixed -unsupported IDs, reruns and missing approvals fail closed. All chat-feature IDs +unsupported IDs, reruns and runs without dispatch authority fail closed. All chat-feature IDs are classified as restricted by trusted code. A target descriptor cannot opt out. Other manual selections keep the existing Actions route only after a trusted per-cell check confirms that the actual selected execution is not restricted. @@ -38,8 +38,8 @@ credentials. Generic nonprivate catalog selection retains its existing semantics A registered ID is not a claim of feature readiness or permission to run it. Each target still needs the exact reviewed producer contracts, matching helper bytes, declared private inputs, fresh source fingerprints, an available original -attempt and exact operation approval. Earlier accepted target branches do not -acquire these bytes or approval automatically. Running jobs keep their pinned +attempt and an allowlisted manual dispatch. Earlier accepted target branches do not +acquire these bytes automatically. Running jobs keep their pinned sources. No retained grade or historical qualification transfers to a new target. The private cell report contract binds the installed pinned Playwright producer @@ -58,30 +58,41 @@ or run. Before a supported run, the operator must: 1. Deploy this workflow and its helpers together on the protected default branch. 2. Keep the existing numeric actor allowlist and protected default-branch checks. 3. Keep `runner-e2e-private-paid` and `runner-e2e-private-history` restricted to - the default branch. Establish administrator-controlled approval for the exact - source and original run. A reviewer UI may supply this control but is not - mandatory. Prove the actual control and runtime contract before admission. + the default branch. 4. Provide the existing private-history role ARN, AWS region and bucket variables in those environments. The existing role must support the existing bounded prefix and bucket-policy, ownership, public-access and retention reads. -5. Provide `RUNNER_E2E_MANUAL_PRIVATE_APPROVALS_JSON` through administrator-controlled - configuration available to both protected jobs. Approve the exact resolved - workflow SHA, target SHA, original run ID, - attempt `1`, selected execution IDs and launcher SHA-256. -6. Use a reviewed compatible target. Its private handoff and policy modules must - match the workflow implementation byte for byte. Its launcher digest must - match the protected approval. Earlier target branches do not gain this - protocol automatically. +5. Use a compatible target. Its private handoff and policy modules must match + the workflow implementation byte for byte. Earlier target branches do not + gain this protocol automatically. -Classification happens before the protected job. It grants no archive authority. -The administrator-controlled record must bind the actual original run ID before -target dependency setup or archive credentials. Prove who can change that record -and that each job consumes the exact approved record. An environment reviewer -gate is one way to govern authorization; its UI is not required. A missing or -stale row still fails closed before target dependency setup, archive credentials, -model credentials or fixture secrets. There is no wildcard approval. +### Dispatch authority -The variable has this closed shape (all values below are placeholders): +In `runner-full-stack-e2e.yml`, the manual run itself authorizes its private +evidence. There is no separate per-run approval record. Trusted code in +`tests/runner-e2e/manual-dispatch-authority.ts` admits a cell only when all of +these are true: + +- The event is `workflow_dispatch` of this workflow on the default branch, with + the `manual` cadence. Scheduled and bounded-cadence runs never qualify. +- The run is on attempt `1`. A rerun does not inherit authority. +- The dispatching actor is in `RUNNER_E2E_ALLOWED_ACTOR_IDS`, the event sender is + that actor, and the triggering actor is the same person. +- The trusted cell selection equals the dispatch's own `id` input. + +The authority binds the workflow SHA, the target SHA resolved by the authorize +job, the run ID and the cells. The dispatcher chooses and vouches for the target +branch, so its launcher digest is recorded in the private identity instead of +compared with a separately approved digest. Classification still happens before +the protected job, and a failed check still stops before target dependency +setup, archive credentials, model credentials or fixture secrets. The per-run +limit of eight cells is unchanged. + +### Stored approval records + +The TestOps catalog case workflow still passes a protected approval record as +`MANUAL_PRIVATE_APPROVALS_JSON`. That record has this closed shape (all values +below are placeholders): ```json { @@ -105,7 +116,7 @@ It prepares pinned publication dependencies with `--ignore-scripts` and a separate store before archive credentials. Transfers use that checkout's absolute helper, tsx and archive-script paths. They do not load the target's node_modules or an ambient Node loader. The target executes only after the -protected approval and private-storage preflight pass. +evidence authority and private-storage preflight pass. The AWS action returns session credentials as step outputs, not job-wide environment exports. Only trusted preflight and transfer steps receive those diff --git a/tests/runner-e2e/manual-dispatch-authority.test.ts b/tests/runner-e2e/manual-dispatch-authority.test.ts new file mode 100644 index 00000000000..88efc84571a --- /dev/null +++ b/tests/runner-e2e/manual-dispatch-authority.test.ts @@ -0,0 +1,90 @@ +import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { authorizeManualDispatch } from "./manual-dispatch-authority.js"; + +const ids = ["activity.agent-feedback", "agents.choose-primary"].map(id => `chat-features.runner-opencode.local.${id}`); +const workflowSha = "a".repeat(40), targetSha = "b".repeat(40); +const env = (overrides: Record = {}): NodeJS.ProcessEnv => ({ + PAPERCLIP_MANUAL_DISPATCH_AUTHORITY: "v1", GITHUB_ACTIONS: "true", GITHUB_EVENT_NAME: "workflow_dispatch", + GITHUB_REPOSITORY: "paperclipai/paperclip", GITHUB_REF: "refs/heads/master", + GITHUB_WORKFLOW_REF: "paperclipai/paperclip/.github/workflows/runner-full-stack-e2e.yml@refs/heads/master", + GITHUB_SHA: workflowSha, TARGET_SHA: targetSha, GITHUB_RUN_ID: "123", GITHUB_RUN_ATTEMPT: "1", + GITHUB_ACTOR: "owner", GITHUB_TRIGGERING_ACTOR: "owner", GITHUB_ACTOR_ID: "42", + MANUAL_DISPATCH_ALLOWED_ACTOR_IDS: "[42,7]", MANUAL_PRIVATE_IDS: JSON.stringify(ids), ...overrides, +}); +const event = (overrides: Record = {}, inputs: Record = {}) => ({ + ref: "refs/heads/master", sender: { id: 42 }, repository: { full_name: "paperclipai/paperclip", default_branch: "master" }, + inputs: { cadence: "manual", target_branch: "candidate", id: ids.join(","), ...inputs }, ...overrides, +}); + +describe("authorizeManualDispatch", () => { + it("authorizes an allowlisted operator's manual dispatch for exactly its own cells", () => { + const authority = authorizeManualDispatch(env(), event()); + expect(authority).toMatchObject({ workflowSha, targetSha, runId: "123", attempt: "1", launchBinding: "dispatcher", private: true }); + expect(authority.executionIds).toHaveLength(2); + expect(authority.approvalSha256).toMatch(/^[a-f0-9]{64}$/); + // Every job of the run derives the same identity. + expect(authorizeManualDispatch(env(), event()).approvalSha256).toBe(authority.approvalSha256); + }); + + it.each([ + ["a scheduled run", env({ GITHUB_EVENT_NAME: "schedule" }), event()], + ["a bounded cadence", env(), event({}, { cadence: "daily" })], + ["an operator outside the allowlist", env({ GITHUB_ACTOR_ID: "99" }), event({ sender: { id: 99 } })], + ["an empty allowlist", env({ MANUAL_DISPATCH_ALLOWED_ACTOR_IDS: "[]" }), event()], + ["a rerun by another person", env({ GITHUB_TRIGGERING_ACTOR: "someone-else" }), event()], + ["a second attempt", env({ GITHUB_RUN_ATTEMPT: "2" }), event()], + ["a non-default workflow ref", env({ GITHUB_WORKFLOW_REF: "paperclipai/paperclip/.github/workflows/runner-full-stack-e2e.yml@refs/heads/feature" }), event()], + ["a different workflow", env({ GITHUB_WORKFLOW_REF: "paperclipai/paperclip/.github/workflows/other.yml@refs/heads/master" }), event()], + ["a sender who is not the actor", env(), event({ sender: { id: 7 } })], + ["a missing target commit", env({ TARGET_SHA: "" }), event()], + ["the authority switch unset", env({ PAPERCLIP_MANUAL_DISPATCH_AUTHORITY: undefined }), event()], + ])("rejects %s", (_label, environment, payload) => { + expect(() => authorizeManualDispatch(environment, payload)).toThrow(); + }); + + it("rejects cells that differ from the dispatch's own selection", () => { + expect(() => authorizeManualDispatch(env(), event({}, { id: ids[0] }))).toThrow(/differ/); + expect(() => authorizeManualDispatch(env({ MANUAL_PRIVATE_IDS: JSON.stringify([ids[0]]) }), event())).toThrow(/differ/); + }); + + it("keeps the per-run cell limit", () => { + const many = ["activity.agent-feedback", "activity.work-timeline", "companies.import-preview", "navigation-preferences.dismiss-announcement", + "tasks.completion-with-gate", "tasks.private-task", "secrets.personal-secret", "agents.choose-primary", "access.cli-authorization"] + .map(id => `chat-features.runner-opencode.local.${id}`); + expect(() => authorizeManualDispatch(env({ MANUAL_PRIVATE_IDS: JSON.stringify(many) }), event({}, { id: many.join(",") }))).toThrow(/limit/); + }); +}); + +const roots: string[] = []; +afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); }); + +it("actual prepare admits a dispatched cell without a stored approval and records the target launcher", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "synthetic-dispatch-prepare-")); roots.push(root); + const target = path.join(root, "target"), modules = path.join(target, "tests/runner-e2e"); await mkdir(modules, { recursive: true }); + await writeFile(path.join(modules, "launch.ts"), "synthetic candidate launcher; never executed"); + for (const name of ["manual-private-handoff.ts", "manual-private-retention.ts", "manual-private-report-assets.ts"]) await copyFile(path.join(import.meta.dirname, name), path.join(modules, name)); + const eventPath = path.join(root, "event.json"), output = path.join(root, "outputs.txt"); + await writeFile(eventPath, JSON.stringify(event())); + const trusted = path.resolve(import.meta.dirname, "../.."), require = createRequire(import.meta.url), tsx = require.resolve("tsx", { paths: [path.join(trusted, "server")] }); + const run = (extra: Record) => spawnSync(process.execPath, ["--import", tsx, path.join(import.meta.dirname, "manual-private-transfer.ts"), "prepare"], { + encoding: "utf8", timeout: 10000, + env: { PATH: process.env.PATH, NODE_OPTIONS: "", NODE_PATH: "", ...env(), SELECTED_EXECUTION_ID: ids[0]!, GITHUB_EVENT_PATH: eventPath, + TARGET_ROOT: target, RUNNER_TEMP: root, RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET: "private-bucket", GITHUB_OUTPUT: output, ...extra } as NodeJS.ProcessEnv, + }); + const denied = run({ GITHUB_ACTOR_ID: "99" }); + expect(denied.status).toBe(1); + await expect(readFile(output)).rejects.toMatchObject({ code: "ENOENT" }); + const admitted = run({}); + expect(admitted.error).toBeUndefined(); expect(admitted.status).toBe(0); + const outputs = await readFile(output, "utf8"); + expect(outputs).toContain("session_policy="); + const identityRoot = outputs.split("\n").find(line => line.startsWith("root="))!.slice(5); + const identity = JSON.parse(await readFile(path.join(identityRoot, "identity.json"), "utf8")); + expect(identity).toMatchObject({ launchBinding: "dispatcher", runId: "123", targetSha, executionIds: [ids[0]] }); + expect(identity.launchSha256).toMatch(/^[a-f0-9]{64}$/); +}); diff --git a/tests/runner-e2e/manual-dispatch-authority.ts b/tests/runner-e2e/manual-dispatch-authority.ts new file mode 100644 index 00000000000..23c14472238 --- /dev/null +++ b/tests/runner-e2e/manual-dispatch-authority.ts @@ -0,0 +1,54 @@ +import { createHash } from "node:crypto"; +import { lstatSync, readFileSync } from "node:fs"; +import { canonicalPrivateManualIds, MANUAL_PRIVATE_EXECUTIONS, MANUAL_PRIVATE_PROTOCOL } from "./manual-private-retention.js"; + +const authority = "paperclip.manual-dispatch-evidence/v1"; +const repository = "paperclipai/paperclip"; +const workflow = ".github/workflows/runner-full-stack-e2e.yml"; +const numericId = /^[1-9][0-9]{0,19}$/; +const values = (value: unknown) => String(value ?? "").split(",").map(item => item.trim()).filter(Boolean); + +/** + * Private chat evidence authority for a manual run of the trusted workflow. + * + * The run itself is the approval: a `workflow_dispatch` of the default-branch + * workflow, on its first attempt, started and triggered by an allowlisted + * operator, for the exact cells named in its own `id` input. Scheduled and + * bounded-cadence runs never qualify. The evidence stays bound to this run, + * its workflow commit, its resolved target commit and those cells. + */ +export function authorizeManualDispatch(env: NodeJS.ProcessEnv, event: unknown) { + const payload = event as { ref?: unknown; sender?: { id?: unknown }; repository?: { full_name?: unknown; default_branch?: unknown }; inputs?: Record }; + const branch = payload.repository?.default_branch; + const actor = env.GITHUB_ACTOR_ID; + let allowed: unknown; + try { allowed = JSON.parse(env.MANUAL_DISPATCH_ALLOWED_ACTOR_IDS ?? "null"); } catch { allowed = null; } + if (env.PAPERCLIP_MANUAL_DISPATCH_AUTHORITY !== "v1" || env.GITHUB_ACTIONS !== "true" || env.GITHUB_EVENT_NAME !== "workflow_dispatch" + || env.GITHUB_REPOSITORY !== repository || payload.repository?.full_name !== repository + || typeof branch !== "string" || !/^[A-Za-z0-9._/-]+$/.test(branch) || env.GITHUB_REF !== `refs/heads/${branch}` || payload.ref !== env.GITHUB_REF + || env.GITHUB_WORKFLOW_REF !== `${repository}/${workflow}@refs/heads/${branch}` + || !/^[a-f0-9]{40}$/.test(env.GITHUB_SHA ?? "") || !/^[a-f0-9]{40}$/.test(env.TARGET_SHA ?? "") + || !numericId.test(env.GITHUB_RUN_ID ?? "") || env.GITHUB_RUN_ATTEMPT !== "1" + || !numericId.test(actor ?? "") || payload.sender?.id !== Number(actor) + || !env.GITHUB_ACTOR || env.GITHUB_TRIGGERING_ACTOR !== env.GITHUB_ACTOR + || !Array.isArray(allowed) || !allowed.length || allowed.some(v => !Number.isSafeInteger(v) || v <= 0) || !allowed.includes(Number(actor)) + || (payload.inputs?.cadence ?? "manual") !== "manual") throw new Error("Invalid manual dispatch evidence authority"); + const requested = canonicalPrivateManualIds(values(payload.inputs?.id)); + const selected = canonicalPrivateManualIds(JSON.parse(env.MANUAL_PRIVATE_IDS ?? "null")); + if (!selected.length || selected.join() !== requested.join() || selected.some(id => !MANUAL_PRIVATE_EXECUTIONS.includes(id))) + throw new Error("Manual dispatch cells differ from the trusted selection"); + const identity = { workflowSha: env.GITHUB_SHA!, targetSha: env.TARGET_SHA!, runId: env.GITHUB_RUN_ID!, attempt: "1", executionIds: Object.freeze(selected) }; + const authoritySha256 = createHash("sha256").update(JSON.stringify({ authority, ...identity, actor })).digest("hex"); + // The dispatcher vouches for the target branch, so its launcher is recorded + // rather than compared with a separately approved hash. + return Object.freeze({ ...identity, authority, protocol: MANUAL_PRIVATE_PROTOCOL, launchBinding: "dispatcher" as const, + authoritySha256, approvalSha256: authoritySha256, private: true as const }); +} + +export function manualDispatchEvidenceAuthority(env: NodeJS.ProcessEnv) { + const eventPath = env.GITHUB_EVENT_PATH; + if (!eventPath) throw new Error("Missing manual dispatch event"); + const stat = lstatSync(eventPath); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 65536) throw new Error("Invalid manual dispatch event custody"); + return authorizeManualDispatch(env, JSON.parse(readFileSync(eventPath, "utf8"))); +} diff --git a/tests/runner-e2e/manual-private-transfer.ts b/tests/runner-e2e/manual-private-transfer.ts index 2f92800d4d8..f0a65165ca6 100644 --- a/tests/runner-e2e/manual-private-transfer.ts +++ b/tests/runner-e2e/manual-private-transfer.ts @@ -12,12 +12,17 @@ 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"; 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 = env.PAPERCLIP_TESTOPS_CHAT_MAIN_ADMISSION !== undefined ? shippedChatEvidenceAuthority(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 ?? ""); + 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) { @@ -30,13 +35,15 @@ async function prepare(env: NodeJS.ProcessEnv) { 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")); - if (hash(launch) !== approval.launchSha256) throw new Error("Target evidence launcher differs from protected approval"); + 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, executionIds: [id], root, status: "pending" }), { mode: 0o600, flag: "wx" }); + 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) {