Write Access Isn’t a Run Button: The GitHub Actions Trigger Allowlist for Agent Identities
GitHub Actions workflow execution protections decide which CI runs an agent can start. Strip edit and dispatch rights, map every cell, then drill each one.
Go deeper. Build your own.
A coding agent’s overnight branch changes one line in package.json, so the test script now runs a helper first. Nobody touches .github/workflows/. The push workflow runs npm test on the branch as written, with the repository’s secrets in its environment. The merge gate reviews the diff in the morning, after the run has happened.
Until Sep 17, GitHub’s rule for who may start that run fit in one line: “every user with write access to a repository can trigger workflows.” GitHub Actions workflow execution protections, generally available since Sep 17, 2026, replace that default with an allowlist of actors and events you can target per workflow file. For a fleet of agents pushing as Apps, bots and tokens, it is the first native place to write down which CI runs each agent identity may start.
The move for Tuesday starts underneath the allowlist: strip workflow-edit and dispatch rights from agent tokens as two drilled steps, then write an agent trigger matrix whose every cell names its enforcing control. Encode its allow and deny cells as policies, drill each identity, time a kill switch, and list agent workflows on pull_request_target in public repositories before Nov 2.
Sep 17: GitHub Actions workflow execution protections reach GA
On Sep 17, 2026, GitHub’s changelog made workflow execution protections generally available for enterprises, organizations and repositories, after a public preview that opened Jun 18. The feature lets you “define an allowlist that controls who can trigger an Actions workflow and what events can start it.” In the post’s own words, “Actor rules cover the who, event rules cover the what, and actions evaluate both before a run.”
Per GitHub’s About Actions policies page, actor rules can name users, repository roles, GitHub Apps, Copilot and Dependabot, and event rules cover push, pull_request, pull_request_target, workflow_dispatch and others. GA added workflow-file targeting, so “a single repository can apply different policies to different workflows”, plus insights with an evaluate mode and a REST API. The how-to for controlling workflow execution marks Evaluate “GitHub Enterprise Cloud only”, layers policies from enterprise to organization to repository, and is blunt: “Non-allowed actors will not be able to run the specified workflows at all.”
The post also adds a default aimed at “Pwn Request” attacks, where fork code in a pull_request_target workflow can “poison your pipeline and exfiltrate secrets.” Public repositories with no event policy get a rule that disables pull_request_target, and “This default does not apply to private or internal repositories.” It starts in evaluate mode. Then: “On November 2, 2026, we’ll automatically enforce the default rule for affected repositories that were using the default pull_request_target policy before general availability.”
Screenshot: GitHub Changelog, “Workflow execution protections in GitHub Actions generally available” (Sep 17, 2026), captured Sep 21, 2026.
All of this lands on an approval patchwork keyed to which token opened the pull request. Copilot coding agent workflows “do not run until approved by a human with the Approve and run workflows button,” and since Mar 13 admins can switch that off per repository. PRs that a workflow creates or updates with GITHUB_TOKEN produce runs “in an approval-required state,” per GitHub’s trigger docs; since Jun 11 such bot PRs can run at all, once someone with write access approves. A PR created or updated by automation with a GitHub App installation token or a PAT runs its pull_request workflows “without the approval prompt described above.” Same agent, three answers; the token decides.
An agent’s push is a run request with the secrets attached
GitHub’s secure-use reference states the stakes: anyone with write access can read every secret configured in the repository. For a human collaborator, that is a trust decision someone made. For an agent, it is a side effect of the push access it needed.
Editing a workflow file is the obvious attack, and Step 0 drills it. The common path needs no workflow edit. An agent that changes a test script, a package.json script or a Makefile has changed what an existing, secret-bearing push workflow executes, and that workflow runs on the push.
Merge-time controls such as the overnight merge gates for agent PRs and the CODEOWNERS and secret-scanning gates in agentic CI/CD judge the change after CI has already run it. So does GitHub’s Sep 9 rule, in public preview, that blocks merging pull requests with unresolved secret-scanning alerts.
The fix-loop guards scope the agent’s own token, and the headless trust tier decides who may trigger an agent run. This runbook covers the inverse: which CI runs an agent identity can start, and what they reach. It assumes one GitHub App per lane, as in the service-principal playbook for agent identity. With one shared identity, every row of the matrix collapses into one.
Step 0: Remove edit rights and dispatch rights as two separate drills
Workflow edits and dispatch are separate permissions on Apps and fine-grained tokens, so remove and drill them one at a time.
Edit rights. Take the workflows permission off every agent GitHub App and fine-grained token, and the workflow scope off every classic PAT an agent holds. GitHub’s permission table for Apps files the writes that touch workflow files under “Workflows”: PUT and DELETE on contents, git ref writes and release writes. The drill: from the agent’s runtime, with its production token, push a branch that changes a file under .github/workflows/. The push must be rejected.
Dispatch rights. Take Actions write off every agent App and fine-grained token. The dispatch endpoint, POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches, sits under repository “Actions” write for Apps and fine-grained tokens alike. The drill: gh workflow run with the agent’s token must fail. Classic PATs are the exception. The dispatch reference says “OAuth tokens and personal access tokens (classic) need the repo scope to use this endpoint,” so any classic PAT holding repo can dispatch any workflow that declares workflow_dispatch. Move PAT agents to an App or a fine-grained token, or deny them workflow_dispatch by policy in Step 3.
Admin rights. The Actions policies API needs Administration write, even for a GET. An agent token that can read your policies can also rewrite them. No lane holds one.
| Token type | Edit right to remove | Dispatch right to remove | The drill must show |
|---|---|---|---|
| GitHub App, one per lane | “Workflows” permission | “Actions” write | workflow-file push rejected; gh workflow run refused |
| Fine-grained PAT | “Workflows” write | “Actions” write | same as an App |
| Classic PAT | workflow scope |
none separate: repo alone can dispatch |
push rejected; a dispatched run fails only once a policy denies the event |
| Any agent token | never grant “Administration” write | not applicable | GET on the policies API refused |
# edit-dispatch-drill.sh: illustrative. Run inside the agent lane's runtime with the
# token that lane receives in production, against a scratch repo nothing depends on.
# The scratch repo holds one workflow that declares workflow_dispatch; git must
# authenticate with the same token (credential helper or remote URL).
set -u
REPO="your-org/trigger-drill"
WF="drill-dispatch.yml"
export GH_TOKEN="$AGENT_TOKEN"
# 1. Edit: a push that touches .github/workflows/ must be rejected.
git switch -c "drill/wf-edit-$(date +%s)"
echo "# drill $(date -u +%FT%TZ)" >> ".github/workflows/$WF"
git commit -qam "drill: workflow edit from agent identity"
git push -q origin HEAD && echo "FAIL edit: push accepted" || echo "PASS edit: push rejected"
# 2. Dispatch: creating a workflow_dispatch event must fail.
gh workflow run "$WF" --repo "$REPO" && echo "FAIL dispatch: run created" || echo "PASS dispatch refused"
# 3. Admin: the policies API needs Administration write; an agent token must not read it.
gh api "repos/$REPO/actions/policies" > /dev/null && echo "FAIL admin: policies readable" || echo "PASS admin refused"
Run it for every lane now and after every token rotation, and keep the exact error each refused call returns.
Step 1: Write the agent trigger matrix, and name the control in every cell
The matrix is a checked-in file the platform team owns, reviewed like any change to .github/workflows/.
Rows are the identities agents push or dispatch as. Five kinds cover most fleets:
- Copilot, which actor rules name directly.
- One GitHub App per lane, so a review agent and a dependency agent get different cells.
- PAT agents. The docs don’t say how a PAT-authenticated push is attributed for actor rules, so test whether a policy sees the agent or the token’s human owner.
github-actions[bot], for pull requests your own workflows open.- Dependabot. GitHub’s own features are exempt, but bot identities such as
dependabot[bot]must be added as allowed actors when a custom workflow needs them.
Columns are workflows grouped by reach, crossed with the event: no secrets, repository secrets, environment secrets, OIDC deploy roles, each split by push, pull_request, pull_request_target and workflow_dispatch.
Every cell gets a verdict (allow, approval-required or deny) and its enforcing control. Execution protections only allow or block; they have no ask. So an approval-required cell means the run may start and the job holding the secret runs in an environment with required reviewers, because “A workflow job cannot access environment secrets until approval is granted by a reviewer.” An approval cell that names no environment is an allow.
The approval prompt is real but narrow. It holds Copilot coding agent runs until an admin turns it off, and runs from GITHUB_TOKEN-created PRs, but never a PR that automation opened with an App token or PAT. Never make it the only control on a secret-bearing column.
Identity, push event (illustrative) |
No secrets | Repo secrets | Environment secrets | OIDC deploy role |
|---|---|---|---|---|
| Copilot coding agent | allow · EP | deny · EP | approval · env reviewers | deny · EP |
| Lane App: review agent | allow · EP | deny · EP | approval · env reviewers | deny · EP |
| PAT agent, attribution untested | deny · replace with an App | deny · replace with an App | deny · replace with an App | deny · replace with an App |
github-actions[bot] |
allow · EP + approval prompt | deny · EP | approval · env reviewers | deny · EP |
dependabot[bot] |
allow · EP, listed actor | deny · EP | deny · EP | deny · EP |
EP is an execution-protection policy, and workflow_dispatch is deny for every agent row. The file looks something like this:
# ci/agent-trigger-matrix.yaml: illustrative shape. Owned by the platform team;
# reviewed like .github/workflows/. EP = execution-protection policy.
identities:
copilot: { kind: copilot }
review-agent: { kind: app, app: acme-review-agent } # one App per lane
deps-agent: { kind: app, app: acme-deps-agent }
legacy-pat-agent: { kind: pat, owner: a-human-login, attribution: untested }
actions-bot: { kind: bot, login: "github-actions[bot]" }
dependabot: { kind: bot, login: "dependabot[bot]" }
workflow_groups:
no-secrets: [.github/workflows/lint.yml, .github/workflows/unit.yml]
repo-secrets: [.github/workflows/integration.yml]
env-secrets: [.github/workflows/release.yml] # release job declares environment: release
oidc-deploy: [.github/workflows/deploy.yml] # deploy job declares environment: production
cells:
- { who: [copilot, review-agent, deps-agent, dependabot], group: no-secrets, event: [push, pull_request], verdict: allow, control: EP }
- { who: [copilot, review-agent, deps-agent], group: env-secrets, event: [push], verdict: approval, control: "EP allow + environment release with required reviewers" }
- { who: agents, group: [repo-secrets, oidc-deploy], event: all, verdict: deny, control: EP }
- { who: agents, group: all, event: [workflow_dispatch], verdict: deny, control: "EP + Step 0 token scopes" }
- { who: legacy-pat-agent, group: all, event: all, verdict: deny, control: "replace with an App before any policy relies on it" }
Step 2: Move secrets out of reach of runs an agent can start
Two gates in series. The allowlist decides whether the run starts; the environment decides whether a job gets its secrets.
Repository secrets sit between the two gates, open to any job in a run that started, which makes the repo-secrets column of the matrix the one that matters most.
For each workflow an agent identity can start, list the secrets it reads. Delete what it doesn’t need. Move what it needs only for a release, deploy or publish step into an environment with required reviewers, as a separate job that declares that environment.
Set the default GITHUB_TOKEN to read-only, as the secure-use reference advises, and grant write per job. Treat an OIDC deploy role the same way: assume it only inside a reviewed environment, never in the job that runs the agent’s test script.
Then re-sort the columns. A workflow that no longer reads repository secrets moves to the no-secrets column and can stay open to agent pushes. Most of this runbook’s risk reduction happens here, before a single policy exists.
Step 3: Encode the allow and deny cells as GitHub Actions workflow execution protections
Only the allow and deny cells become policies. Approval cells are the environment settings from Step 2.
The REST reference for Actions policies gives the fields: name; enforcement, one of disabled, active or evaluate; rules, holding restrict_actions_actors and restrict_action_events; and conditions, using repository_name, repository_id, repository_property or workflow_path. Actor types are User, Bot, Team and App. Three rules keep the translation honest.
Target every policy by workflow file. The reference says why: “Omitting workflow_path targets all workflows without storing an explicit condition.”
Split before you deny dispatch. Per the how-to, “If you also restrict events, these users will only be able to trigger workflows with the allowed events.” A policy’s allowed actors get only its allowed events. If one workflow file takes agent pushes and human dispatches, allowing both events lets agents dispatch, and allowing only push takes dispatch away from the humans too. Split it into a push workflow agents may start and a dispatch workflow only humans may start, then deny workflow_dispatch to agent identities on the second.
Shadow it where you can. Run a week in evaluate mode if you have GitHub Enterprise Cloud; elsewhere, put one repository in active and run the Step 4 drill the same day. Whether non-enterprise organizations can view policy insights is disputed, so check your own org first.
Apply the policy files from a human-owned pipeline that holds the Administration-write credential, never from a lane.
# policies/agent-trigger.yaml: illustrative intent file. A human-owned pipeline
# with Administration write translates each entry into the REST fields
# (name, enforcement, rules, conditions) and applies it.
- name: release-deploy-humans-only
scope: organization
enforcement: evaluate # Enterprise Cloud only; elsewhere one repo in active first
conditions:
workflow_path: [.github/workflows/release-dispatch.yml, .github/workflows/deploy.yml]
allowed_actors: [team:release-managers]
allowed_events: [push, workflow_dispatch]
- name: tests-agents-may-start
scope: organization
enforcement: evaluate
conditions:
workflow_path: [.github/workflows/lint.yml, .github/workflows/unit.yml]
allowed_actors: [team:engineering, app:acme-review-agent, app:acme-deps-agent, copilot, "bot:dependabot[bot]"]
allowed_events: [push, pull_request]
- name: kill-switch-agents-off-sensitive
scope: organization
enforcement: disabled # a human flips this to active (Step 5)
conditions:
repository_property: { sensitivity: high }
allowed_actors: [team:engineering]
allowed_events: [push, pull_request, workflow_dispatch]
Step 4: Drill from every agent identity, and write down what you see
A policy you haven’t drilled is a guess. From each agent identity, try each denied cell: push to a branch that triggers a denied workflow, open a PR, dispatch. Add a positive control, a human dispatch that must still run, so you notice when you have denied too much.
Expect a failed run with an error. GitHub documents the text only for event blocks: “Event ‘workflow_dispatch’ is not allowed to trigger Actions workflows. Workflow file: ‘.github/workflows/0-welcome.yml’.” The docs show no text for an actor-rule block, so record the exact error each run gives you rather than matching on a string.
Screenshot: GitHub Docs, “About Actions policies” (undated), captured Sep 21, 2026.
Two tests cover what the docs don’t say. The first finds which identity counts as the actor on a pull request synchronize push, both when an agent pushes to a human’s PR and the reverse. The second finds whether a PAT agent’s run is attributed to the agent or to the token’s owner. Let both answers rewrite the PAT and bot rows.
| Identity | Workflow | Event | Expected | Observed | Error text, verbatim | Date |
|---|---|---|---|---|---|---|
| app: acme-review-agent | integration.yml | push |
blocked | |||
| app: acme-review-agent | release-dispatch.yml | workflow_dispatch |
blocked | |||
| app: acme-review-agent | unit.yml | pull_request synchronize on a human’s PR |
record the actor | |||
| PAT agent | unit.yml | push |
record the actor | |||
| human release manager | release-dispatch.yml | workflow_dispatch |
runs (positive control) |
Re-run the log after every policy change, token rotation or new lane. I’d keep it beside the matrix, so a reviewer sees a cell change and its drill result together.
Step 5: Time an org-level kill switch for agent actors
Write one more organization policy before you need it. It targets every sensitive workflow, by workflow_path or by a repository_property such as a sensitivity tag, allows human actors only, and sits in disabled (the last entry in the Step 3 file). When a lane misbehaves, a person flips it to active and every agent identity drops off those workflows.
Don’t assume the switch is instant. Measure it: minutes from the decision to the first blocked run from an agent identity. In the same drill, prove the switch blocks an agent that another policy allows.
Policies are evaluated before a run starts, so the switch stops new runs, and runs already in flight need a separate cancel. Rehearse it each quarter with nothing at stake, and keep the credential that flips it with a human.
Step 6: Before Nov 2, list agent workflows on pull_request_target in public repos
Every date is from GitHub’s changelog. Nov 2 is scheduled, not yet in force, and covers affected public repositories only.
The default rule reaches only public repositories that were on the default pull_request_target policy, and it blocks only one event, so the inventory is small enough to finish this week. GitHub’s secure-use reference already warns that “The pull_request_target and workflow_run workflow triggers, when used with the checkout of an untrusted pull request, expose the repository to security compromises.” Agent review and triage jobs are prime candidates.
For each public repository:
- List the workflows triggered by
pull_request_target(script below). - Mark the ones that run an agent: review bots, triage lanes, auto-fix jobs.
- Check whether the repository already has an event policy. If it does, the default rule doesn’t apply, and your policy decides.
- Decide per workflow: let the rule block it and move the agent to
pull_requestwith no secrets, or write an explicit event policy that allowspull_request_targetfor that one workflow file and review what it checks out. - Where insights are visible to you, compare your list with what evaluate mode says would fail.
# prt-inventory.sh: illustrative. Lists public repos whose workflow files mention
# pull_request_target, for a human to sort into agent and non-agent workflows.
ORG="your-org"
gh repo list "$ORG" --visibility public --limit 1000 --json name --jq '.[].name' |
while read -r repo; do
gh api "repos/$ORG/$repo/contents/.github/workflows" --jq '.[].path' 2> /dev/null |
while read -r path; do
gh api "repos/$ORG/$repo/contents/$path" --jq '.content' | base64 -d |
grep -q 'pull_request_target' && echo "$repo $path"
done
done
Where GitHub Actions workflow execution protections leak, and the signal for each
The secret-bearing push workflow nobody moved. Signal: an agent commit that changes package.json scripts, a Makefile or a test helper on a branch whose push workflow reads repository secrets. Fix: Step 2 for that workflow, today.
A PAT agent wearing its owner’s identity. Signal: the Step 4 drill from a PAT agent succeeds where the same cell blocks the lane App. Fix: move the agent to an App; if the policy sees the human, no actor rule can tell the two apart.
The untargeted policy. Signal: human runs blocked on workflows you never meant to touch. Cause: a policy with no workflow_path, which targets everything.
The new workflow file. Signal: a workflow file added since the last policy review that no targeted policy names. Fix: a daily diff of workflow files against policy targets, the same coverage assert the guard contract suite runs against hook matchers.
A gate that isn’t there. Execution protections are a guardrail on run starts, not a complete boundary. A policy left in disabled or evaluate, a workflow no targeted policy names, GitHub’s exempt own features and a run that started before the switch flipped all get through. The wall behind the allowlist is Step 0’s tokens that can’t edit or dispatch and Step 2’s secrets that wait for a reviewer. Keep both even when the policies look complete.
Trigger rights belong in the fleet’s identity inventory
The matrix is an inventory record first and a GitHub setting second. Every agent identity should appear in it the day it is created, next to its token scopes and publish rights; the stage-only token classes for publish lanes are the same exercise for npm. A lane that reads untrusted text and can start a secret-bearing workflow holds all three legs of the Rule of Two, and the matrix is where you will see it first.
That view belongs in the command center that runs the fleet, beside the kill switch and the drill log, not on a settings page three clicks into one organization. GitHub now supplies a native allow and block. Deciding which identity gets which run, and proving it still holds after next month’s new lane, stays your job.
FAQ
Can a coding agent with write access trigger GitHub Actions workflows?
Often, yes. Until Sep 17, GitHub’s rule was that every user with write access could trigger workflows, and only some agent tokens hit an approval prompt. Workflow execution protections now let you allowlist the actors and events that may start each workflow file, so you decide which runs each agent identity can start.
Does GitHub’s default pull_request_target rule apply to private repositories?
No. GitHub says the default rule does not apply to private or internal repositories. Automatic enforcement on Nov 2 reaches only public repositories with no event policy that were on the default pull_request_target policy before general availability. Where a public repository has an event policy, the default rule doesn’t apply and your policy decides.
Can a classic personal access token dispatch GitHub Actions workflows?
Yes. GitHub’s dispatch reference says classic personal access tokens need the repo scope, so any classic PAT holding repo can dispatch any workflow that declares workflow_dispatch. There is no separate dispatch right to remove. Move PAT agents to a GitHub App or fine-grained token, or deny them workflow_dispatch with an execution-protection policy.
Sources
- GitHub Changelog: Workflow execution protections in GitHub Actions generally available (Sep 17, 2026)
- GitHub Changelog: Optionally skip approval for Copilot coding agent Actions workflows (Mar 13, 2026)
- GitHub Docs: About Actions policies (rule types, event-block error)
- GitHub Docs: Controlling who can execute GitHub Actions workflows (evaluate mode, layering)
- GitHub Docs: Secure use reference (secrets,
pull_request_target, environments) - GitHub Docs: Triggering a workflow (approval prompt by token type)
- GitHub REST API: Actions policies (policy fields)
- GitHub REST API: Create a workflow dispatch event (classic PAT
reposcope) - GitHub REST API: Permissions required for GitHub Apps (Actions, Workflows)
- GitHub REST API: Permissions required for fine-grained personal access tokens (Actions, Administration)
