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
| 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()) |