Protect Coding-Agent CI From Forked Pull Requests

Last reviewed: November 5, 2026; primary documentation and Security Lab guidance checked November 5, 2026 UTC.

Direct answer

Run code from a forked pull request in an unprivileged pull_request workflow. If a result must be written back to the repository, use a separate, narrowly scoped workflow_run job that reads a small, validated artifact and does not check out or execute the fork’s files. Keep the privileged job’s token permissions and secrets limited to the one write it needs. This gives a coding agent useful CI feedback while keeping untrusted repository content away from credentials and write-capable automation.

GitHub’s current workflow event reference says that pull_request runs forked code with read-only GITHUB_TOKEN permissions and without repository secrets, while pull_request_target runs in the base repository context. The same page warns that running untrusted code with pull_request_target can enable cache poisoning or unintended access to secrets and write privileges. GitHub’s secure-use guidance therefore recommends avoiding privileged triggers for untrusted checkout and treating artifacts from those runs cautiously. The GitHub Security Lab’s preventing pwn requests guidance documents the two-workflow pattern: build the pull request under pull_request, store a result, then let a privileged workflow_run workflow perform a small repository operation.

The boundary is about data and capabilities, not about whether the pull request author is familiar. A fork can contain modified build scripts, tests, package lifecycle hooks, action references, or files that a coding agent is asked to inspect. If those bytes execute in a job that can read a signing key or write a branch, the agent has inherited the job’s authority.

Who this is for

This guide is for maintainers who let coding agents inspect, test, summarize, or propose changes from external forks. It assumes GitHub Actions is already part of the pull request path and focuses on the boundary between a job that executes proposed code and a job that can write to the repository. It does not replace application review, dependency review, or a security scan.

Key takeaways

  • Run fork code under pull_request with read-only permissions and no repository secrets.
  • Keep pull_request_target and workflow_run jobs away from untrusted checkout and executable artifacts.
  • Pass only a bounded, revision-bound result between stages.
  • Give the privileged job the smallest job-level permission for one fixed API operation.
  • Use disposable runners and rerun the boundary when the pull request head changes.

Sources checked

The GitHub workflow event reference supports the event semantics and fork restrictions described here. GitHub’s secure-use reference supports the least-privilege, artifact, action-dependency, and runner guidance. The first-party GitHub Security Lab preventing pwn requests article supports the two-stage artifact handoff and the warning against privileged checkout. The two-stage design, schema, and test matrix below are implementation recommendations; adapt them to the repository’s actual API, runner, and review controls.

Treat the fork as executable input

A pull request supplies more than the lines shown in a diff. The checkout can change the test command, dependency graph, generated files, or scripts invoked by the runner. A normal install can execute package hooks before the test command starts. A prompt assembled from an issue or pull request can also contain instructions that try to redirect the agent. Keep the repository prompt-injection defenses in Defend Coding Agents From Prompt Injection next to this workflow; both treat external text as data that cannot grant a new capability.

Start by writing the trust contract in the workflow itself:

  • The pull_request job may read the repository, run tests, and upload a bounded result.
  • It has no deployment credentials, no write permission, and no path that turns an artifact into a script.
  • The workflow_run job may read the result and perform one approved operation, such as posting a status or comment.
  • It must not execute binaries, scripts, package hooks, or generated code from the fork.
  • Every new privilege or artifact field requires a review of the contract.

This contract is more useful than a broad instruction to “be careful.” A reviewer can compare each permission, checkout, command, and artifact path with the stated boundary.

Contract details to verify

