@@ -10,11 +10,16 @@ name: Triage issue with Claude
1010# triage it using only local information — the skill plus the checked-out source.
1111# It then posts the resulting report back onto the issue as a sticky comment.
1212#
13- # Security: the issue body/comments are untrusted input. Claude runs fully
14- # offline for triage — no WebFetch/WebSearch and no Bash, so it cannot follow
15- # links or exfiltrate anything. It only produces a local report file; a separate
16- # deterministic step (which holds the issues:write permission) posts the comment,
17- # so a successful prompt injection cannot post a tampered comment on its own.
13+ # Security: the issue body/comments are untrusted input. Triage is split across
14+ # two jobs so the writable token is never present while the model runs:
15+ # * `triage` runs Claude with `contents: read` only — the GITHUB_TOKEN in its
16+ # environment cannot write to issues. Claude also runs fully offline (no
17+ # WebFetch/WebSearch and no Bash), so it cannot follow links or exfiltrate
18+ # anything. It only produces a local report file.
19+ # * `comment` is a separate, deterministic job that holds `issues: write` and
20+ # does nothing but post the report. It never runs the model.
21+ # So even a successful prompt injection has no write-capable token to abuse and
22+ # cannot post a tampered comment on its own.
1823
1924on :
2025 issues :
2833 required : true
2934 type : string
3035
36+ # Least privilege by default; each job narrows or widens this as needed.
3137permissions :
3238 contents : read
33- issues : write # only the final step uses this, to upsert the triage comment
3439
3540jobs :
3641 triage :
@@ -46,13 +51,18 @@ jobs:
4651 contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association))
4752 runs-on : ubuntu-latest
4853 timeout-minutes : 15
54+ # Read-only: the token handed to Claude below cannot write to issues.
55+ permissions :
56+ contents : read
4957 concurrency :
5058 group : claude-issue-triage-${{ github.repository }}-${{ github.event.inputs.issue_number || github.event.issue.number }}
5159 cancel-in-progress : true
60+ outputs :
61+ issue : ${{ steps.prep.outputs.issue }}
5262 steps :
5363 # Check out the repo so the triage skill (.cursor/skills/triage-issues/)
5464 # and the module source are available locally for offline triage.
55- - uses : actions/checkout@v6
65+ - uses : actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
5666 with :
5767 fetch-depth : 1
5868 persist-credentials : false
@@ -102,13 +112,15 @@ jobs:
102112 uses : anthropics/claude-code-action@fefa07e9c665b7320f08c3b525980457f22f58aa # v1.0.111
103113 with :
104114 anthropic_api_key : ${{ secrets.ANTHROPIC_API_KEY }}
105- # Use the runner-injected GITHUB_TOKEN rather than minting one via OIDC.
115+ # Use the runner-injected GITHUB_TOKEN. Because this job's permissions
116+ # are `contents: read`, this token is read-only and cannot post
117+ # comments or edit issues even if the model is manipulated.
106118 github_token : ${{ github.token }}
107119 # Local-only triage. Claude may read the checked-out repo (skill +
108120 # source) and write only the local report file. No network tools
109121 # (WebFetch/WebSearch) and no Bash, so it cannot follow links, run
110122 # commands, or exfiltrate anything. It cannot edit repo files or post
111- # comments — the next workflow step does that deterministically.
123+ # comments — the separate `comment` job does that deterministically.
112124 claude_args : |
113125 --allowedTools "Read,Glob,Grep,Write"
114126 --disallowedTools "Edit,MultiEdit,NotebookEdit,WebFetch,WebSearch,Bash"
@@ -152,17 +164,53 @@ jobs:
152164 put open items under "Missing Information / Questions for User". Write only the
153165 report to that file — no extra commentary.
154166
167+ - name : Ensure triage report was produced
168+ run : |
169+ set -euo pipefail
170+ REPORT_FILE="triage/triage-report.md"
171+ if [ ! -s "$REPORT_FILE" ]; then
172+ echo "::error::Claude did not produce $REPORT_FILE"
173+ exit 1
174+ fi
175+
176+ # Hand the report to the privileged job via an artifact. Nothing with a
177+ # writable token has run up to this point.
178+ - name : Upload triage report
179+ uses : actions/upload-artifact@v4
180+ with :
181+ name : triage-report
182+ path : triage/triage-report.md
183+ if-no-files-found : error
184+ retention-days : 1
185+
186+ comment :
187+ name : Post triage comment
188+ needs : triage
189+ runs-on : ubuntu-latest
190+ timeout-minutes : 5
191+ # The only job that can write to issues. It runs no model — it just posts
192+ # the report produced by the read-only `triage` job.
193+ permissions :
194+ contents : read
195+ issues : write
196+ steps :
197+ - name : Download triage report
198+ uses : actions/download-artifact@v4
199+ with :
200+ name : triage-report
201+ path : triage
202+
155203 - name : Post triage report as an issue comment
156204 env :
157205 GH_TOKEN : ${{ github.token }}
158206 REPO : ${{ github.repository }}
159- ISSUE : ${{ steps.prep .outputs.issue }}
207+ ISSUE : ${{ needs.triage .outputs.issue }}
160208 run : |
161209 set -euo pipefail
162210
163211 REPORT_FILE="triage/triage-report.md"
164212 if [ ! -s "$REPORT_FILE" ]; then
165- echo "::error::Claude did not produce $REPORT_FILE "
213+ echo "::error::missing $REPORT_FILE artifact from triage job "
166214 exit 1
167215 fi
168216
0 commit comments