From 752068487d721ac92ffdfa074aa5522799021296 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 00:32:20 +0000 Subject: [PATCH 1/2] ci: triage on plain pull_request with explicit github token; skip fork prs Fixes the ai-triage Claude step's startup failure (#312): the action's OIDC-to-app-token exchange returns 401 for tokens minted under pull_request_target, so the step died before any session started. Bun-style fix, plus a deliberate scope cut: - Trigger switches pull_request_target -> pull_request, and the job if gains a head-repo guard so fork PRs skip cleanly (auto AND label paths). Fork PRs are no longer triaged at all - same trade-off oven-sh/bun makes, removing the pull_request_target risk class instead of mitigating it. - The Claude step now passes github_token (GITHUB_TOKEN), which short-circuits the OIDC exchange entirely; id-token: write is dropped. Triage comments post as github-actions[bot]. - Everything probing those comments is now author-agnostic (marker + Bot-type author, never a login): the three triage command pre-checks and auto-close-duplicates.mjs findMarkerComment (which previously hard-required claude[bot] and would have matched nothing ever again). - Docs: ai-development guide + root CLAUDE.md note the same-repo-only PR scope and the new comment author. Fixes #312 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Pohc8xLkdx4gwXkW3xd7up --- .claude/commands/triage-dedupe.md | 5 +- .claude/commands/triage-find-duplicate-prs.md | 5 +- .claude/commands/triage-find-issues.md | 5 +- .github/workflows/ai-triage.yml | 56 +++++++++++++------ CLAUDE.md | 3 +- .../guides/getting-started/ai-development.md | 6 +- scripts/auto-close-duplicates.mjs | 14 +++-- 7 files changed, 64 insertions(+), 30 deletions(-) diff --git a/.claude/commands/triage-dedupe.md b/.claude/commands/triage-dedupe.md index 47ffac7d4..7ae937957 100644 --- a/.claude/commands/triage-dedupe.md +++ b/.claude/commands/triage-dedupe.md @@ -16,8 +16,9 @@ run locally), and `AUTOCLOSE` (`active` or `off`; assume `off` locally). If 1. `gh issue view NUMBER --repo REPO --json state,title,body,comments` — if the issue is not open, stop. -2. Marker check — does any existing comment by a claude bot account contain - ``? +2. Marker check — does any existing bot-authored comment contain + ``? (Match the marker + a bot author, not a + specific login — the workflow posts as github-actions[bot].) - `TRIGGER=opened` and marker present → stop (already triaged). - `TRIGGER=labeled` and marker present → continue; at the end REFRESH that comment instead of posting a new one: if it is your most recent diff --git a/.claude/commands/triage-find-duplicate-prs.md b/.claude/commands/triage-find-duplicate-prs.md index 5965beb5e..04e90f4d2 100644 --- a/.claude/commands/triage-find-duplicate-prs.md +++ b/.claude/commands/triage-find-duplicate-prs.md @@ -14,8 +14,9 @@ assume `labeled` locally). If `NUMBER` is missing, ask. 1. `gh pr view NUMBER --repo REPO --json state,title,body,files,comments` — if the PR is not open, stop. -2. Marker check — a claude-bot comment containing - ``: +2. Marker check — a bot-authored comment containing + `` (match marker + bot author, not + a specific login — the workflow posts as github-actions[bot]): - `TRIGGER=opened` and marker present → stop. - `TRIGGER=labeled` and marker present → continue; at the end refresh that comment (`gh pr comment NUMBER --repo REPO --edit-last --body ...` diff --git a/.claude/commands/triage-find-issues.md b/.claude/commands/triage-find-issues.md index 2e8b2b4aa..1b212c5f0 100644 --- a/.claude/commands/triage-find-issues.md +++ b/.claude/commands/triage-find-issues.md @@ -14,8 +14,9 @@ EXACTLY ONE comment (or nothing). Context from the caller: `REPO`, `NUMBER` 1. `gh pr view NUMBER --repo REPO --json state,title,body,comments` — if the PR is not open, stop. -2. Marker check — a claude-bot comment containing - ``: +2. Marker check — a bot-authored comment containing + `` (match marker + bot author, not a + specific login — the workflow posts as github-actions[bot]): - `TRIGGER=opened` and marker present → stop. - `TRIGGER=labeled` and marker present → continue; at the end refresh that comment (`gh pr comment NUMBER --repo REPO --edit-last --body ...` diff --git a/.github/workflows/ai-triage.yml b/.github/workflows/ai-triage.yml index 0d380de32..d7755d108 100644 --- a/.github/workflows/ai-triage.yml +++ b/.github/workflows/ai-triage.yml @@ -42,12 +42,26 @@ name: AI Triage # - auto runs skip bot authors (bestaxbot, *[bot]), claude/* heads, and # PRs whose body already links an issue — AI-loop PRs self-link by # construction, so auto-triage would only duplicate the loop. +# - SAME-REPO PRs ONLY (both the auto and label paths). This uses plain +# `pull_request`, NOT `pull_request_target`, and the job `if` skips +# fork heads explicitly. Fork PRs therefore never get triage — the +# same trade-off oven-sh/bun makes. Deliberate, two reasons: +# 1. `pull_request_target` hands fork-triggered runs our secrets and +# a write token; the no-PR-checkout + allowlist hardening below +# made that survivable, but same-repo-only removes the risk class +# instead of mitigating it. +# 2. The Claude action's OIDC→app-token exchange 401s under +# `pull_request_target` anyway (#312) — it was never exercisable. +# Do not switch this back to `pull_request_target` without reading +# #312. Fork runs would also lack secrets under `pull_request`; the +# explicit guard just turns that failure into a clean, silent skip. # - This never checks out PR head code — only the base repo's default -# branch — so `pull_request_target` is safe here. The Claude tool -# allowlist is GET-only gh commands + the two comment commands + Task -# (the fan-out sub-agents) — no `gh api`, which could also -# POST/PATCH/DELETE, and no file-editing tools, because the session -# ingests untrusted issue/PR text. +# branch (defense in depth, and it keeps the job honest if the fork +# guard is ever loosened). The Claude tool allowlist is GET-only gh +# commands + the two comment commands + Task (the fan-out sub-agents) +# — no `gh api`, which could also POST/PATCH/DELETE, and no +# file-editing tools, because the session ingests untrusted issue/PR +# text. # # KILL SWITCHES: AI_TRIAGE_MODE=off (this workflow only) or # AI_LOOP_ENABLED=false (all Claude spend, repo-wide). @@ -56,7 +70,7 @@ name: AI Triage # "Run one-shot AI triage: related issues + duplicates (sonnet; triage+ only)". on: - pull_request_target: + pull_request: types: [opened, labeled] issues: types: [opened, labeled] @@ -92,9 +106,14 @@ jobs: # - 'auto' ⇒ both paths live. # labeled events additionally require the ai-triage label from a real # user; anything else is a silent no-op that spends no usage. + # The head-repo guard kills fork PRs (auto AND label paths) before any + # step runs — see SECURITY above; issue events have no pull_request and + # pass the null arm. if: | vars.AI_LOOP_ENABLED == 'true' && vars.AI_TRIAGE_MODE != 'off' && + (github.event.pull_request == null || + github.event.pull_request.head.repo.full_name == github.repository) && ( (github.event.action == 'labeled' && github.event.label.name == 'ai-triage' && @@ -108,7 +127,8 @@ jobs: contents: read # checkout of the base default branch pull-requests: write # post the triage comment on PRs; remove the label issues: write # post the triage comment on issues; budget marker; remove the label - id-token: write # OIDC exchange for the Claude OAuth token + # No id-token: the Claude step gets github_token explicitly, which + # short-circuits the action's OIDC→app-token exchange (#312). steps: # Layer 2 (authoritative, zero Claude usage). Two shapes: # labeled → GitHub already restricts labeling to triage+; re-verify @@ -238,12 +258,12 @@ jobs: echo "budget charged: $NEXT/$LIMIT for $TODAY — running triage" out run true - # Check out ONLY the base repo's default branch — never the PR head. - # Under pull_request_target that keeps untrusted PR code out of the - # workspace; the triage agent reads PR content via read-only gh - # commands. The checkout also provides the .claude/commands/ triage - # commands the session runs. No pnpm/node setup and no dependency - # install: this job never runs project code. + # Check out ONLY the base repo's default branch — never the PR head + # (untrusted PR code stays out of the workspace; the triage agent + # reads PR content via read-only gh commands). The checkout also + # provides the .claude/commands/ triage commands the session runs. + # No pnpm/node setup and no dependency install: this job never runs + # project code. - name: Checkout base default branch if: steps.gate.outputs.run == 'true' uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 @@ -255,10 +275,14 @@ jobs: if: steps.gate.outputs.run == 'true' uses: anthropics/claude-code-action@51c47629174b5dbca955cf93690fd39c8a41dadf # v1 with: - # OAuth token only — posts as claude[bot]. Safe: these are plain - # top-level comments; the loop's state machine only classifies - # review threads. + # CLAUDE_CODE_OAUTH_TOKEN pays for the session. github_token is + # passed explicitly (Bun-style) so the action skips its OIDC→app + # token exchange (#312); triage comments therefore post as + # github-actions[bot] — anything probing for them must match the + # marker + a Bot-type author, never a specific login. Bonus: + # GITHUB_TOKEN-authored comments can't re-trigger workflows. claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} # Task is required for the commands' fan-out search sub-agents # (they inherit this same allowlist). Still no `gh api` and no # Edit/Write — the session ingests untrusted issue/PR text. diff --git a/CLAUDE.md b/CLAUDE.md index 70373ac49..28809b9e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,8 @@ Separately, `ai-triage` runs a one-shot sonnet triage session that comments with issues/duplicates: automatic on new issues/PRs when `AI_TRIAGE_MODE=auto` (outside authors capped at `AI_TRIAGE_DAILY_LIMIT`/day via a counter comment on issue #290; items opened by triage+ collaborators are uncapped), or on demand via the label -(triage+ only, budget-exempt; auto-removed after the run). Flagged duplicates may be +(triage+ only, budget-exempt; auto-removed after the run). Fork PRs are never triaged +(same-repo `pull_request` only — never `pull_request_target`; see #312). Flagged duplicates may be auto-closed after 14 days per `AI_TRIAGE_AUTOCLOSE` (see the ai-development docs guide). Stale automation: PRs go `stale` at 30 days and close 14 days later — except Claude-assisted PRs (`claude-assisted` label or bestaxbot author), which skip that sweep and instead close diff --git a/docs/docs/guides/getting-started/ai-development.md b/docs/docs/guides/getting-started/ai-development.md index 8c7bd9331..2d2462f4e 100644 --- a/docs/docs/guides/getting-started/ai-development.md +++ b/docs/docs/guides/getting-started/ai-development.md @@ -62,7 +62,11 @@ PRs authored by the loop move through a small label lifecycle: ### AI triage New issues and PRs can get an automatic triage comment: likely duplicates (issues), the open -issues a PR probably resolves, and overlapping PRs. Three repository variables control it: +issues a PR probably resolves, and overlapping PRs. Triage comments are posted by +`github-actions[bot]`. **Only same-repo PRs are triaged** — PRs opened from forks are always +skipped, automatic and label alike (the workflow deliberately avoids GitHub's +`pull_request_target` trigger, so fork-originated events can never run with repository +secrets); issues have no such restriction. Three repository variables control it: - **`AI_TRIAGE_MODE`** — `auto` (new issues/PRs are triaged automatically and the label still works), `label` (opt-in only; the default when unset), or `off` (disables both automatic diff --git a/scripts/auto-close-duplicates.mjs b/scripts/auto-close-duplicates.mjs index 74b2358b4..d8667ac5c 100644 --- a/scripts/auto-close-duplicates.mjs +++ b/scripts/auto-close-duplicates.mjs @@ -5,7 +5,7 @@ * Ported from oven-sh/bun's scripts/auto-close-duplicates.ts, with the * objection window widened from 3 to 14 days (deliberate owner choice). * - * An issue is a close candidate when its LATEST claude[bot] comment carries + * An issue is a close candidate when its LATEST bot-authored comment carries * the `` idempotency marker AND a machine-parseable * `Duplicate of #N` line (posted by the ai-triage workflow, PR A of #289). * The candidate is closed only if ALL of these hold: @@ -81,15 +81,17 @@ export function isBot(user) { } /** - * Find the LATEST comment authored by claude[bot] that carries the dedupe - * marker and a parseable `Duplicate of #N`. Returns - * { comment, target } or null. `comments` must be in ascending created order - * (the REST API default for issue comments). + * Find the LATEST bot-authored comment that carries the dedupe marker and a + * parseable `Duplicate of #N`. Matched by marker + Bot author, never a + * specific login (Bun's convention): the triage workflow posts via + * GITHUB_TOKEN as github-actions[bot] since #312, while pre-#312 comments + * are claude[bot]. Returns { comment, target } or null. `comments` must be + * in ascending created order (the REST API default for issue comments). */ export function findMarkerComment(comments) { for (let i = comments.length - 1; i >= 0; i--) { const c = comments[i]; - if (c.user?.login !== 'claude[bot]' || c.user?.type !== 'Bot') continue; + if (!isBot(c.user)) continue; if (!c.body?.includes(MARKER)) continue; const m = c.body.match(DUPLICATE_RE); if (!m) continue; From c586facf7319abdbe7b3a1d332a7140b9e0e7ffb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 00:39:05 +0000 Subject: [PATCH 2/2] ci: document the fork-pr stuck-label residual in ai-triage The job-level fork guard also skips the always() label-removal step, so labeling a fork PR wedges the ai-triage label. Not fixable under plain pull_request (fork runs cap GITHUB_TOKEN at read-only, so even a standalone unlabel job's DELETE would 403) - accepted and documented: fork PRs are never triaged, the label is inert, manual removal. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Pohc8xLkdx4gwXkW3xd7up --- .github/workflows/ai-triage.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ai-triage.yml b/.github/workflows/ai-triage.yml index d7755d108..4218e6f80 100644 --- a/.github/workflows/ai-triage.yml +++ b/.github/workflows/ai-triage.yml @@ -333,6 +333,14 @@ jobs: # re-pressable button and never wedges "on". Removal uses only # GITHUB_TOKEN, so an unauthorized labeling still spends zero Claude # usage. Opened runs have no label to remove. + # + # Known residual: labeling a FORK PR wedges the label (the job-level + # fork guard skips this step too), and it is not fixable under plain + # `pull_request` — fork-triggered runs cap GITHUB_TOKEN at read-only, + # so even a standalone unlabel job's DELETE would 403. Accepted: + # fork PRs are never triaged anyway, the stuck label is inert, and a + # maintainer removes it by hand. Unwedging would need + # `pull_request_target`, which this workflow deliberately dropped. - name: Remove ai-triage label if: always() && github.event.action == 'labeled' env: