@@ -4,7 +4,7 @@ name: claude-review
44# 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 permission check on the App token )
7+ # - Only write/admin/maintain users can trigger (enforced by explicit collaborator permission gate )
88# - Network sandbox: Squid proxy (L7 domain allowlist) + iptables (L3 egress block)
99# - Claude CLI pre-installed before network lockdown
1010#
@@ -15,8 +15,6 @@ name: claude-review
1515on :
1616 issue_comment :
1717 types : [created]
18- pull_request_review_comment :
19- types : [created]
2018
2119permissions : {}
2220
2523 name : claude-review
2624 if : |
2725 contains(github.event.comment.body, '@claude') &&
28- ( github.event.issue.pull_request || github.event_name == 'pull_request_review_comment')
26+ github.event.issue.pull_request
2927 runs-on : ubuntu-latest
3028 timeout-minutes : 60
3129 permissions :
@@ -39,12 +37,32 @@ jobs:
3937
4038 - uses : actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
4139 with :
40+ # Always use default branch contents for workflow runtime files.
41+ ref : ${{ github.event.repository.default_branch }}
4242 persist-credentials : false
4343 fetch-depth : 0
4444
4545 - name : Install uv # Required by internal skill scripts
4646 uses : astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b
4747
48+ - name : Enforce actor repository permissions
49+ run : |
50+ PERMISSION=$(gh api "repos/${REPO}/collaborators/${ACTOR}/permission" --jq '.permission' 2>/dev/null || echo "none")
51+ echo "Actor permission level: ${PERMISSION}"
52+
53+ case "$PERMISSION" in
54+ admin|write|maintain)
55+ ;;
56+ *)
57+ echo "::error::Actor '${ACTOR}' must have write/admin/maintain permission to trigger this workflow (got '${PERMISSION}')"
58+ exit 1
59+ ;;
60+ esac
61+ env :
62+ GH_TOKEN : ${{ github.token }}
63+ REPO : ${{ github.repository }}
64+ ACTOR : ${{ github.actor }}
65+
4866 - name : Clone private repositories
4967 run : |
5068 gh repo clone zama-ai/zama-marketplace /tmp/zama-marketplace
@@ -54,12 +72,14 @@ jobs:
5472
5573 - name : Fetch PR/issue metadata
5674 run : |
75+ CONTEXT_EOF="CTX_$(openssl rand -hex 8)"
76+
5777 if [[ -n "$ISSUE_PR_URL" ]]; then
5878 PR_NUMBER="$ISSUE_NUMBER_INPUT"
5979 PR_DATA=$(gh pr view "$PR_NUMBER" --json title,author,headRefName,baseRefName,state,additions,deletions,commits,files)
6080
6181 {
62- echo "FORMATTED_CONTEXT<<EOF "
82+ echo "FORMATTED_CONTEXT<<${CONTEXT_EOF} "
6383 echo "PR Title: $(echo "$PR_DATA" | jq -r '.title')"
6484 echo "PR Author: $(echo "$PR_DATA" | jq -r '.author.login')"
6585 echo "PR Number: ${PR_NUMBER}"
@@ -69,24 +89,24 @@ jobs:
6989 echo "PR Deletions: $(echo "$PR_DATA" | jq -r '.deletions')"
7090 echo "Total Commits: $(echo "$PR_DATA" | jq -r '.commits | length')"
7191 echo "Changed Files: $(echo "$PR_DATA" | jq '.files | length') files"
72- echo "EOF "
92+ echo "${CONTEXT_EOF} "
7393 } >> "$GITHUB_ENV"
7494 else
7595 {
76- echo "FORMATTED_CONTEXT<<EOF "
96+ echo "FORMATTED_CONTEXT<<${CONTEXT_EOF} "
7797 echo "Issue Title: ${ISSUE_TITLE_INPUT}"
7898 echo "Issue Author: ${ISSUE_AUTHOR_INPUT}"
7999 echo "Issue State: ${ISSUE_STATE_INPUT^^}"
80- echo "EOF "
100+ echo "${CONTEXT_EOF} "
81101 } >> "$GITHUB_ENV"
82102 fi
83103 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 }}
104+ GH_TOKEN : ${{ github.token }}
105+ ISSUE_PR_URL : ${{ github.event.issue.pull_request.url || github.event.pull_request.url || '' }}
106+ ISSUE_NUMBER_INPUT : ${{ github.event.issue.number || github.event.pull_request.number }}
107+ ISSUE_TITLE_INPUT : ${{ github.event.issue.title || github.event.pull_request.title || '' }}
108+ ISSUE_AUTHOR_INPUT : ${{ github.event.issue.user.login || github.event.pull_request.user.login || '' }}
109+ ISSUE_STATE_INPUT : ${{ github.event.issue.state || github.event.pull_request.state || '' }}
90110
91111 - name : Build custom system prompt
92112 run : |
@@ -95,11 +115,12 @@ jobs:
95115 <formatted_context>
96116 ${FORMATTED_CONTEXT}
97117 </formatted_context>"
118+ PROMPT_EOF="PROMPT_$(openssl rand -hex 8)"
98119
99120 {
100- echo "CUSTOM_SYSTEM_PROMPT<<EOF "
121+ echo "CUSTOM_SYSTEM_PROMPT<<${PROMPT_EOF} "
101122 echo "$SYSTEM_PROMPT"
102- echo "EOF "
123+ echo "${PROMPT_EOF} "
103124 } >> "$GITHUB_ENV"
104125
105126 # ── Phase 2: Authenticate & install CLI (before lockdown) ──────────
@@ -183,30 +204,33 @@ jobs:
183204
184205 - name : Lock down iptables
185206 run : |
186- RUNNER_UID=$(id -u)
187- # Save current firewall rules so they can be restored during always-run cleanup.
188- sudo iptables-save | tee /tmp/iptables-pre-claude.rules > /dev/null
189-
190207 # Resolve Squid container's IP dynamically to avoid allowing the entire 172.16/12 range
191208 SQUID_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' sandbox-proxy)
192209 if [ -z "$SQUID_IP" ]; then
193210 echo "::error::Could not determine Squid container IP"; exit 1
194211 fi
195212 echo "Squid IP: $SQUID_IP"
196213
197- # Allow established connections and loopback (covers local DNS via 127.0.0.53)
214+ # IPv4: allow only proxy traffic, then block all runner egress paths.
198215 sudo iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
199- sudo iptables -A OUTPUT -o lo -j ACCEPT
216+ sudo iptables -A OUTPUT -o lo -p tcp --dport 3128 - j ACCEPT
200217
201218 # Allow traffic to Squid container only (single host, port 3128)
202219 sudo iptables -A OUTPUT -d "$SQUID_IP" -p tcp --dport 3128 -j ACCEPT
203220
204- # Block new outbound TCP from runner UID — forces proxy use
205- sudo iptables -A OUTPUT -m owner --uid-owner "$RUNNER_UID" -p tcp --syn -j REJECT --reject-with tcp-reset
206-
207- # Block UDP and ICMP from runner UID — prevents DNS tunneling and ICMP exfiltration
208- sudo iptables -A OUTPUT -m owner --uid-owner "$RUNNER_UID" -p udp -j DROP
209- sudo iptables -A OUTPUT -m owner --uid-owner "$RUNNER_UID" -p icmp -j DROP
221+ # Block all remaining outbound traffic — deny-by-default after explicit proxy allows.
222+ sudo iptables -A OUTPUT -p tcp --syn -j REJECT --reject-with tcp-reset
223+ sudo iptables -A OUTPUT -p udp -j DROP
224+ sudo iptables -A OUTPUT -p icmp -j DROP
225+
226+ # IPv6: mirror egress restrictions if IPv6 tooling is present on the runner.
227+ if command -v ip6tables >/dev/null 2>&1; then
228+ sudo ip6tables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
229+ sudo ip6tables -A OUTPUT -o lo -p tcp --dport 3128 -j ACCEPT
230+ sudo ip6tables -A OUTPUT -p tcp --syn -j REJECT --reject-with tcp-reset
231+ sudo ip6tables -A OUTPUT -p udp -j DROP
232+ sudo ip6tables -A OUTPUT -p ipv6-icmp -j DROP
233+ fi
210234
211235 # Verify: direct blocked, proxy works
212236 if curl -sf --max-time 5 -o /dev/null https://google.com 2>/dev/null; then
@@ -276,15 +300,6 @@ jobs:
276300 GH_TOKEN : ${{ steps.oidc-exchange.outputs.app_token }}
277301 HTTPS_PROXY : http://localhost:3128
278302
279- - name : Restore iptables
280- if : always()
281- run : |
282- if [ -f /tmp/iptables-pre-claude.rules ]; then
283- sudo sh -c 'iptables-restore < /tmp/iptables-pre-claude.rules' || echo "::warning::Failed to restore iptables"
284- else
285- echo "::warning::No iptables backup found at /tmp/iptables-pre-claude.rules"
286- fi
287-
288303 - name : Stop Squid proxy
289304 if : always()
290305 run : docker rm -f sandbox-proxy 2>/dev/null || true
0 commit comments