Files
paperclip/doc/manual-e2e-private-evidence.md
DottaandClaude Opus 5.5 b7e28eb2f7 fix(evals): let an allowlisted manual dispatch authorize its private evidence (#19599)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Chat-feature evals run in the trusted `runner-full-stack-e2e`
workflow and keep their evidence in private history
> - A manual chat run needs authority to write that private evidence
> - Today that authority is a stored approval for the exact run ID in
`RUNNER_E2E_MANUAL_PRIVATE_APPROVALS_JSON`
> - The run ID exists only after dispatch, so every manual run stops
until a person writes the record
> - The dispatcher is already checked against
`RUNNER_E2E_ALLOWED_ACTOR_IDS`, so the second step adds friction without
a new decision
> - This pull request lets an allowlisted manual dispatch authorize its
own private evidence, as the TestOps admission already does
> - The benefit is that an allowlisted operator can start a chat eval
and it runs, while scheduled runs still never touch chat evidence

## Linked Issues or Issue Description

Refs #18134, Refs #18170

**What existing behavior does this improve?**
A manual `runner-full-stack-e2e` run with chat-feature IDs fails at
private evidence transfer unless a person first adds an approval row for
that run ID to a repository variable.

**Subsystem affected**
Runner E2E manual workflow private evidence authorization
(`manual-private-transfer.ts`, `runner-full-stack-e2e.yml`).

**Proposed change**
Derive the evidence authority from the dispatch itself, with the same
checks the TestOps chat admission uses.

## What Changed

- New `tests/runner-e2e/manual-dispatch-authority.ts`:
`authorizeManualDispatch` admits private evidence only when all of these
are true:
- The event is `workflow_dispatch` of `runner-full-stack-e2e.yml` on the
default branch, with the `manual` cadence.
  - The run attempt is `1`.
- The 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 from the
authorize job, the run ID and the cells. Every job derives the same
identity hash.
- `manual-private-transfer.ts`: uses the TestOps authority first, then
the dispatch authority, then the stored approval. With the dispatch
authority, `prepare` records the target launcher digest instead of
comparing it with an approved digest.
- `runner-full-stack-e2e.yml`: the 14 steps that read
`RUNNER_E2E_MANUAL_PRIVATE_APPROVALS_JSON` now set
`PAPERCLIP_MANUAL_DISPATCH_AUTHORITY: v1` and pass
`RUNNER_E2E_ALLOWED_ACTOR_IDS`.
- `doc/manual-e2e-private-evidence.md`: describes the dispatch
authority. The stored-approval shape stays documented for the TestOps
catalog case workflow.
- The new file is separate on purpose. `manual-private-retention.ts` and
the other protocol files must match each target byte for byte, so
existing candidate branches keep working.

## Verification

- `vitest run tests/runner-e2e/manual-dispatch-authority.test.ts
tests/runner-e2e/manual-private-transfer.test.ts
tests/runner-e2e/manual-private-report.test.ts
tests/runner-e2e/manual-private-report-integration.test.ts
tests/runner-e2e/manual-private-policy.test.ts`: 57 tests passed.
- New tests cover one valid dispatch and rejection of: a scheduled run,
a bounded cadence, a non-allowlisted actor, an empty allowlist, a rerun
by another person, a second attempt, a non-default or different workflow
ref, a sender who is not the actor, a missing target SHA, a missing
authority switch, and cells that differ from the dispatch input. The
eight-cell limit still applies.
- A `prepare` test runs the real transfer script: a non-allowlisted
actor produces no outputs, and an allowlisted dispatch produces the
session policy and an identity that records the target launcher digest.
- Negative control: with the previous `manual-private-transfer.ts`, the
new `prepare` test fails.
- Workflow tests that read this workflow: 111 of 116 passed. The 5
failures in `workflow-target.test.ts` (`IMAGE_ONLY: unbound variable`)
also fail on unchanged `master`. `paid-runner-routing.test.mjs` passed
(7 tests).
- I did not run a live dispatch. The first live proof is a manual
dispatch of chat IDs after merge.

## Risks

- The dispatcher now vouches for the target branch. A target branch with
a changed launcher can write private evidence if an allowlisted operator
dispatches it. The protocol files must still match the trusted copies
byte for byte.
- The eight-cell limit is unchanged. The report job has eight fixed read
slots, and each read policy must fit the 2,048-character session policy
limit. Lifting the limit needs a separate change to the report sessions.
- The `runner-e2e-paid` environment still has required reviewers. That
is a repository setting and this change does not touch it.
- The authority uses `RUNNER_E2E_ALLOWED_ACTOR_IDS`, not `CODEOWNERS`.
Keep that variable aligned with the people who may start paid runs.

> 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

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use and code
execution in Claude Code.

## 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
- [ ] All Paperclip CI gates are green
- [ ] 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 <[email protected]>
2026-11-06 14:26:31 -06:00

10 KiB

Restricted evidence for manual AWS E2E runs

The manual AWS workflow has a separate restricted evidence route. It uses the existing private-history bucket, protected environments, OIDC role and bounded archive transfer. It does not change the scheduled private-history lane.

GitHub Actions artifacts in a public repository are readable by repository readers. They are not an organization-private evidence store. The restricted route never uploads raw cell evidence or a merged report to Actions, S3 public history, or Pages. This stays true on setup failure, launcher failure and report failure. Previously staged files do not enable a public fallback.

Supported selections

An approved original run selects at most eight distinct execution IDs from one fixed registry of the frozen 244 chat scenarios. All use profile runner-opencode and environment local. The 242 catalog tasks retain their exact area.case IDs. The two standalone scenarios retain their existing task IDs: tasks.create-backlog maps to chat-features.runner-opencode.local.create-backlog, and tasks.delegate-announcement maps to chat-features.runner-opencode.local.delegate-announcement. The dotted task 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 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. Selector whitespace and exclusivity follow the catalog producer.

The registry size does not increase the per-run limit or the eight fixed report read slots. Classification, cell binding, every approval row and session policy reject a private selection larger than eight before actionable outputs or archive 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 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 and the selected cell before it accepts canonical static trace files. Default cell evidence keeps its existing unknown-carrier rules. Excluded installed build staging is outside the evidence tree; unknown carriers inside standard evidence paths still fail. A target with stronger ZIP or embedded-report scanning must retain those checks when it adapts this contract. Installed-target identities and runtime evidence remain separate from source-preparation metadata.

Deployment and operator gates

This source change alone does not configure a role, bucket, environment, target, 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.
  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. 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.

Dispatch authority

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):

