66
77import json
88import pathlib
9+ import re
910import shlex
1011import subprocess
1112import sys
1617SIGN_IN_MARKER = "anthropic_federation_rule_id"
1718CLAUDE_ACTIONS = ("anthropics/claude-code-action" , "anthropics/claude-code-base-action" )
1819HELP = '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+ )
1928
2029# Key: "<workflow file name>:<job id>". Value: why that job is exempt from the table's rule.
2130EXEMPT_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 ] = {}
2532
2633
2734def stop (message : str ):
@@ -99,10 +106,12 @@ def job_calls_claude(job: dict) -> bool:
99106 return False
100107
101108
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 :
103110 """The message for a step whose permission mode is wrong, or None if it is right.
104111
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'.
106115 """
107116 inputs = step .get ("with" ) or {}
108117 lines = str (inputs .get ("claude_args" , "" )).splitlines ()
@@ -141,16 +150,19 @@ def permission_mode_problem(step: dict, exempt: bool) -> str | None:
141150 f"'claude_args' has '--permission-mode { mode } '. "
142151 "Change it to '--permission-mode auto'"
143152 )
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 )
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 )
149162 ]
163+ settings = [("the step's 'settings'" , inputs .get ("settings" , "" ))]
150164 settings += [
151- ("'--settings' in 'claude_args'" , arg .split ("=" , 1 )[1 ])
152- for arg in args
153- if arg .startswith ("--settings=" )
165+ ("'--settings' in 'claude_args'" , value ) for value in flag_values (args , "--settings" )
154166 ]
155167 for where , value in settings :
156168 text = settings_text (value )
@@ -161,9 +173,42 @@ def permission_mode_problem(step: dict, exempt: bool) -> str | None:
161173 )
162174 if "defaultMode" in text :
163175 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+ )
164189 return None
165190
166191
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+
167212def settings_text (value ) -> str | None :
168213 """The settings JSON a 'settings' value stands for: the value itself, or the contents of
169214 the file it names when it is a path inside the repository. None when it names a path
@@ -183,7 +228,7 @@ def settings_text(value) -> str | None:
183228 return text
184229
185230
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 ]:
187232 key = f"{ file_name } :{ job_id } "
188233 where = f".github/workflows/{ file_name } : job '{ job_id } '"
189234 errors = []
@@ -221,7 +266,8 @@ def check_job(file_name: str, job_id: str, job: dict) -> list[str]:
221266 for index , step in enumerate (steps_of (job ), start = 1 ):
222267 if not runs_claude_code_action (step ):
223268 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 )
225271 if problem :
226272 step_label = f"step '{ step ['name' ]} '" if "name" in step else f"step { index } "
227273 errors .append (f"{ where } , { step_label } : { problem } . { HELP } " )
@@ -275,7 +321,7 @@ def main() -> int:
275321 if not isinstance (job , dict ) or not job_calls_claude (job ):
276322 continue
277323 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 {} ))
279325 if checked :
280326 errors .extend (check_policy ())
281327 for error in errors :
0 commit comments