Skip to content

Commit 7dbaea2

Browse files
committed
Run @claude mentions and dedupe in auto mode on a model that supports it
claude-sonnet-4-5 predates auto mode, so Claude Code fell back to its default permission mode in the dedupe job. Move it and claude.yml to claude-sonnet-4-6. The action sets --permission-mode acceptEdits for @claude mentions and appends the workflow's claude_args after it, so the --permission-mode auto in claude.yml wins. Drop claude.yml from EXEMPT_FROM_AUTO_MODE. The check now also fails when a step in auto mode names a model older than claude-opus-4-6.
1 parent 574ef0b commit 7dbaea2

5 files changed

Lines changed: 73 additions & 23 deletions

File tree

Binary file not shown.

‎.github/scripts/check_workflow_hardening.py‎

Lines changed: 62 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import json
88
import pathlib
9+
import re
910
import shlex
1011
import subprocess
1112
import sys
@@ -16,12 +17,18 @@
1617
SIGN_IN_MARKER = "anthropic_federation_rule_id"
1718
CLAUDE_ACTIONS = ("anthropics/claude-code-action", "anthropics/claude-code-base-action")
1819
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+
)
1928

2029
# Key: "<workflow file name>:<job id>". Value: why that job is exempt from the table's rule.
2130
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] = {}
2532

2633

2734
def 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+
167212
def 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:

‎.github/workflows/claude-dedupe-issues.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ jobs:
4545
# --permission-mode auto: a tool call that needs permission and is outside the allowed-tools
4646
# list in .claude/commands/dedupe.md runs only if Claude Code's safety review passes it.
4747
# Tools in --disallowedTools never run.
48-
claude_args: '--model claude-sonnet-4-5-20250929 --permission-mode auto --disallowedTools "WebFetch,WebSearch,Write,Edit,MultiEdit,NotebookEdit"'
48+
claude_args: '--model claude-sonnet-4-6 --permission-mode auto --disallowedTools "WebFetch,WebSearch,Write,Edit,MultiEdit,NotebookEdit"'
4949

5050
- name: Log duplicate comment event to Statsig
5151
if: always()

‎.github/workflows/claude.yml‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,5 +42,8 @@ jobs:
4242
anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }}
4343
anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }}
4444
anthropic_workspace_id: ${{ vars.ANTHROPIC_WORKSPACE_ID }}
45-
claude_args: "--model claude-sonnet-4-5-20250929"
45+
# --permission-mode auto: a tool call that needs permission and that the allowed tools
46+
# do not cover runs only if Claude Code's safety review passes it. The action sets
47+
# --permission-mode acceptEdits for @claude mentions, and this one, which comes after it, wins.
48+
claude_args: "--model claude-sonnet-4-6 --permission-mode auto"
4649

‎CLAUDE.md‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,13 @@ Keep them when you add or edit a workflow.
1919
`claude_args` input) passes `--permission-mode auto` in `claude_args`. A tool
2020
call that needs permission and that the allowed tools do not cover then runs
2121
only if Claude Code's safety review passes it. Allow only the tools the job
22-
needs, and keep any `--disallowedTools` list a step has.
22+
needs, and keep any `--disallowedTools` list a step has. Use `claude-opus-4-6`
23+
or a newer model: on an older one Claude Code falls back to its default
24+
permission mode.
2325

24-
Exception (listed with its reason in an exemption table in
25-
`.github/scripts/check_workflow_hardening.py`): `claude.yml` answers `@claude`
26-
mentions. For those the Claude Code action sets `--permission-mode acceptEdits`
27-
itself. Do not add a `--permission-mode` there.
26+
`claude.yml` answers `@claude` mentions. The Claude Code action sets
27+
`--permission-mode acceptEdits` for those, and the `--permission-mode auto` in
28+
the workflow's `claude_args`, which comes after it, replaces it.
2829

2930
`.github/workflows/workflow-hardening.yml` fails when a job that runs the Claude
3031
Code action or mentions `ANTHROPIC_FEDERATION_RULE_ID` breaks protection 1 or 3,

0 commit comments

Comments
 (0)