From 54c1aa565c33d5d659856e5227c46eaa27210f4a Mon Sep 17 00:00:00 2001 From: Claude Leet Date: Thu, 1 Oct 2026 20:21:00 +0100 Subject: [PATCH 1/2] Skip the review on bot and fork pull requests instead of failing GitHub gives no secrets to a run that Dependabot or a fork pull request started, and the review action rejects a bot actor, so the review would have ended as a red job on every such pull request. Bot pull requests are reviewed by hand by decision, so a gate job now checks for the token and a human actor first, and skips with a notice when either is missing. The notice keeps the skip visible: a missing token on a pull request that is neither bot nor fork means the secret is not set or not passed. The token input is no longer marked required, because a required secret that arrives empty is exactly the case being handled. Tested locally by running the gate's script for no token, a bot actor, and a person with a token. Signed-off-by: Claude Leet --- .github/workflows/claude-review.yaml | 35 ++++++++++++++++++++++++++-- README.md | 4 ++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/.github/workflows/claude-review.yaml b/.github/workflows/claude-review.yaml index 0630d68..31ee027 100644 --- a/.github/workflows/claude-review.yaml +++ b/.github/workflows/claude-review.yaml @@ -53,11 +53,42 @@ on: default: sonnet secrets: CLAUDE_CODE_OAUTH_TOKEN: - description: 'Subscription token from `claude setup-token`. Usage draws on the allowance of whoever generated it.' - required: true + description: 'Subscription token from `claude setup-token`. Usage draws on the allowance of whoever generated it. Not required, so that a run without it - a Dependabot or fork pull request, which GitHub gives no secrets - is skipped with a notice rather than failing.' + required: false jobs: + gate: + # Decides whether this run can be reviewed at all, before a runner is spent on the review. + # GitHub gives no secrets to a run that Dependabot or a fork pull request started, and the + # review action rejects a bot actor, so either would otherwise end as a red job on every + # such pull request. Those are skipped by design: bot pull requests are reviewed by hand. + # The skip is reported as a notice rather than passed over in silence, so a missing token + # in a repository that should have one is visible. + runs-on: [ubuntu-latest] + name: Can this be reviewed + timeout-minutes: 2 + outputs: + run: ${{ steps.check.outputs.run }} + steps: + - name: Check for a token and a human actor + id: check + env: + HAS_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN != '' }} + ACTOR: ${{ github.actor }} + run: | + if [ "$HAS_TOKEN" != true ]; then + echo "::notice::Review skipped: no CLAUDE_CODE_OAUTH_TOKEN reached this run. GitHub withholds secrets from Dependabot and fork pull requests; elsewhere, check the secret is set and passed by the caller." + echo "run=false" >> "$GITHUB_OUTPUT" + elif [[ "$ACTOR" == *'[bot]' ]]; then + echo "::notice::Review skipped: $ACTOR is a bot, and bot pull requests are reviewed by hand." + echo "run=false" >> "$GITHUB_OUTPUT" + else + echo "run=true" >> "$GITHUB_OUTPUT" + fi + review: + needs: gate + if: needs.gate.outputs.run == 'true' runs-on: [ubuntu-latest] name: Review # A backstop against a runaway session only. Coverage is deliberately not capped with diff --git a/README.md b/README.md index 13785ab..401746b 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,10 @@ inputs are at the top of `.github/workflows/claude-review.yaml`. and the range it covers starts at the last commit a posted review recorded, so commits the older one would have reviewed are not skipped. A burst of pushes during a review produces one delta review of all of them. +- **Bot and fork pull requests are skipped.** GitHub gives no secrets to a run started by + Dependabot or by a fork pull request, and bot pull requests are reviewed by hand, so a gate job + skips those runs with a notice instead of failing. A notice saying no token arrived, on a + pull request that is neither, means the secret is missing or not passed. - **The caller needs** `contents: read` and `pull-requests: write`, and must pass the secret explicitly. From 61fc126f66e4593f3164f6272ebc7467c45b6d95 Mon Sep 17 00:00:00 2001 From: Claude Leet Date: Thu, 1 Oct 2026 20:24:15 +0100 Subject: [PATCH 2/2] Refuse a review of a fork pull request, even on request A maintainer's /claude-review on a fork pull request ran with the token over text written by someone with no access to the repository, who could write it to steer the verdict. Claude holds no write token and has no network tools, so the exposure is a misleading verdict rather than a leaked secret, but fork pull requests are now refused outright by decision. A refused comment request gets a reply so it does not look ignored. The lookup runs first and on its own, so a failure stops the job instead of reading as not a fork. Tested locally with a stand-in gh across five cases: fork by comment (refused, reply), fork by event (refused), no token, bot actor, and a person on a same-repository branch (runs). Signed-off-by: Claude Leet --- .github/workflows/claude-review.yaml | 21 +++++++++++++++++++-- README.md | 4 +++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/.github/workflows/claude-review.yaml b/.github/workflows/claude-review.yaml index 31ee027..dbebe48 100644 --- a/.github/workflows/claude-review.yaml +++ b/.github/workflows/claude-review.yaml @@ -64,19 +64,36 @@ jobs: # such pull request. Those are skipped by design: bot pull requests are reviewed by hand. # The skip is reported as a notice rather than passed over in silence, so a missing token # in a repository that should have one is visible. + # + # A fork pull request is refused even when a maintainer asks by comment, by decision: that + # run carries the token while the reviewed text is written by someone with no access, who + # could steer the verdict. A refused request gets a reply so it does not look ignored. runs-on: [ubuntu-latest] name: Can this be reviewed timeout-minutes: 2 outputs: run: ${{ steps.check.outputs.run }} steps: - - name: Check for a token and a human actor + - name: Check for a token, a human actor and a same-repository branch id: check env: HAS_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN != '' }} ACTOR: ${{ github.actor }} + EVENT: ${{ github.event_name }} + GH_TOKEN: ${{ github.token }} + PR: ${{ inputs.pr-number }} run: | - if [ "$HAS_TOKEN" != true ]; then + # Fetched first and on its own, so a failed lookup stops the job rather than reading + # as "not a fork". + cross=$(gh pr view "$PR" --repo "$GITHUB_REPOSITORY" --json isCrossRepository --jq '.isCrossRepository') + if [ "$cross" = true ]; then + echo "::notice::Review refused: pull request #$PR comes from a fork, and fork pull requests are not reviewed." + if [ "$EVENT" = issue_comment ]; then + gh pr comment "$PR" --repo "$GITHUB_REPOSITORY" \ + --body 'Claude review: refused. This pull request comes from a fork, and fork pull requests are not reviewed automatically, because the reviewed text is written by someone with no access while the run carries the token.' + fi + echo "run=false" >> "$GITHUB_OUTPUT" + elif [ "$HAS_TOKEN" != true ]; then echo "::notice::Review skipped: no CLAUDE_CODE_OAUTH_TOKEN reached this run. GitHub withholds secrets from Dependabot and fork pull requests; elsewhere, check the secret is set and passed by the caller." echo "run=false" >> "$GITHUB_OUTPUT" elif [[ "$ACTOR" == *'[bot]' ]]; then diff --git a/README.md b/README.md index 401746b..b42fd20 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,9 @@ inputs are at the top of `.github/workflows/claude-review.yaml`. delta review of all of them. - **Bot and fork pull requests are skipped.** GitHub gives no secrets to a run started by Dependabot or by a fork pull request, and bot pull requests are reviewed by hand, so a gate job - skips those runs with a notice instead of failing. A notice saying no token arrived, on a + skips those runs with a notice instead of failing. A fork pull request is refused even when a + maintainer asks by comment, because that run carries the token over text written by someone + with no access; the request gets a reply saying so. A notice saying no token arrived, on a pull request that is neither, means the secret is missing or not passed. - **The caller needs** `contents: read` and `pull-requests: write`, and must pass the secret explicitly.