Skip to content

Commit 0365240

Browse files
committed
ci(claude): rewrite workflow from template, address PR #1995 security review
- Drop action wrapper, run claude CLI directly (avoids MCP stdin blocking) - Remove dead pull_request trigger - Separate GH_TOKEN from system prompt construction step - Tighten iptables: resolve Squid IP dynamically, block UDP/ICMP - Restrict squid allowlist to 3 domains (api.anthropic.com, platform.claude.com, github.com) - Cache Squid Docker image, add iptables save/restore cleanup - Add tracking comment for run visibility - Fix token revocation to use HTTPS_PROXY
1 parent c5d575b commit 0365240

File tree

2 files changed

+149
-127
lines changed

2 files changed

+149
-127
lines changed

.github/squid/sandbox-proxy-rules.conf

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,9 @@
66
# Leading dot means "this domain and all subdomains".
77

88
acl allowed_domains dstdomain \
9-
.anthropic.com \
10-
.claude.ai \
9+
.api.anthropic.com \
1110
.platform.claude.com \
12-
.github.com \
13-
.githubusercontent.com \
14-
.npmjs.org \
15-
.pypi.org \
16-
.pythonhosted.org \
17-
.googleapis.com
11+
.github.com
1812

1913
# Allow only explicitly allowed domains
2014
http_access deny !allowed_domains

.github/workflows/claude.yml

Lines changed: 147 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,12 @@
11
name: 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`)
@@ -21,24 +17,22 @@ on:
2117
types: [created]
2218
pull_request_review_comment:
2319
types: [created]
24-
pull_request:
25-
types: [opened, synchronize]
2620

2721
permissions: {}
2822

2923
jobs:
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) ──────────────────────────────────
@@ -48,7 +42,7 @@ jobs:
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

Comments
 (0)