From ad5433a03d7b3f0d761083b461b6b59126e22963 Mon Sep 17 00:00:00 2001 From: Ryan Melton Date: Mon, 28 Sep 2026 17:40:24 -0600 Subject: [PATCH] Add Manual Malicious Scan Capability and Decouple auto running of the AI Review --- .github/workflows/ai-review-reusable.yml | 16 +- .github/workflows/ai-review-run.yml | 9 +- .../malicious-code-scan-reusable.yml | 107 +++++--- ai-review/ai_review_gate.sh | 80 ++++-- malicious-code-scan/scan_record.py | 31 ++- tests/test_ai_review.py | 254 ++++++++++++++++-- workflow-templates/ai-review.yml | 8 +- workflow-templates/malicious-code-scan.yml | 21 +- 8 files changed, 420 insertions(+), 106 deletions(-) diff --git a/.github/workflows/ai-review-reusable.yml b/.github/workflows/ai-review-reusable.yml index 493fa3c..c812f19 100644 --- a/.github/workflows/ai-review-reusable.yml +++ b/.github/workflows/ai-review-reusable.yml @@ -8,8 +8,9 @@ # # 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 (which the scan uses when it passes). The gate lets only the -# run that sees everything finished go ahead. workflow_run only uses the +# 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. # @@ -25,7 +26,8 @@ # PR could not merge until someone pushed again. # # The caller must grant the job actions: read, contents: read, pull-requests: write and -# statuses: read. Add the `skip-ai-review` label to a PR to opt out. +# statuses: read. 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. @@ -81,10 +83,15 @@ on: type: string default: "" scan_workflow_name: - description: Name of the caller's Malicious Code Scan workflow, which the gate does not wait on + 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) + required: false + type: string + default: "" scan_status_context: description: Commit status the Malicious Code Scan reports, which must be success required: false @@ -179,6 +186,7 @@ jobs: 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 }} shared_ref: ${{ inputs.shared_ref }} secrets: diff --git a/.github/workflows/ai-review-run.yml b/.github/workflows/ai-review-run.yml index ca48692..da4bbb7 100644 --- a/.github/workflows/ai-review-run.yml +++ b/.github/workflows/ai-review-run.yml @@ -61,6 +61,10 @@ on: required: false type: string default: Malicious Code Scan + scan_wait_minutes: + required: false + type: string + default: "" scan_status_context: required: false type: string @@ -88,7 +92,8 @@ jobs: gate: name: Wait for CI and collect failures runs-on: ubuntu-latest - timeout-minutes: 15 + # Leaves room for the wait on the scan (SCAN_WAIT_MINUTES) and collecting CI logs + timeout-minutes: 30 permissions: actions: read contents: read @@ -145,6 +150,7 @@ jobs: 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' }} OUT_DIR: ${{ runner.temp }}/ai-review run: bash shared/ai-review/ai_review_gate.sh @@ -187,6 +193,7 @@ jobs: registry-1.docker.io:443 auth.docker.io:443 production.cloudflare.docker.com:443 + production.cloudfront.docker.com:443 results-receiver.actions.githubusercontent.com:443 *.blob.core.windows.net:443 diff --git a/.github/workflows/malicious-code-scan-reusable.yml b/.github/workflows/malicious-code-scan-reusable.yml index 5610058..8fbaa93 100644 --- a/.github/workflows/malicious-code-scan-reusable.yml +++ b/.github/workflows/malicious-code-scan-reusable.yml @@ -1,6 +1,7 @@ # Scans a PR for obfuscated code, prompt injection, and other malicious changes -# (see malicious-code-scan/malicious_code_scan.py), and gates the AI Review -# workflow on the result. +# (see malicious-code-scan/malicious_code_scan.py). The scan knows nothing of +# the AI Review workflow and never starts it: the review waits for this scan's +# status and checks its record itself before reviewing. # # Called from workflow-templates/malicious-code-scan.yml on pull_request_target, # so the caller comes from the default branch and the scanner from this @@ -9,6 +10,10 @@ # executed here -- its commits are fetched and read with git diff as data. Do # not add steps that run code from the PR. # +# It can also be run by hand (workflow_dispatch with pr_number), e.g. for a PR +# opened before the scan was set up or whose record has expired. Only a run from +# the default branch is accepted, so the caller is still the merged copy. +# # The result is posted as the commit status `security/malicious-code-scan` on # the PR head; make that a required check in branch protection. # @@ -25,7 +30,7 @@ # Secrets: ANTHROPIC_API_KEY (the Claude review is skipped with a warning without it) # # The caller must grant the job contents: read, statuses: write, pull-requests: write and -# actions: write. +# actions: read. # # 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. @@ -35,11 +40,11 @@ name: Malicious Code Scan (Reusable) on: workflow_call: inputs: - review_workflow: - description: File name of the caller's AI Review workflow to start when the scan passes; empty for none + pr_number: + description: PR to scan (from the caller's workflow_dispatch); empty for pull_request_target required: false type: string - default: ai-review.yml + default: "" project_description: description: What the repository is, for the Claude review (default names the repository) required: false @@ -88,18 +93,17 @@ jobs: # Queue pending runs too: a description edit must not replace a queued full scan. # Keep this on the job so unrelated labels do not enter the queue. concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number || inputs.pr_number }} cancel-in-progress: false queue: max permissions: contents: read statuses: write # report the result on the PR head commit pull-requests: write # remove a stale override label - actions: write # start AI Review once the scan passes + actions: read # read earlier scans' records, to verify an override + # PR_NUMBER, HEAD_SHA, BASE_SHA and PR_FILE (the PR as JSON) are set by "Find the PR" env: GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.pull_request.number }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} METADATA_ONLY: ${{ github.event.action == 'edited' && !github.event.changes.base }} SCANNER: ${{ github.workspace }}/shared/malicious-code-scan/malicious_code_scan.py SCAN_RECORD: ${{ github.workspace }}/shared/malicious-code-scan/scan_record.py @@ -110,12 +114,49 @@ jobs: egress-policy: audit - name: Check the trigger - # Under pull_request the caller would come from the PR, which could skip the scan - if: github.event_name != 'pull_request_target' + # Under pull_request, or dispatched from another branch, the caller would come from the PR, + # which could skip the scan + if: >- + github.event_name != 'pull_request_target' && + (github.event_name != 'workflow_dispatch' || + github.ref != format('refs/heads/{0}', github.event.repository.default_branch)) run: | - echo "::error::Call this workflow on pull_request_target" + echo "::error::Call this workflow on pull_request_target, or on workflow_dispatch from the default branch" exit 1 + - name: Find the PR + env: + PR_INPUT: ${{ inputs.pr_number }} + run: | + PR_FILE="${RUNNER_TEMP}/malicious-scan-pr.json" + if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then + if [[ ! "$PR_INPUT" =~ ^[0-9]+$ ]]; then + echo "::error::pr_number must be a PR number, not '$PR_INPUT'" + exit 1 + fi + gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_INPUT}" > "$PR_FILE" + if [[ "$(jq -r '.state' "$PR_FILE")" != "open" ]]; then + echo "::error::PR #${PR_INPUT} is not open" + exit 1 + fi + else + jq '.pull_request' "$GITHUB_EVENT_PATH" > "$PR_FILE" + fi + number="$(jq -r '.number' "$PR_FILE")" + head="$(jq -r '.head.sha' "$PR_FILE")" + base="$(jq -r '.base.sha' "$PR_FILE")" + if [[ ! "$number" =~ ^[0-9]+$ || ! "$head" =~ ^[0-9a-f]{40,64}$ || ! "$base" =~ ^[0-9a-f]{40,64}$ ]]; then + echo "::error::Could not read the PR number and commits" + exit 1 + fi + echo "Scanning PR #${number} at ${head}" + { + echo "PR_FILE=$PR_FILE" + echo "PR_NUMBER=$number" + echo "HEAD_SHA=$head" + echo "BASE_SHA=$base" + } >> "$GITHUB_ENV" + - name: Mark scan pending # A metadata-only recheck leaves the full scan's result in place unless it finds something if: env.METADATA_ONLY != 'true' @@ -169,11 +210,14 @@ jobs: SCAN_CLAUDE_MODEL: ${{ inputs.claude_model }} SCAN_PROJECT_DESCRIPTION: ${{ inputs.project_description }} SCAN_GENERATED_PATHS: ${{ inputs.generated_paths }} - BASE_SHA: ${{ github.event.pull_request.base.sha }} - # Passed through env, never interpolated into the script: both are attacker-controlled - PR_TITLE: ${{ github.event.pull_request.title }} - PR_BODY: ${{ github.event.pull_request.body }} run: | + # Read from the event (or, dispatched, the API) and passed through env, never interpolated + # into the script: both are attacker-controlled. jq -j and the x sentinel keep trailing newlines. + PR_TITLE="$(jq -j '.title' "$PR_FILE"; printf x)" + PR_TITLE="${PR_TITLE%x}" + PR_BODY="$(jq -j '.body // ""' "$PR_FILE"; printf x)" + PR_BODY="${PR_BODY%x}" + export PR_TITLE PR_BODY mode=() [[ "$METADATA_ONLY" == "true" ]] && mode=(--metadata-only) uv run --script --locked --no-build "$SCANNER" \ @@ -184,9 +228,6 @@ jobs: id: metadata if: steps.scan.outcome == 'success' working-directory: repo - env: - EVENT_PR_TITLE: ${{ github.event.pull_request.title }} - EVENT_PR_BODY: ${{ github.event.pull_request.body }} run: | # Events can queue out of order, and the text can change during a full scan. # Check the current text before publishing, and do not reuse an override for new text. @@ -195,7 +236,11 @@ jobs: echo "stale=true" >> "$GITHUB_OUTPUT" exit 0 fi - # jq -j and the x sentinel keep trailing newlines, which the event text also keeps + # jq -j and the x sentinel keep trailing newlines + EVENT_PR_TITLE="$(jq -j '.title' "$PR_FILE"; printf x)" + EVENT_PR_TITLE="${EVENT_PR_TITLE%x}" + EVENT_PR_BODY="$(jq -j '.body // ""' "$PR_FILE"; printf x)" + EVENT_PR_BODY="${EVENT_PR_BODY%x}" PR_TITLE="$(jq -j '.title' <<< "$pr"; printf x)" PR_TITLE="${PR_TITLE%x}" PR_BODY="$(jq -j '.body // ""' <<< "$pr"; printf x)" @@ -211,7 +256,8 @@ jobs: - name: Report result id: report - if: always() + # Nothing to report on without a PR head + if: always() && env.HEAD_SHA != '' env: SCAN_OUTCOME: ${{ steps.scan.outcome }} METADATA_OUTCOME: ${{ steps.metadata.outcome }} @@ -329,8 +375,9 @@ jobs: echo "status_id=$(jq -er '.status_id' "${RUNNER_TEMP}/malicious-scan-record.json")" >> "$GITHUB_OUTPUT" echo "state=$state" >> "$GITHUB_OUTPUT" - # Upload even a blocking result, so a later maintainer override can verify it. Artifacts are - # scoped to this trusted run and immutable. Each new status (including reruns) gets its own. + # Upload even a blocking result, so a later maintainer override can verify it, and AI Review + # can verify a pass. Artifacts are scoped to this trusted run and immutable. Each new status + # (including reruns) gets its own. - name: Upload scan record id: record if: always() && steps.report.outputs.status_id != '' @@ -340,18 +387,6 @@ jobs: path: ${{ runner.temp }}/malicious-scan-record.json if-no-files-found: error - - name: Start AI Review - if: steps.record.outcome == 'success' && steps.report.outputs.state == 'success' && env.METADATA_ONLY != 'true' - env: - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - REVIEW_WORKFLOW: ${{ inputs.review_workflow }} - run: | - # The gate can now authenticate the status even before this run concludes. - if [[ -n "$REVIEW_WORKFLOW" ]]; then - gh workflow run "$REVIEW_WORKFLOW" --repo "$GITHUB_REPOSITORY" --ref "$DEFAULT_BRANCH" -f pr_number="$PR_NUMBER" \ - || echo "::warning::Could not start AI Review" - fi - - name: Fail if the scan or record failed if: always() && steps.report.outputs.state != '' env: diff --git a/ai-review/ai_review_gate.sh b/ai-review/ai_review_gate.sh index 9ff4797..910c56f 100644 --- a/ai-review/ai_review_gate.sh +++ b/ai-review/ai_review_gate.sh @@ -13,11 +13,15 @@ # Decides whether the AI review loop should run for a PR and collects failed # CI job logs for the reviewers. Every CI workflow completion triggers the AI # Review workflow, so this lets only the run that sees all CI finished proceed. +# The Malicious Code Scan does not trigger the review, so that run waits for a +# scan still running on the PR head. A manual (workflow_dispatch) run fails +# instead of skipping, so it is not mistaken for a review that passed. # # Required env: GH_TOKEN, GITHUB_REPOSITORY, EVENT_NAME, OUT_DIR # One of: PR_NUMBER, HEAD_SHA # Optional env: FORCE (review even if this commit was already reviewed), REVIEW_WORKFLOW, -# SCAN_WORKFLOW, SCAN_CONTEXT, MAX_CI_ROUNDS, LOG_LINES +# SCAN_WORKFLOW, SCAN_CONTEXT, MAX_CI_ROUNDS, LOG_LINES, +# SCAN_WAIT_MINUTES (how long to wait for a running scan), SCAN_POLL_SECONDS # # Step outputs: skip, reason, pr, head_sha, head_ref, base_ref, ci_failures @@ -32,6 +36,8 @@ SCAN_WORKFLOW="${SCAN_WORKFLOW:-Malicious Code Scan}" SCAN_CONTEXT="${SCAN_CONTEXT:-security/malicious-code-scan}" FORCE="${FORCE:-false}" MAX_CI_ROUNDS="${MAX_CI_ROUNDS:-3}" +SCAN_WAIT_MINUTES="${SCAN_WAIT_MINUTES:-10}" +SCAN_POLL_SECONDS="${SCAN_POLL_SECONDS:-30}" LOG_LINES="${LOG_LINES:-150}" GITHUB_OUTPUT="${GITHUB_OUTPUT:-/dev/null}" repo="$GITHUB_REPOSITORY" @@ -44,9 +50,13 @@ CI_FILE="$OUT_DIR/ci_failures.md" output() { echo "$1=$2" >> "$GITHUB_OUTPUT"; } skip() { - echo "Skipping AI review: $1" output skip true output reason "$1" + if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then + echo "::error::Not reviewing: $1" + exit 1 + fi + echo "::notice::Skipping AI review: $1" exit 0 } @@ -73,30 +83,6 @@ if [[ -n "$HEAD_SHA" && "$HEAD_SHA" != "$pr_head" ]]; then fi HEAD_SHA="$pr_head" -# Never hand a PR to agents holding secrets and a write token until the malicious code scan passes -scan_status="$(gh api "repos/$repo/commits/$HEAD_SHA/status" --paginate \ - --jq ".statuses[] | select(.context == \"$SCAN_CONTEXT\")" | jq -s '.[0] // {}')" -scan_state="$(jq -r '.state // ""' <<< "$scan_status")" -# Status URLs are caller-controlled. Require the trusted scan run's artifact to attest the exact -# status ID, PR and head, so pointing a forged status at an old passing run cannot authorize review. -if [[ "$scan_state" == "success" ]]; then - script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - allow_running=() - # The scan uploads its record before dispatching review, then concludes. - [[ "$EVENT_NAME" == "workflow_dispatch" ]] && allow_running=(--allow-running) - if ! python3 "$script_dir/../malicious-code-scan/scan_record.py" \ - --workflow "$SCAN_WORKFLOW" --pr "$PR_NUMBER" --head "$HEAD_SHA" --context "$SCAN_CONTEXT" \ - --state success ${allow_running[@]+"${allow_running[@]}"} <<< "$scan_status" > /dev/null; then - skip "the malicious code scan status on $HEAD_SHA has no verified record from a passing $SCAN_WORKFLOW run" - fi -fi -case "$scan_state" in - success) ;; - "") skip "the malicious code scan has not reported on $HEAD_SHA" ;; - pending) skip "the malicious code scan is still running on $HEAD_SHA" ;; - *) skip "the malicious code scan blocked $HEAD_SHA ($scan_state)" ;; -esac - # Paginate: every CI completion adds an AI Review run for this commit, which can push CI runs off page one runs="$(gh api "repos/$repo/actions/runs?head_sha=$HEAD_SHA&per_page=100" --paginate \ --jq ".workflow_runs[] | select(.name != \"$REVIEW_WORKFLOW\" and .name != \"$SCAN_WORKFLOW\")" | jq -s .)" @@ -119,6 +105,48 @@ if [[ "$FORCE" != "true" ]] && skip "$HEAD_SHA was already reviewed" fi +# Never hand a PR to agents holding secrets and a write token until the malicious code scan passes. +# Nothing triggers this review when the scan finishes, so if CI finished first, wait for it here. +scan_status() { + gh api "repos/$repo/commits/$HEAD_SHA/status" --paginate \ + --jq ".statuses[] | select(.context == \"$SCAN_CONTEXT\")" | jq -s '.[0] // {}' +} +# The scan marks the commit pending when it starts; before then (queued for a runner, or behind +# the scan of an earlier push) there is only its run. Any unfinished scan in the repository counts, +# which at worst waits out SCAN_WAIT_MINUTES for a PR that has no scan coming. +scan_queued() { + gh api "repos/$repo/actions/runs?event=pull_request_target&per_page=20" \ + --jq "[.workflow_runs[] | select(.name == \"$SCAN_WORKFLOW\" and .status != \"completed\")] | length" +} +deadline=$((SECONDS + SCAN_WAIT_MINUTES * 60)) +while true; do + scan_status="$(scan_status)" + scan_state="$(jq -r '.state // ""' <<< "$scan_status")" + [[ "$scan_state" == "pending" || ( -z "$scan_state" && "$(scan_queued)" != "0" ) ]] || break + (( SECONDS < deadline )) || break + # A push abandons the scan of this commit, which then never reports + [[ "$(gh api "repos/$repo/pulls/$PR_NUMBER" --jq .head.sha)" == "$HEAD_SHA" ]] || + skip "the PR head moved past $HEAD_SHA while waiting for the malicious code scan" + echo "Waiting for the malicious code scan on $HEAD_SHA (${scan_state:-queued})" + sleep "$SCAN_POLL_SECONDS" +done +# Status URLs are caller-controlled. Require the trusted scan run's artifact to attest the exact +# status ID, PR and head, so pointing a forged status at an old passing run cannot authorize review. +if [[ "$scan_state" == "success" ]]; then + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + if ! python3 "$script_dir/../malicious-code-scan/scan_record.py" \ + --workflow "$SCAN_WORKFLOW" --pr "$PR_NUMBER" --head "$HEAD_SHA" --context "$SCAN_CONTEXT" \ + --state success <<< "$scan_status" > /dev/null; then + skip "the malicious code scan status on $HEAD_SHA has no verified record from a passing $SCAN_WORKFLOW run; run $SCAN_WORKFLOW for PR #$PR_NUMBER from the Actions tab" + fi +fi +case "$scan_state" in + success) ;; + "") skip "the malicious code scan has not reported on $HEAD_SHA; run $SCAN_WORKFLOW for PR #$PR_NUMBER from the Actions tab" ;; + pending) skip "the malicious code scan was still running on $HEAD_SHA after ${SCAN_WAIT_MINUTES} minute(s); run AI Review by hand once it passes" ;; + *) skip "the malicious code scan blocked $HEAD_SHA ($scan_state)" ;; +esac + # Collect failed job logs, with a workflow-level fallback for failures before jobs start. failures=0 while IFS=$'\t' read -r run_id run_name run_conclusion run_url; do diff --git a/malicious-code-scan/scan_record.py b/malicious-code-scan/scan_record.py index 3f5ba53..65d6210 100644 --- a/malicious-code-scan/scan_record.py +++ b/malicious-code-scan/scan_record.py @@ -16,6 +16,9 @@ along with the repository, PR, head, and result. An unrelated workflow cannot upload artifacts into that run. Missing/expired records fail closed; rerun the scan to produce a new one. +A trusted run is one of the scan workflow on pull_request_target, or on workflow_dispatch from a +commit of the default branch (dispatched from any other ref, the caller workflow could be the PR's). + Reads one status JSON object from stdin and prints its description only after verification. Uses gh for authentication and downloads; archive contents are read in memory, never extracted. """ @@ -36,6 +39,22 @@ def api(path: str, *options: str) -> bytes: return subprocess.run(["gh", "api", path, *options], capture_output=True, check=True).stdout +def trusted_run(run: dict, args: argparse.Namespace) -> bool: + if run.get("name") != args.workflow: + return False + if run.get("event") == "pull_request_target": + return True + if run.get("event") != "workflow_dispatch": + return False + default_branch = json.loads(api(f"repos/{args.repository}"))["default_branch"] + head = run.get("head_sha") or "" + if run.get("head_branch") != default_branch or not re.fullmatch(r"[0-9a-f]{40,64}", head): + return False + # head_branch alone could be a tag of the same name; the commit itself must be on the branch + status = api(f"repos/{args.repository}/compare/{head}...{default_branch}", "--jq", ".status") + return status.decode().strip() in ("ahead", "identical") + + def verified_description(status: dict, args: argparse.Namespace) -> str: if status.get("state") != args.state or status.get("context") != args.context: raise ValueError("unexpected status state or context") @@ -49,7 +68,7 @@ def verified_description(status: dict, args: argparse.Namespace) -> str: run_id = int(url[len(prefix) :]) run_path = f"repos/{args.repository}/actions/runs/{run_id}" run = json.loads(api(run_path)) - if run.get("event") != "pull_request_target" or run.get("name") != args.workflow: + if not trusted_run(run, args): raise ValueError("status does not link to the trusted scan workflow") name = f"malicious-scan-status-{status_id}" @@ -87,12 +106,9 @@ def verified_description(status: dict, args: argparse.Namespace) -> str: # A later rerun must not change the provenance or conclusion of an earlier status. if attempt != run["run_attempt"]: run = json.loads(api(f"{run_path}/attempts/{attempt}")) - if run.get("event") != "pull_request_target" or run.get("name") != args.workflow: - raise ValueError("untrusted scan attempt") - conclusions = {args.state} - if args.allow_running: - conclusions.add(None) - if run.get("conclusion") not in conclusions: + if not trusted_run(run, args): + raise ValueError("untrusted scan attempt") + if run.get("conclusion") != args.state: raise ValueError("scan attempt has not concluded with the reported result") return record["description"] @@ -105,7 +121,6 @@ def main() -> int: parser.add_argument("--head", required=True) parser.add_argument("--context", required=True) parser.add_argument("--state", required=True, choices=("success", "failure")) - parser.add_argument("--allow-running", action="store_true") args = parser.parse_args() try: description = verified_description(json.load(sys.stdin), args) diff --git a/tests/test_ai_review.py b/tests/test_ai_review.py index 573f96c..b36faa3 100644 --- a/tests/test_ai_review.py +++ b/tests/test_ai_review.py @@ -34,6 +34,8 @@ SCANNER = ROOT / "malicious-code-scan/malicious_code_scan.py" WORKFLOW = (ROOT / ".github/workflows/malicious-code-scan-reusable.yml").read_text() REVIEW_WORKFLOW = (ROOT / ".github/workflows/ai-review-reusable.yml").read_text() +SCAN_TEMPLATE = (ROOT / "workflow-templates/malicious-code-scan.yml").read_text() +REVIEW_TEMPLATE = (ROOT / "workflow-templates/ai-review.yml").read_text() GATE = ROOT / "ai-review/ai_review_gate.sh" CHECK_TRIGGERS = ROOT / "ai-review/check_triggers.py" # Imported only for its rules; keep bytecode out of the scanner directory @@ -50,9 +52,15 @@ BOT_IDENTITY = ("github-actions[bot]", "41898282+github-actions[bot]@users.noreply.github.com") -def workflow_script(name): - step = WORKFLOW.split(f" - name: {name}\n", 1)[1].split("\n - ", 1)[0] - return textwrap.dedent(step.split(" run: |\n", 1)[1]) +def workflow_script(name, workflow=WORKFLOW): + step = workflow.split(f" - name: {name}\n", 1)[1].split("\n - ", 1)[0] + script = [] + for line in step.split(" run: |\n", 1)[1].splitlines(): + # The step ends at the next job (or anything else less indented than its script) + if line.strip() and not line.startswith(" "): + break + script.append(line) + return textwrap.dedent("\n".join(script) + "\n") FAKE_GH = """ @@ -77,6 +85,10 @@ def workflow_script(name): print(json.dumps(status)) sys.exit(0) value = fixtures[path] +if isinstance(value, dict) and 'test_sequence' in value: + sequence = value['test_sequence'] + value = sequence.pop(0) if len(sequence) > 1 else sequence[0] + fixture_path.write_text(json.dumps(fixtures)) if isinstance(value, dict) and value.get('test_api_error'): sys.exit(1) if isinstance(value, dict) and 'test_zip' in value: @@ -205,6 +217,8 @@ def setUp(self): "run_attempt": 1, }, "repos/owner/repo/commits/test-head/statuses": [], + # Scans not yet finished, which the gate waits for when the commit has no scan status + "repos/owner/repo/actions/runs?event=pull_request_target&per_page=20": {"workflow_runs": []}, "repos/owner/repo/issues/1/comments": [], "repos/owner/repo/actions/runs?head_sha=test-head&per_page=100": { "workflow_runs": [ @@ -243,12 +257,14 @@ def setUp(self): STATUS_CONTEXT=CONTEXT, GITHUB_STEP_SUMMARY=str(self.directory / "summary.md"), MAX_CI_ROUNDS="3", + SCAN_POLL_SECONDS="0", OVERRIDE_LABEL="malicious-scan-override", - EVENT_PR_TITLE="Clean title", - EVENT_PR_BODY="Clean description", + # The PR as the scan's "Find the PR" step saved it + PR_FILE=str(self.directory / "pr.json"), RUNNER_TEMP=str(self.directory), SCAN_RECORD=str(SCANNER.parent / "scan_record.py"), ) + (self.directory / "pr.json").write_text(json.dumps(self.fixtures["repos/owner/repo/pulls/1"])) self.add_scan_record(self.fixtures["repos/owner/repo/commits/test-head/status"]["statuses"][0]) for name, code in { "gh": FAKE_GH, @@ -299,13 +315,10 @@ def report(self, **extra): result = self.run_shell(workflow_script("Report result"), settings) if result.returncode or not record_file.exists(): return result - # Stand in for upload-artifact, then execute the two subsequent run steps with the - # workflow's conditions. The record is the actual file produced by Report result. + # Stand in for upload-artifact, then execute the next run step with the workflow's + # conditions. The record is the actual file produced by Report result. status = self.statuses()[0] self.add_scan_record(status, **json.loads(record_file.read_text())) - if status["state"] == "success" and settings["METADATA_ONLY"] != "true": - dispatch = self.run_shell(workflow_script("Start AI Review"), settings) - self.assertEqual(dispatch.returncode, 0, dispatch.stderr) final = self.run_shell( workflow_script("Fail if the scan or record failed"), {"STATE": status["state"], "RECORD_OUTCOME": "success"}, @@ -345,6 +358,9 @@ def add_scan_record(self, status, **changes): self.fixtures[f"repos/owner/repo/actions/artifacts/{artifact_id}/zip"] = {"test_zip": record} return record + def statuses_fixture(self): + return self.fixtures["repos/owner/repo/commits/test-head/status"]["statuses"] + def statuses(self): return self.fixtures["repos/owner/repo/commits/test-head/statuses"] @@ -1165,11 +1181,14 @@ def test_override_cannot_accept_a_commit_blocked_after_the_label(self): self.assertIn('"DELETE"', self.calls_path.read_text()) self.assertNotIn('"workflow"', self.calls_path.read_text()) - def test_clean_full_scan_dispatches_review(self): + def test_scan_does_not_start_the_review_itself(self): + # AI Review starts on the scan's completion (workflow_run), so the scan needs no actions: write result = self.report() self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(self.statuses()[0]["state"], "success") - self.assertIn('"workflow"', self.calls_path.read_text()) + self.assertNotIn('"workflow"', self.calls_path.read_text()) + self.assertNotIn("actions: write", WORKFLOW) + self.assertNotIn("gh workflow run", WORKFLOW) def test_clean_metadata_recheck_preserves_existing_result(self): result = self.report(METADATA_ONLY="true", ACTION="edited") @@ -1188,7 +1207,9 @@ def test_stale_head_does_not_publish_or_dispatch(self): def test_scan_types_share_a_queue_without_cancelling_pending_scans(self): concurrency = WORKFLOW.split(" concurrency:\n", 1)[1].split(" permissions:\n", 1)[0] settings = dict(line.strip().split(": ", 1) for line in concurrency.splitlines() if line.strip()) - self.assertEqual(settings["group"], "${{ github.workflow }}-${{ github.event.pull_request.number }}") + self.assertEqual( + settings["group"], "${{ github.workflow }}-${{ github.event.pull_request.number || inputs.pr_number }}" + ) self.assertEqual(settings["cancel-in-progress"], "false") self.assertEqual(settings["queue"], "max") @@ -1202,9 +1223,9 @@ def test_gate_only_accepts_a_scan_status_from_the_scan_workflow(self): self.assertEqual(self.outputs()["skip"], "true") self.assertIn("no verified record", self.outputs()["reason"]) - def test_gate_only_accepts_an_unfinished_scan_run_from_its_dispatch(self): - # The scan dispatches the review before its own run concludes; a status forged while the - # scan is still running and pointed at that run must not start a CI-triggered review + def test_gate_never_accepts_an_unfinished_scan_run(self): + # The review starts only once the scan has concluded; a status forged while the scan is + # still running and pointed at that run must not start a review self.fixtures["repos/owner/repo/actions/runs/80"] = { "event": "pull_request_target", "name": "Malicious Code Scan", @@ -1221,8 +1242,8 @@ def test_gate_only_accepts_an_unfinished_scan_run_from_its_dispatch(self): self.assertIn("no verified record", self.outputs()["reason"]) self.outputs_path.unlink() result = self.run_shell(f'bash "{GATE}"', {"EVENT_NAME": "workflow_dispatch"}) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(self.outputs()["skip"], "false") + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn("no verified record", self.outputs()["reason"]) def test_gate_rejects_forged_success_pointing_at_a_passing_scan(self): # A status writer copies every field and the URL of an old passing run. GitHub assigns @@ -1232,7 +1253,8 @@ def test_gate_rejects_forged_success_pointing_at_a_passing_scan(self): for event in ("workflow_run", "workflow_dispatch"): with self.subTest(event=event): result = self.run_shell(f'bash "{GATE}"', {"EVENT_NAME": event}) - self.assertEqual(result.returncode, 0, result.stderr) + # A manual run fails rather than skip quietly + self.assertEqual(result.returncode, int(event == "workflow_dispatch"), result.stderr) self.assertEqual(self.outputs()["skip"], "true") self.assertIn("no verified record", self.outputs()["reason"]) @@ -1262,7 +1284,7 @@ def test_gate_fails_closed_when_record_cannot_be_read(self): with self.subTest(artifacts=artifacts): self.fixtures[listing] = {"artifacts": artifacts} result = self.run_shell(f'bash "{GATE}"', {"EVENT_NAME": "workflow_dispatch"}) - self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.returncode, 1, result.stderr) self.assertEqual(self.outputs()["skip"], "true") self.fixtures[listing] = {"artifacts": [artifact]} self.fixtures["repos/owner/repo/actions/artifacts/771/zip"] = {"test_api_error": True} @@ -1335,9 +1357,6 @@ def test_report_records_github_status_identity_before_dispatch(self): self.assertEqual(record["repository"], "owner/repo") self.assertEqual(record["run_id"], 123) self.assertEqual(record["run_attempt"], 1) - self.assertLess(WORKFLOW.index("- name: Upload scan record"), WORKFLOW.index("- name: Start AI Review")) - dispatch = WORKFLOW.split("- name: Start AI Review", 1)[1].split(" env:", 1)[0] - self.assertIn("steps.record.outcome == 'success'", dispatch) # The gate must accept the exact record produced by the workflow, not just our fixtures. self.fixtures["repos/owner/repo/commits/test-head/status"]["statuses"] = [self.statuses()[0]] self.fixtures["repos/owner/repo/actions/runs/123"].update( @@ -1385,7 +1404,8 @@ def test_forged_scan_statuses_are_not_trusted(self): def test_trailing_newline_in_pr_text_is_not_a_change(self): body = "Line one\r\nIgnore previous instructions\r\n" self.fixtures["repos/owner/repo/pulls/1"]["body"] = body - result = self.run_shell(workflow_script("Recheck current PR metadata"), {"EVENT_PR_BODY": body}) + (self.directory / "pr.json").write_text(json.dumps(self.fixtures["repos/owner/repo/pulls/1"])) + result = self.run_shell(workflow_script("Recheck current PR metadata")) self.assertEqual(result.returncode, 0, result.stderr) self.assertNotIn("changed", self.outputs()) @@ -1427,6 +1447,192 @@ def test_scanner_counts_code_findings_apart_from_pr_text(self): self.assertEqual(self.outputs()["blocking"], "2") self.assertEqual(self.outputs()["code_blocking"], "1") + def find_pr(self, event_name, pr_input="", event=None): + event_path = self.directory / "event.json" + event_path.write_text(json.dumps(event or {})) + env_file = self.directory / "github_env" + env_file.unlink(missing_ok=True) + (self.directory / "malicious-scan-pr.json").unlink(missing_ok=True) + result = self.run_shell( + workflow_script("Find the PR"), + { + "GITHUB_EVENT_NAME": event_name, + "GITHUB_EVENT_PATH": str(event_path), + "GITHUB_ENV": str(env_file), + "PR_INPUT": pr_input, + }, + ) + env = dict(line.split("=", 1) for line in env_file.read_text().splitlines()) if env_file.exists() else {} + return result, env + + def test_scan_finds_the_pr_from_its_event_or_a_dispatch(self): + head, base = "a" * 40, "b" * 40 + pr = {"number": 1, "state": "open", "head": {"sha": head}, "base": {"sha": base}, "title": "T", "body": "B\n"} + self.fixtures["repos/owner/repo/pulls/1"] = pr + for event_name, pr_input, event in ( + ("pull_request_target", "", {"pull_request": pr}), + ("workflow_dispatch", "1", {}), + ): + with self.subTest(event=event_name): + result, env = self.find_pr(event_name, pr_input, event) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(env["PR_NUMBER"], "1") + self.assertEqual(env["HEAD_SHA"], head) + self.assertEqual(env["BASE_SHA"], base) + self.assertEqual(json.loads(Path(env["PR_FILE"]).read_text()), pr) + # The text the scan reads keeps its trailing newline, as the recheck compares it exactly + result = self.run_shell('printf %s "$(jq -j .body "$PR_FILE"; printf x)"', {"PR_FILE": env["PR_FILE"]}) + self.assertEqual(result.stdout, "B\nx") + + def test_dispatched_scan_refuses_a_bad_or_closed_pr(self): + self.fixtures["repos/owner/repo/pulls/2"] = {"number": 2, "state": "closed"} + for pr_input in ("", "1; echo", "2"): + with self.subTest(pr_input=pr_input): + result, env = self.find_pr("workflow_dispatch", pr_input) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertEqual(env, {}) + # A PR JSON without real commits (here the fixture's test-head) is refused too + result, env = self.find_pr("workflow_dispatch", "1") + self.assertEqual(result.returncode, 1, result.stderr) + self.assertEqual(env, {}) + + def test_scan_runs_only_on_pull_request_target_or_a_dispatch_from_the_default_branch(self): + check = WORKFLOW.split(" - name: Check the trigger\n", 1)[1].split(" run: |", 1)[0] + self.assertIn("github.event_name != 'pull_request_target'", check) + self.assertIn( + "github.ref != format('refs/heads/{0}', github.event.repository.default_branch)", " ".join(check.split()) + ) + self.assertLess(WORKFLOW.index("- name: Check the trigger"), WORKFLOW.index("- name: Find the PR")) + self.assertLess(WORKFLOW.index("- name: Find the PR"), WORKFLOW.index("- name: Mark scan pending")) + # Neither the PR nor its text may come from the event alone, which a dispatch does not have + self.assertNotIn("github.event.pull_request.head", WORKFLOW) + self.assertNotIn("github.event.pull_request.title", WORKFLOW) + self.assertNotIn("github.event.pull_request.body", WORKFLOW) + + def test_templates_can_be_run_by_hand(self): + for template in (SCAN_TEMPLATE, REVIEW_TEMPLATE): + dispatch = template.split(" workflow_dispatch:\n", 1)[1].split("\npermissions:", 1)[0] + self.assertIn(" pr_number:\n", dispatch) + self.assertIn("pr_number: ${{ inputs.pr_number }}", template) + + def dispatched_scan(self, **run): + self.fixtures["repos/owner/repo"] = {"default_branch": "main"} + self.fixtures["repos/owner/repo/actions/runs/77"].update( + {"event": "workflow_dispatch", "head_branch": "main", "head_sha": "c" * 40} | run + ) + self.fixtures[f"repos/owner/repo/compare/{'c' * 40}...main"] = {"status": "ahead"} + self.outputs_path.unlink(missing_ok=True) + result = self.run_shell(f'bash "{GATE}"') + self.assertEqual(result.returncode, 0, result.stderr) + return self.outputs() + + def test_gate_accepts_a_scan_dispatched_from_the_default_branch(self): + self.assertEqual(self.dispatched_scan()["skip"], "false") + + def test_gate_rejects_a_scan_dispatched_from_another_ref(self): + for run in ({"head_branch": "feature"}, {"head_sha": "not-a-sha"}, {"event": "push"}): + with self.subTest(run=run): + outputs = self.dispatched_scan(**run) + self.assertEqual(outputs["skip"], "true") + self.assertIn("no verified record", outputs["reason"]) + # A tag named like the default branch, on a commit that is not on it + self.dispatched_scan() + self.fixtures[f"repos/owner/repo/compare/{'c' * 40}...main"] = {"status": "diverged"} + self.outputs_path.unlink() + result = self.run_shell(f'bash "{GATE}"') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.outputs()["skip"], "true") + + def test_manual_review_fails_when_it_cannot_review(self): + self.fixtures["repos/owner/repo/commits/test-head/status"]["statuses"] = [] + result = self.run_shell(f'bash "{GATE}"', {"EVENT_NAME": "workflow_dispatch"}) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn("::error::Not reviewing: the malicious code scan has not reported", result.stdout) + self.assertIn("run Malicious Code Scan for PR #1", result.stdout) + self.assertEqual(self.outputs()["skip"], "true") + # CI and scan completions skip quietly: a later completion starts the review + self.outputs_path.unlink() + result = self.run_shell(f'bash "{GATE}"') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("::notice::Skipping AI review", result.stdout) + + def scan_statuses(self, *states): + # Each read of the commit status returns the next state; the last one repeats + passing = self.statuses_fixture()[0] + sequence = [{"statuses": [passing | {"state": state}] if state else []} for state in states] + self.fixtures["repos/owner/repo/commits/test-head/status"] = {"test_sequence": sequence} + + def test_gate_waits_for_a_scan_still_running_once_ci_is_done(self): + # The scan does not start the review, so the last CI completion must wait for it + self.scan_statuses("pending", "pending", "success") + result = self.run_shell(f'bash "{GATE}"') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.outputs()["skip"], "false") + self.assertEqual(result.stdout.count("Waiting for the malicious code scan"), 2) + + def test_gate_waits_for_a_scan_that_has_not_started(self): + self.scan_statuses("", "success") + runs = "repos/owner/repo/actions/runs?event=pull_request_target&per_page=20" + self.fixtures[runs] = {"workflow_runs": [{"name": "Malicious Code Scan", "status": "queued"}]} + result = self.run_shell(f'bash "{GATE}"') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.outputs()["skip"], "false") + self.assertIn("(queued)", result.stdout) + # With no scan coming, it does not wait + self.fixtures["repos/owner/repo/commits/test-head/status"] = {"statuses": []} + self.fixtures[runs] = {"workflow_runs": [{"name": "Malicious Code Scan", "status": "completed"}]} + self.outputs_path.unlink() + result = self.run_shell(f'bash "{GATE}"') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("has not reported", self.outputs()["reason"]) + self.assertNotIn("Waiting", result.stdout) + + def test_gate_gives_up_waiting_for_the_scan(self): + self.scan_statuses("pending") + result = self.run_shell(f'bash "{GATE}"', {"SCAN_WAIT_MINUTES": "0"}) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("still running", self.outputs()["reason"]) + self.assertIn("run AI Review by hand", self.outputs()["reason"]) + + def test_gate_stops_waiting_when_the_pr_moves_on(self): + # The scan of an abandoned commit goes stale and never reports + self.scan_statuses("pending") + self.fixtures["repos/owner/repo/pulls/1"] = { + "test_sequence": [self.fixtures["repos/owner/repo/pulls/1"]] * 2 + + [self.fixtures["repos/owner/repo/pulls/1"] | {"head": {"sha": "new-head"}}] + } + result = self.run_shell(f'bash "{GATE}"') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("moved past test-head while waiting", self.outputs()["reason"]) + + def test_gate_does_not_wait_while_ci_is_running_or_for_a_reviewed_commit(self): + self.scan_statuses("pending") + self.fixtures["repos/owner/repo/actions/runs?head_sha=test-head&per_page=100"]["workflow_runs"][0].update( + status="in_progress", event="pull_request" + ) + result = self.run_shell(f'bash "{GATE}"') + self.assertIn("still in progress", self.outputs()["reason"]) + self.assertNotIn("commits/test-head/status", self.calls_path.read_text()) + self.fixtures["repos/owner/repo/actions/runs?head_sha=test-head&per_page=100"]["workflow_runs"][0].update( + status="completed" + ) + self.fixtures["repos/owner/repo/issues/1/comments"] = [ + { + "user": {"login": "github-actions[bot]", "type": "Bot"}, + "body": "\n", + } + ] + self.outputs_path.unlink() + result = self.run_shell(f'bash "{GATE}"') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("already reviewed", self.outputs()["reason"]) + self.assertNotIn("commits/test-head/status", self.calls_path.read_text()) + + def test_scan_is_not_an_ai_review_trigger(self): + workflows = REVIEW_TEMPLATE.split(" workflows:\n", 1)[1].split(" types:", 1)[0] + self.assertNotIn("Malicious Code Scan", workflows) + self.assertNotIn("ai-review", SCAN_TEMPLATE.split("\non:", 1)[1]) + def test_ai_review_queues_every_trigger_for_a_pr_together(self): review = REVIEW_WORKFLOW.split("\n review:\n", 1)[1] self.assertIn(" needs: pr\n", review) diff --git a/workflow-templates/ai-review.yml b/workflow-templates/ai-review.yml index e68ff9c..d27af01 100644 --- a/workflow-templates/ai-review.yml +++ b/workflow-templates/ai-review.yml @@ -5,7 +5,8 @@ # (the review never starts without a passing scan), and: # 1. List every workflow that runs on pull_request under `workflows:` below. The review # starts when one of them completes and all CI is done, so a missing one that finishes -# last means no review. Each run warns about any that are missing. +# last means no review. Each run warns about any that are missing. Do not list the +# Malicious Code Scan: the review waits for it (up to scan_wait_minutes) when CI is done. # 2. Replace $default-branch under `branches-ignore:` with your default branch name. # GitHub substitutes it only when you start the workflow from the Actions tab; the # literal matches no branch, which only adds skipped runs for pushes. @@ -17,6 +18,10 @@ # # Add the `skip-ai-review` label to a PR to opt out. Changes to this file take effect once # they are on the default branch. +# +# To review a PR by hand, run this workflow from the Actions tab with the PR number. The run fails +# with the reason if it cannot review, e.g. when the Malicious Code Scan has not passed on the +# PR's head: run that workflow for the PR first, then this one. name: AI Review @@ -55,6 +60,7 @@ jobs: # review_instructions: | # Uncomment to add repository-specific guidance for the reviewers # - Check that ... # max_turns: "6" # Uncomment and update if needed + # scan_wait_minutes: "10" # Uncomment to wait longer for a scan still running once CI is done # claude_model: claude-opus-5-5 # Uncomment and update if needed secrets: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} diff --git a/workflow-templates/malicious-code-scan.yml b/workflow-templates/malicious-code-scan.yml index c1aa25c..7defd2d 100644 --- a/workflow-templates/malicious-code-scan.yml +++ b/workflow-templates/malicious-code-scan.yml @@ -1,18 +1,22 @@ # Scans every pull request for obfuscated code, prompt injection, and other -# malicious changes, and gates the AI Review workflow on the result. +# malicious changes. It stands alone and never starts the AI review; ai-review.yml, if the +# repo uses it, waits for this to pass before reviewing. # Copy this file to .github/workflows/ in the repo and: # 1. Add a Claude API key to the repo (or org) secrets as ANTHROPIC_API_KEY. Without it # only the deterministic rules run, with a warning. # 2. Make the `security/malicious-code-scan` commit status a required check in branch protection. # 3. Create a `malicious-scan-override` label; a maintainer with write access adds it to # accept blocking findings on a commit after reviewing them. -# 4. If you also use ai-review.yml under a different file name, update review_workflow. # Scan results and overrides require the scan's stored artifact. Re-run the scan if its # record has expired or the result predates scan records; old status URLs alone are not trusted. +# To scan a PR by hand (e.g. one opened before this workflow was added), run this workflow from +# the Actions tab on the default branch with the PR number. A passing scan does not start +# AI Review; if the PR's CI had already finished, run AI Review by hand too. # # This must stay on pull_request_target: the scan then runs from the default branch and this -# repository, so a PR cannot change it. The PR is only ever read as data. The workflow name is -# what ai-review.yml's scan_workflow_name expects; keep them in step if you rename it. +# repository, so a PR cannot change it. A manual run is accepted only from the default branch for +# the same reason. The PR is only ever read as data. The workflow name is what ai-review.yml's +# scan_workflow_name expects; keep them in step if you rename it. name: Malicious Code Scan @@ -25,6 +29,11 @@ on: - edited - ready_for_review - labeled + workflow_dispatch: + inputs: + pr_number: + description: PR number to scan + required: true permissions: contents: read @@ -36,9 +45,9 @@ jobs: contents: read statuses: write pull-requests: write - actions: write + actions: read with: - review_workflow: ai-review.yml # Set to "" if the repo has no AI Review workflow + pr_number: ${{ inputs.pr_number }} # project_description: "OpenC3 COSMOS plugin for ..." # Uncomment to describe the repo to the Claude review # claude_model: claude-opus-5-5 # Uncomment to use a different model # Uncomment if the repo commits build output (e.g. a site published from docs/). Minified