From 3a569ba8a182f810d7a4327564f748c7f3bcac3b Mon Sep 17 00:00:00 2001 From: Tony Li Date: Fri, 12 Jun 2026 03:07:06 +0800 Subject: [PATCH] ci: harden Claude workflows (gate @claude, skip-fork PR review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the hardening applied to mcp-server-synology: - claude.yml: @claude ran on privileged comment/issue events (secrets, contents: write, PR-head checkout) gated only by sender != Bot + the text "@claude" — so ANY non-bot user could trigger it. Add a per-event author_association gate (OWNER/MEMBER/COLLABORATOR), bound to the actor of the firing event so an untrusted commenter can't pass via a trusted issue author on a maintainer-owned issue/PR. - claude-review.yml: gate the review job to same-repo PRs (head.repo == this repo). Fork PRs are skipped — no failing check, zero token spend, no secret exposure. Fork review on demand via gated @claude. --- .github/workflows/claude-review.yml | 24 +++++++++++++++++- .github/workflows/claude.yml | 38 +++++++++++++++++++++++++---- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index fdb5a14..d75dbac 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -1,5 +1,20 @@ name: PR Review +# Auto-review only for SAME-REPO pull requests. +# +# Why not fork PRs: anthropics/claude-code-action cannot authenticate on a fork +# PR by any trigger. `on: pull_request` from a fork gets no OIDC token; switching +# to `on: pull_request_target` makes secrets available but then fails at the +# OIDC -> GitHub App token exchange (401 "Invalid OIDC token"). Either way a fork +# PR can never produce a green review check — it only burns a run (and, with +# pull_request_target, exposes secrets to fork code). +# +# So this workflow uses the safe `pull_request` event and the job is gated to +# same-repo PRs (head.repo == this repo). Result: +# - same-repo PRs (trusted, ours): reviewed automatically (OIDC works); +# - fork PRs: the job is SKIPPED — no failing check, zero token spend, no +# secret exposure. Review a fork PR on demand by commenting `@claude` +# (claude.yml, gated to OWNER/MEMBER/COLLABORATOR). on: pull_request: types: [opened] @@ -12,6 +27,12 @@ permissions: jobs: review: + # Same-repo PRs only. Fork PRs (head.repo != this repo) are skipped before any + # token is spent — see the header for why fork PRs can't be reviewed in CI. + # Also skip Dependabot (isolated secret store -> empty token -> always fails). + if: >- + github.event.pull_request.head.repo.full_name == github.repository + && github.event.pull_request.user.login != 'dependabot[bot]' runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -20,6 +41,7 @@ jobs: - uses: anthropics/claude-code-action@v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + allowed_bots: "dependabot" prompt: | REPO: ${{ github.repository }} PR: #${{ github.event.pull_request.number }} @@ -30,7 +52,7 @@ jobs: - Security implications - Performance concerns - The PR branch is checked out in the working directory. + Read the PR's changes via `gh pr diff` and `gh pr view`. Use `gh pr comment` for top-level summary feedback (one comment max). Use mcp__github_inline_comment__create_inline_comment (with confirmed: true) diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index c88ea1d..44ce8d1 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -19,12 +19,40 @@ permissions: jobs: claude: + # Trust gate (load-bearing): this job runs from the default branch WITH secrets, + # contents: write, and a checkout of the PR head — comment/issue events are NOT + # protected by the fork-PR "require approval" gate, so without this check ANY + # non-bot user who types "@claude" could trigger a privileged, write-capable run. + # + # The association MUST be checked against the actor of the *firing* event — the + # commenter for *_comment, the reviewer for pull_request_review, the issue + # author for issues. An issue_comment payload carries BOTH `comment` and + # `issue`, so a blanket OR across them lets an untrusted commenter pass via the + # trusted issue author on a maintainer-owned issue/PR. So each event clause + # binds its own actor's association inline. if: | - github.event.sender.type != 'Bot' && ( - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || - (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + github.event.sender.type != 'Bot' + && ( + (github.event_name == 'issue_comment' + && contains(github.event.comment.body, '@claude') + && (github.event.comment.author_association == 'OWNER' + || github.event.comment.author_association == 'MEMBER' + || github.event.comment.author_association == 'COLLABORATOR')) || + (github.event_name == 'pull_request_review_comment' + && contains(github.event.comment.body, '@claude') + && (github.event.comment.author_association == 'OWNER' + || github.event.comment.author_association == 'MEMBER' + || github.event.comment.author_association == 'COLLABORATOR')) || + (github.event_name == 'pull_request_review' + && contains(github.event.review.body, '@claude') + && (github.event.review.author_association == 'OWNER' + || github.event.review.author_association == 'MEMBER' + || github.event.review.author_association == 'COLLABORATOR')) || + (github.event_name == 'issues' + && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')) + && (github.event.issue.author_association == 'OWNER' + || github.event.issue.author_association == 'MEMBER' + || github.event.issue.author_association == 'COLLABORATOR')) ) runs-on: ubuntu-latest steps: