GitHub Actions PR Gate for AI-Generated Code
AI-generated code needs a pull-request gate.
Not because AI code is always bad. Because AI tools make it cheap to produce large, plausible diffs that touch code, tests, dependencies, and workflow files at the same time.
A reviewer can miss the important part:
- an auth guard removed during a route cleanup
- a tenant filter dropped from a query
- a validation schema replaced with a looser type
- a new package added for a tiny helper function
- a GitHub Actions workflow changed to run with broader permissions
- a realistic-looking secret committed as an example
The right answer is not "ban AI-generated code." The right answer is a GitHub Actions PR gate for AI-generated code that treats AI-assisted PRs like any other high-speed contributor: useful, but not trusted until checked.
This guide gives you a practical workflow.
The short version
Use this model:
| Layer | Purpose | Should it block? |
|---|---|---|
| Local scan | Catch obvious issues before the PR exists | Developer choice |
| GitHub Actions PR gate | Check changed code, secrets, CI YAML, dependencies, and removed controls | Yes, for high-confidence findings |
| Human review | Review product behavior, architecture, and intent | Yes |
| Optional cloud history | Track trends, exceptions, evidence, and repeated findings over time | Team choice |
The most important GitHub Actions rule is simple:
Do not run untrusted PR code in a privileged workflow.
For most AI-generated PRs, use pull_request, not pull_request_target.
GitHub's own hardening guidance warns that pull_request_target and workflow_run can expose the repository when they are used with untrusted pull request code. GitHub also documents that values from the github context, including pull request titles, bodies, branch names, and similar event fields, should be treated as untrusted input because they can lead to script injection.
Sources:
- GitHub Actions secure use reference
- GitHub Actions script injection docs
- GitHub GITHUB_TOKEN docs
- GitHub OIDC docs
What makes AI-generated PRs different?
Traditional CI usually asks:
Does the code build and do tests pass?
That is not enough for AI-assisted changes.
AI-generated pull requests often fail in ways that build systems do not see:
| Failure mode | Why tests miss it |
|---|---|
| Removed auth check | Happy-path tests still pass for valid users |
| Removed tenant filter | Single-tenant fixtures do not expose cross-tenant reads |
| Weaker validation | Valid sample payload still passes |
| Missing timeout | Unit tests use fast local mocks |
| Hallucinated helper | The name looks project-native but has no real implementation |
| New dependency | Lockfile changed, but nobody reviewed install scripts or package trust |
| Workflow permission change | App tests pass while CI token scope gets wider |
So the PR gate needs to inspect both:
- What the new code contains
- What the old code used to protect
That second part is the AI-specific failure mode.
The safe baseline workflow
This is the shape of a safer AI PR security gate in GitHub Actions:
name: AI PR security gate
on:
pull_request:
branches: [main]
permissions:
contents: read
jobs:
skylos:
name: Scan changed code
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install Skylos
run: python -m pip install --upgrade skylos
- name: Scan changed code
run: |
skylos . -a --diff "origin/${{ github.base_ref }}" --json > skylos-report.json
- name: Annotate findings
if: always()
run: |
skylos cicd annotate --input skylos-report.json --max 50
- name: Enforce gate
run: |
skylos cicd gate --input skylos-report.json --summary
- name: Upload report artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: skylos-report
path: skylos-report.json
if-no-files-found: error
retention-days: 30
That workflow is intentionally boring.
It runs on pull_request. It grants contents: read. It does not access production secrets. It does not deploy. It does not ask a model to decide whether the PR is safe. It runs deterministic checks and fails the gate when the findings are strong enough.
You can also generate a starter workflow from the CLI:
pip install skylos
skylos cicd init
If you want uploaded scan history in Skylos Cloud later:
skylos cicd init --upload
The local-first path matters. Run the scanner on one repository first. Do not design a company-wide workflow before you know which findings are useful on your codebase.
Why not use pull_request_target?
pull_request_target is not automatically wrong. It is sharp.
It runs in the context of the base repository, not the pull request branch. That can be useful when you need to label a PR, comment on it, or run trusted automation that does not execute the proposed code.
It becomes dangerous when you combine it with untrusted code execution.
Bad pattern:
on:
pull_request_target:
permissions:
contents: write
pull-requests: write
jobs:
unsafe:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: npm install
- run: npm test
This pattern checks out and runs pull request code inside a privileged workflow context. A malicious or compromised branch can modify package scripts, test commands, build tools, or repository config so that the privileged workflow executes attacker-controlled code.
Safer pattern:
- use
pull_requestfor untrusted code execution - keep
contents: readunless you truly need more - separate "test untrusted code" from "write comments or labels"
- do not pass repository secrets into untrusted PR jobs
If you need comments, prefer GitHub annotations first. If you need full PR review comments, scope permissions narrowly:
permissions:
contents: read
pull-requests: write
Only add that when the job really posts comments.
What the AI PR gate should check
1. Removed security controls
This is the highest-value check for AI-assisted PRs.
Look for deleted or weakened:
- auth middleware
- role checks
- tenant filters
- ownership checks
- validation schemas
- rate limits
- CSRF protection
- CORS restrictions
- audit logs
- timeout handling
Example:
- user: User = Depends(require_user)
db: Session = Depends(get_db)
return (
db.query(Customer)
.filter(Customer.id == customer_id)
- .filter(Customer.tenant_id == user.tenant_id)
.one()
)
The route still works. The test may still pass. The tenant boundary is gone.
Your PR gate should make that kind of change visible before review.
2. Secrets and realistic placeholders
AI tools often generate realistic-looking examples:
OPENAI_API_KEY = "sk-proj-..."
STRIPE_WEBHOOK_SECRET = "whsec_..."
DATABASE_URL = "postgres://admin:password@localhost:5432/app"
The gate should block committed tokens, high-entropy strings, private keys, and suspicious credentials even when they appear in tests or examples.
3. Dependency changes
AI agents like adding packages.
Sometimes that is fine. Sometimes it is a new supply-chain risk for a helper function that should have been ten lines of local code.
Review:
- new package names
- version bumps
- lockfile changes
- install scripts
- new transitive dependency size
- packages added only by generated code
The gate should not blindly reject every new dependency. It should force the dependency change to be seen.
4. GitHub Actions workflow changes
Workflow files are privileged code. AI-generated changes to .github/workflows/*.yml deserve the same review as application code.
Block or review:
pull_request_targetadded to a workflow- broad
permissions: write-all - missing
permissionsblock - unpinned third-party actions
persist-credentials: truewhen not neededid-token: writein jobs that run repo-controlled scripts- secrets used in PR-triggered jobs
- untrusted GitHub context expanded directly into
run:
GitHub's script-injection guidance is especially relevant for AI-generated workflow edits. Pull request titles, branch names, issue bodies, and similar fields are attacker-controlled input.
Risky:
- name: Check PR title
run: |
title="${{ github.event.pull_request.title }}"
echo "Checking $title"
Better:
- name: Check PR title
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
python scripts/check_pr_title.py "$PR_TITLE"
Using an environment variable does not magically make everything safe, but it avoids directly splicing untrusted expression output into a shell script.
5. Generated tests that only prove the happy path
AI-generated tests often confirm that the new code works for the ideal case.
For security-sensitive routes, require negative tests:
- unauthenticated request fails
- unauthorized role fails
- cross-tenant access fails
- invalid input fails
- rate-limited request fails
- export without permission fails
- revoked token fails
If the PR touches auth, billing, exports, admin actions, invitation flow, webhooks, secrets, or integrations, happy-path tests are not enough.
Local-first version before CI
Before putting a new gate into branch protection, run it locally on a few real branches.
pip install skylos
skylos . -a --diff origin/main
If the signal is too noisy, tune thresholds and ignores before making the gate blocking.
Then generate CI:
skylos cicd init
Then make the gate required only after the team trusts the findings.
That rollout sequence matters:
- local scan
- advisory CI
- blocking CI for high-confidence findings
- optional Cloud history for teams that need trends, exceptions, and evidence
Do not start with a blocking company-wide rollout if you have not reviewed the findings on one repo.
What to block vs what to warn on
Not every finding should fail the PR.
Use this split:
| Finding | Gate behavior |
|---|---|
| Committed secret | Block |
| Removed auth, tenant scoping, validation, or rate limit | Block |
| Dangerous GitHub Actions trigger or broad write permission | Block or require security review |
| New vulnerable dependency | Block |
| New high-confidence injection, SSRF, path traversal, command execution | Block |
| Dead code in changed files | Warn first, then block if the team agrees |
| Complexity or maintainability debt | Warn or track |
| Low-confidence style issue | Do not block |
The goal is not to make CI angry. The goal is to prevent expensive misses while keeping developer trust.
Where Skylos fits
Skylos is useful here because the PR gate needs more than one scanner category.
For AI-generated code, you want one workflow that can check:
- security findings
- secrets
- dead code
- technical debt hotspots
- CI workflow risk
- hallucinated imports or AI-code mistakes
- changed-code regressions
- removed controls
Run it locally:
skylos . -a --diff origin/main
Use it in CI:
skylos cicd init
Use Cloud later only when the team needs history, compare, exceptions, exports, or PR evidence.
Skylos should not replace human review. It should remove the machine-checkable risks from the reviewer's plate before the PR reaches main.
Final checklist
Before you let AI-generated PRs merge, make sure your GitHub Actions gate does this:
- runs on
pull_requestfor untrusted PR code - grants
contents: readby default - avoids repository secrets in untrusted PR jobs
- avoids
pull_request_targetunless no untrusted code is executed - pins important third-party actions where practical
- disables persisted checkout credentials unless needed
- scans changed code, not only the full final tree
- scans secrets and dependency changes
- scans workflow files as privileged code
- blocks removed auth, tenant scoping, validation, rate limits, and dangerous sinks
- keeps low-confidence findings advisory
- requires human approval for security-sensitive changes
The practical rule is simple:
Let AI write code. Do not let AI bypass the merge gate.