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:
Qing Wang
2026-09-30 06:33:49 +00:00
committed by GitHub
co-authored by Claude
parent fd1c128679
commit a8cb0db4d8
12 changed files with 454 additions and 21 deletions
+29
View File
@@ -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
+339
View File
@@ -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())
+7 -1
View File
@@ -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"
+7 -1
View File
@@ -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
+7 -1
View File
@@ -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 }}
+4 -4
View File
@@ -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: |
+4 -3
View File
@@ -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:
+8 -4
View File
@@ -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
+10 -5
View File
@@ -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"]}'
+23
View File
@@ -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
+14
View File
@@ -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.