From 5f0d2974e491e0bb12b906d6aa8ee7380efadd45 Mon Sep 17 00:00:00 2001 From: MaryWylde Date: Tue, 11 Aug 2026 12:20:58 +0200 Subject: [PATCH] fix(ci): restrict @claude mention workflow to trusted authors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This repo is public with issues enabled. The job only checked that the text contained "@claude", so any GitHub user could trigger it by opening an issue. Unlike a fork's pull_request run, `issues` and `issue_comment` events execute in this repo's context and DO receive repository secrets, so those runs had CLAUDE_CODE_OAUTH_TOKEN in the environment. And because the action executes whatever instructions are in the comment that tagged it, reaching that token needed no injection trick — just a comment. Gates on author_association (OWNER / MEMBER / COLLABORATOR) per event, checked against the account that actually triggered it. CONTRIBUTOR is excluded: it only means someone had a commit merged once. The check is deliberately not one shared OR across event types. On issue_comment the payload carries both comment.author_association and issue.author_association, so a flat OR would have let an untrusted account comment on a trusted author's issue and pass. Also: - timeout-minutes: 15, so a wedged run cannot bill the subscription for the 6h default. - concurrency group per issue/PR, without cancel-in-progress — a superseded @claude task is still work someone asked for. - drops id-token: write, which is only needed for OIDC federation and is dead weight on the static-token path. No evidence of abuse: every run of this workflow to date was a legitimate internal one, the most recent on 2026-06-23. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/claude.yml | 43 +++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 6b15fac7..4f3c8e17 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -10,19 +10,52 @@ on: pull_request_review: types: [submitted] +# Serialise per issue/PR so a burst of mentions queues instead of running in +# parallel. Deliberately NOT cancel-in-progress: a superseded @claude task is +# still doing work someone asked for, unlike a stale PR review. +concurrency: + group: claude-mention-${{ github.event.issue.number || github.event.pull_request.number }} + cancel-in-progress: false + jobs: claude: + # This repo is PUBLIC and has issues enabled. Unlike `pull_request` runs from + # a fork, `issues` and `issue_comment` events execute in this repo's context + # and therefore DO see repository secrets. Matching on the text "@claude" + # alone let any GitHub user run this job with CLAUDE_CODE_OAUTH_TOKEN in the + # environment — and since the action executes the instructions in the + # comment that tagged it, no injection trick was even required. + # + # So gate on author_association as well. OWNER/MEMBER/COLLABORATOR are + # people with org membership or explicit repo access. CONTRIBUTOR is NOT + # enough: it only means a commit of theirs was merged once. + # The association is checked per event against the person who actually + # triggered it. Do NOT flatten this into one shared OR: on issue_comment the + # payload carries BOTH comment.author_association (the commenter) and + # issue.author_association (whoever opened the thread), so a shared OR would + # let an untrusted account comment "@claude ..." on an issue opened by a + # trusted one and pass the gate. if: | - (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_name == 'issue_comment' && + contains(github.event.comment.body, '@claude') && + contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) || + (github.event_name == 'pull_request_review_comment' && + contains(github.event.comment.body, '@claude') && + contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) || + (github.event_name == 'pull_request_review' && + contains(github.event.review.body, '@claude') && + contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.review.author_association)) || + (github.event_name == 'issues' && + (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')) && + contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.issue.author_association)) runs-on: ubuntu-latest + # Without this a wedged run bills against the Max subscription for the full + # 6h default. + timeout-minutes: 15 permissions: contents: read pull-requests: read issues: read - id-token: write actions: read # Required for Claude to read CI results on PRs steps: - name: Checkout repository