| | @@ -6,6 +6,7 @@ |
| 6 | 6 | |
| 7 | 7 | import json |
| 8 | 8 | import pathlib |
| 9 | +import re |
| 9 | 10 | import shlex |
| 10 | 11 | import subprocess |
| 11 | 12 | import sys |
| | @@ -16,12 +17,18 @@ |
| 16 | 17 | SIGN_IN_MARKER = "anthropic_federation_rule_id" |
| 17 | 18 | CLAUDE_ACTIONS = ("anthropics/claude-code-action", "anthropics/claude-code-base-action") |
| 18 | 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 | +) |
| 19 | 28 | |
| 20 | 29 | # Key: "<workflow file name>:<job id>". Value: why that job is exempt from the table's rule. |
| 21 | 30 | EXEMPT_FROM_FIREWALL_RUNNER: dict[str, str] = {} |
| 22 | | -EXEMPT_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 | | -} |
| 31 | +EXEMPT_FROM_AUTO_MODE: dict[str, str] = {} |
| 25 | 32 | |
| 26 | 33 | |
| 27 | 34 | def stop(message: str): |
| | @@ -99,10 +106,12 @@ |
| 99 | 106 | return False |
| 100 | 107 | |
| 101 | 108 | |
| 102 | | -def permission_mode_problem(step: dict, exempt: bool) -> str | None: |
| 109 | +def permission_mode_problem(step: dict, exempt: bool, inherited_env: dict) -> str | None: |
| 103 | 110 | """The message for a step whose permission mode is wrong, or None if it is right. |
| 104 | 111 | |
| 105 | | - A step must set auto mode. A step of a job in EXEMPT_FROM_AUTO_MODE must set no mode at all. |
| 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'. |
| 106 | 115 | """ |
| 107 | 116 | inputs = step.get("with") or {} |
| 108 | 117 | lines = str(inputs.get("claude_args", "")).splitlines() |
| | @@ -141,17 +150,20 @@ |
| 141 | 150 | f"'claude_args' has '--permission-mode {mode}'. " |
| 142 | 151 | "Change it to '--permission-mode auto'" |
| 143 | 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 | + ] |
| 144 | 163 | settings = [("the step's 'settings'", inputs.get("settings", ""))] |
| 145 | 164 | 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) |
| 165 | + ("'--settings' in 'claude_args'", value) for value in flag_values(args, "--settings") |
| 149 | 166 | ] |
| 150 | | - settings += [ |
| 151 | | - ("'--settings' in 'claude_args'", arg.split("=", 1)[1]) |
| 152 | | - for arg in args |
| 153 | | - if arg.startswith("--settings=") |
| 154 | | - ] |
| 155 | 167 | for where, value in settings: |
| 156 | 168 | text = settings_text(value) |
| 157 | 169 | if text is None: |
| | @@ -161,9 +173,42 @@ |
| 161 | 173 | ) |
| 162 | 174 | if "defaultMode" in text: |
| 163 | 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 | + ) |
| 164 | 189 | return None |
| 165 | 190 | |
| 166 | 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 | + |
| 167 | 212 | def settings_text(value) -> str | None: |
| 168 | 213 | """The settings JSON a 'settings' value stands for: the value itself, or the contents of |
| 169 | 214 | the file it names when it is a path inside the repository. None when it names a path |
| | @@ -183,7 +228,7 @@ |
| 183 | 228 | return text |
| 184 | 229 | |
| 185 | 230 | |
| 186 | | -def check_job(file_name: str, job_id: str, job: dict) -> list[str]: |
| 231 | +def check_job(file_name: str, job_id: str, job: dict, workflow_env: dict) -> list[str]: |
| 187 | 232 | key = f"{file_name}:{job_id}" |
| 188 | 233 | where = f".github/workflows/{file_name}: job '{job_id}'" |
| 189 | 234 | errors = [] |
| | @@ -221,7 +266,8 @@ |
| 221 | 266 | for index, step in enumerate(steps_of(job), start=1): |
| 222 | 267 | if not runs_claude_code_action(step): |
| 223 | 268 | continue |
| 224 | | - problem = permission_mode_problem(step, exempt) |
| 269 | + inherited_env = {**workflow_env, **(job.get("env") or {})} |
| 270 | + problem = permission_mode_problem(step, exempt, inherited_env) |
| 225 | 271 | if problem: |
| 226 | 272 | step_label = f"step '{step['name']}'" if "name" in step else f"step {index}" |
| 227 | 273 | errors.append(f"{where}, {step_label}: {problem}. {HELP}") |
| | @@ -275,7 +321,7 @@ |
| 275 | 321 | if not isinstance(job, dict) or not job_calls_claude(job): |
| 276 | 322 | continue |
| 277 | 323 | checked += 1 |
| 278 | | - errors.extend(check_job(path.name, job_id, job)) |
| 324 | + errors.extend(check_job(path.name, job_id, job, workflow.get("env") or {})) |
| 279 | 325 | if checked: |
| 280 | 326 | errors.extend(check_policy()) |
| 281 | 327 | for error in errors: |