Before enabling the workflow, write down the contract that the two stages must satisfy. Verify the actual rendered workflow and run metadata rather than relying on the YAML intent alone:

  • Trigger and revision: The test job is triggered by pull_request, records the base repository, pull request number, head SHA, and run ID, and starts a new run when the head changes.
  • Execution boundary: The unprivileged job checks out only the proposed revision and runs the repository’s test command on a disposable runner. It has no deployment credentials, environment secrets, or write-capable token.
  • Artifact schema: The first job emits one small, versioned result with bounded strings and numbers. It contains no shell text, paths to execute, binaries, source bundle, environment dump, or raw untrusted log.
  • Privileged consumer: The second job listens only for the expected workflow and event, downloads the artifact for the exact run ID, extracts it outside the workspace, validates every field, and performs one fixed API operation.
  • Permission delta: The first job has only the read permission it needs. The second job names its required actions: read and write permission explicitly at job scope; no repository-wide write default is used.
  • Staleness and failure: A mismatched head SHA, wrong workflow, malformed artifact, canceled run, or failed test stops the handoff. None of those states silently becomes approval.
  • Action and runner supply chain: Action references are locked or pinned according to repository policy, their source is reviewed, and the runner cannot retain files or credentials for another untrusted job.

Capture the checks as sanitized evidence: workflow names, event, run ID, head SHA, artifact schema version, effective permissions, runner class, decision, and timestamp. This record lets a reviewer distinguish a safe refusal from a successful privileged write and makes a later workflow change auditable.

Stage 1: test the pull request without privilege

Use pull_request for the workflow that checks the proposed code. Set an explicit top-level permission baseline and raise it only when a job has a documented need. A minimal shape is:

name: Test pull request

on:
  pull_request:
    types: [opened, synchronize, reopened]

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@FULL_COMMIT_SHA
      - run: ./ci/test
      - name: Write bounded result
        run: ./ci/write-result --output result/summary.json
      - uses: actions/upload-artifact@FULL_COMMIT_SHA
        with:
          name: pr-result
          path: result/summary.json

The example is a contract sketch, not a copy-and-paste guarantee. Pin action references according to your repository policy, use current action versions you have reviewed, and make the test command explicit. The result writer should emit a small schema such as a test status, count, duration, and an identifier for the pull request head. It should not copy logs, source files, environment variables, or arbitrary text into the artifact.

Run the test in a clean, disposable runner. GitHub warns that self-hosted runners can be persistently compromised by untrusted workflow code and should almost never be used for public repositories. If a coding agent needs a self-hosted environment for a private repository, treat that as a separate risk decision with an isolated runner, restricted network, and no unrelated credentials.

Do not add a secret to the first job “just for a test.” Fork restrictions are useful only when the workflow does not create a side channel around them. If a test genuinely needs a service credential, redesign the test around a fixture or move the credentialed operation behind an approval-controlled boundary.

Stage 2: make one narrow privileged handoff

A workflow_run workflow can receive the completion event for the unprivileged job and use a write-capable token for a specific repository action. GitHub’s Security Lab example stores a pull request number in an artifact, downloads it into a temporary directory, validates it as an integer, and comments on the pull request. That pattern matters because the privileged workflow receives a different event context and because artifact contents remain untrusted.

Keep the second stage small:

  1. Check that the triggering workflow is the expected test workflow.
  2. Check that its event was pull_request and that its conclusion is the one your policy allows.
  3. Download only the named artifact for the exact run identifier.
  4. Extract outside the workspace that could contain executable files.
  5. Parse a strict schema and validate ranges, identifiers, and allowed states.
  6. Call one fixed API operation with a job-level permission such as pull-requests: write.

Do not check out the pull request head in this job. Do not run a command read from the artifact. A status string can be safe to display after length and character validation; a shell fragment, path, serialized object with executable hooks, or binary is not an acceptable input to a privileged action. If the handoff needs a coverage report, pass numbers and a link to a protected artifact rather than asking the privileged job to open generated HTML or run a reporter from the fork.

Bind the result to the correct revision. Record the base repository, pull request number, head SHA, triggering run ID, artifact name, and schema version in the unprivileged result. Before posting, compare the head SHA in the result with the pull request head you are about to update. If the pull request changed while the two workflows were running, discard the stale result and wait for the new run.

Permissions, actions, and secrets

Use the smallest token permission at each stage. The first job normally needs contents: read; the second may need actions: read to download the artifact and pull-requests: write to post a comment or label. Avoid a repository-wide write default. GitHub’s secure-use guidance recommends read-only defaults and job-level increases, and says to audit the source code of actions that can see repository content or secrets.

