Immutable privileged workflow executor (same-repository trust boundary)
Status
Section titled “Status”Accepted — this ADR records the target design for the auto-PR trust boundary and supersedes the same-run trust rationale of ADR 0002. It is documentation only; the workflow/action changes land in Task 1.2+ of the repository remediation plan.
The ingress trade-off in Open Question for Maintainer has been resolved: Option A (workflow_dispatch manual baseline) + Option B (scheduled default-branch discovery) as the default automatic mode, with Option C (repository_dispatch external event bridge) documented as an opt-in for adopters who need seconds-latency. This is a maintainer decision, not a re-derivation from the research below — see that section for the reasoning it was based on.
Context and Problem Statement
Section titled “Context and Problem Statement”auto-pr generates PR titles/bodies from conventional commits on ai/** branches. Today a single push-triggered entry workflow (auto-pr.yml) calls two reusable workflows: an unprivileged generate job (auto-pr-generate-reusable.yml, contents: read) that checks out the pushed branch and, via auto-pr-set-pkg, derives a package ref github:knirski/auto-pr#<pushed-branch>; and a privileged create job (auto-pr-create-reusable.yml, pull-requests: write, mints a GitHub App token from APP_ID/APP_PRIVATE_KEY) that installs and executes that branch-derived package ref.
The design in ADR 0002 treated the split-into-two-reusable-files boundary as a trust boundary. It is not. It was chosen to satisfy CodeQL’s file-level analysis, and it does that, but CodeQL passing is not the same as the boundary being sound.
The defect. For a push event, GitHub Actions evaluates the workflow definition from the pushed branch’s own commit — including auto-pr.yml, the reusable-workflow refs it names, and every permissions: block. A same-repository attacker who can push an ai/** branch therefore controls, in one revision they author: (a) the entry workflow that claims to separate privilege, (b) the permissions: blocks of both phases, and (c) the package ref the privileged create job installs and runs. The privileged job installs bun add "$AUTO_PR_PKG" (which runs the package’s prepare/build scripts) and then executes it with a pull-requests: write GitHub App token. This is a full same-repository privilege escalation to arbitrary code execution under the App identity. The auto_pr_pkg validation in the create workflow (must start with github:knirski/auto-pr) does not help: the attacker rewrites that validation step in their branch copy of the file, because that file too runs from their branch.
The root cause is structural, not a coding bug in any one step: a push-triggered workflow cannot be a trust boundary against the pusher, because the pusher supplies the workflow definition. No amount of job-level permissions: hardening inside a push-triggered file fixes this.
Problem. Establish an ingress and executor design where the privileged, App-token-holding workflow definition is guaranteed to come from a ref that a same-repository branch author cannot influence (the default branch), and where nothing that job installs, checks out, or executes is selectable by the untrusted branch.
Decision Drivers
Section titled “Decision Drivers”- Threat model is same-repository untrusted: any actor who can push an
ai/**branch (or open/update a PR from a same-repo branch) is untrusted with respect to the App token. knirski/auto-pris a public, personal-account repository (verified viagh api repos/knirski/auto-pr:visibility: public,owner.type: User,default_branch: main). This constrains which enforcement primitives are available (see Research findings, points 6–7).- The project currently advertises “push to
ai/**→ PR appears automatically.” Any fix that removes push as the trusted entry point has a real product cost that must be named, not hidden. - Adopting repos copy these workflow files via
auto-pr-init; whatever pin/update mechanism the executor needs must work for adopters, ideally reusing the existing self-referential pin automation (ADR 0004).
Considered Options
Section titled “Considered Options”The correctness requirements (below, “Decision Outcome”) are firm. The genuinely open trade-off is how automatic generation is triggered once a branch-defined push workflow can no longer be the trusted entry point. Three ingress options are viable for this repo:
- Option A —
workflow_dispatch-only (manual) baseline. Generation is started manually (UI/API) against a default-branch workflow definition. Zero new infrastructure. Latency: whenever a human/automation triggers it. Adopter burden: none beyond copying files. New blast radius: none. - Option B — Scheduled default-branch discovery. A
schedule(cron) job, defined on the default branch, polls forai/**branches lacking an up-to-date PR and generates for them. No external component. Latency: up to the cron interval (GitHub’s minimum is 5 minutes and scheduled runs are best-effort/often delayed under load, so effective latency is frequently 10–30+ minutes). Adopter burden: copy one more workflow; no hosting. New blast radius: none (all default-branch-controlled). - Option C —
repository_dispatchevent bridge. An external GitHub App / webhook receiver listens forpushtoai/**and issues arepository_dispatchthat starts a default-branch workflow definition. Latency: seconds (preserves today’s UX). Adopter burden: high — the adopter must run/host (or trust a shared hosted) App + webhook receiver holding a dispatch-capable credential. New blast radius: the bridge itself. If compromised it can trigger the privileged executor with an attacker-chosen payload — but because the executor (per decisions 5–7 below) validates the head SHA, installs only a maintainer-pinned executor SHA, and never checks out or installs anything the payload names, a compromised bridge can at most cause a PR to be opened/updated for a ref, not achieve code execution under the App token. It nonetheless converts “copy some YAML” into “operate a network service,” which is a material change in the project’s distribution model. - Option D — Administrator-enforced Actions “workflow execution protection” / required workflows. Rejected as unavailable for this repo. GitHub’s required-workflows / repository-ruleset enforcement that would pin a trusted workflow definition regardless of trigger is an organization-level feature; it is not available on a personal-account repository. It cannot be relied on here and is not offered to adopters generically.
For the ingress trade-off, see Open Question for Maintainer. Everything else below is decided.
Decision Outcome
Section titled “Decision Outcome”Adopt an immutable privileged executor design in which the privileged workflow definition and everything it runs are controlled solely by the default branch. Concretely (mapping to the remediation plan’s recommended-decision points 1, 3–9; point 2 is deferred to the Open Question):
-
(1) Remove the branch-defined
pushworkflow as a claimed trust boundary. No workflow definition resolved from a pushed branch may hold secrets,pull-requests: write, or any other privileged capability. Apush-triggered file may still exist as a convenience producer of data (see decision 3) feeding a downstream privileged workflow — this is not, by itself, a re-import of the defect, becauseworkflow_run’s definition is always evaluated from the default branch regardless of what triggered the upstream run (decision 4). What must never happen is a privileged action executing from a workflow definition the branch supplies. -
(3) The generate phase holds no long-lived secrets, gets a read-only token imposed from outside branch content, checks out the requested head SHA explicitly, and produces data only. It must not receive
APP_ID/APP_PRIVATE_KEYor any environment secret. Its token iscontents: read(plusmodels: readfor GitHub Models) granted by a definition the branch cannot edit for the privileged phase. It checks out an explicit head SHA (not a mutable ref name) and its only output is the PR-content artifact. This phase may still bepush-triggered (decision 1) — its definition being branch-resolved is harmless precisely because it is secret-free and produces only data that decision 5 treats as untrusted input. -
(4) A second, default-branch-controlled privileged workflow runs after successful generation.
workflow_runis acceptable for this transition regardless of what triggered the upstream generate run (including a branch-resolvedpush), because GitHub always evaluates aworkflow_run-triggered workflow’s definition — code,permissions:, and secrets access — from the default branch (GITHUB_SHA= last commit on default branch,GITHUB_REF= default branch), never from the triggering run’s ref. What the privileged workflow inherits from the trigger is not trust in a workflow definition, but untrusted input data (theworkflow_runpayload and the generate job’s artifact) — and decisions 5–7 exist precisely to validate and constrain that data before it can influence anything privileged.workflow_runmust not, however, be used to chain two privileged workflows together, nor may its payload be trusted without the validation in decision 5: the guarantee is about which code runs, not about what the upstream run claims. -
(5) The privileged workflow downloads only the artifact from the exact triggering run ID and validates everything before use. Use
actions/download-artifactwithrun-id: ${{ github.event.workflow_run.id }}+ agithub-token(never “latest matching name”). Validate:github.event.workflow_run.repository.full_name== this repo;github.event.workflow_run.eventis the expected producer event;head_branchmatches the expectedai/**pattern;head_shaequals the artifact’s recorded SHA and still equals the current tip of that branch at execution time (re-resolve via API to reject a force-push race);conclusion == 'success'; artifact name and expected file set (title.txt,body.md,branch.txt,default_branch.txt) match; and reject anything unexpected. All of these come from the workflow_run payload the branch cannot forge. -
(6) The executor package is pinned to a literal, full 40-character trusted SHA. The artifact, the source branch, and the generate job must not be able to select what code the privileged job installs/runs.
auto-pr-set-pkg’s branch-derivedgithub:knirski/auto-pr#<branch>ref must not flow into the privileged job. The privileged executor installs a maintainer-controlled, SHA-pinned build only. -
(7) The privileged workflow never checks out or installs the triggering branch. No
actions/checkoutof the head ref, nobun addof a branch-derived ref, no execution of branch-supplied scripts. It consumes validated data + the pinned executor only. -
(8) Every App-secret consumer runs in a protected environment restricted to trusted/default-branch refs;
APP_PRIVATE_KEYis removed from repository secrets. MoveAPP_ID/APP_PRIVATE_KEYfrom repository secrets into a GitHub Actions environment whose deployment branch policy admits only the default branch. Because a job cannot read environment secrets until the environment’s protection rules pass, and the deployment branch policy is matched againstGITHUB_REF, a job running from a pushedai/**branch (GITHUB_REF= that branch) is denied the App secret outright. The protected environment is mandatory, not a fallback. Its admin-bypass toggle (Allow administrators to bypass configured protection rules) must be disabled — the repo’s existinggithub-pagesenvironment currently reportscan_admins_bypass: true, so this must be set explicitly for the new environment. -
(9) If GitHub-plan limitations prevent enforcement, fail setup closed and document the required external broker/policy.
auto-pr-init(and CI self-checks) must detect when the protected-environment branch restriction cannot be enforced (e.g. a private repo on GitHub Free, where environments are unavailable) and refuse to configure the automatic privileged path, directing the operator to a supported ingress. Do not silently weaken the same-repository-untrusted threat model.
Open Question for Maintainer
Section titled “Open Question for Maintainer”Resolved. Which automatic-generation ingress does the project adopt (recommended-decision point 2)? This was a product/ops trade-off with no single correct answer, and it changes the advertised “automatic on push within seconds” promise.
Decision (confirmed by maintainer): A as firm baseline + B as the default automatic mode, with C documented as an opt-in for adopters who need seconds-latency. Option D remains rejected as unavailable.
- Firm baseline (decided): the portable, always-available entry is a default-branch
workflow_dispatch(Option A). Every adopter gets this regardless of plan/visibility. - Default “automatic” experience (decided): Option B (scheduled default-branch discovery). It restores near-automatic behavior with no external infrastructure and no adopter hosting burden, and its entire definition lives on the default branch (fully inside the trust boundary). The honest cost is latency: not “seconds” but up-to-cron-interval (realistically 10–30+ min) — the README/marketing claim of “within seconds” must be revised to “within minutes” for this mode.
- Option C (repository_dispatch bridge) is documented as an opt-in only, not the default, for adopters who genuinely need seconds-latency and accept operating a GitHub App/webhook service. It reintroduces an external trusted component and converts distribution from “copy YAML” to “run a service”; its blast radius is bounded by decisions 5–7, but it is still net-new attack surface and operational cost.
- Option D (org-enforced workflow execution protection) is not available to this personal-account public repo and cannot be offered to adopters generically; do not design around it.
Task 1.2+ implements A and B. C is documented (Task 1.4 adopter docs) as an advanced opt-in, not built by default in this workstream.
Consequences
Section titled “Consequences”- Good: the App token is never reachable from a same-repository branch author — the privileged definition, its permissions, its executor code, and its secret access are all default-branch-gated and independently validated.
- Good: reuses existing primitives —
workflow_run(platform-guaranteed default-branch evaluation), protected environments (branch-policy secret gating), SHA pinning (ADR 0004), anddownload-artifactrun-id scoping. - Bad: loses “automatic on push within seconds” as the default; the baseline is manual
workflow_dispatchand the default automatic mode is scheduled discovery with minutes-scale latency. Marketing copy must change. - Bad / operational: adopters must create a protected environment and move
APP_PRIVATE_KEYinto it;auto-pr-initmust guide this and fail closed where it cannot be enforced (decision 9). More setup than “paste one YAML file.” - Operational safeguard (decision 9): because GitHub silently auto-creates a referenced environment with no protection rules rather than erroring, a documented live-repository settings check (
scripts/check-app-credentials-environment.sh) verifies viagh apithat the live environment actually has admin-bypass disabled, a custom deployment-branch policy limited to exactly the default branch, and both App secrets present. It is a manual check (needsgh+ network, so it is not part of the sandboxed test suite/CI) to be run after any environment change and before removing repository-level App secrets. See docs/INTEGRATION.md Step 5. - Neutral: CodeQL’s file-level concern (ADR 0002) remains satisfied — the privileged workflow still performs no untrusted checkout — but the reason the design is safe is now the trust properties above, not CodeQL passing.
Research findings
Section titled “Research findings”Each finding is grounded in current official GitHub documentation (URLs inline) and, where relevant, this repo’s actual settings via gh api.
-
Workflow-file revision per trigger.
push(same-repo): the workflow definition is taken from the pushed branch commit itself, including workflows not on the default branch — so a pushed branch supplies its ownauto-pr.ymlandpermissions:blocks. This is the crux of the defect. (events-that-trigger-workflows)workflow_run: the triggered workflow only runs if the file exists on the default branch, and it runs from the default branch —GITHUB_SHA= “Last commit on default branch”,GITHUB_REF= “Default branch”. A pushed branch cannot substitute this file. (events-that-trigger-workflows)- Reusable workflow call
uses: owner/repo/.github/workflows/x.yml@ref: the@refdetermines the called version (SHA “safest option for stability and security”; a same-name tag beats a branch). For apush-triggered caller, the caller file is the pushed-branch revision, so its@refvalues are attacker-chosen too. (reuse-workflows)
-
Default-branch-only ingress options.
workflow_dispatch(manual/API),repository_dispatch(external event bridge), andscheduleall run definitions resolved from the default branch. There is no personal-repo “workflow execution protection” that pins a trusted definition across arbitrary triggers — required-workflows/rulesets enforcement is organization-scoped. (Trade-offs enumerated in Considered Options; events-that-trigger-workflows) -
When repository vs environment secrets become available. Environment secrets are available only to jobs that reference the environment, and a job cannot access them until all the environment’s protection rules pass. The deployment branch policy is matched against
GITHUB_REF, so a job from a non-matching branch is blocked before secret access — the mechanism that keepsAPP_PRIVATE_KEYunreachable from anai/**branch (decision 8). Plain repository secrets, by contrast, are available to any workflow run in the repo (including from a pushed branch) — which is exactly why the current repository-scopedAPP_PRIVATE_KEYis unsafe. (manage-environments, deployments-and-environments) -
Context values on the privileged (
workflow_run) trigger.GITHUB_SHA/GITHUB_REFpoint at the default branch (see finding 1). Trust the triggering run via the payload, not the ambient ref:github.event.workflow_run.head_sha/.head_branch(the producing run’s commit/branch),.head_repository.full_name(reject cross-repo),.event(producer trigger type),.id(run id for artifact scoping),.conclusion(must besuccess; other terminal values includefailure,cancelled,timed_out). Re-resolvehead_shaagainst the branch tip at execution to reject a force-push swap. (contexts reference, events-that-trigger-workflows) -
Downloading an artifact from the exact triggering run.
actions/download-artifacttakesrun-id(default${{ github.run_id }}) andgithub-token; when a token is supplied,run-id+repositoryselect which run’s artifacts are fetched — setrun-id: ${{ github.event.workflow_run.id }}to bind to the triggering run rather than “latest matching name.” (actions/download-artifact) -
Artifact retention & rerun. Default retention is 90 days, configurable (repo/org setting, or per-upload
retention-days). Re-running a workflow produces a new run attempt; artifacts are scoped per run, so the executor must bind to a specificrun-id(finding 5) rather than assume a single artifact. (download-workflow-artifacts) -
Plan/visibility limitations (verified for
knirski/auto-pr).gh api repos/knirski/auto-pr→visibility: public,private: false,owner.type: User,default_branch: main. Environments (with deployment branch policies, required reviewers, wait timers) are available for public repositories on all plans; for private repos they require GitHub Pro/Team/Enterprise. So the protected-environment control in decision 8 is available to this public repo for free. Caveats: (a) “prevent self-review” and multi-reviewer gating are weak on a single-owner personal repo (there is effectively no second reviewer), so the deployment branch policy — not required reviewers — is the load-bearing control; (b) admin bypass is a per-environment toggle (can_admins_bypass); the existinggithub-pagesenvironment reportscan_admins_bypass: true, so the new environment must explicitly disable it; (c)maincurrently has no branch protection (gh api .../branches/main/protection→ 404), so a “Protected branches only” deployment policy would misbehave — use an explicit “Selected branches” policy naming the default branch, or add branch protection first. (manage-environments) -
Proving a same-repo branch cannot replace the privileged definition or elevate its token. Two independent guarantees compose: (i) a
workflow_run/workflow_dispatch/schedule/repository_dispatchdefinition is evaluated only from the default branch and only if present there (finding 1) — a branch commit cannot substitute the file or itspermissions:; and (ii) reusable-workflow permissions “can only be maintained or reduced — not elevated — throughout the chain,” so even a called component cannot widen the token. Combined with the protected-environment branch gate (finding 3), a branch author can neither redefine the privileged workflow nor reach the secret it uses. (events-that-trigger-workflows, reuse-workflows) -
Adopter delivery of the trusted executor SHA. The existing
update-workflow-pinsautomation (ADR 0004,update-pins.sh) rewrites onlyuses: knirski/auto-pr/<path>@<40-char-sha>lines (reusable workflows + composite actions) to a single shared SHA; it does not touchgithub:knirski/auto-pr#<ref>npm-style package refs (auto-pr-set-pkg, the create job’sbun add). Consequence: if the immutable executor is delivered as a SHA-pinneduses:reusable workflow or composite action, the existing pin mechanism and itscheck_onlyvalidation extend naturally and adopters get it for free. If instead the executor stays an npm-stylegithub:package ref, that ref is outside the pin updater’s regex and would need a new pin/update mechanism. Recommendation for Task 1.2+: deliver the executor as a SHA-pinned callable so it rides the existing automation rather than inventing a parallel pinning path.
References
Section titled “References”- ADR 0002 — Two-phase auto-PR workflow (superseded trust rationale)
- ADR 0004 — Workflow pin automation
- Repository remediation plan (Task 1.1)
- docs/WORKFLOW_SECURITY.md, docs/CI.md — Workflow pin automation
- GitHub docs cited inline above (events-that-trigger-workflows, reuse-workflows, manage-environments, deployments-and-environments, contexts, download-artifact).