Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"hooks": [
{
"type": "command",
"command": "bun run format"
"command": "bunx prettier@3.5.3 --no-config --write ."
}
],
"matcher": "Edit|Write|MultiEdit"
Expand Down
280 changes: 280 additions & 0 deletions .github/scripts/check_workflow_hardening.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
#!/usr/bin/env python3
"""Fail if a workflow job that calls Claude breaks a rule in CLAUDE.md,
"Security hardening for GitHub Actions". Run from the repository root. A job calls Claude when it runs the
Claude Code action, or when it or a local action it uses mentions ANTHROPIC_FEDERATION_RULE_ID.
"""

import json
import pathlib
import re
import shlex
import subprocess
import sys

WORKFLOW_DIR = pathlib.Path(".github/workflows")
SIGN_IN_MARKER = "anthropic_federation_rule_id"
CLAUDE_ACTIONS = ("step-security/claude-code-action", "step-security/claude-code-base-action")
HELP = 'See CLAUDE.md, "Security hardening for GitHub Actions".'
# Claude Code runs auto mode only on claude-opus-4-6 and newer models. On an older model it
# falls back to its default permission mode, with no safety review.
AUTO_MODE_MIN_VERSION = (4, 6)
# The version in a model name: claude-opus-4-6, claude-sonnet-4-5-20250929, claude-3-5-sonnet-latest.
MODEL_VERSION = re.compile(
r"claude-(?:(?:opus|sonnet|haiku)-(\d+)(?:-(\d{1,2}))?"
r"|(\d+)(?:-(\d{1,2}))?-(?:opus|sonnet|haiku))(?![\d.])"
)

# Key: "<workflow file name>:<job id>". Value: why that job is exempt from the table's rule.
EXEMPT_FROM_AUTO_MODE: dict[str, str] = {}


def stop(message: str):
sys.exit(f"::error::{message}")


def load_yaml(path: pathlib.Path):
"""Parse a YAML file with PyYAML, or with the yq command if PyYAML is absent."""
try:
import yaml
except ImportError:
try:
result = subprocess.run(
["yq", "-o=json", ".", str(path)], check=True, capture_output=True, text=True
)
except FileNotFoundError:
stop(
f"Cannot read {path}: Python has no 'yaml' module and no 'yq' command was found. "
"Add a step that runs 'pip install pyyaml' before this check."
)
except subprocess.CalledProcessError:
stop(f"Cannot read {path}: 'yq' could not parse it. Check that the file is valid YAML.")
return json.loads(result.stdout)
try:
with path.open(encoding="utf-8") as handle:
return yaml.safe_load(handle)
except yaml.YAMLError as error:
stop(f"Cannot read {path}: it is not valid YAML ({error}).")


def contains_marker(node) -> bool:
"""Whether any key or string under node contains SIGN_IN_MARKER, ignoring case."""
if isinstance(node, dict):
return any(contains_marker(k) or contains_marker(v) for k, v in node.items())
if isinstance(node, list):
return any(contains_marker(item) for item in node)
return isinstance(node, str) and SIGN_IN_MARKER in node.lower()


def load_local_action(uses: str):
"""The parsed action file of a local action (uses: ./path), or None."""
if not uses.startswith("./"):
return None
for name in ("action.yml", "action.yaml"):
action_file = pathlib.Path(uses) / name
if action_file.is_file():
action = load_yaml(action_file)
return action if isinstance(action, dict) else {}
return None


def steps_of(job: dict) -> list[dict]:
return [step for step in job.get("steps") or [] if isinstance(step, dict)]


def runs_claude_code_action(step: dict) -> bool:
"""Whether the step runs the Claude Code action: the published action, or a
local action that accepts a claude_args input."""
uses = str(step.get("uses", ""))
if uses.lower().startswith(CLAUDE_ACTIONS):
return True
action = load_local_action(uses)
return action is not None and "claude_args" in (action.get("inputs") or {})


def job_calls_claude(job: dict) -> bool:
if contains_marker(job):
return True
for step in steps_of(job):
if runs_claude_code_action(step):
return True
action = load_local_action(str(step.get("uses", "")))
if action is not None and contains_marker(action):
return True
return False


