spacehub

surshar / claude-code

Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.

Built by Anthropic · github.com/anthropics

claude-code / .github / scripts / check_workflow_hardening.py
339 lines · 13.2 KBRaw
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
4Claude Code action, or when it or a local action it uses mentions ANTHROPIC_FEDERATION_RULE_ID.
5"""
6
7import json
8import pathlib
9import re
10import shlex
11import subprocess
12import sys
13
14FIREWALL_RUNNER = "ubuntu-24.04-firewall"
15WORKFLOW_DIR = pathlib.Path(".github/workflows")
16POLICY_PATH = pathlib.Path(".github/egress-firewall.yaml")
17SIGN_IN_MARKER = "anthropic_federation_rule_id"
18CLAUDE_ACTIONS = ("anthropics/claude-code-action", "anthropics/claude-code-base-action")
19HELP = '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.
22AUTO_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.
24MODEL_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.
30EXEMPT_FROM_FIREWALL_RUNNER: dict[str, str] = {}
31EXEMPT_FROM_AUTO_MODE: dict[str, str] = {}
32
33
34def stop(message: str):
35 sys.exit(f"::error::{message}")
36
37
38def 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
62def 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
71def 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
83def steps_of(job: dict) -> list[dict]:
84 return [step for step in job.get("steps") or [] if isinstance(step, dict)]
85
86
87def 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
97def 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
109def 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
192def 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
200def 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
212def 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
231def 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
277def 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
312def 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
338if __name__ == "__main__":
339 sys.exit(main())