Treat every third-party action as code with the job’s authority. Prefer a workflow dependency lockfile when your platform supports it. Otherwise pin actions to a full commit SHA from the intended repository, verify the SHA, and review updates. A pinned action can still be given too much authority, so pairing pinning with least privilege matters more than either control alone.

Keep cloud credentials out of both stages unless the operation truly needs them. For deployments or cloud APIs, GitHub recommends short-lived, well-scoped OpenID Connect credentials where supported. Environment protection and required reviewers can place a human decision before a job receives an environment secret, but that review does not make a job safe to execute untrusted code. The job still needs a trusted checkout and a controlled input boundary.

For a broader evidence gate after the workflow is safe, use Gate Coding-Agent Pull Requests With Security Scans and SARIF Evidence . A scanner result is a separate check; it does not authorize a privileged checkout.

Test the boundary, not just the happy path

Exercise the workflow with a harmless fork fixture and confirm each case produces the intended owner and status:

  • A normal fork pull request runs tests with no secrets and a read-only token.
  • A modified test or package hook cannot read a secret, push a branch, or reach an unrelated service.
  • A malformed artifact is rejected before the privileged API call.
  • An artifact from the wrong workflow or run ID is rejected.
  • A stale head SHA cannot update the current pull request.
  • A pull request update creates a new result rather than reusing an earlier one.
  • A privileged job never checks out, imports, or executes the fork.
  • A canceled, failed, or timed-out test has an explicit outcome rather than an implicit approval.

Keep sanitized evidence: workflow name, run ID, head SHA, artifact schema version, permission set, decision, and timestamp. Do not retain prompts, full event payloads, secret values, or untrusted logs in the handoff record.

Failure modes

Using pull_request_target to run the agent’s tests. This gives the job base-repository context and is intended for safe operations such as labeling or commenting. Checking out and running the fork turns that privilege into an execution path for attacker-controlled code. Move execution to pull_request and leave the privileged job for the fixed API operation.

Trusting an artifact because the upload succeeded. An artifact can be validly uploaded and still contain attacker-controlled content. Define a small schema, validate every field, and keep the privileged consumer from executing it.

Downloading into the workspace. A file from an artifact can shadow a script or be picked up by a later step. Extract into a temporary directory outside the checkout and read only the expected file.

Using a broad write token to simplify comments. Narrow the permission to the single API operation. If a comment does not need contents write, do not grant it.

Running the first job on a shared self-hosted runner. Untrusted code can persist beyond the job or inspect data left by another job. Use an ephemeral isolated runner or a hosted runner appropriate to the repository’s exposure.

Posting a result for a newer revision. Bind artifacts to the head SHA and run ID. A successful old test is not evidence for a changed pull request.

FAQ

Is pull_request always safe?

It provides important fork restrictions, including read-only token permissions and no repository secrets, but the job still executes untrusted code. Keep the runner disposable, limit network and permissions, and avoid placing unrelated credentials in the environment.

When is pull_request_target appropriate?

Use it only for operations that do not execute pull request code, such as a carefully scoped label or comment workflow. GitHub’s current guidance says to avoid it when you need to build or run the pull request.

Can the privileged workflow trust a coverage file?

Treat it as untrusted input. Parse a strict, bounded format and use values only for the approved operation. Never execute a reporter, open a generated page as code, or treat a path from the artifact as a command.

Do environment reviewers solve the problem?

They add an approval gate for environment secrets, but they do not make a privileged checkout safe. Keep untrusted execution in the unprivileged stage and make the approval correspond to the exact, fixed operation.

Reader next step

Create two test workflows in a disposable repository: one pull_request workflow that runs a harmless fork fixture and writes a tiny JSON result, and one workflow_run workflow that validates that result and posts a fixed comment. Inspect the rendered permissions, runner type, checkout ref, artifact path, and head-SHA comparison. Then add a branch-protection requirement using the same check names used by the merge path. Once the boundary passes, attach the sanitized run record to your normal review handoff checklist and repeat the exercise when a workflow, action reference, permission, or artifact schema changes.