def permission_mode_problem(step: dict, exempt: bool, inherited_env: dict) -> str | None:
"""The message for a step whose permission mode is wrong, or None if it is right.

A step must set auto mode, on a model that supports it. A step of a job in
EXEMPT_FROM_AUTO_MODE must set no mode at all. inherited_env is the workflow's and the
job's 'env'.
"""
inputs = step.get("with") or {}
lines = str(inputs.get("claude_args", "")).splitlines()
text = " ".join(line for line in lines if not line.strip().startswith("#"))
try:
args = shlex.split(text, comments=True)
except ValueError:
return "'claude_args' has a quote that is never closed. Close it"
modes = []
for index, arg in enumerate(args):
if arg == "--dangerously-skip-permissions":
return (
"remove '--dangerously-skip-permissions' from 'claude_args': "
"it turns off permission checks"
)
if arg == "--permission-mode":
modes.append(args[index + 1] if index + 1 < len(args) else "")
elif arg.startswith("--permission-mode="):
modes.append(arg.split("=", 1)[1])
if exempt and modes:
return (
"remove '--permission-mode' from 'claude_args': this job is listed in "
"EXEMPT_FROM_AUTO_MODE (.github/scripts/check_workflow_hardening.py), and a job listed "
"there must not set a permission mode"
)
if not modes and not exempt:
return "add '--permission-mode auto' to 'claude_args' under the step's 'with:'"
for mode in modes:
if mode == "":
return (
"'claude_args' has '--permission-mode' with nothing after it. "
"Write '--permission-mode auto'"
)
if mode != "auto":
return (
f"'claude_args' has '--permission-mode {mode}'. "
"Change it to '--permission-mode auto'"
)
env = {**inherited_env, **(step.get("env") or {})}
models = [
("the step's 'model'", inputs.get("model", "")),
("ANTHROPIC_MODEL", env.get("ANTHROPIC_MODEL", "")),
]
models += [
(f"'{flag}' in 'claude_args'", value)
for flag in ("--model", "--fallback-model")
for value in flag_values(args, flag)
]
settings = [("the step's 'settings'", inputs.get("settings", ""))]
settings += [
("'--settings' in 'claude_args'", value) for value in flag_values(args, "--settings")
]
for where, value in settings:
text = settings_text(value)
if text is None:
return (
f"{where} names a file outside the repository, which this check cannot read. "
"Use inline settings or a file inside the repository"
)
if "defaultMode" in text:
return f"remove 'defaultMode' from {where}: settings must not set a permission mode"
try:
parsed = json.loads(text) if text else {}
except ValueError:
parsed = {}
if isinstance(parsed, dict) and "model" in parsed:
models.append((f"'model' in {where}", parsed["model"]))
if not exempt:
for where, model in models:
if predates_auto_mode(str(model or "")):
return (
f"{where} is '{model}', which Claude Code does not run in auto mode: it "
"falls back to the default permission mode. Use claude-opus-4-6 or a newer model"
)
return None


def flag_values(args: list[str], flag: str) -> list[str]:
"""The values a flag in claude_args is given, as '--flag value' or '--flag=value'."""
values = [
args[index + 1] for index, arg in enumerate(args) if arg == flag and index + 1 < len(args)
]
return values + [arg.split("=", 1)[1] for arg in args if arg.startswith(f"{flag}=")]


def predates_auto_mode(model: str) -> bool:
"""Whether the model is older than AUTO_MODE_MIN_VERSION. A name with no version, such as
'opus' or 'default', stands for a current model, except 'haiku' (claude-haiku-4-5)."""
if model.strip().lower() == "haiku":
return True
match = MODEL_VERSION.search(model.lower())
if not match:
return False
major, minor = match.group(1, 2) if match.group(1) else match.group(3, 4)
return (int(major), int(minor or 0)) < AUTO_MODE_MIN_VERSION


def settings_text(value) -> str | None:
"""The settings JSON a 'settings' value stands for: the value itself, or the contents of
the file it names when it is a path inside the repository. None when it names a path
outside the repository."""
text = str(value or "").strip()
if not text or text.startswith("{"):
return text
path = pathlib.Path(text)
root = pathlib.Path.cwd().resolve()
try:
resolved = path.resolve()
resolved.relative_to(root)
except (OSError, ValueError):
return None
if resolved.is_file():
return resolved.read_text(encoding="utf-8", errors="replace")
return text


