Private repository and fork PR safe mode
Issue: #42
Goal
BoardReadyOps treats private-repository pull requests, fork pull requests, and draft pull requests as higher-risk execution contexts without making private same-repository development unusable.
Safe-mode triggers
The normalized lifecycle action records these reasons in deterministic order:
draft-pull-requestfork-pull-requestprivate-repository
Safe mode is enabled when one or more reasons apply.
Dispatch policy
| Context | Workflow dispatch | Release-run terminal state |
|---|---|---|
| Public, same-repository, ready PR | Dispatch normally | Runner callback decides |
| Private, same-repository, ready PR | Dispatch with safe-mode metadata | Runner callback decides |
| Fork PR | Do not dispatch privileged workflow | completed / neutral |
| Draft PR | Do not dispatch privileged workflow | completed / neutral |
Private repositories are not skipped solely because they are private. The central readiness workflow receives validated safe-mode metadata and must avoid privileged operations for that run.
Fork and draft skips complete both the database release run and the GitHub check with the same completion timestamp. Webhook retries can repair an incomplete GitHub check without redispatching a terminal run.
Workflow inputs
The GitHub App passes:
safe_mode: exactlytrueorfalsesafe_mode_reasons: a comma-separated list from the three allowed reasons
The client rejects unknown reasons, an enabled flag without a reason, and reasons supplied while safe mode is disabled. It deduplicates reasons and emits them in the canonical order above.
The workflow independently validates:
- release run ID as a lowercase UUID,
- target as a bounded GitHub-style
owner/repositoryidentifier, - head SHA as a full lowercase 40-character commit SHA,
- safe-mode flag/reason consistency,
- reason allow-list and duplicate reasons,
- exact production callback URL for the release run.
Invalid input fails the runner preparation step. The OIDC-authenticated callback records an error result when the callback identity and run binding remain valid.
Callback trust binding
The GitHub Actions callback cryptographically binds the dispatch trust mode and canonical safe-mode reason list into its OIDC audience and carries the same values in dedicated request headers. Before OIDC verification and result persistence, the callback route reloads the current release run and execution attempt, then requires those headers to exactly match the persisted immutable trust snapshot. Missing, changed, duplicated, or reordered trust metadata fails closed with the same generic authentication response as other callback-binding failures.
Retry and idempotency behavior
The lifecycle store returns the existing run status with the idempotent enqueue result.
- A queued run with an existing check may retry workflow dispatch after a transient dispatch failure.
- A dispatched, running, or terminal run is not dispatched again.
- A completed fork/draft run may re-complete its neutral GitHub check to repair a prior API failure.
- Workflow dispatch no longer writes a dispatch identifier into the readiness
decisionfield.
Runner expectations
When safe mode is enabled, runners must:
- avoid privileged writes unless explicitly authorized by repository policy,
- avoid exposing private artifacts,
- never make installation or repository secrets available to untrusted fork code,
- disable repository-provided plugins and notifier dispatch before analysis starts,
- suppress Action artifact uploads, SARIF publication, and pull-request comments even if requested by configuration,
- treat repository content as untrusted input,
- prefer advisory findings unless policy explicitly enables enforcement,
- preserve artifact and log isolation between installations.
Artifact publication boundary
A self-hosted runner may still analyze a private same-repository checkout in safe mode, but it does not request artifact upload capabilities or upload generated files. The terminal result preserves bounded findings, readiness, waiver, and numeric metric evidence while publishing an empty artifact list. Numeric metrics record generated, uploaded, and suppressed artifact counts, and the runner emits only the run ID, execution-attempt ID, aggregate count, and canonical safe-mode reasons in runner.artifacts.suppressed telemetry.
Standard-mode runs continue to publish declared artifacts through lease-bound, single-use upload capabilities. Draft and fork snapshots remain ineligible for runner leases, so untrusted fork code never reaches the artifact transport path.
Current implementation slice
The implementation provides detection, immutable trust snapshots, guarded runner-lease binding, validated workflow inputs, callback trust binding, fail-safe dispatch policy, terminal skip consistency, retry behavior, and safe-mode artifact suppression. Policy evaluation and public Marketplace hardening remain separate runtime work.
Durable trust-mode evidence
Every newly enqueued release run snapshots its execution trust mode before any Check Run or workflow effect is created:
standardrecords an empty safe-mode reason set.saferecords the canonical reason set (draft-pull-request,fork-pull-request, and/orprivate-repository).- The enqueue transaction emits one tenant-scoped
release_run.trust_mode_selectedaudit event for the new run; idempotent webhook replays do not create duplicate events. - The run investigation dashboard shows the persisted trust mode and reasons next to the exact source and runtime metadata.
- Queued and terminal GitHub Check Runs, plus readiness comments, show the persisted trust mode, canonical reasons, and the restrictions actually enforced for that execution. Safe-mode terminal output states that managed evidence artifacts were unavailable for that execution; self-hosted runner telemetry separately proves artifact suppression and forced workspace cleanup. Fork/draft skips state that no runner, managed artifact, or result-callback authority was granted.
The snapshot is historical evidence. Later repository visibility or pull-request state changes do not rewrite it.
Runner lease binding
Runner claims use the persisted release-run trust snapshot rather than recalculating safe mode from current repository visibility. Draft and fork snapshots are excluded before any execution attempt or lease is created, closing the race between runner polling and neutral safe-mode completion. Private same-repository runs remain eligible and receive the exact persisted private-repository reason even if repository metadata later changes. The runner.lease.claimed audit event records only the trust mode and canonical reason identifiers; it does not include secrets or source content.