From 719da6fbdd908187ad0e8c6df3d29406bdb3d590 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 10 Aug 2026 23:21:15 -0400 Subject: [PATCH] ci: gate claude.yml on same-repo PRs and pin actions to commit SHAs (#1882) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pieces of hardening on the Claude Code workflow, plus a comment for a finding that needed no change. Same-repo gating. `Get PR details` now emits an `is_fork` output, and the PR-head checkout and the `Run Claude Code` step are both gated on it. A fork PR is declined outright — no checkout, no agent run, with the reason written to the step summary — rather than falling through to a "metadata-only" review of the base tree, which would trade an untrusted-code problem for a wrong-tree one. A deleted fork (`head.repo` null) counts as a fork. The head checkout also drops its `repository:` input: only a same-repo head reaches that step now, and checkout's default `github.repository` is a value no PR can influence. SHA pins. actions/checkout, actions/github-script, and claude-code-action were all on mutable major tags in a job that holds ANTHROPIC_API_KEY and grants the agent Bash. Each is now pinned to a full commit SHA with the trailing `# vX.Y.Z` comment Dependabot reads, so upgrade automation is unaffected. Pinned to the v7 / v9 SHAs so this does not regress the bumps in the open Dependabot PR #1922. The failure fallback Copilot flagged turns out not to exist: `Checkout repository` carries no status-check function, so GitHub applies an implicit `success()` and skips it after a failed lookup regardless of the `outcome` comparison. Its condition is now the explicit `== 'skipped'` and says so. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt --- .github/workflows/claude.yml | 53 ++++++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 8aa4ba71a..f68ae5275 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -35,13 +35,18 @@ jobs: id-token: write actions: read steps: + # Actions are pinned to full commit SHAs rather than mutable major tags: + # this job holds ANTHROPIC_API_KEY and grants the agent Bash, so a + # force-moved tag would be an unreviewed code change inside a + # secret-holding job. The trailing `# vX.Y.Z` comment is the form + # Dependabot reads, so pinning costs us no upgrade automation. (#1882) - name: Get PR details if: | (github.event_name == 'issue_comment' && github.event.issue.pull_request) || github.event_name == 'pull_request_review_comment' || github.event_name == 'pull_request_review' id: pr - uses: actions/github-script@v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | let prNumber; @@ -57,26 +62,58 @@ jobs: pull_number: prNumber }); + // A fork PR's head lives in a different repository — or in none at + // all, if the fork was deleted after the PR was opened (`head.repo` + // is then null). Neither is ours to check out, so both count as a + // fork here and the steps below decline rather than guess. + const headRepo = pr.data.head.repo?.full_name ?? null; + const isFork = headRepo !== `${context.repo.owner}/${context.repo.repo}`; + core.setOutput('sha', pr.data.head.sha); - core.setOutput('repo', pr.data.head.repo.full_name); + core.setOutput('is_fork', String(isFork)); + + # A fork PR's head is untrusted code, and checking it out would put it in + # reach of a tool-enabled agent run holding ANTHROPIC_API_KEY. Reviewing + # the base tree instead would only trade that for a confident review of + # the wrong tree, so decline visibly and leave the reason in the run. + - name: Decline fork PR + if: steps.pr.outcome == 'success' && steps.pr.outputs.is_fork == 'true' + run: | + { + echo "### Claude Code declined this pull request" + echo + echo "The head branch lives in a fork, so its code is not checked out and Claude is not run." + echo "Push the branch to this repository and re-trigger if a review is needed." + } >> "$GITHUB_STEP_SUMMARY" + # No `repository:` — a same-repo head is all that reaches this step, and + # checkout defaults to `github.repository`, which no PR can influence. - name: Checkout PR branch - if: steps.pr.outcome == 'success' - uses: actions/checkout@v6 + if: steps.pr.outcome == 'success' && steps.pr.outputs.is_fork == 'false' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ steps.pr.outputs.sha }} - repository: ${{ steps.pr.outputs.repo }} fetch-depth: 0 + # `skipped` means the trigger was an issue or a non-PR comment, so there + # is no head to check out and the base tree is the right one. The lookup + # having *failed* is deliberately not included: this condition carries no + # status-check function, so GitHub applies an implicit `success()` and + # skips the step after a failed prior step anyway. Spelling out `skipped` + # keeps that from having to be re-derived by the next reader. - name: Checkout repository - if: steps.pr.outcome != 'success' - uses: actions/checkout@v6 + if: steps.pr.outcome == 'skipped' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Run Claude Code + # Runs only against a tree this workflow actually checked out: the base + # repo (non-PR trigger) or a same-repo PR head. A fork PR reaches here + # with `is_fork == 'true'` and both disjuncts false, so it is skipped. + if: steps.pr.outcome == 'skipped' || steps.pr.outputs.is_fork == 'false' id: claude - uses: anthropics/claude-code-action@v1 + uses: anthropics/claude-code-action@5ef2e550a465a721f4f45e4a7d3c340c873e1dcc # v1.0.190 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}