Skip to content

Commit 6ef4953

Browse files
fix: use pull_request_target for agentic CI on fork PRs (#541)
* fix: use pull_request_target for agentic CI on fork PRs * fix: read recipe files from base branch to prevent prompt injection Recipe files define the agent's prompt. When using pull_request_target, the fork's HEAD is checked out, so a malicious fork could craft recipe files to exfiltrate API secrets via prompt injection. Fix by adding a second sparse checkout from the base branch for .agents/recipes/ and reading prompts from there instead of the fork tree. * fix: align actions/checkout version for base-recipes checkout Match the base-branch recipe checkout to v6.0.2 (same SHA as the PR branch checkout) for consistency. * fix: move expression interpolations to env vars in gate and review jobs Replace direct ${{ }} interpolation in run: blocks with env vars. Most values are GitHub-controlled, but github.event.label.name can contain arbitrary characters and could break shell quoting. Moving everything to env: is consistent with the injection-hardening pattern applied in the rest of the workflow.
1 parent a9af365 commit 6ef4953

1 file changed

Lines changed: 42 additions & 16 deletions

File tree

.github/workflows/agentic-ci-pr-review.yml

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name: "Agentic CI: PR Review"
22

33
on:
4-
pull_request:
4+
pull_request_target:
55
types: [opened, ready_for_review, labeled]
66
branches: [main]
77
workflow_dispatch:
@@ -32,23 +32,30 @@ jobs:
3232
id: check
3333
env:
3434
GH_TOKEN: ${{ github.token }}
35+
EVENT_NAME: ${{ github.event_name }}
36+
EVENT_ACTION: ${{ github.event.action }}
37+
LABEL_NAME: ${{ github.event.label.name }}
38+
IS_DRAFT: ${{ github.event.pull_request.draft }}
39+
SENDER_LOGIN: ${{ github.event.sender.login }}
40+
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
41+
REPO: ${{ github.repository }}
3542
run: |
3643
# workflow_dispatch callers already have write access.
37-
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
44+
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
3845
echo "allowed=true" >> "$GITHUB_OUTPUT"
3946
exit 0
4047
fi
4148
4249
# Only the agent-review label should trigger a run.
43-
if [ "${{ github.event.action }}" = "labeled" ] && [ "${{ github.event.label.name }}" != "agent-review" ]; then
50+
if [ "$EVENT_ACTION" = "labeled" ] && [ "$LABEL_NAME" != "agent-review" ]; then
4451
echo "Skipping: labeled event but not agent-review"
4552
echo "allowed=false" >> "$GITHUB_OUTPUT"
4653
exit 0
4754
fi
4855
4956
# Skip drafts unless agent-review label is being added.
50-
if [ "${{ github.event.pull_request.draft }}" = "true" ]; then
51-
if [ "${{ github.event.action }}" != "labeled" ] || [ "${{ github.event.label.name }}" != "agent-review" ]; then
57+
if [ "$IS_DRAFT" = "true" ]; then
58+
if [ "$EVENT_ACTION" != "labeled" ] || [ "$LABEL_NAME" != "agent-review" ]; then
5259
echo "Skipping: draft PR"
5360
echo "allowed=false" >> "$GITHUB_OUTPUT"
5461
exit 0
@@ -58,15 +65,15 @@ jobs:
5865
# For labeled events, check the sender (who added the label) so
5966
# maintainers can authorize reviews on external PRs.
6067
# For other events, check the PR author.
61-
if [ "${{ github.event.action }}" = "labeled" ]; then
62-
USER="${{ github.event.sender.login }}"
68+
if [ "$EVENT_ACTION" = "labeled" ]; then
69+
USER="$SENDER_LOGIN"
6370
echo "Checking sender (labeler): ${USER}"
6471
else
65-
USER="${{ github.event.pull_request.user.login }}"
72+
USER="$PR_AUTHOR"
6673
echo "Checking PR author: ${USER}"
6774
fi
6875
69-
PERMISSION=$(gh api "repos/${{ github.repository }}/collaborators/${USER}/permission" --jq '.permission' 2>/dev/null || echo "none")
76+
PERMISSION=$(gh api "repos/${REPO}/collaborators/${USER}/permission" --jq '.permission' 2>/dev/null || echo "none")
7077
echo "permission=${PERMISSION}"
7178
7279
if [ "$PERMISSION" = "admin" ] || [ "$PERMISSION" = "write" ]; then
@@ -80,15 +87,20 @@ jobs:
8087
needs: gate
8188
if: needs.gate.outputs.allowed == 'true'
8289
runs-on: [self-hosted, agentic-ci]
90+
environment: agentic-ci
8391
timeout-minutes: 15
8492
steps:
8593
- name: Determine PR number
8694
id: pr
95+
env:
96+
EVENT_NAME: ${{ github.event_name }}
97+
INPUT_PR_NUMBER: ${{ github.event.inputs.pr_number }}
98+
PR_NUMBER: ${{ github.event.pull_request.number }}
8799
run: |
88-
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
89-
echo "number=${{ github.event.inputs.pr_number }}" >> "$GITHUB_OUTPUT"
100+
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
101+
echo "number=${INPUT_PR_NUMBER}" >> "$GITHUB_OUTPUT"
90102
else
91-
echo "number=${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT"
103+
echo "number=${PR_NUMBER}" >> "$GITHUB_OUTPUT"
92104
fi
93105
94106
- name: Validate PR number
@@ -113,12 +125,14 @@ jobs:
113125
id: head
114126
env:
115127
GH_TOKEN: ${{ github.token }}
128+
EVENT_NAME: ${{ github.event_name }}
116129
PR_NUMBER: ${{ steps.pr.outputs.number }}
130+
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
117131
run: |
118-
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
132+
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
119133
SHA=$(gh pr view "$PR_NUMBER" --json headRefOid -q '.headRefOid')
120134
else
121-
SHA="${{ github.event.pull_request.head.sha }}"
135+
SHA="$PR_HEAD_SHA"
122136
fi
123137
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
124138
@@ -128,6 +142,16 @@ jobs:
128142
ref: ${{ steps.head.outputs.sha }}
129143
fetch-depth: 0
130144

145+
# SECURITY: Recipe files define the agent's prompt. Always read them
146+
# from the base branch so a fork PR cannot inject malicious instructions
147+
# while API secrets are in scope.
148+
- name: Checkout base branch recipes
149+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
150+
with:
151+
ref: ${{ github.event.pull_request.base.sha || 'main' }}
152+
sparse-checkout: .agents/recipes
153+
path: base-recipes
154+
131155
- name: Pre-flight checks
132156
env:
133157
ANTHROPIC_BASE_URL: ${{ secrets.AGENTIC_CI_API_BASE_URL }}
@@ -168,8 +192,10 @@ jobs:
168192
set -o pipefail
169193
170194
# Build the prompt from _runner.md + recipe, substituting template vars.
171-
RUNNER_CTX=$(cat .agents/recipes/_runner.md)
172-
RECIPE_BODY=$(cat .agents/recipes/pr-review/recipe.md \
195+
# Read from base-recipes/ (checked out from the base branch) so fork
196+
# PRs cannot tamper with the agent prompt.
197+
RUNNER_CTX=$(cat base-recipes/.agents/recipes/_runner.md)
198+
RECIPE_BODY=$(cat base-recipes/.agents/recipes/pr-review/recipe.md \
173199
| sed '1,/^---$/{ /^---$/,/^---$/d }')
174200
175201
PROMPT=$(printf '%s\n\n%s\n' "${RUNNER_CTX}" "${RECIPE_BODY}" \

0 commit comments

Comments
 (0)