Skip to content

Commit 732e167

Browse files
authored
Merge pull request #97952 from anthropics/security-hardening-gh-actions
ci: security hardening for GitHub Actions workflows that call Claude
2 parents 2282079 + 89c73ce commit 732e167

7 files changed

Lines changed: 451 additions & 5 deletions

File tree

‎.github/egress-firewall.yaml‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Hosts that jobs on GitHub's egress-firewall runner (runs-on: ubuntu-24.04-firewall)
2+
# may reach. All other hosts are blocked, apart from any that GitHub's firewall allows
3+
# by default. Add a host only when a workflow step needs it, name it in full (no '*'), and say what uses it.
4+
mode: enforce
5+
allow:
6+
# Claude API: model requests, trading the workflow's GitHub identity token for a
7+
# short-lived API token, and fetching the Claude GitHub App's token in claude.yml
8+
- api.anthropic.com
9+
# GitHub API: calls made by the Claude Code action, and by gh in scripts/gh.sh,
10+
# scripts/edit-issue-labels.sh and scripts/comment-on-duplicates.sh
11+
- api.github.com
12+
# Claude Code install script, fetched by the Claude Code action
13+
- claude.ai
14+
# Claude Code binary, downloaded by the install script
15+
- downloads.claude.ai
16+
# Bun release download (oven-sh/setup-bun, used by the Claude Code action)
17+
- release-assets.githubusercontent.com
18+
# npm packages for the Claude Code action (bun install)
19+
- registry.npmjs.org
20+
# apt packages bubblewrap and socat, which the Claude Code action installs when a
21+
# workflow admits users without write access (claude-issue-triage.yml,
22+
# claude-dedupe-issues.yml)
23+
- azure.archive.ubuntu.com
24+
- archive.ubuntu.com
25+
- security.ubuntu.com
26+
# Statsig event logging (claude-dedupe-issues.yml, "Log duplicate comment event to Statsig")
27+
- events.statsigapi.net
Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
#!/usr/bin/env python3
2+
"""Fail if a workflow job that calls Claude, or .github/egress-firewall.yaml, breaks a rule in CLAUDE.md,
3+
"Security hardening for GitHub Actions". Run from the repository root. A job calls Claude when it runs the
4+
Claude Code action, or when it or a local action it uses mentions ANTHROPIC_FEDERATION_RULE_ID.
5+
"""
6+
7+
import json
8+
import pathlib
9+
import re
10+
import shlex
11+
import subprocess
12+
import sys
13+
14+
FIREWALL_RUNNER = "ubuntu-24.04-firewall"
15+
WORKFLOW_DIR = pathlib.Path(".github/workflows")
16+
POLICY_PATH = pathlib.Path(".github/egress-firewall.yaml")
17+
SIGN_IN_MARKER = "anthropic_federation_rule_id"
18+
CLAUDE_ACTIONS = ("anthropics/claude-code-action", "anthropics/claude-code-base-action")
19+
HELP = 'See CLAUDE.md, "Security hardening for GitHub Actions".'
20+
# Claude Code runs auto mode only on claude-opus-4-6 and newer models. On an older model it
21+
# falls back to its default permission mode, with no safety review.
22+
AUTO_MODE_MIN_VERSION = (4, 6)
23+
# The version in a model name: claude-opus-4-6, claude-sonnet-4-5-20250929, claude-3-5-sonnet-latest.
24+
MODEL_VERSION = re.compile(
25+
r"claude-(?:(?:opus|sonnet|haiku)-(\d+)(?:-(\d{1,2}))?"
26+
r"|(\d+)(?:-(\d{1,2}))?-(?:opus|sonnet|haiku))(?![\d.])"
27+
)
28+
29+
# Key: "<workflow file name>:<job id>". Value: why that job is exempt from the table's rule.
30+
EXEMPT_FROM_FIREWALL_RUNNER: dict[str, str] = {}
31+
EXEMPT_FROM_AUTO_MODE: dict[str, str] = {}
32+
33+
34+
def stop(message: str):
35+
sys.exit(f"::error::{message}")
36+
37+
38+
def load_yaml(path: pathlib.Path):
39+
"""Parse a YAML file with PyYAML, or with the yq command if PyYAML is absent."""
40+
try:
41+
import yaml
42+
except ImportError:
43+
try:
44+
result = subprocess.run(
45+
["yq", "-o=json", ".", str(path)], check=True, capture_output=True, text=True
46+
)
47+
except FileNotFoundError:
48+
stop(
49+
f"Cannot read {path}: Python has no 'yaml' module and no 'yq' command was found. "
50+
"Add a step that runs 'pip install pyyaml' before this check."
51+
)
52+
except subprocess.CalledProcessError:
53+
stop(f"Cannot read {path}: 'yq' could not parse it. Check that the file is valid YAML.")
54+
return json.loads(result.stdout)
55+
try:
56+
with path.open(encoding="utf-8") as handle:
57+
return yaml.safe_load(handle)
58+
except yaml.YAMLError as error:
59+
stop(f"Cannot read {path}: it is not valid YAML ({error}).")
60+
61+
62+
def contains_marker(node) -> bool:
63+
"""Whether any key or string under node contains SIGN_IN_MARKER, ignoring case."""
64+
if isinstance(node, dict):
65+
return any(contains_marker(k) or contains_marker(v) for k, v in node.items())
66+
if isinstance(node, list):
67+
return any(contains_marker(item) for item in node)
68+
return isinstance(node, str) and SIGN_IN_MARKER in node.lower()
69+
70+
71+
def load_local_action(uses: str):
72+
"""The parsed action file of a local action (uses: ./path), or None."""
73+
if not uses.startswith("./"):
74+
return None
75+
for name in ("action.yml", "action.yaml"):
76+
action_file = pathlib.Path(uses) / name
77+
if action_file.is_file():
78+
action = load_yaml(action_file)
79+
return action if isinstance(action, dict) else {}
80+
return None
81+
82+
83+
def steps_of(job: dict) -> list[dict]:
84+
return [step for step in job.get("steps") or [] if isinstance(step, dict)]
85+
86+
87+
def runs_claude_code_action(step: dict) -> bool:
88+
"""Whether the step runs the Claude Code action: the published action, or a
89+
local action that accepts a claude_args input."""
90+
uses = str(step.get("uses", ""))
91+
if uses.lower().startswith(CLAUDE_ACTIONS):
92+
return True
93+
action = load_local_action(uses)
94+
return action is not None and "claude_args" in (action.get("inputs") or {})
95+
96+
97+
def job_calls_claude(job: dict) -> bool:
98+
if contains_marker(job):
99+
return True
100+
for step in steps_of(job):
101+
if runs_claude_code_action(step):
102+
return True
103+
action = load_local_action(str(step.get("uses", "")))
104+
if action is not None and contains_marker(action):
105+
return True
106+
return False
107+
108+
109+
def permission_mode_problem(step: dict, exempt: bool, inherited_env: dict) -> str | None:
110+
"""The message for a step whose permission mode is wrong, or None if it is right.
111+
112+
A step must set auto mode, on a model that supports it. A step of a job in
113+
EXEMPT_FROM_AUTO_MODE must set no mode at all. inherited_env is the workflow's and the
114+
job's 'env'.
115+
"""
116+
inputs = step.get("with") or {}
117+
lines = str(inputs.get("claude_args", "")).splitlines()
118+
text = " ".join(line for line in lines if not line.strip().startswith("#"))
119+
try:
120+
args = shlex.split(text, comments=True)
121+
except ValueError:
122+
return "'claude_args' has a quote that is never closed. Close it"
123+
modes = []
124+
for index, arg in enumerate(args):
125+
if arg == "--dangerously-skip-permissions":
126+
return (
127+
"remove '--dangerously-skip-permissions' from 'claude_args': "
128+
"it turns off permission checks"
129+
)
130+
if arg == "--permission-mode":
131+
modes.append(args[index + 1] if index + 1 < len(args) else "")
132+
elif arg.startswith("--permission-mode="):
133+
modes.append(arg.split("=", 1)[1])
134+
if exempt and modes:
135+
return (
136+
"remove '--permission-mode' from 'claude_args': this job is listed in "
137+
"EXEMPT_FROM_AUTO_MODE (.github/scripts/check_workflow_hardening.py), and a job listed "
138+
"there must not set a permission mode"
139+
)
140+
if not modes and not exempt:
141+
return "add '--permission-mode auto' to 'claude_args' under the step's 'with:'"
142+
for mode in modes:
143+
if mode == "":
144+
return (
145+
"'claude_args' has '--permission-mode' with nothing after it. "
146+
"Write '--permission-mode auto'"
147+
)
148+
if mode != "auto":
149+
return (
150+
f"'claude_args' has '--permission-mode {mode}'. "
151+
"Change it to '--permission-mode auto'"
152+
)
153+
env = {**inherited_env, **(step.get("env") or {})}
154+
models = [
155+
("the step's 'model'", inputs.get("model", "")),
156+
("ANTHROPIC_MODEL", env.get("ANTHROPIC_MODEL", "")),
157+
]
158+
models += [
159+
(f"'{flag}' in 'claude_args'", value)
160+
for flag in ("--model", "--fallback-model")
161+
for value in flag_values(args, flag)
162+
]
163+
settings = [("the step's 'settings'", inputs.get("settings", ""))]
164+
settings += [
165+
("'--settings' in 'claude_args'", value) for value in flag_values(args, "--settings")
166+
]
167+
for where, value in settings:
168+
text = settings_text(value)
169+
if text is None:
170+
return (
171+
f"{where} names a file outside the repository, which this check cannot read. "
172+
"Use inline settings or a file inside the repository"
173+
)
174+
if "defaultMode" in text:
175+
return f"remove 'defaultMode' from {where}: settings must not set a permission mode"
176+
try:
177+
parsed = json.loads(text) if text else {}
178+
except ValueError:
179+
parsed = {}
180+
if isinstance(parsed, dict) and "model" in parsed:
181+
models.append((f"'model' in {where}", parsed["model"]))
182+
if not exempt:
183+
for where, model in models:
184+
if predates_auto_mode(str(model or "")):
185+
return (
186+
f"{where} is '{model}', which Claude Code does not run in auto mode: it "
187+
"falls back to the default permission mode. Use claude-opus-4-6 or a newer model"
188+
)
189+
return None
190+
191+
192+
def flag_values(args: list[str], flag: str) -> list[str]:
193+
"""The values a flag in claude_args is given, as '--flag value' or '--flag=value'."""
194+
values = [
195+
args[index + 1] for index, arg in enumerate(args) if arg == flag and index + 1 < len(args)
196+
]
197+
return values + [arg.split("=", 1)[1] for arg in args if arg.startswith(f"{flag}=")]
198+
199+
200+
def predates_auto_mode(model: str) -> bool:
201+
"""Whether the model is older than AUTO_MODE_MIN_VERSION. A name with no version, such as
202+
'opus' or 'default', stands for a current model, except 'haiku' (claude-haiku-4-5)."""
203+
if model.strip().lower() == "haiku":
204+
return True
205+
match = MODEL_VERSION.search(model.lower())
206+
if not match:
207+
return False
208+
major, minor = match.group(1, 2) if match.group(1) else match.group(3, 4)
209+
return (int(major), int(minor or 0)) < AUTO_MODE_MIN_VERSION
210+
211+
212+
def settings_text(value) -> str | None:
213+
"""The settings JSON a 'settings' value stands for: the value itself, or the contents of
214+
the file it names when it is a path inside the repository. None when it names a path
215+
outside the repository."""
216+
text = str(value or "").strip()
217+
if not text or text.startswith("{"):
218+
return text
219+
path = pathlib.Path(text)
220+
root = pathlib.Path.cwd().resolve()
221+
try:
222+
resolved = path.resolve()
223+
resolved.relative_to(root)
224+
except (OSError, ValueError):
225+
return None
226+
if resolved.is_file():
227+
return resolved.read_text(encoding="utf-8", errors="replace")
228+
return text
229+
230+
231+
def check_job(file_name: str, job_id: str, job: dict, workflow_env: dict) -> list[str]:
232+
key = f"{file_name}:{job_id}"
233+
where = f".github/workflows/{file_name}: job '{job_id}'"
234+
errors = []
235+
runs_on = job.get("runs-on")
236+
if isinstance(runs_on, list) and len(runs_on) == 1:
237+
runs_on = runs_on[0]
238+
if key in EXEMPT_FROM_FIREWALL_RUNNER:
239+
print(
240+
f"The egress-firewall runner is not required for job '{job_id}' in {file_name}. "
241+
f"Reason: {EXEMPT_FROM_FIREWALL_RUNNER[key]}."
242+
)
243+
elif runs_on != FIREWALL_RUNNER:
244+
if "runs-on" not in job:
245+
has = "no 'runs-on'"
246+
elif isinstance(job["runs-on"], str):
247+
has = f"'runs-on: {job['runs-on']}'"
248+
else:
249+
has = "a 'runs-on' list or group"
250+
errors.append(
251+
f"{where} calls Claude, so it must have 'runs-on: {FIREWALL_RUNNER}'. "
252+
f"It has {has}. {HELP}"
253+
)
254+
exempt = key in EXEMPT_FROM_AUTO_MODE
255+
if exempt:
256+
print(
257+
f"Auto permission mode is not required for job '{job_id}' in {file_name}. "
258+
f"Reason: {EXEMPT_FROM_AUTO_MODE[key]}."
259+
)
260+
if "defaultMode" in json.dumps(job):
261+
# Catches a settings file that an earlier step of the job writes, which the
262+
# step-level check cannot read.
263+
errors.append(
264+
f"{where} mentions 'defaultMode': settings must not set a permission mode. {HELP}"
265+
)
266+
for index, step in enumerate(steps_of(job), start=1):
267+
if not runs_claude_code_action(step):
268+
continue
269+
inherited_env = {**workflow_env, **(job.get("env") or {})}
270+
problem = permission_mode_problem(step, exempt, inherited_env)
271+
if problem:
272+
step_label = f"step '{step['name']}'" if "name" in step else f"step {index}"
273+
errors.append(f"{where}, {step_label}: {problem}. {HELP}")
274+
return errors
275+
276+
277+
def check_policy() -> list[str]:
278+
if not POLICY_PATH.is_file():
279+
return [
280+
f"{POLICY_PATH} is missing. Jobs on the egress-firewall runner need it "
281+
f"to limit outbound network access. {HELP}"
282+
]
283+
policy = load_yaml(POLICY_PATH)
284+
if not isinstance(policy, dict):
285+
return [
286+
f"{POLICY_PATH} is empty or is not a set of 'name: value' lines. It needs 'mode: enforce' "
287+
f"and an 'allow:' list of hosts. {HELP}"
288+
]
289+
errors = []
290+
if "mode" not in policy:
291+
errors.append(f"{POLICY_PATH}: 'mode' is missing. Add 'mode: enforce'. {HELP}")
292+
elif policy["mode"] != "enforce":
293+
errors.append(
294+
f"{POLICY_PATH}: 'mode' is '{policy['mode']}'. It must be 'enforce'. {HELP}"
295+
)
296+
allow = policy.get("allow")
297+
if not isinstance(allow, list) or not allow:
298+
errors.append(
299+
f"{POLICY_PATH}: the 'allow' list is missing or empty. List under 'allow:' "
300+
f"each host the jobs need. {HELP}"
301+
)
302+
else:
303+
for host in allow:
304+
if "*" in str(host):
305+
errors.append(
306+
f"{POLICY_PATH}: the 'allow' entry '{host}' contains '*'. "
307+
f"Name each host in full. {HELP}"
308+
)
309+
return errors
310+
311+
312+
def main() -> int:
313+
if not WORKFLOW_DIR.is_dir():
314+
stop(f"{WORKFLOW_DIR} not found. Run this check from the repository root.")
315+
errors = []
316+
checked = 0
317+
for path in sorted([*WORKFLOW_DIR.glob("*.yml"), *WORKFLOW_DIR.glob("*.yaml")]):
318+
workflow = load_yaml(path)
319+
jobs = workflow.get("jobs") if isinstance(workflow, dict) else None
320+
for job_id, job in (jobs or {}).items():
321+
if not isinstance(job, dict) or not job_calls_claude(job):
322+
continue
323+
checked += 1
324+
errors.extend(check_job(path.name, job_id, job, workflow.get("env") or {}))
325+
if checked:
326+
errors.extend(check_policy())
327+
for error in errors:
328+
print(f"::error::{error}")
329+
if errors:
330+
return 1
331+
if checked == 0:
332+
print("OK: no workflow job calls Claude, so there was nothing to check.")
333+
else:
334+
print(f"OK: checked {checked} job(s) that call Claude and found no problems.")
335+
return 0
336+
337+
338+
if __name__ == "__main__":
339+
sys.exit(main())

