Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 38 additions & 6 deletions .github/workflows/claude.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,19 +49,50 @@ jobs:
actions: read
id-token: write
steps:
# CodeQL actions/untrusted-checkout-toctou: refs/pull/N/head is mutable, an
# attacker could push a different commit after the trusted comment fires this
# job but before checkout runs. Resolve the SHA once, right after the trust
# gate above, and pin checkout to it instead of the branch ref.
# The trust gate above vouches for the commenter, not for the PR head: a fork
# author can push a new commit after a maintainer comments and before this job
# starts, and the run would review code nobody with write access has seen
# (CodeQL actions/untrusted-checkout/high and untrusted-checkout-toctou).
#
# So the SHA is resolved once and checkout is pinned to it, and on a fork PR
# the run stops when the fork was pushed at or after the comment. The fork's
# pushed_at is set by GitHub on push, unlike commit dates, which the author
# controls. It is read after the SHA, so a push in between also stops the run.
# Same-repo PRs skip the date check: their pushers already have write access.
# The automatic pull_request review has no comment and only runs on same-repo
# PRs (see the job's if:).
# https://github.com/AdnaneKhan/ActionsTOCTOU
# https://docs.github.com/en/actions/reference/security/secure-use
- name: Resolve trusted PR head SHA
id: resolve-sha
shell: bash
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }}
COMMENTED_AT: ${{ github.event.comment.created_at || github.event.review.submitted_at }}
run: |
PR_NUMBER="${{ github.event.issue.number || github.event.pull_request.number }}"
SHA=$(gh pr view "$PR_NUMBER" --repo "${{ github.repository }}" --json headRefOid --jq .headRefOid)
set -euo pipefail
PR_FIELDS=$(gh api "repos/$REPO/pulls/$PR_NUMBER" \
--jq '[.head.sha, (.head.repo.full_name != .base.repo.full_name), (.head.repo.full_name // "")] | @tsv')
read -r SHA IS_FORK HEAD_REPO <<< "$PR_FIELDS"
if [[ ! "$SHA" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::Could not resolve the head SHA of PR #$PR_NUMBER"
exit 1
fi

if [[ -n "$COMMENTED_AT" && "$IS_FORK" == "true" ]]; then
if [[ -z "$HEAD_REPO" ]]; then
echo "::error::The fork behind PR #$PR_NUMBER no longer exists; refusing to check out its head"
exit 1
fi
HEAD_PUSHED_AT=$(gh api "repos/$HEAD_REPO" --jq .pushed_at)
if [[ $(date -d "$HEAD_PUSHED_AT" +%s) -ge $(date -d "$COMMENTED_AT" +%s) ]]; then
echo "::error::$HEAD_REPO was pushed at $HEAD_PUSHED_AT, at or after the triggering comment ($COMMENTED_AT). Review the latest push and mention @claude again."
exit 1
fi
fi

echo "sha=$SHA" >> "$GITHUB_OUTPUT"

- name: Checkout PR head
Expand All @@ -70,6 +101,7 @@ jobs:
# Agent mode performs no branch setup of its own (unlike tag mode), and
# a comment event points at the default branch — without an explicit ref
# every file Claude reads would be master's copy, not the change's.
# Only the SHA vetted by the step above: never the mutable PR ref.
ref: ${{ steps.resolve-sha.outputs.sha }}
fetch-depth: 1

Expand Down
Loading