mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-11-10 17:10:00 +08:00
ci: security hardening for GitHub Actions workflows that call Claude (#1867)
* Run workflows that call Claude on the egress-firewall runner in auto permission mode Move every workflow job that signs in to the Claude API from ubuntu-latest to GitHub's egress-firewall runner (ubuntu-24.04-firewall), and pass --permission-mode auto to the Claude Code action in each of them except claude.yml. In auto permission mode Claude Code reviews each tool call that needs permission and that the allowed tools do not cover, and runs it only if Claude Code's safety review passes it. Until now these runs, which have nobody to ask, refused every such call. The issue triage step, which anyone can start by opening an issue, also passes a --disallowedTools list for tools it never needs. claude.yml answers @claude mentions, and for those the action sets --permission-mode acceptEdits itself. Its permission mode is unchanged. Allowed tools, settings, models, triggers and permissions are unchanged. The network allow list for the firewall follows in the next change. * Add the network allow list, a security check and CLAUDE.md guidance - .github/egress-firewall.yaml: the hosts that jobs on the egress-firewall runner may reach, in enforce mode, each with what uses it. - .github/workflows/workflow-hardening.yml and .github/scripts/check_workflow_hardening.py: a check that fails when a job that calls Claude is not on the egress-firewall runner, does not pass --permission-mode auto, or when the allow list is missing, empty, not in enforce mode or names a host with '*'. claude.yml is listed as exempt from the permission mode rule, with the reason. - CLAUDE.md: a "Security hardening for GitHub Actions" section so that new and edited workflows keep these protections. * Pin the model for issue triage The step named no model, so it ran on Claude Code's default model. That default changed with Claude Code 2.1.280 to a model that is not available to this CI account. Pass --model claude-opus-5, the model this repository's test-*.yml workflows (ANTHROPIC_MODEL) and the Python SDK's example runs are pinned to for the same reason. This is independent of the security hardening in the two changes before it. * Check settings files and --settings for a permission mode The security check looked for defaultMode only in an inline 'settings' input. Also read the file a 'settings' input or a --settings flag in claude_args names, when it is inside the repository, and fail if it sets a permission mode. * Flag defaultMode anywhere in a job that calls Claude A settings file that an earlier step writes at run time can't be read by the step-level check, so also fail when the job's definition mentions defaultMode at all. * Fail the check when settings name a file outside the repository The check can't read such a file, so it can't tell whether it sets a permission mode. * Run @claude mentions in auto permission mode 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: Claude Code does not run auto mode on those and falls back to its default permission mode. --------- Co-authored-by: Claude <[email protected]>
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
# Hosts that jobs on GitHub's egress-firewall runner (runs-on: ubuntu-24.04-firewall)
|
||||
# may reach. All other hosts are blocked, apart from any that GitHub's firewall allows
|
||||
# by default. Add a host only when a workflow step needs it, name it in full (no '*'), and say what uses it.
|
||||
mode: enforce
|
||||
allow:
|
||||
# Claude API: model requests, trading the workflow's GitHub identity token for a
|
||||
# short-lived API token, and fetching the Claude GitHub App's token in claude.yml and
|
||||
# claude-review.yml
|
||||
- api.anthropic.com
|
||||
# GitHub API: calls made by the Claude Code action, by gh in scripts/gh.sh and
|
||||
# scripts/edit-issue-labels.sh, and by the /review-pr command
|
||||
- api.github.com
|
||||
# Claude Code install script: fetched by the Claude Code action, and by
|
||||
# test-custom-executables.yml ("Install Claude Code manually")
|
||||
- claude.ai
|
||||
# Claude Code binary, downloaded by the install script
|
||||
- downloads.claude.ai
|
||||
# Bun and Node.js release downloads (oven-sh/setup-bun and actions/setup-node)
|
||||
- release-assets.githubusercontent.com
|
||||
# npm packages (bun install in action.yml, base-action/action.yml and
|
||||
# test-mcp-servers.yml)
|
||||
- registry.npmjs.org
|
||||
# Bun install script (test-custom-executables.yml, "Install Bun manually")
|
||||
- bun.sh
|
||||
# apt packages bubblewrap and socat, which the Claude Code action installs when a
|
||||
# workflow admits users without write access (issue-triage.yml)
|
||||
- azure.archive.ubuntu.com
|
||||
- archive.ubuntu.com
|
||||
- security.ubuntu.com
|
||||
Executable
+339
@@ -0,0 +1,339 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail if a workflow job that calls Claude, or .github/egress-firewall.yaml, 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
|
||||
|
||||
FIREWALL_RUNNER = "ubuntu-24.04-firewall"
|
||||
WORKFLOW_DIR = pathlib.Path(".github/workflows")
|
||||
POLICY_PATH = pathlib.Path(".github/egress-firewall.yaml")
|
||||
SIGN_IN_MARKER = "anthropic_federation_rule_id"
|
||||
CLAUDE_ACTIONS = ("anthropics/claude-code-action", "anthropics/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_FIREWALL_RUNNER: dict[str, str] = {}
|
||||
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 = []
|
||||
runs_on = job.get("runs-on")
|
||||
if isinstance(runs_on, list) and len(runs_on) == 1:
|
||||
runs_on = runs_on[0]
|
||||
if key in EXEMPT_FROM_FIREWALL_RUNNER:
|
||||
print(
|
||||
f"The egress-firewall runner is not required for job '{job_id}' in {file_name}. "
|
||||
f"Reason: {EXEMPT_FROM_FIREWALL_RUNNER[key]}."
|
||||
)
|
||||
elif runs_on != FIREWALL_RUNNER:
|
||||
if "runs-on" not in job:
|
||||
has = "no 'runs-on'"
|
||||
elif isinstance(job["runs-on"], str):
|
||||
has = f"'runs-on: {job['runs-on']}'"
|
||||
else:
|
||||
has = "a 'runs-on' list or group"
|
||||
errors.append(
|
||||
f"{where} calls Claude, so it must have 'runs-on: {FIREWALL_RUNNER}'. "
|
||||
f"It has {has}. {HELP}"
|
||||
)
|
||||
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 check_policy() -> list[str]:
|
||||
if not POLICY_PATH.is_file():
|
||||
return [
|
||||
f"{POLICY_PATH} is missing. Jobs on the egress-firewall runner need it "
|
||||
f"to limit outbound network access. {HELP}"
|
||||
]
|
||||
policy = load_yaml(POLICY_PATH)
|
||||
if not isinstance(policy, dict):
|
||||
return [
|
||||
f"{POLICY_PATH} is empty or is not a set of 'name: value' lines. It needs 'mode: enforce' "
|
||||
f"and an 'allow:' list of hosts. {HELP}"
|
||||
]
|
||||
errors = []
|
||||
if "mode" not in policy:
|
||||
errors.append(f"{POLICY_PATH}: 'mode' is missing. Add 'mode: enforce'. {HELP}")
|
||||
elif policy["mode"] != "enforce":
|
||||
errors.append(
|
||||
f"{POLICY_PATH}: 'mode' is '{policy['mode']}'. It must be 'enforce'. {HELP}"
|
||||
)
|
||||
allow = policy.get("allow")
|
||||
if not isinstance(allow, list) or not allow:
|
||||
errors.append(
|
||||
f"{POLICY_PATH}: the 'allow' list is missing or empty. List under 'allow:' "
|
||||
f"each host the jobs need. {HELP}"
|
||||
)
|
||||
else:
|
||||
for host in allow:
|
||||
if "*" in str(host):
|
||||
errors.append(
|
||||
f"{POLICY_PATH}: the 'allow' entry '{host}' contains '*'. "
|
||||
f"Name each host in full. {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 {}))
|
||||
if checked:
|
||||
errors.extend(check_policy())
|
||||
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())
|
||||
@@ -8,7 +8,9 @@ jobs:
|
||||
review:
|
||||
# Skip on fork PRs since they can't mint the OIDC token used for Claude API auth
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
# This job calls Claude, so it runs on GitHub's egress-firewall runner, which
|
||||
# filters the job's outbound network traffic (allow list: .github/egress-firewall.yaml).
|
||||
runs-on: ubuntu-24.04-firewall
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
@@ -30,6 +32,10 @@ jobs:
|
||||
anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }}
|
||||
|
||||
prompt: "/review-pr REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }}"
|
||||
# --permission-mode auto: a tool call that needs permission and is outside --allowedTools
|
||||
# below and the allowed-tools list in .claude/commands/review-pr.md runs only if Claude
|
||||
# Code's safety review passes it.
|
||||
claude_args: |
|
||||
--permission-mode auto
|
||||
--allowedTools "mcp__github_inline_comment__create_inline_comment"
|
||||
--model "claude-opus-4-7"
|
||||
|
||||
@@ -17,7 +17,9 @@ jobs:
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
# This job calls Claude, so it runs on GitHub's egress-firewall runner, which
|
||||
# filters the job's outbound network traffic (allow list: .github/egress-firewall.yaml).
|
||||
runs-on: ubuntu-24.04-firewall
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
@@ -39,6 +41,10 @@ jobs:
|
||||
anthropic_federation_rule_id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }}
|
||||
anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }}
|
||||
anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }}
|
||||
# --permission-mode auto: a tool call that needs permission and that the allowed tools
|
||||
# do not cover runs only if Claude Code's safety review passes it. The action sets
|
||||
# --permission-mode acceptEdits for @claude mentions, and this one, which comes after it, wins.
|
||||
claude_args: |
|
||||
--allowedTools "Bash(bun install),Bash(bun test:*),Bash(bun run format),Bash(bun typecheck)"
|
||||
--model "claude-opus-4-7"
|
||||
--permission-mode auto
|
||||
|
||||
@@ -6,7 +6,9 @@ on:
|
||||
|
||||
jobs:
|
||||
triage-issue:
|
||||
runs-on: ubuntu-latest
|
||||
# This job calls Claude, so it runs on GitHub's egress-firewall runner, which
|
||||
# filters the job's outbound network traffic (allow list: .github/egress-firewall.yaml).
|
||||
runs-on: ubuntu-24.04-firewall
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -33,5 +35,9 @@ jobs:
|
||||
anthropic_federation_rule_id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }}
|
||||
anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }}
|
||||
anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }}
|
||||
# --permission-mode auto: a tool call that needs permission and is outside the allowed-tools list in
|
||||
# .claude/commands/label-issue.md runs only if Claude Code's safety review passes it. Tools in
|
||||
# --disallowedTools never run. --model is set because Claude Code's default model changes between releases.
|
||||
claude_args: '--model claude-opus-5 --permission-mode auto --disallowedTools "WebFetch,WebSearch,Write,Edit,MultiEdit,NotebookEdit"'
|
||||
allowed_non_write_users: "*" # Required for issue triage workflow, if users without repo write access create issues
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
test-inline-prompt:
|
||||
# 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
|
||||
runs-on: ubuntu-24.04-firewall
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
anthropic_federation_rule_id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }}
|
||||
anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }}
|
||||
anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }}
|
||||
claude_args: '--allowedTools "LS,Read"'
|
||||
claude_args: '--allowedTools "LS,Read" --permission-mode auto'
|
||||
|
||||
- name: Verify inline prompt output
|
||||
run: |
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
test-prompt-file:
|
||||
# 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
|
||||
runs-on: ubuntu-24.04-firewall
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
anthropic_federation_rule_id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }}
|
||||
anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }}
|
||||
anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }}
|
||||
claude_args: '--allowedTools "LS,Read"'
|
||||
claude_args: '--allowedTools "LS,Read" --permission-mode auto'
|
||||
|
||||
- name: Verify prompt file output
|
||||
run: |
|
||||
|
||||
@@ -21,7 +21,7 @@ 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
|
||||
runs-on: ubuntu-24.04-firewall
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }}
|
||||
path_to_claude_code_executable: /home/runner/.local/bin/claude
|
||||
path_to_bun_executable: /home/runner/.bun/bin/bun
|
||||
claude_args: '--allowedTools "LS,Read"'
|
||||
claude_args: '--allowedTools "LS,Read" --permission-mode auto'
|
||||
|
||||
- name: Verify custom executables worked
|
||||
run: |
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
test-mcp-integration:
|
||||
# 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
|
||||
runs-on: ubuntu-24.04-firewall
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
anthropic_federation_rule_id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }}
|
||||
anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }}
|
||||
anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }}
|
||||
claude_args: --allowedTools mcp__test-server__test_tool
|
||||
claude_args: --allowedTools mcp__test-server__test_tool --permission-mode auto
|
||||
env:
|
||||
# Change to test directory so it finds .mcp.json
|
||||
CLAUDE_WORKING_DIR: ${{ github.workspace }}/base-action/test/mcp-test
|
||||
@@ -103,7 +103,7 @@ jobs:
|
||||
test-mcp-config-flag:
|
||||
# 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
|
||||
runs-on: ubuntu-24.04-firewall
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -138,6 +138,7 @@ jobs:
|
||||
anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }}
|
||||
anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }}
|
||||
claude_args: |
|
||||
--permission-mode auto
|
||||
--allowedTools mcp__test-server__test_tool
|
||||
--mcp-config '{"mcpServers":{"test-server":{"type":"stdio","command":"bun","args":["simple-mcp-server.ts"],"env":{}}}}'
|
||||
env:
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
test-settings-inline-allow:
|
||||
# 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
|
||||
runs-on: ubuntu-24.04-firewall
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -34,6 +34,7 @@ jobs:
|
||||
anthropic_federation_rule_id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }}
|
||||
anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }}
|
||||
anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }}
|
||||
claude_args: "--permission-mode auto"
|
||||
settings: |
|
||||
{
|
||||
"permissions": {
|
||||
@@ -74,7 +75,7 @@ jobs:
|
||||
test-settings-inline-deny:
|
||||
# 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
|
||||
runs-on: ubuntu-24.04-firewall
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -87,6 +88,7 @@ jobs:
|
||||
anthropic_federation_rule_id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }}
|
||||
anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }}
|
||||
anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }}
|
||||
claude_args: "--permission-mode auto"
|
||||
settings: |
|
||||
{
|
||||
"permissions": {
|
||||
@@ -110,7 +112,7 @@ jobs:
|
||||
test-settings-file-allow:
|
||||
# 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
|
||||
runs-on: ubuntu-24.04-firewall
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -133,6 +135,7 @@ jobs:
|
||||
anthropic_federation_rule_id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }}
|
||||
anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }}
|
||||
anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }}
|
||||
claude_args: "--permission-mode auto"
|
||||
settings: "test-settings.json"
|
||||
|
||||
- name: Verify echo worked
|
||||
@@ -168,7 +171,7 @@ jobs:
|
||||
test-settings-file-deny:
|
||||
# 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
|
||||
runs-on: ubuntu-24.04-firewall
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -191,6 +194,7 @@ jobs:
|
||||
anthropic_federation_rule_id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }}
|
||||
anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }}
|
||||
anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }}
|
||||
claude_args: "--permission-mode auto"
|
||||
settings: "test-settings.json"
|
||||
|
||||
- name: Verify echo was denied
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
name: Test Basic Type Conversions
|
||||
# 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
|
||||
runs-on: ubuntu-24.04-firewall
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -43,6 +43,7 @@ jobs:
|
||||
anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }}
|
||||
anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }}
|
||||
claude_args: |
|
||||
--permission-mode auto
|
||||
--allowedTools Bash
|
||||
--json-schema '{"type":"object","properties":{"text_field":{"type":"string"},"number_field":{"type":"number"},"boolean_true":{"type":"boolean"},"boolean_false":{"type":"boolean"}},"required":["text_field","number_field","boolean_true","boolean_false"]}'
|
||||
|
||||
@@ -85,7 +86,7 @@ jobs:
|
||||
name: Test Arrays and Objects
|
||||
# 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
|
||||
runs-on: ubuntu-24.04-firewall
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -105,6 +106,7 @@ jobs:
|
||||
anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }}
|
||||
anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }}
|
||||
claude_args: |
|
||||
--permission-mode auto
|
||||
--allowedTools Bash
|
||||
--json-schema '{"type":"object","properties":{"items":{"type":"array","items":{"type":"string"}},"config":{"type":"object"},"empty_array":{"type":"array"}},"required":["items","config","empty_array"]}'
|
||||
|
||||
@@ -140,7 +142,7 @@ jobs:
|
||||
name: Test Edge Cases
|
||||
# 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
|
||||
runs-on: ubuntu-24.04-firewall
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -161,6 +163,7 @@ jobs:
|
||||
anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }}
|
||||
anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }}
|
||||
claude_args: |
|
||||
--permission-mode auto
|
||||
--allowedTools Bash
|
||||
--json-schema '{"type":"object","properties":{"zero":{"type":"number"},"empty_string":{"type":"string"},"negative":{"type":"number"},"decimal":{"type":"number"}},"required":["zero","empty_string","negative","decimal"]}'
|
||||
|
||||
@@ -203,7 +206,7 @@ jobs:
|
||||
name: Test Output Name Sanitization
|
||||
# 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
|
||||
runs-on: ubuntu-24.04-firewall
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -219,6 +222,7 @@ jobs:
|
||||
anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }}
|
||||
anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }}
|
||||
claude_args: |
|
||||
--permission-mode auto
|
||||
--allowedTools Bash
|
||||
--json-schema '{"type":"object","properties":{"test-result":{"type":"string"},"item_count":{"type":"number"}},"required":["test-result","item_count"]}'
|
||||
|
||||
@@ -247,7 +251,7 @@ jobs:
|
||||
name: Test Execution File Format
|
||||
# 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
|
||||
runs-on: ubuntu-24.04-firewall
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -261,6 +265,7 @@ jobs:
|
||||
anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }}
|
||||
anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }}
|
||||
claude_args: |
|
||||
--permission-mode auto
|
||||
--allowedTools Bash
|
||||
--json-schema '{"type":"object","properties":{"done":{"type":"boolean"}},"required":["done"]}'
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Fails when a workflow job that calls Claude lacks its security settings.
|
||||
# The rules are in CLAUDE.md, "Security hardening for GitHub Actions".
|
||||
name: Security check for workflows that call Claude
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
workflow-hardening:
|
||||
name: Check security settings
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Check security settings of workflows that call Claude
|
||||
run: python3 .github/scripts/check_workflow_hardening.py
|
||||
@@ -42,3 +42,17 @@ Single entrypoint: `src/entrypoints/run.ts` orchestrates everything — prepare
|
||||
- `moduleResolution: "bundler"` — imports don't need `.js` extensions.
|
||||
- GitHub API calls should use retry logic (`src/utils/retry.ts`).
|
||||
- MCP servers are auto-installed at runtime to `~/.claude/mcp/github-{type}-server/`.
|
||||
|
||||
## Security hardening for GitHub Actions
|
||||
|
||||
Workflow jobs in this repository that call Claude run with three protections. Keep them when you add or edit a workflow.
|
||||
|
||||
1. **Egress-firewall runner.** The job has `runs-on: ubuntu-24.04-firewall`, a GitHub-hosted runner that filters the job's outbound network traffic. Do not move a job that calls Claude to another runner.
|
||||
2. **Network allow list.** `.github/egress-firewall.yaml` lists the hosts those jobs may reach, besides any that GitHub's firewall allows by default. Keep `mode: enforce`, which is what makes the firewall block the rest. Follow that file's header when you add a host.
|
||||
3. **Auto permission mode.** Every step that runs the Claude Code action (`uses: anthropics/claude-code-action`, or this repository's own `./` and `./base-action`) passes `--permission-mode auto` in `claude_args`. A tool call that needs permission and that the allowed tools do not cover then runs only if Claude Code's safety review passes it. Allow only the tools the job needs, and keep any `--disallowedTools` list a step has. Use `claude-opus-4-6` or a newer model: on an older one Claude Code falls back to its default permission mode.
|
||||
|
||||
`claude.yml` answers `@claude` mentions. The Claude Code action sets `--permission-mode acceptEdits` for those, and the `--permission-mode auto` in the workflow's `claude_args`, which comes after it, replaces it.
|
||||
|
||||
`.github/workflows/workflow-hardening.yml` fails when a job that runs the Claude Code action or mentions `ANTHROPIC_FEDERATION_RULE_ID` breaks protection 1 or 3, or when the allow list is missing, empty, not `mode: enforce`, or names a host with `*`. It cannot see a job that calls Claude another way, so check new workflows by hand too. If a job cannot meet protection 1 or 3, add it with the reason to the matching exemption table in `.github/scripts/check_workflow_hardening.py`. A job in `EXEMPT_FROM_AUTO_MODE` must set no permission mode at all. Do not skip or weaken the check.
|
||||
|
||||
Keep each workflow's `permissions:` block minimal, and never print tokens or environment variables in workflow logs.
|
||||
|
||||
Reference in New Issue
Block a user