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
253 lines · 9.5 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 if "defaultMode" in str(inputs.get("settings", "")):
145 return (
146 "remove 'defaultMode' from the step's 'settings': "
147 "'settings' must not set a permission mode"
148 )
149 return None
150
151
152def check_job(file_name: str, job_id: str, job: dict) -> list[str]:
153 key = f"{file_name}:{job_id}"
154 where = f".github/workflows/{file_name}: job '{job_id}'"
155 errors = []
156 runs_on = job.get("runs-on")
157 if isinstance(runs_on, list) and len(runs_on) == 1:
158 runs_on = runs_on[0]
159 if key in EXEMPT_FROM_FIREWALL_RUNNER:
160 print(
161 f"The egress-firewall runner is not required for job '{job_id}' in {file_name}. "
162 f"Reason: {EXEMPT_FROM_FIREWALL_RUNNER[key]}."
163 )
164 elif runs_on != FIREWALL_RUNNER:
165 if "runs-on" not in job:
166 has = "no 'runs-on'"
167 elif isinstance(job["runs-on"], str):
168 has = f"'runs-on: {job['runs-on']}'"
169 else:
170 has = "a 'runs-on' list or group"
171 errors.append(
172 f"{where} calls Claude, so it must have 'runs-on: {FIREWALL_RUNNER}'. "
173 f"It has {has}. {HELP}"
174 )
175 exempt = key in EXEMPT_FROM_AUTO_MODE
176 if exempt:
177 print(
178 f"Auto permission mode is not required for job '{job_id}' in {file_name}. "
179 f"Reason: {EXEMPT_FROM_AUTO_MODE[key]}."
180 )
181 for index, step in enumerate(steps_of(job), start=1):
182 if not runs_claude_code_action(step):
183 continue
184 problem = permission_mode_problem(step, exempt)
185 if problem:
186 step_label = f"step '{step['name']}'" if "name" in step else f"step {index}"
187 errors.append(f"{where}, {step_label}: {problem}. {HELP}")
188 return errors
189
190
191def check_policy() -> list[str]:
192 if not POLICY_PATH.is_file():
193 return [
194 f"{POLICY_PATH} is missing. Jobs on the egress-firewall runner need it "
195 f"to limit outbound network access. {HELP}"
196 ]
197 policy = load_yaml(POLICY_PATH)
198 if not isinstance(policy, dict):
199 return [
200 f"{POLICY_PATH} is empty or is not a set of 'name: value' lines. It needs 'mode: enforce' "
201 f"and an 'allow:' list of hosts. {HELP}"
202 ]
203 errors = []
204 if "mode" not in policy:
205 errors.append(f"{POLICY_PATH}: 'mode' is missing. Add 'mode: enforce'. {HELP}")
206 elif policy["mode"] != "enforce":
207 errors.append(
208 f"{POLICY_PATH}: 'mode' is '{policy['mode']}'. It must be 'enforce'. {HELP}"
209 )
210 allow = policy.get("allow")
211 if not isinstance(allow, list) or not allow:
212 errors.append(
213 f"{POLICY_PATH}: the 'allow' list is missing or empty. List under 'allow:' "
214 f"each host the jobs need. {HELP}"
215 )
216 else:
217 for host in allow:
218 if "*" in str(host):
219 errors.append(
220 f"{POLICY_PATH}: the 'allow' entry '{host}' contains '*'. "
221 f"Name each host in full. {HELP}"
222 )
223 return errors
224
225
226def main() -> int:
227 if not WORKFLOW_DIR.is_dir():
228 stop(f"{WORKFLOW_DIR} not found. Run this check from the repository root.")
229 errors = []
230 checked = 0
231 for path in sorted([*WORKFLOW_DIR.glob("*.yml"), *WORKFLOW_DIR.glob("*.yaml")]):
232 workflow = load_yaml(path)
233 jobs = workflow.get("jobs") if isinstance(workflow, dict) else None
234 for job_id, job in (jobs or {}).items():
235 if not isinstance(job, dict) or not job_calls_claude(job):
236 continue
237 checked += 1
238 errors.extend(check_job(path.name, job_id, job))
239 if checked:
240 errors.extend(check_policy())
241 for error in errors:
242 print(f"::error::{error}")
243 if errors:
244 return 1
245 if checked == 0:
246 print("OK: no workflow job calls Claude, so there was nothing to check.")
247 else:
248 print(f"OK: checked {checked} job(s) that call Claude and found no problems.")
249 return 0
250
251
252if __name__ == "__main__":
253 sys.exit(main())