Skip to content

Commit 0ff0e43

Browse files
committed
Claude PR review: harden against untrusted reviews
- Only respond to reviews from maintainers (OWNER, MEMBER, COLLABORATOR) - Do not give the agent the fork token: no GH_TOKEN, persist-credentials: false - Push commits, reply and post status in a separate job, so nothing the agent leaves running can read the tokens
1 parent 393a0a9 commit 0ff0e43

1 file changed

Lines changed: 83 additions & 13 deletions

File tree

‎.github/workflows/claude-pr-review.yml‎

Lines changed: 83 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,20 @@ jobs:
4343
with:
4444
egress-policy: audit
4545

46-
- name: Check if review is from phpstan-bot
46+
- name: Check if review is from a maintainer
4747
id: check
4848
env:
4949
GH_TOKEN: ${{ secrets.PHPSTAN_BOT_PR_TOKEN }}
5050
run: |
51-
reviewer=$(gh api "repos/phpstan/phpstan-src/pulls/${{ inputs.pr_number }}/reviews/${{ inputs.review_id }}" --jq '.user.login')
51+
review=$(gh api "repos/phpstan/phpstan-src/pulls/${{ inputs.pr_number }}/reviews/${{ inputs.review_id }}")
52+
reviewer=$(echo "$review" | jq -r '.user.login')
53+
association=$(echo "$review" | jq -r '.author_association')
5254
if [ "$reviewer" = "phpstan-bot" ]; then
5355
echo "Skipping review from phpstan-bot"
5456
echo "skip=true" >> "$GITHUB_OUTPUT"
57+
elif [ "$association" != "OWNER" ] && [ "$association" != "MEMBER" ] && [ "$association" != "COLLABORATOR" ]; then
58+
echo "Skipping review from $reviewer ($association), not a maintainer"
59+
echo "skip=true" >> "$GITHUB_OUTPUT"
5560
else
5661
echo "skip=false" >> "$GITHUB_OUTPUT"
5762
fi
@@ -62,8 +67,11 @@ jobs:
6267
runs-on: ubuntu-latest
6368
timeout-minutes: 120
6469
permissions:
65-
contents: write
66-
pull-requests: write
70+
contents: read
71+
outputs:
72+
pr_head_ref: ${{ steps.review.outputs.pr_head_ref }}
73+
pr_head_sha: ${{ steps.review.outputs.pr_head_sha }}
74+
claude_outcome: ${{ steps.run-claude.outcome }}
6775
steps:
6876
- name: Harden the runner (Audit all outbound calls)
6977
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
@@ -140,7 +148,7 @@ jobs:
140148
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
141149
with:
142150
ref: ${{ steps.review.outputs.pr_head_ref }}
143-
token: ${{ secrets.PHPSTAN_BOT_FORK_TOKEN }}
151+
persist-credentials: false
144152
fetch-depth: 0
145153