‎.github/workflows/claude-dedupe-issues.yml‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ on:
1212

1313
jobs:
1414
claude-dedupe-issues:
15-
runs-on: ubuntu-latest
15+
# This job calls Claude, so it runs on GitHub's egress-firewall runner, which
16+
# filters the job's outbound network traffic (allow list: .github/egress-firewall.yaml).
17+
runs-on: ubuntu-24.04-firewall
1618
timeout-minutes: 10
1719
permissions:
1820
contents: read
@@ -40,7 +42,10 @@ jobs:
4042
anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }}
4143
anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }}
4244
anthropic_workspace_id: ${{ vars.ANTHROPIC_WORKSPACE_ID }}
43-
claude_args: "--model claude-sonnet-4-5-20250929"
45+
# --permission-mode auto: a tool call that needs permission and is outside the allowed-tools
46+
# list in .claude/commands/dedupe.md runs only if Claude Code's safety review passes it.
47+
# Tools in --disallowedTools never run.
48+
claude_args: '--model claude-sonnet-4-6 --permission-mode auto --disallowedTools "WebFetch,WebSearch,Write,Edit,MultiEdit,NotebookEdit"'
4449

4550
- name: Log duplicate comment event to Statsig
4651
if: always()

0 commit comments

Comments
 (0)