From 7a7eeb29ec10ba4ef26460b4dac66fff87569b5f Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 6 Oct 2026 11:40:16 -0700 Subject: [PATCH] fix(ci): keep the Claude app token out of the on-demand reviewer's reach claude-code-action, with no github_token input, swaps the job token for a `claude` GitHub App token minted over OIDC. Its scopes come from the app installation (contents, workflows and actions write), not from the job's `permissions:` block, so `contents: read` was never the boundary. The action then writes that token into the origin remote URL in .git/config after checkout, undoing `persist-credentials: false`, and the model's `Read` plus `gh pr comment` is a path from that file to a public comment. Hand both on-demand workflows the job's own token, drop `id-token: write`, and deny `Read(.git/**)` and `Read(//proc/**)`, the latter because the action also passes its token to the Claude process's environment. --- .../workflows/claude-code-focus-on-demand.yml | 15 +++++-- .../claude-code-review-on-demand.yml | 45 ++++++++++++++----- 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/.github/workflows/claude-code-focus-on-demand.yml b/.github/workflows/claude-code-focus-on-demand.yml index 524957e5..44157466 100644 --- a/.github/workflows/claude-code-focus-on-demand.yml +++ b/.github/workflows/claude-code-focus-on-demand.yml @@ -13,14 +13,19 @@ name: Claude Code Focus (on demand) # short: # - fires ONLY on a maintainer's comment (author_association gate); # - no `contents: write`, and a read-only tool allowlist, so it can never -# write code or push; +# write code or push. That holds only because the action is handed +# `github_token`; otherwise it mints a `claude` App token whose scopes the +# `permissions:` block does not limit; # - the comment body is never interpolated into the prompt. The only thing # this workflow takes from it is N, extracted by a shell regex as digits # only and clamped to a range. The action separately forwards whatever # follows `@claude /focus` as plain text (see the review workflow header), # which for `@claude /focus 5` is just `5`; # - `Read` is safe ONLY alongside `persist-credentials: false` on the -# checkout, and the checkout MUST be the PR's own ref. +# checkout, `github_token`, and the `--disallowedTools` deny rules on +# `.git` and `/proc`, since the action embeds its token in the origin +# remote URL and in the Claude process's environment. The checkout MUST be +# the PR's own ref. # # `issue_comment` only, unlike `/review`, which also answers an inline review # comment. `/focus` is a whole-diff question, and an inline trigger has no @@ -42,11 +47,11 @@ jobs: github.event.comment.author_association == 'COLLABORATOR') && github.event.issue.pull_request runs-on: ubuntu-latest + # No `id-token: write`: see the review workflow's `permissions:` comment. permissions: contents: read pull-requests: write issues: write - id-token: write steps: # The body arrives as an ENVIRONMENT VARIABLE, never as a `${{ }}` @@ -129,6 +134,9 @@ jobs: uses: anthropics/claude-code-action@0a8d3c9443bbff909ab973b6a17a340b913f229f # v1.0.221 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + # The job's own token, bounded by `permissions:` above, instead of the + # `claude` App token. See the review workflow's `github_token` comment. + github_token: ${{ github.token }} track_progress: true # Without the command word here, tag mode forwards `/focus` to the CLI # as a slash command and the run ends before a single model call. The @@ -247,6 +255,7 @@ jobs: # `gh api` stay out. Do not widen this one independently. claude_args: | --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Read" + --disallowedTools "Read(.git/**),Read(//proc/**)" --append-system-prompt "Correction to the base instructions, which were written for a code-writing run and do not describe this one. This is a READ-ONLY triage invocation. You cannot stage, commit, push or delete files, there is no push script, and git is not on the allowlist, so git add, git commit, git rm, git status, git diff and git log are all refused. Ignore the base instruction to use git diff origin/main...HEAD for the PR diff. Use gh pr diff for the diff, gh pr view --json commits for the commit list, gh pr view --json headRefOid for the head SHA, gh pr view --json files for changed files, and Read for file contents. Per-file history is genuinely unavailable. Never state that you ran a git command." # A run that posts NOTHING must not report success. The review diff --git a/.github/workflows/claude-code-review-on-demand.yml b/.github/workflows/claude-code-review-on-demand.yml index 2758a228..afe6a615 100644 --- a/.github/workflows/claude-code-review-on-demand.yml +++ b/.github/workflows/claude-code-review-on-demand.yml @@ -45,19 +45,32 @@ name: Claude Code Review (on demand) # Scoped so Claude can never write code from an invocation: # - runs the code-review PLUGIN with a review prompt (analyze + post findings), # not the general code-writing action; -# - no `contents: write`, so it cannot push commits; +# - no `contents: write`, so it cannot push commits. This holds only because +# the action is handed `github_token` (see that input): without it, the +# action mints a `claude` GitHub App token over OIDC, whose scopes come from +# the app installation (contents, workflows and actions write) and which +# the `permissions:` block below does not limit at all; # - fires ONLY on a maintainer's comment (author_association gate), so an # outside contributor on a fork can never trigger it. # -# `Read` in `--allowedTools` is COUPLED to `persist-credentials: false` on the -# checkout below. The reviewer needs to open whole files — the findings worth +# `Read` in `--allowedTools` is COUPLED to keeping every token out of the +# model's reach. The reviewer needs to open whole files — the findings worth # having come from surviving references in untouched regions, from a directory's # real contents, from a cross-file ordering dependency — none of which a diff -# hunk shows. But without `persist-credentials: false`, actions/checkout writes -# this job's `GITHUB_TOKEN` into `.git/config` INSIDE the tree being reviewed, -# and `Read` plus `Bash(gh pr comment:*)` is then a complete path from that file -# to a public comment on a public repo. Do not remove either one without -# removing the other: they are safe together and unsafe apart. +# hunk shows. But `Read` plus `Bash(gh pr comment:*)` is a complete path from +# any file holding a token to a public comment on a public repo, so: +# - `persist-credentials: false` on the checkout keeps actions/checkout's +# token out of `.git/config`; +# - that is NOT sufficient on its own. The action rewrites the origin remote +# URL to embed ITS token (`https://x-access-token:@github.com/...`, +# logged as "Updated remote URL with authentication token") after checkout, +# whatever the checkout did. So `--disallowedTools` denies `Read(.git/**)`; +# - the token also sits in the Claude process's environment, which is how +# `gh pr comment` authenticates, so `Read(//proc/**)` is denied as well. +# Do not remove any of these without removing `Read`: they are safe together +# and unsafe apart. Claude Code applies `Read` deny rules to Grep and Glob on a +# best-effort basis, and the action grants both by default (it merges its own +# tool list into ours: check "SDK options" in a run log for the real set). # # The checkout MUST be the PR's own ref, never the default one. Neither # `issue_comment` nor `pull_request_review_comment` is a PR event, so @@ -91,11 +104,14 @@ jobs: (github.event_name == 'pull_request_review_comment' || github.event.issue.pull_request) runs-on: ubuntu-latest + # These scopes ARE the model's reach, because the action is handed this + # job's token. No `id-token: write`: the action used OIDC only to mint the + # broader `claude` App token, which is exactly what `github_token` avoids. + # Claude itself authenticates with the OAuth token, not OIDC. permissions: contents: read pull-requests: write issues: write - id-token: write steps: # Resolved BEFORE checkout: the checkout ref depends on it. The PR number @@ -225,6 +241,13 @@ jobs: uses: anthropics/claude-code-action@0a8d3c9443bbff909ab973b6a17a340b913f229f # v1.0.221 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + # The job's own token, bounded by `permissions:` above. A provided + # token skips the action's OIDC exchange for the `claude` App token, + # which carries the installation's scopes (contents, workflows and + # actions write) and would be embedded in `.git/config` for the + # model to find. Comments are posted as github-actions[bot]; the + # verify step filters on `.user.type == "Bot"`, not on a login. + github_token: ${{ github.token }} # Single tracking comment (in-progress → results), updated in place. track_progress: true # The COMMAND WORD belongs in the trigger phrase. `track_progress` @@ -297,7 +320,8 @@ jobs: # In agent mode (comment-triggered) Claude only posts if it has these # tools. All read-only or comment-posting — no local build/test, no CI # reads. `Read` is read-only file access, and is safe ONLY alongside - # `persist-credentials: false` above (see the header). Do NOT add + # `persist-credentials: false`, `github_token`, and the + # `--disallowedTools` deny rules below (see the header). Do NOT add # `gh api`, `git`, or a bare `Bash`: `gh api` is write-capable, and # this model ingests untrusted diff content. Privileged work belongs # in workflow steps, which are deterministic and never read model @@ -364,6 +388,7 @@ jobs: # looking for another way round. claude_args: | --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Read" + --disallowedTools "Read(.git/**),Read(//proc/**)" --append-system-prompt "Correction to the base instructions, which were written for a code-writing run and do not describe this one. This is a REVIEW-ONLY invocation. You cannot stage, commit, push or delete files, there is no push script, and git is not on the allowlist, so git add, git commit, git rm, git status, git diff and git log are all refused. Ignore the base instruction to use git diff origin/main...HEAD for the PR diff. Use gh pr diff for the diff, gh pr view --json commits for the commit list, gh pr view --json headRefOid for the head SHA, gh pr view --json files for changed files, and Read for file contents. Per-file history is genuinely unavailable. Never state that you ran a git command." # A review that posts NOTHING must not report success.