146154
- name: "Install PHP"
@@ -360,7 +368,7 @@ jobs:
360368
prompt+="Please address this review. Make the requested code changes, then run tests with \`make tests\` and static analysis with \`make phpstan\` to verify."
361369
prompt+=$'\n'
362370
prompt+="Commit each logical change separately with a descriptive message."
363-
prompt+="Push the commits in the repo."
371+
prompt+=" Do not push, the commits will be pushed in a separate step. "
364372
prompt+="The results of this prompt will be posted to the pull request in a separate step."
365373
366374
{
@@ -376,9 +384,9 @@ jobs:
376384
id: run-claude
377385
env:
378386
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
379-
GH_TOKEN: ${{ secrets.PHPSTAN_BOT_FORK_TOKEN }}
380387
PROMPT: ${{ steps.prompt.outputs.PROMPT }}
381388
run: |
389+
git rev-parse HEAD > /tmp/base-sha.txt
382390
git config user.name "phpstan-bot"
383391
git config user.email "ondrej+phpstanbot@mirtes.cz"
384392
@@ -388,14 +396,75 @@ jobs:
388396
-p "$PROMPT" \
389397
> /tmp/claude-response.txt
390398
399+
- name: Save Claude output
400+
if: always()
401+
run: |
402+
mkdir -p /tmp/claude-output
403+
if [ -f /tmp/claude-response.txt ]; then
404+
cp /tmp/claude-response.txt /tmp/claude-output/response.txt
405+
fi
406+
base_sha=$(cat /tmp/base-sha.txt)
407+
if [ "$(git rev-list --count "$base_sha..HEAD")" -gt 0 ]; then
408+
git bundle create /tmp/claude-output/changes.bundle "$base_sha..HEAD"
409+
fi
410+
411+
- name: Upload Claude output
412+
if: always()
413+
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
414+
with:
415+
name: claude-output
416+
path: /tmp/claude-output/
417+
if-no-files-found: ignore
418+
419+
# Runs in a fresh VM so that nothing the agent left behind can read these tokens.
420+
publish:
421+
needs: respond
422+
if: always() && needs.respond.result != 'skipped'
423+
runs-on: ubuntu-latest
424+
timeout-minutes: 10
425+
permissions:
426+
contents: read
427+
steps:
428+
- name: Harden the runner (Audit all outbound calls)
429+
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
430+
with:
431+
egress-policy: audit
432+
433+
- name: Download Claude output
434+
continue-on-error: true
435+
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
436+
with:
437+
name: claude-output
438+
path: /tmp/claude-output
439+
440+
- name: "Checkout"
441+
if: needs.respond.outputs.claude_outcome == 'success'
442+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
443+
with:
444+
ref: ${{ needs.respond.outputs.pr_head_ref }}
445+
token: ${{ secrets.PHPSTAN_BOT_FORK_TOKEN }}
446+
447+
- name: Push commits
448+
id: push
449+
if: needs.respond.outputs.claude_outcome == 'success'
450+
env:
451+
PR_HEAD_REF: ${{ needs.respond.outputs.pr_head_ref }}
452+
run: |
453+
if [ ! -f /tmp/claude-output/changes.bundle ]; then
454+
echo "No new commits to push"
455+
exit 0
456+
fi
457+
git fetch /tmp/claude-output/changes.bundle HEAD
458+
git push origin "FETCH_HEAD:refs/heads/$PR_HEAD_REF"
459+
391460
- name: Reply to review
392461
if: always()
393462
env:
394463
GH_TOKEN: ${{ secrets.PHPSTAN_BOT_PR_TOKEN }}
395464
run: |
396465
body=""
397-
if [ -f /tmp/claude-response.txt ]; then
398-
body=$(cat /tmp/claude-response.txt)
466+
if [ -f /tmp/claude-output/response.txt ]; then
467+
body=$(cat /tmp/claude-output/response.txt)
399468
fi
400469
401470
if [ -z "$body" ]; then
@@ -405,7 +474,7 @@ jobs:
405474
gh api \
406475
"repos/phpstan/phpstan-src/pulls/${{ inputs.pr_number }}/comments" \
407476
-f body="$body" \
408-
-F in_reply_to="$(jq -r '.[0].id // empty' /tmp/review-comments.json)" \
477+
-F in_reply_to="$(gh api "repos/phpstan/phpstan-src/pulls/${{ inputs.pr_number }}/reviews/${{ inputs.review_id }}/comments" --jq '.[0].id // empty')" \
409478
2>/dev/null \
410479
|| gh pr comment "${{ inputs.pr_number }}" \
411480
--repo "phpstan/phpstan-src" \
@@ -415,11 +484,12 @@ jobs:
415484
if: always()
416485
env:
417486
GH_TOKEN: ${{ secrets.PHPSTAN_BOT_PR_TOKEN }}
418-
PR_HEAD_SHA: ${{ steps.review.outputs.pr_head_sha }}
487+
PR_HEAD_SHA: ${{ needs.respond.outputs.pr_head_sha }}
419488
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
420-
CLAUDE_OUTCOME: ${{ steps.run-claude.outcome }}
489+
CLAUDE_OUTCOME: ${{ needs.respond.outputs.claude_outcome }}
490+
PUSH_OUTCOME: ${{ steps.push.outcome }}
421491
run: |
422-
if [ "$CLAUDE_OUTCOME" = "success" ]; then
492+
if [ "$CLAUDE_OUTCOME" = "success" ] && [ "$PUSH_OUTCOME" != "failure" ]; then
423493
state=success
424494
description="Review addressed"
425495
else

0 commit comments

Comments
 (0)