11name : claude-review
22
33# Triggered by @claude mention in PR comments.
4- # The action extracts text after "@claude" as the prompt .
4+ # The prompt is extracted as the text after "@claude" in the comment body .
55#
66# Security model:
7- # - Only write/admin users can trigger (enforced by the action )
7+ # - Only write/admin users can trigger (enforced by permission check on the App token )
88# - Network sandbox: Squid proxy (L7 domain allowlist) + iptables (L3 egress block)
9- # - All dependencies pre-installed before lockdown; action skips its internal installs
10- #
11- # Why pre-install? The action internally runs setup-bun, bun install, and claude install.
12- # These use fetch() which ignores HTTP_PROXY and gets blocked by iptables.
13- # We do them beforehand and pass paths via inputs to skip those steps.
9+ # - Claude CLI pre-installed before network lockdown
1410#
1511# Secrets:
1612# - CLAUDE_CODE_OAUTH_TOKEN: Anthropic API auth (from `claude setup-token`)
2117 types : [created]
2218 pull_request_review_comment :
2319 types : [created]
24- pull_request :
25- types : [opened, synchronize]
2620
2721permissions : {}
2822
2923jobs :
3024 claude-review :
31- name : claude-review/respond
25+ name : claude-review
3226 if : |
3327 contains(github.event.comment.body, '@claude') &&
3428 (github.event.issue.pull_request || github.event_name == 'pull_request_review_comment')
3529 runs-on : ubuntu-latest
30+ timeout-minutes : 60
3631 permissions :
3732 contents : read # Checkout repository code and read files
3833 pull-requests : write # Post review comments and update PR status
3934 issues : write # Respond to @claude mentions in issue comments
4035 id-token : write # OIDC token for GitHub App token exchange
41- actions : read # Read workflow run context for action inputs
4236 steps :
4337
4438 # ── Phase 1: Setup (full network) ──────────────────────────────────
4842 persist-credentials : false
4943 fetch-depth : 0
5044
51- - name : Install uv
45+ - name : Install uv # Required by internal skill scripts
5246 uses : astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b
5347
5448 - name : Clone private repositories
@@ -58,49 +52,45 @@ jobs:
5852 env :
5953 GH_TOKEN : ${{ secrets.CLAUDE_ACCESS_TOKEN }}
6054
61- - name : Build custom system prompt
62- id : custom_prompt
55+ - name : Fetch PR/issue metadata
6356 run : |
64- if [[ "$EVENT_NAME" == "pull_request" ]] || [[ -n "$ISSUE_PR_URL" ]]; then
65- if [[ "$EVENT_NAME" == "pull_request" ]]; then
66- PR_TITLE="$PR_TITLE_INPUT"
67- PR_AUTHOR="$PR_AUTHOR_INPUT"
68- PR_HEAD="$PR_HEAD_INPUT"
69- PR_BASE="$PR_BASE_INPUT"
70- PR_STATE="$PR_STATE_INPUT"
71- PR_ADDITIONS="$PR_ADDITIONS_INPUT"
72- PR_DELETIONS="$PR_DELETIONS_INPUT"
73- PR_COMMITS="$PR_COMMITS_INPUT"
74- PR_FILES="$PR_FILES_INPUT"
75- else
76- PR_NUMBER="$ISSUE_NUMBER_INPUT"
77- PR_DATA=$(gh pr view "$PR_NUMBER" --json title,author,headRefName,baseRefName,state,additions,deletions,commits)
78- PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
79- PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.author.login')
80- PR_HEAD=$(echo "$PR_DATA" | jq -r '.headRefName')
81- PR_BASE=$(echo "$PR_DATA" | jq -r '.baseRefName')
82- PR_STATE=$(echo "$PR_DATA" | jq -r '.state')
83- PR_ADDITIONS=$(echo "$PR_DATA" | jq -r '.additions')
84- PR_DELETIONS=$(echo "$PR_DATA" | jq -r '.deletions')
85- PR_COMMITS=$(echo "$PR_DATA" | jq -r '.commits | length')
86- PR_FILES=$(gh pr view "$PR_NUMBER" --json files | jq '.files | length')
87- fi
88-
89- FORMATTED_CONTEXT="PR Title: ${PR_TITLE}
90- PR Author: ${PR_AUTHOR}
91- PR Branch: ${PR_HEAD} -> ${PR_BASE}
92- PR State: ${PR_STATE^^}
93- PR Additions: ${PR_ADDITIONS}
94- PR Deletions: ${PR_DELETIONS}
95- Total Commits: ${PR_COMMITS}
96- Changed Files: ${PR_FILES} files"
57+ if [[ -n "$ISSUE_PR_URL" ]]; then
58+ PR_NUMBER="$ISSUE_NUMBER_INPUT"
59+ PR_DATA=$(gh pr view "$PR_NUMBER" --json title,author,headRefName,baseRefName,state,additions,deletions,commits,files)
60+
61+ {
62+ echo "FORMATTED_CONTEXT<<EOF"
63+ echo "PR Title: $(echo "$PR_DATA" | jq -r '.title')"
64+ echo "PR Author: $(echo "$PR_DATA" | jq -r '.author.login')"
65+ echo "PR Number: ${PR_NUMBER}"
66+ echo "PR Branch: $(echo "$PR_DATA" | jq -r '.headRefName') -> $(echo "$PR_DATA" | jq -r '.baseRefName')"
67+ echo "PR State: $(echo "$PR_DATA" | jq -r '.state | ascii_upcase')"
68+ echo "PR Additions: $(echo "$PR_DATA" | jq -r '.additions')"
69+ echo "PR Deletions: $(echo "$PR_DATA" | jq -r '.deletions')"
70+ echo "Total Commits: $(echo "$PR_DATA" | jq -r '.commits | length')"
71+ echo "Changed Files: $(echo "$PR_DATA" | jq '.files | length') files"
72+ echo "EOF"
73+ } >> "$GITHUB_ENV"
9774 else
98- FORMATTED_CONTEXT="Issue Title: ${ISSUE_TITLE_INPUT}
99- Issue Author: ${ISSUE_AUTHOR_INPUT}
100- Issue State: ${ISSUE_STATE_INPUT^^}"
75+ {
76+ echo "FORMATTED_CONTEXT<<EOF"
77+ echo "Issue Title: ${ISSUE_TITLE_INPUT}"
78+ echo "Issue Author: ${ISSUE_AUTHOR_INPUT}"
79+ echo "Issue State: ${ISSUE_STATE_INPUT^^}"
80+ echo "EOF"
81+ } >> "$GITHUB_ENV"
10182 fi
83+ env :
84+ GH_TOKEN : ${{ secrets.CLAUDE_ACCESS_TOKEN }}
85+ ISSUE_PR_URL : ${{ github.event.issue.pull_request.url || '' }}
86+ ISSUE_NUMBER_INPUT : ${{ github.event.issue.number }}
87+ ISSUE_TITLE_INPUT : ${{ github.event.issue.title }}
88+ ISSUE_AUTHOR_INPUT : ${{ github.event.issue.user.login }}
89+ ISSUE_STATE_INPUT : ${{ github.event.issue.state }}
10290
103- SYSTEM_PROMPT="You are Claude, an AI assistant designed to help with GitHub issues and pull requests. Think carefully as you analyze the context and respond appropriately. Here's the context for your current task:
91+ - name : Build custom system prompt
92+ run : |
93+ SYSTEM_PROMPT="You are Claude, an AI assistant running in a non-interactive CI environment. You MUST act autonomously: never ask for confirmation, never ask follow-up questions, never wait for user input. Execute the requested task completely and stop.
10494
10595 <formatted_context>
10696 ${FORMATTED_CONTEXT}
@@ -111,75 +101,60 @@ jobs:
111101 echo "$SYSTEM_PROMPT"
112102 echo "EOF"
113103 } >> "$GITHUB_ENV"
114- env :
115- GH_TOKEN : ${{ secrets.CLAUDE_ACCESS_TOKEN }}
116- EVENT_NAME : ${{ github.event_name }}
117- ISSUE_PR_URL : ${{ github.event.issue.pull_request.url || '' }}
118- PR_TITLE_INPUT : ${{ github.event.pull_request.title }}
119- PR_AUTHOR_INPUT : ${{ github.event.pull_request.user.login }}
120- PR_HEAD_INPUT : ${{ github.event.pull_request.head.ref }}
121- PR_BASE_INPUT : ${{ github.event.pull_request.base.ref }}
122- PR_STATE_INPUT : ${{ github.event.pull_request.state }}
123- PR_ADDITIONS_INPUT : ${{ github.event.pull_request.additions }}
124- PR_DELETIONS_INPUT : ${{ github.event.pull_request.deletions }}
125- PR_COMMITS_INPUT : ${{ github.event.pull_request.commits }}
126- PR_FILES_INPUT : ${{ github.event.pull_request.changed_files }}
127- ISSUE_NUMBER_INPUT : ${{ github.event.issue.number }}
128- ISSUE_TITLE_INPUT : ${{ github.event.issue.title }}
129- ISSUE_AUTHOR_INPUT : ${{ github.event.issue.user.login }}
130- ISSUE_STATE_INPUT : ${{ github.event.issue.state }}
131104
132- # ── Phase 2: Pre-install dependencies (before lockdown) ────────────
133- # The action's internal setup-bun, bun install, and claude install all
134- # use fetch() which ignores HTTP_PROXY → blocked by iptables.
135- # Pre-installing and passing paths via inputs skips those steps entirely.
105+ # ── Phase 2: Authenticate & install CLI (before lockdown) ──────────
136106
137- # OIDC → Anthropic exchange → GitHub App token (normally done inside the action)
138107 - name : Exchange OIDC for GitHub App token
139108 id : oidc-exchange
140109 run : |
141110 OIDC_TOKEN=$(curl -sf \
142111 -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
143112 "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=claude-code-github-action" | jq -r '.value')
144- if [ -z "$OIDC_TOKEN" ] || [ "$OIDC_TOKEN" = "null" ]; then
145- echo "::error::OIDC token request failed"; exit 1
146- fi
113+ [ -n "$OIDC_TOKEN" ] && [ "$OIDC_TOKEN" != "null" ] || { echo "::error::OIDC token request failed"; exit 1; }
147114
148115 APP_TOKEN=$(curl -sf -X POST \
149116 -H "Authorization: Bearer $OIDC_TOKEN" \
150117 -H "Content-Type: application/json" \
151118 -d '{"permissions":{"contents":"write","pull_requests":"write","issues":"write"}}' \
152119 "https://api.anthropic.com/api/github/github-app-token-exchange" | jq -r '.token')
153- if [ -z "$APP_TOKEN" ] || [ "$APP_TOKEN" = "null" ]; then
154- echo "::error::Token exchange failed"; exit 1
155- fi
120+ [ -n "$APP_TOKEN" ] && [ "$APP_TOKEN" != "null" ] || { echo "::error::Token exchange failed"; exit 1; }
156121
157122 echo "::add-mask::$APP_TOKEN"
158123 echo "app_token=$APP_TOKEN" >> "$GITHUB_OUTPUT"
159124
160- # Bun runtime — needed by the action's TypeScript orchestrator
161- - name : Install Bun
162- uses : oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3
163- with :
164- bun-version : latest
165-
166- # Action's node_modules — `bun install` inside the action would need network
167- - name : Pre-install action dependencies
168- id : setup-deps
125+ - name : Post tracking comment
126+ id : tracking-comment
169127 run : |
170- cd "/home/runner/work/_actions/anthropics/claude-code-action/b433f16b30d54063fd3bab6b12f46f3da00e41b6"
171- bun install --production
172- echo "bun_path=$(which bun)" >> "$GITHUB_OUTPUT"
128+ RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
129+ ISSUE_NUMBER="${{ github.event.issue.number || github.event.pull_request.number }}"
130+ BODY="**Claude is working on @${ACTOR}'s request...** — [View run]($RUN_URL)"
131+ COMMENT_ID=$(gh api "repos/${{ github.repository }}/issues/${ISSUE_NUMBER}/comments" \
132+ -X POST -f body="$BODY" --jq '.id')
133+ echo "comment_id=$COMMENT_ID" >> "$GITHUB_OUTPUT"
134+ env :
135+ GH_TOKEN : ${{ steps.oidc-exchange.outputs.app_token }}
136+ ACTOR : ${{ github.actor }}
173137
174- # Claude Code CLI via npm — native binary ignores HTTP_PROXY (anthropics/claude-code#14165)
175- - name : Install Claude Code (npm)
176- id : setup-claude
177- run : |
178- npm install -g @anthropic-ai/claude-code@2.1.42
179- echo "path=$(which claude)" >> "$GITHUB_OUTPUT"
138+ - name : Install Claude Code CLI
139+ run : npm install -g @anthropic-ai/claude-code@2.1.42
180140
181141 # ── Phase 3: Network sandbox ───────────────────────────────────────
182142
143+ - name : Cache Squid Docker image
144+ uses : actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
145+ with :
146+ path : /tmp/squid-image.tar
147+ key : squid-image-ubuntu-squid-latest
148+
149+ - name : Load or pull Squid image
150+ run : |
151+ if [ -f /tmp/squid-image.tar ]; then
152+ docker load < /tmp/squid-image.tar
153+ else
154+ docker pull ubuntu/squid
155+ docker save ubuntu/squid > /tmp/squid-image.tar
156+ fi
157+
183158 - name : Start Squid proxy
184159 run : |
185160 docker run -d --name sandbox-proxy -p 3128:3128 \
@@ -205,15 +180,30 @@ jobs:
205180 - name : Lock down iptables
206181 run : |
207182 RUNNER_UID=$(id -u)
183+ # Save current firewall rules so they can be restored during always-run cleanup.
184+ sudo iptables-save | tee /tmp/iptables-pre-claude.rules > /dev/null
208185
209- # Allow established connections, loopback, and Docker bridge
186+ # Resolve Squid container's IP dynamically to avoid allowing the entire 172.16/12 range
187+ SQUID_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' sandbox-proxy)
188+ if [ -z "$SQUID_IP" ]; then
189+ echo "::error::Could not determine Squid container IP"; exit 1
190+ fi
191+ echo "Squid IP: $SQUID_IP"
192+
193+ # Allow established connections and loopback (covers local DNS via 127.0.0.53)
210194 sudo iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
211195 sudo iptables -A OUTPUT -o lo -j ACCEPT
212- sudo iptables -A OUTPUT -d 172.16.0.0/12 -j ACCEPT
196+
197+ # Allow traffic to Squid container only (single host, port 3128)
198+ sudo iptables -A OUTPUT -d "$SQUID_IP" -p tcp --dport 3128 -j ACCEPT
213199
214200 # Block new outbound TCP from runner UID — forces proxy use
215201 sudo iptables -A OUTPUT -m owner --uid-owner "$RUNNER_UID" -p tcp --syn -j REJECT --reject-with tcp-reset
216202
203+ # Block UDP and ICMP from runner UID — prevents DNS tunneling and ICMP exfiltration
204+ sudo iptables -A OUTPUT -m owner --uid-owner "$RUNNER_UID" -p udp -j DROP
205+ sudo iptables -A OUTPUT -m owner --uid-owner "$RUNNER_UID" -p icmp -j DROP
206+
217207 # Verify: direct blocked, proxy works
218208 if curl -sf --max-time 5 -o /dev/null https://google.com 2>/dev/null; then
219209 echo "::error::Direct connection not blocked!"; exit 1
@@ -223,36 +213,74 @@ jobs:
223213 fi
224214
225215 # ── Phase 4: Run Claude Code (sandboxed) ───────────────────────────
216+ #
217+ # Runs claude directly (no action wrapper) to avoid MCP server processes
218+ # that block on stdin and keep the job alive after Claude finishes.
219+ # See: https://github.com/anthropics/claude-code-action/issues/865
226220
227221 - name : Run Claude Code
228222 id : run-claude
229- uses : anthropics/claude-code-action@b433f16b30d54063fd3bab6b12f46f3da00e41b6 # 2026-02-10
223+ run : |
224+ # Install plugins from local marketplace (no network needed)
225+ claude plugin marketplace add /tmp/zama-marketplace
226+ claude plugin install project-manager@zama-marketplace
227+ claude plugin install zama-developer@zama-marketplace
228+
229+ # Extract prompt: everything after "@claude" in the comment
230+ PROMPT=$(echo "$COMMENT_BODY" | sed 's/.*@claude[[:space:]]*//')
231+
232+ claude -p "$PROMPT" \
233+ --model opus \
234+ --dangerously-skip-permissions \
235+ --verbose \
236+ --system-prompt "$CUSTOM_SYSTEM_PROMPT"
230237 env :
238+ GITHUB_TOKEN : ${{ steps.oidc-exchange.outputs.app_token }}
239+ GH_TOKEN : ${{ steps.oidc-exchange.outputs.app_token }}
231240 HTTP_PROXY : http://localhost:3128
232241 HTTPS_PROXY : http://localhost:3128
233242 NO_PROXY : localhost,127.0.0.1
234- with :
235- claude_code_oauth_token : ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
236- github_token : ${{ steps.oidc-exchange.outputs.app_token }}
237- path_to_bun_executable : ${{ steps.setup-deps.outputs.bun_path }}
238- path_to_claude_code_executable : ${{ steps.setup-claude.outputs.path }}
239- plugin_marketplaces : " /tmp/zama-marketplace"
240- plugins : |
241- project-manager@zama-marketplace
242- zama-developer@zama-marketplace
243- prompt : " "
244- claude_args : |
245- --model opus
246- --dangerously-skip-permissions
247- --system-prompt "${{ env.CUSTOM_SYSTEM_PROMPT }}"
243+ COMMENT_BODY : ${{ github.event.comment.body }}
244+ CLAUDE_CODE_OAUTH_TOKEN : ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
248245
249246 # ── Cleanup ────────────────────────────────────────────────────────
250- # The action skips token revocation when github_token is provided — do it ourselves
247+
248+ - name : Update tracking comment
249+ if : always() && steps.tracking-comment.outputs.comment_id != ''
250+ run : |
251+ RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
252+ if [ "$CLAUDE_OUTCOME" = "success" ]; then
253+ BODY="**Claude finished @${ACTOR}'s task** — [View run]($RUN_URL)"
254+ else
255+ BODY="**Run finished with status: ${CLAUDE_OUTCOME}** — [View run]($RUN_URL)"
256+ fi
257+ gh api "repos/${{ github.repository }}/issues/comments/${{ steps.tracking-comment.outputs.comment_id }}" \
258+ -X PATCH -f body="$BODY"
259+ env :
260+ GH_TOKEN : ${{ steps.oidc-exchange.outputs.app_token }}
261+ HTTPS_PROXY : http://localhost:3128
262+ ACTOR : ${{ github.actor }}
263+ CLAUDE_OUTCOME : ${{ steps.run-claude.outcome }}
264+
251265 - name : Revoke GitHub App token
252266 if : always() && steps.oidc-exchange.outputs.app_token != ''
253267 run : |
254- curl -sf -X DELETE \
255- -H "Authorization: Bearer $APP_TOKEN" \
256- "https://api.github.com/installation/token"
268+ if ! gh api "installation/token" -X DELETE --silent 2>/dev/null; then
269+ echo "::warning::Token revocation failed"
270+ fi
257271 env :
258- APP_TOKEN : ${{ steps.oidc-exchange.outputs.app_token }}
272+ GH_TOKEN : ${{ steps.oidc-exchange.outputs.app_token }}
273+ HTTPS_PROXY : http://localhost:3128
274+
275+ - name : Restore iptables
276+ if : always()
277+ run : |
278+ if [ -f /tmp/iptables-pre-claude.rules ]; then
279+ sudo sh -c 'iptables-restore < /tmp/iptables-pre-claude.rules' || echo "::warning::Failed to restore iptables"
280+ else
281+ echo "::warning::No iptables backup found at /tmp/iptables-pre-claude.rules"
282+ fi
283+
284+ - name : Stop Squid proxy
285+ if : always()
286+ run : docker rm -f sandbox-proxy 2>/dev/null || true
0 commit comments