Files
PaperClipAI/doc/manual-e2e-private-evidence.md
T
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 <noreply@anthropic.com>
2026-11-06 14:26:31 -06:00

181 lines
10 KiB
Markdown

# 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):
```json
{
"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.