Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 32 additions & 33 deletions .github/workflows/ai-review-reusable.yml
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
# Adversarial AI review: once every CI run for the PR head has finished and the
# Malicious Code Scan has passed (see malicious-code-scan-reusable.yml), Claude
# and Codex take turns reviewing the PR, fixing CI failures and other issues,
# and committing fixes until one of them approves without changes. This
# workflow finds the PR and queues its reviews; ai-review-run.yml does the
# review itself, split across runners so the agents never share one with a
# token that can write (see that file).
# Adversarial AI review: once every CI run for the PR head has finished, the PR
# is scanned for malicious changes (malicious-code-scan/malicious_code_scan.py)
# and, if the scan passes, Claude and Codex take turns reviewing the PR, fixing
# CI failures and other issues, and committing fixes until one of them approves
# without changes. This workflow finds the PR and queues its reviews;
# ai-review-run.yml does the scan and the review itself, split across runners so
# the agents never share one with a token that can write (see that file).
#
# Called from workflow-templates/ai-review.yml, which a repository copies in and
# triggers on workflow_run (once per completed CI workflow) and on
# workflow_dispatch (by hand). The gate lets only the run that sees all CI
# finished go ahead, and waits there for a Malicious Code Scan still running.
# The scan does not start the review itself. workflow_run only uses the
# caller's copy on its default branch, and the scripts and prompt come from this
# repository, so a PR cannot change how it is reviewed.
# finished go ahead. workflow_run only uses the caller's copy on its default
# branch, and the scripts, scanner and prompt come from this repository, so a PR
# cannot change how it is scanned or reviewed.
#
# Blocking scan findings stop the review and are reported on the PR. A maintainer
# (write access or above) accepts them by adding the `malicious-scan-override`
# label and running the review by hand (a later CI completion for that commit also goes ahead);
# a new push is scanned again.
#
# Secrets:
# ANTHROPIC_API_KEY - Claude API key (required)
# ANTHROPIC_API_KEY - Claude API key, for the review and the scan (required)
# OPENAI_API_KEY - Codex / OpenAI API key (required)
# SONAR_TOKEN - SonarQube token that can browse the project (optional; only a private
# project needs it). The gate collects the PR's SonarQube findings from
Expand All @@ -24,18 +28,17 @@
# contents:write and pull-requests:write on this repository and no
# workflows permission, so GitHub itself refuses a push that changes a
# workflow. Without it the review still runs and comments, but fixes are
# not published: a GITHUB_TOKEN push or PR triggers neither CI nor the
# Malicious Code Scan, so the required scan status would never report on
# the new head and it could not merge until someone pushed again.
# not published: a GITHUB_TOKEN push or PR does not trigger CI, so the
# fixes would never be checked or reviewed.
#
# By default (fix_mode: pull_request) the fixes go on a new branch, ai-review/pr-<number>-<run id>,
# with a PR from it into the PR's branch, so the author decides whether to take them; a newer fix
# PR closes the one it supersedes. CI failures on a fix PR are fixed on its own branch. With
# fix_mode: push the fixes are committed straight onto the PR's branch.
#
# The caller must grant the job actions: read, checks: read, contents: read, pull-requests: write
# and statuses: read (without checks: read, SonarQube findings are left out). Add the `skip-ai-review` label to a PR to opt out. A manual run
# fails when it cannot review, so it is not mistaken for a review that passed.
# The caller must grant the job actions: read, checks: read, contents: read and pull-requests: write
# (without checks: read, SonarQube findings are left out). Add the `skip-ai-review` label to a PR to
# opt out. A manual run fails when it cannot review, so it is not mistaken for a review that passed.
#
# Third party actions are pinned to a full commit SHA, because a tag can be moved
# to point at different code. The comment after each pin records the tag it was.
Expand Down Expand Up @@ -71,7 +74,7 @@ on:
type: string
default: ""
claude_model:
description: Claude model (default claude-opus-5-5)
description: Claude model, for the review and the scan (default claude-opus-5-5)
required: false
type: string
default: ""
Expand All @@ -90,21 +93,19 @@ on:
required: false
type: string
default: ""
scan_workflow_name:
description: Name of the caller's Malicious Code Scan workflow, which the gate waits on through its status instead of as CI
required: false
type: string
default: Malicious Code Scan
scan_wait_minutes:
description: How long the gate waits for a scan still running on the PR head once CI is done (default 10)
scan_project_description:
description: What the repository is, for the malicious code scan's Claude review (default names the repository)
required: false
type: string
default: ""
scan_status_context:
description: Commit status the Malicious Code Scan reports, which must be success
scan_generated_paths:
description: >-
Committed build output, as git :(glob) patterns one per line (e.g. docs/assets/**), for the malicious
code scan to check only with high-signal rules and leave out of its Claude review; minified files always
are. Keep it narrow: a PR chooses its file paths, and nothing listed here should run in CI.
required: false
type: string
default: security/malicious-code-scan
default: ""
sonar_project_key:
description: SonarQube project key whose PR findings the reviewers fix (default is taken from the SonarQube Cloud check run)
required: false
Expand Down Expand Up @@ -200,7 +201,6 @@ jobs:
checks: read
contents: read
pull-requests: write
statuses: read
# A uses: ref cannot be an expression; keep this on the ref callers use, as with shared_ref
uses: OpenC3/.github/.github/workflows/ai-review-run.yml@main
with:
Expand All @@ -213,9 +213,8 @@ jobs:
codex_model: ${{ inputs.codex_model }}
claude_max_budget_usd: ${{ inputs.claude_max_budget_usd }}
codex_sandbox: ${{ inputs.codex_sandbox }}
scan_workflow_name: ${{ inputs.scan_workflow_name }}
scan_wait_minutes: ${{ inputs.scan_wait_minutes }}
scan_status_context: ${{ inputs.scan_status_context }}
scan_project_description: ${{ inputs.scan_project_description }}
scan_generated_paths: ${{ inputs.scan_generated_paths }}
sonar_project_key: ${{ inputs.sonar_project_key }}
sonar_wait_minutes: ${{ inputs.sonar_wait_minutes }}
fix_mode: ${{ inputs.fix_mode }}
Expand Down
89 changes: 70 additions & 19 deletions .github/workflows/ai-review-run.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
#
# gate - decides whether to review and collects failed CI logs and SonarQube
# findings (read-only token)
# scan - scans the PR for obfuscated code, prompt injection, and other
# malicious changes (ai-review/ai_review_scan.sh); blocking findings stop
# the review before any agent sees the PR. The PR is never checked out or
# run here: its commits are fetched and read with git diff as data. Do not
# add steps that run code from the PR.
# review - Claude and Codex take turns in throwaway containers with no network
# but an API proxy holding the turn's key (ai-review/ai_review_loop.sh).
# The job's token can only read the repository, and the runner's own
Expand Down Expand Up @@ -59,18 +64,14 @@ on:
required: false
type: string
default: ""
scan_workflow_name:
required: false
type: string
default: Malicious Code Scan
scan_wait_minutes:
scan_project_description:
required: false
type: string
default: ""
scan_status_context:
scan_generated_paths:
required: false
type: string
default: security/malicious-code-scan
default: ""
sonar_project_key:
required: false
type: string
Expand Down Expand Up @@ -108,14 +109,13 @@ jobs:
gate:
name: Wait for CI and collect failures
runs-on: ubuntu-latest
# Leaves room for the wait on the scan (SCAN_WAIT_MINUTES) and collecting CI logs
# Leaves room for the wait on SonarQube (SONAR_WAIT_MINUTES) and collecting CI logs
timeout-minutes: 30
permissions:
actions: read
checks: read
contents: read
pull-requests: read
statuses: read
outputs:
skip: ${{ steps.gate.outputs.skip }}
pr: ${{ steps.gate.outputs.pr }}
Expand All @@ -135,9 +135,7 @@ jobs:
with:
repository: OpenC3/.github
ref: ${{ inputs.shared_ref }}
sparse-checkout: |
ai-review
malicious-code-scan
sparse-checkout: ai-review
path: shared
persist-credentials: false

Expand Down Expand Up @@ -167,9 +165,6 @@ jobs:
FORCE: ${{ inputs.force }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
REVIEW_WORKFLOW: ${{ github.workflow }}
SCAN_WORKFLOW: ${{ inputs.scan_workflow_name }}
SCAN_CONTEXT: ${{ inputs.scan_status_context }}
SCAN_WAIT_MINUTES: ${{ inputs.scan_wait_minutes || '10' }}
MAX_CI_ROUNDS: ${{ inputs.max_ci_rounds || '3' }}
SONAR_PROJECT_KEY: ${{ inputs.sonar_project_key }}
SONAR_WAIT_MINUTES: ${{ inputs.sonar_wait_minutes || '10' }}
Expand All @@ -189,11 +184,67 @@ jobs:
# A re-run of the job replaces the earlier attempt's
overwrite: true

review:
name: Review
scan:
name: Malicious code scan
needs: gate
if: needs.gate.outputs.skip == 'false'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
pull-requests: write # say on the PR why it was not reviewed, and remove a stale override label
outputs:
passed: ${{ steps.scan.outputs.passed }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1
with:
egress-policy: audit

# The trusted scanner
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: OpenC3/.github
ref: ${{ inputs.shared_ref }}
sparse-checkout: |
ai-review
malicious-code-scan
path: shared
persist-credentials: false

# Base branch only, for its history; the PR is fetched into it as data
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.gate.outputs.base_ref }}
path: repo
fetch-depth: 0
persist-credentials: false

- name: Install uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
version: "0.12.5"
python-version: "3.12"

- name: Scan
id: scan
working-directory: repo
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ needs.gate.outputs.pr }}
HEAD_SHA: ${{ needs.gate.outputs.head_sha }}
SCANNER: ${{ github.workspace }}/shared/malicious-code-scan/malicious_code_scan.py
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
SCAN_CLAUDE_MODEL: ${{ inputs.claude_model }}
SCAN_PROJECT_DESCRIPTION: ${{ inputs.scan_project_description }}
SCAN_GENERATED_PATHS: ${{ inputs.scan_generated_paths }}
run: bash ../shared/ai-review/ai_review_scan.sh

review:
name: Review
needs: [gate, scan]
if: needs.gate.outputs.skip == 'false' && needs.scan.outputs.passed == 'true'
runs-on: ubuntu-latest
# The loop stops its turns after its TIME_LIMIT_MINUTES (75 by default), leaving time for the other steps
timeout-minutes: 90
# Read-only: the agents run on this runner, and a push happens only in the publish job
Expand Down Expand Up @@ -296,8 +347,8 @@ jobs:

publish:
name: Publish
needs: [gate, review]
# Also after a failed review, so the PR hears about it
needs: [gate, scan, review]
# Also after a failed review, so the PR hears about it (a blocked scan has said so itself)
if: >-
!cancelled() && needs.gate.outputs.skip == 'false' &&
needs.review.result != 'skipped' && needs.review.outputs.stale != 'true'
Expand Down
Loading
Loading