def check_job(file_name: str, job_id: str, job: dict, workflow_env: dict) -> list[str]:
key = f"{file_name}:{job_id}"
where = f".github/workflows/{file_name}: job '{job_id}'"
errors = []
exempt = key in EXEMPT_FROM_AUTO_MODE
if exempt:
print(
f"Auto permission mode is not required for job '{job_id}' in {file_name}. "
f"Reason: {EXEMPT_FROM_AUTO_MODE[key]}."
)
if "defaultMode" in json.dumps(job):
# Catches a settings file that an earlier step of the job writes, which the
# step-level check cannot read.
errors.append(
f"{where} mentions 'defaultMode': settings must not set a permission mode. {HELP}"
)
for index, step in enumerate(steps_of(job), start=1):
if not runs_claude_code_action(step):
continue
inherited_env = {**workflow_env, **(job.get("env") or {})}
problem = permission_mode_problem(step, exempt, inherited_env)
if problem:
step_label = f"step '{step['name']}'" if "name" in step else f"step {index}"
errors.append(f"{where}, {step_label}: {problem}. {HELP}")
return errors


def main() -> int:
if not WORKFLOW_DIR.is_dir():
stop(f"{WORKFLOW_DIR} not found. Run this check from the repository root.")
errors = []
checked = 0
for path in sorted([*WORKFLOW_DIR.glob("*.yml"), *WORKFLOW_DIR.glob("*.yaml")]):
workflow = load_yaml(path)
jobs = workflow.get("jobs") if isinstance(workflow, dict) else None
for job_id, job in (jobs or {}).items():
if not isinstance(job, dict) or not job_calls_claude(job):
continue
checked += 1
errors.extend(check_job(path.name, job_id, job, workflow.get("env") or {}))
for error in errors:
print(f"::error::{error}")
if errors:
return 1
if checked == 0:
print("OK: no workflow job calls Claude, so there was nothing to check.")
else:
print(f"OK: checked {checked} job(s) that call Claude and found no problems.")
return 0


if __name__ == "__main__":
sys.exit(main())
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ jobs:

- uses: step-security/setup-bun@f6f5dadeac34f70c7828f731569e8d6e8330b8fb #v2.1.3
with:
bun-version: latest
bun-version: 1.2.12

- name: Install dependencies
run: bun install
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/claude-review-local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@ jobs:
prompt: "/review-pr REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }}"
claude_args: |
--allowedTools "mcp__github_inline_comment__create_inline_comment"
--permission-mode auto
13 changes: 11 additions & 2 deletions .github/workflows/test-base-action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,15 @@ on:
default: "List the files in the current directory starting with 'package'"
workflow_call:

# Pin the model so these tests don't depend on the CLI's default model,
# which can change with each Claude Code release.
env:
ANTHROPIC_MODEL: claude-opus-5

jobs:
test-inline-prompt:
# Skip on fork PRs since they can't access secrets for Claude API auth
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
Expand All @@ -30,7 +37,7 @@ jobs:
with:
prompt: ${{ github.event.inputs.test_prompt || 'List the files in the current directory starting with "package"' }}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
allowed_tools: "LS,Read"
claude_args: '--allowedTools "LS,Read" --permission-mode auto'

- name: Verify inline prompt output
run: |
Expand Down Expand Up @@ -69,6 +76,8 @@ jobs:
fi

test-prompt-file:
# Skip on fork PRs since they can't access secrets for Claude API auth
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
Expand All @@ -92,7 +101,7 @@ jobs:
with:
prompt_file: "test-prompt.txt"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
allowed_tools: "LS,Read"
claude_args: '--allowedTools "LS,Read" --permission-mode auto'

- name: Verify prompt file output
run: |
Expand Down
9 changes: 8 additions & 1 deletion .github/workflows/test-custom-executables.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,15 @@ on:
workflow_dispatch:
workflow_call:

# Pin the model so these tests don't depend on the CLI's default model,
# which can change with each Claude Code release.
env:
ANTHROPIC_MODEL: claude-opus-5

jobs:
test-custom-executables:
# Skip on fork PRs since they can't mint the OIDC token used for Claude API auth
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
Expand Down Expand Up @@ -58,7 +65,7 @@ jobs:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
path_to_claude_code_executable: /home/runner/.local/bin/claude
path_to_bun_executable: /home/runner/.bun/bin/bun
allowed_tools: "LS,Read"
claude_args: '--allowedTools "LS,Read" --permission-mode auto'

- name: Verify custom executables worked
run: |
Expand Down
Loading
Loading