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
293 lines · 11.1 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 shlex
10import subprocess
11import sys
12
13FIREWALL_RUNNER = "ubuntu-24.04-firewall"
14WORKFLOW_DIR = pathlib.Path(".github/workflows")
15POLICY_PATH = pathlib.Path(".github/egress-firewall.yaml")
16SIGN_IN_MARKER = "anthropic_federation_rule_id"
17CLAUDE_ACTIONS = ("anthropics/claude-code-action", "anthropics/claude-code-base-action")
18HELP = 'See CLAUDE.md, "Security hardening for GitHub Actions".'
19
20# Key: "<workflow file name>:<job id>". Value: why that job is exempt from the table's rule.
21EXEMPT_FROM_FIREWALL_RUNNER: dict[str, str] = {}
22EXEMPT_FROM_AUTO_MODE: dict[str, str] = {
23 "claude.yml:claude": "answers @claude mentions, and for those the Claude Code action sets --permission-mode acceptEdits itself",
24}
25
26
27def stop(message: str):
28 sys.exit(f"::error::{message}")
29
30
31def load_yaml(path: pathlib.Path):
32 """Parse a YAML file with PyYAML, or with the yq command if PyYAML is absent."""
33 try:
34 import yaml
35 except ImportError:
36 try:
37 result = subprocess.run(
38 ["yq", "-o=json", ".", str(path)], check=True, capture_output=True, text=True
39 )
40 except FileNotFoundError:
41 stop(
42 f"Cannot read {path}: Python has no 'yaml' module and no 'yq' command was found. "
43 "Add a step that runs 'pip install pyyaml' before this check."
44 )
45 except subprocess.CalledProcessError:
46 stop(f"Cannot read {path}: 'yq' could not parse it. Check that the file is valid YAML.")
47 return json.loads(result.stdout)
48 try:
49 with path.open(encoding="utf-8") as handle:
50 return yaml.safe_load(handle)
51 except yaml.YAMLError as error:
52 stop(f"Cannot read {path}: it is not valid YAML ({error}).")
53
54
55def contains_marker(node) -> bool:
56 """Whether any key or string under node contains SIGN_IN_MARKER, ignoring case."""
57 if isinstance(node, dict):
58 return any(contains_marker(k) or contains_marker(v) for k, v in node.items())
59 if isinstance(node, list):
60 return any(contains_marker(item) for item in node)
61 return isinstance(node, str) and SIGN_IN_MARKER in node.lower()
62
63
64def load_local_action(uses: str):
65 """The parsed action file of a local action (uses: ./path), or None."""
66 if not uses.startswith("./"):
67 return None
68 for name in ("action.yml", "action.yaml"):
69 action_file = pathlib.Path(uses) / name
70 if action_file.is_file():
71 action = load_yaml(action_file)
72 return action if isinstance(action, dict) else {}
73 return None
74
75
76def steps_of(job: dict) -> list[dict]:
77 return [step for step in job.get("steps") or [] if isinstance(step, dict)]
78
79
80def runs_claude_code_action(step: dict) -> bool:
81 """Whether the step runs the Claude Code action: the published action, or a
82 local action that accepts a claude_args input."""
83 uses = str(step.get("uses", ""))
84 if uses.lower().startswith(CLAUDE_ACTIONS):
85 return True
86 action = load_local_action(uses)
87 return action is not None and "claude_args" in (action.get("inputs") or {})
88
89
90def job_calls_claude(job: dict) -> bool:
91 if contains_marker(job):
92 return True
93 for step in steps_of(job):
94 if runs_claude_code_action(step):
95 return True
96 action = load_local_action(str(step.get("uses", "")))
97 if action is not None and contains_marker(action):
98 return True
99 return False
100
101
102def permission_mode_problem(step: dict, exempt: bool) -> str | None:
103 """The message for a step whose permission mode is wrong, or None if it is right.
104
105 A step must set auto mode. A step of a job in EXEMPT_FROM_AUTO_MODE must set no mode at all.
106 """
107 inputs = step.get("with") or {}
108 lines = str(inputs.get("claude_args", "")).splitlines()
109 text = " ".join(line for line in lines if not line.strip().startswith("#"))
110 try:
111 args = shlex.split(text, comments=True)
112 except ValueError:
113 return "'claude_args' has a quote that is never closed. Close it"
114 modes = []
115 for index, arg in enumerate(args):
116 if arg == "--dangerously-skip-permissions":
117 return (
118 "remove '--dangerously-skip-permissions' from 'claude_args': "
119 "it turns off permission checks"
120 )
121 if arg == "--permission-mode":
122 modes.append(args[index + 1] if index + 1 < len(args) else "")
123 elif arg.startswith("--permission-mode="):
124 modes.append(arg.split("=", 1)[1])
125 if exempt and modes:
126 return (
127 "remove '--permission-mode' from 'claude_args': this job is listed in "
128 "EXEMPT_FROM_AUTO_MODE (.github/scripts/check_workflow_hardening.py), and a job listed "
129 "there must not set a permission mode"
130 )
131 if not modes and not exempt:
132 return "add '--permission-mode auto' to 'claude_args' under the step's 'with:'"
133 for mode in modes:
134 if mode == "":
135 return (
136 "'claude_args' has '--permission-mode' with nothing after it. "
137 "Write '--permission-mode auto'"
138 )
139 if mode != "auto":
140 return (
141 f"'claude_args' has '--permission-mode {mode}'. "
142 "Change it to '--permission-mode auto'"
143 )
144 settings = [("the step's 'settings'", inputs.get("settings", ""))]
145 settings += [
146 ("'--settings' in 'claude_args'", args[index + 1])
147 for index, arg in enumerate(args)
148 if arg == "--settings" and index + 1 < len(args)
149 ]
150 settings += [
151 ("'--settings' in 'claude_args'", arg.split("=", 1)[1])
152 for arg in args
153 if arg.startswith("--settings=")
154 ]
155 for where, value in settings:
156 text = settings_text(value)
157 if text is None:
158 return (
159 f"{where} names a file outside the repository, which this check cannot read. "
160 "Use inline settings or a file inside the repository"
161 )
162 if "defaultMode" in text:
163 return f"remove 'defaultMode' from {where}: settings must not set a permission mode"
164 return None
165
166
167def settings_text(value) -> str | None:
168 """The settings JSON a 'settings' value stands for: the value itself, or the contents of
169 the file it names when it is a path inside the repository. None when it names a path
170 outside the repository."""
171 text = str(value or "").strip()
172 if not text or text.startswith("{"):
173 return text
174 path = pathlib.Path(text)
175 root = pathlib.Path.cwd().resolve()
176 try:
177 resolved = path.resolve()
178 resolved.relative_to(root)
179 except (OSError, ValueError):
180 return None
181 if resolved.is_file():
182 return resolved.read_text(encoding="utf-8", errors="replace")
183 return text
184
185
186def check_job(file_name: str, job_id: str, job: dict) -> list[str]:
187 key = f"{file_name}:{job_id}"
188 where = f".github/workflows/{file_name}: job '{job_id}'"
189 errors = []
190 runs_on = job.get("runs-on")
191 if isinstance(runs_on, list) and len(runs_on) == 1:
192 runs_on = runs_on[0]
193 if key in EXEMPT_FROM_FIREWALL_RUNNER:
194 print(
195 f"The egress-firewall runner is not required for job '{job_id}' in {file_name}. "
196 f"Reason: {EXEMPT_FROM_FIREWALL_RUNNER[key]}."
197 )
198 elif runs_on != FIREWALL_RUNNER:
199 if "runs-on" not in job:
200 has = "no 'runs-on'"
201 elif isinstance(job["runs-on"], str):
202 has = f"'runs-on: {job['runs-on']}'"
203 else:
204 has = "a 'runs-on' list or group"
205 errors.append(
206 f"{where} calls Claude, so it must have 'runs-on: {FIREWALL_RUNNER}'. "
207 f"It has {has}. {HELP}"
208 )
209 exempt = key in EXEMPT_FROM_AUTO_MODE
210 if exempt:
211 print(
212 f"Auto permission mode is not required for job '{job_id}' in {file_name}. "
213 f"Reason: {EXEMPT_FROM_AUTO_MODE[key]}."
214 )
215 if "defaultMode" in json.dumps(job):
216 # Catches a settings file that an earlier step of the job writes, which the
217 # step-level check cannot read.
218 errors.append(
219 f"{where} mentions 'defaultMode': settings must not set a permission mode. {HELP}"
220 )
221 for index, step in enumerate(steps_of(job), start=1):
222 if not runs_claude_code_action(step):
223 continue
224 problem = permission_mode_problem(step, exempt)
225 if problem:
226 step_label = f"step '{step['name']}'" if "name" in step else f"step {index}"
227 errors.append(f"{where}, {step_label}: {problem}. {HELP}")
228 return errors
229
230
231def check_policy() -> list[str]:
232 if not POLICY_PATH.is_file():
233 return [
234 f"{POLICY_PATH} is missing. Jobs on the egress-firewall runner need it "
235 f"to limit outbound network access. {HELP}"
236 ]
237 policy = load_yaml(POLICY_PATH)
238 if not isinstance(policy, dict):
239 return [
240 f"{POLICY_PATH} is empty or is not a set of 'name: value' lines. It needs 'mode: enforce' "
241 f"and an 'allow:' list of hosts. {HELP}"
242 ]
243 errors = []
244 if "mode" not in policy:
245 errors.append(f"{POLICY_PATH}: 'mode' is missing. Add 'mode: enforce'. {HELP}")
246 elif policy["mode"] != "enforce":
247 errors.append(
248 f"{POLICY_PATH}: 'mode' is '{policy['mode']}'. It must be 'enforce'. {HELP}"
249 )
250 allow = policy.get("allow")
251 if not isinstance(allow, list) or not allow:
252 errors.append(
253 f"{POLICY_PATH}: the 'allow' list is missing or empty. List under 'allow:' "
254 f"each host the jobs need. {HELP}"
255 )
256 else:
257 for host in allow:
258 if "*" in str(host):
259 errors.append(
260 f"{POLICY_PATH}: the 'allow' entry '{host}' contains '*'. "
261 f"Name each host in full. {HELP}"
262 )
263 return errors
264
265
266def main() -> int:
267 if not WORKFLOW_DIR.is_dir():
268 stop(f"{WORKFLOW_DIR} not found. Run this check from the repository root.")
269 errors = []
270 checked = 0
271 for path in sorted([*WORKFLOW_DIR.glob("*.yml"), *WORKFLOW_DIR.glob("*.yaml")]):
272 workflow = load_yaml(path)
273 jobs = workflow.get("jobs") if isinstance(workflow, dict) else None
274 for job_id, job in (jobs or {}).items():
275 if not isinstance(job, dict) or not job_calls_claude(job):
276 continue
277 checked += 1
278 errors.extend(check_job(path.name, job_id, job))
279 if checked:
280 errors.extend(check_policy())
281 for error in errors:
282 print(f"::error::{error}")
283 if errors:
284 return 1
285 if checked == 0:
286 print("OK: no workflow job calls Claude, so there was nothing to check.")
287 else:
288 print(f"OK: checked {checked} job(s) that call Claude and found no problems.")
289 return 0
290
291
292if __name__ == "__main__":
293 sys.exit(main())