{
  "version": 1,
  "approvals": [{
    "workflowSha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
    "targetSha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
    "runId": "123456789",
    "attempt": "1",
    "executionIds": ["chat-features.runner-opencode.local.activity.agent-feedback"],
    "protocol": "paperclip.manual-private-evidence/v1",
    "launchSha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
  }]
}

Trust and credential lifetime

The workflow checks out its own immutable github.sha into a separate directory. 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 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 outputs. Transfers require an explicit complete session and disable EC2 metadata fallback. A failed or expired session fails retention. It never enables an Actions upload. Exact-key session policies limit the intended transfer calls. Each current cell uses its own two-object read session; the report writer uses a separate session. Every generated policy is checked against the 2,048-character STS limit before the credential action. No wildcard object access is added.

This is operator-approved target execution, not a sandbox for malicious source. The protected job has OIDC permission. Its target launcher can inherit the GitHub OIDC request environment, and the existing harness copies its process environment into server/native children. A target that can request a new OIDC session may re-assume the role. An inline session policy does not prevent that. Reviewers must approve the complete exact target, not just its evidence flag. This change does not alter IAM, OIDC trust, host roles or credential duration.

Evidence and failure rules

The launcher keeps its ordinary standard evidence in an owned temporary root. It preserves the original framework result before launcher result updates. It also seals final packaged results after cleanup and campaign finalization. The transfer validates exact file inventories and hashes before archiving. It excludes instance databases, workspaces, credential registries, auth files, raw capture directories and arbitrary files outside the standard evidence scope. Free-form launcher console capture is not an approved retained carrier.

The existing credential scanners and PASS PNG, HTML, blob and snapshot checks still apply. Original text, ZIP and media bytes receive those scans before retention. Raw and encoded registered roots and provider-shaped names are also rejected from original paths and inventories. Unknown extensions and SVG files that the ordinary packager does not inspect cannot become complete originals. Files cannot be silently dropped to meet a bound. Credential contamination, unsupported carriers or a changed inventory stop the transfer. Missing required PASS files still convert the attempt to infrastructure FAIL. The private inventory retains all safe available originals and records requiredEvidenceComplete: false and the exact missing list. Its complete field means the available file inventory is complete; it does not certify PASS evidence completeness. Failed framework attempts do not claim that PASS evidence checks ran. No file is synthesized and no secret-bearing original is uploaded just because the bucket is private.

The existing archive bounds remain unchanged: at most 256 MiB of source files, 4,097 archive entries and 264 MiB transferred archive bytes. Duplicated original and packaged files count toward those bounds. A run that exceeds a bound does not have complete retained evidence and needs an explicit disposition.

Private objects use immutable conditional puts and version receipts under runner-e2e-private/bounded-v1/campaigns/gha-RUN_ID-1/. Each cell has a hash-derived object name. The report reads only the approved current cells and verifies each archive against its retained transfer digest. Fresh merged HTML, blob, normalized output and report logs receive a final standard-file scan and sealed inventory with the actual current report-step credential roots, including its explicit AWS session. The report uses the same raw, encoded-root and provider-shape checks before transfer. It retains the scanned report in the same restricted store. A report error stays a report error; retention does not change its grade or manufacture a public page.

Authenticated retrieval uses the existing private-history operator machinery and approved bucket access. This PR does not grant a reader role or perform an upload. Actual deployment, environment approval, compatible target fingerprints, private storage access and one exact run approval remain operational gates.