spacehub

surshar / claude-code

Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.

Built by Anthropic · github.com/anthropics

Merge pull request #97952 from anthropics/security-hardening-gh-actionsBrowse files
ci: security hardening for GitHub Actions workflows that call Claude
Qing Wang authored 23 days ago · GitHub committed 23 days ago parents 2282079 + 89c73ce · commit 732e167ee9d7
7 changed files+451 -5compared with 2282079
A.github/egress-firewall.yaml
A.github/scripts/check_workflow_hardening.py
M.github/workflows/claude-dedupe-issues.yml
M.github/workflows/claude-issue-triage.yml
M.github/workflows/claude.yml
A.github/workflows/workflow-hardening.yml
ACLAUDE.md
@@ -1,0 +1,27 @@
1+# Hosts that jobs on GitHub's egress-firewall runner (runs-on: ubuntu-24.04-firewall)
2+# may reach. All other hosts are blocked, apart from any that GitHub's firewall allows
3+# by default. Add a host only when a workflow step needs it, name it in full (no '*'), and say what uses it.
4+mode: enforce
5+allow:
6+ # Claude API: model requests, trading the workflow's GitHub identity token for a
7+ # short-lived API token, and fetching the Claude GitHub App's token in claude.yml
8+ - api.anthropic.com
9+ # GitHub API: calls made by the Claude Code action, and by gh in scripts/gh.sh,
10+ # scripts/edit-issue-labels.sh and scripts/comment-on-duplicates.sh
11+ - api.github.com
12+ # Claude Code install script, fetched by the Claude Code action
13+ - claude.ai
14+ # Claude Code binary, downloaded by the install script
15+ - downloads.claude.ai
16+ # Bun release download (oven-sh/setup-bun, used by the Claude Code action)
17+ - release-assets.githubusercontent.com
18+ # npm packages for the Claude Code action (bun install)
19+ - registry.npmjs.org
20+ # apt packages bubblewrap and socat, which the Claude Code action installs when a
21+ # workflow admits users without write access (claude-issue-triage.yml,
22+ # claude-dedupe-issues.yml)
23+ - azure.archive.ubuntu.com
24+ - archive.ubuntu.com
25+ - security.ubuntu.com
26+ # Statsig event logging (claude-dedupe-issues.yml, "Log duplicate comment event to Statsig")
27+ - events.statsigapi.net
@@ -1,0 +1,339 @@
1+#!/usr/bin/env python3
2+"""Fail if a workflow job that calls Claude, or .github/egress-firewall.yaml, breaks a rule in CLAUDE.md,
3+"Security hardening for GitHub Actions". Run from the repository root. A job calls Claude when it runs the
4+Claude Code action, or when it or a local action it uses mentions ANTHROPIC_FEDERATION_RULE_ID.
5+"""
6+
7+import json
8+import pathlib
9+import re
10+import shlex
11+import subprocess
12+import sys
13+
14+FIREWALL_RUNNER = "ubuntu-24.04-firewall"
15+WORKFLOW_DIR = pathlib.Path(".github/workflows")
16+POLICY_PATH = pathlib.Path(".github/egress-firewall.yaml")
17+SIGN_IN_MARKER = "anthropic_federation_rule_id"
18+CLAUDE_ACTIONS = ("anthropics/claude-code-action", "anthropics/claude-code-base-action")
19+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+)
28+
29+# Key: "<workflow file name>:<job id>". Value: why that job is exempt from the table's rule.
30+EXEMPT_FROM_FIREWALL_RUNNER: dict[str, str] = {}
31+EXEMPT_FROM_AUTO_MODE: dict[str, str] = {}
32+
33+
34+def stop(message: str):
35+ sys.exit(f"::error::{message}")
36+
37+
38+def load_yaml(path: pathlib.Path):
39+ """Parse a YAML file with PyYAML, or with the yq command if PyYAML is absent."""
40+ try:
41+ import yaml
42+ except ImportError:
43+ try:
44+ result = subprocess.run(
45+ ["yq", "-o=json", ".", str(path)], check=True, capture_output=True, text=True
46+ )
47+ except FileNotFoundError:
48+ stop(
49+ f"Cannot read {path}: Python has no 'yaml' module and no 'yq' command was found. "
50+ "Add a step that runs 'pip install pyyaml' before this check."
51+ )
52+ except subprocess.CalledProcessError:
53+ stop(f"Cannot read {path}: 'yq' could not parse it. Check that the file is valid YAML.")
54+ return json.loads(result.stdout)
55+ try:
56+ with path.open(encoding="utf-8") as handle:
57+ return yaml.safe_load(handle)
58+ except yaml.YAMLError as error:
59+ stop(f"Cannot read {path}: it is not valid YAML ({error}).")
60+
61+
62+def contains_marker(node) -> bool:
63+ """Whether any key or string under node contains SIGN_IN_MARKER, ignoring case."""
64+ if isinstance(node, dict):
65+ return any(contains_marker(k) or contains_marker(v) for k, v in node.items())
66+ if isinstance(node, list):
67+ return any(contains_marker(item) for item in node)
68+ return isinstance(node, str) and SIGN_IN_MARKER in node.lower()
69+
70+
71+def load_local_action(uses: str):
72+ """The parsed action file of a local action (uses: ./path), or None."""
73+ if not uses.startswith("./"):
74+ return None
75+ for name in ("action.yml", "action.yaml"):
76+ action_file = pathlib.Path(uses) / name
77+ if action_file.is_file():
78+ action = load_yaml(action_file)
79+ return action if isinstance(action, dict) else {}
80+ return None
81+
82+
83+def steps_of(job: dict) -> list[dict]:
84+ return [step for step in job.get("steps") or [] if isinstance(step, dict)]
85+
86+
87+def runs_claude_code_action(step: dict) -> bool:
88+ """Whether the step runs the Claude Code action: the published action, or a
89+ local action that accepts a claude_args input."""
90+ uses = str(step.get("uses", ""))
91+ if uses.lower().startswith(CLAUDE_ACTIONS):
92+ return True
93+ action = load_local_action(uses)
94+ return action is not None and "claude_args" in (action.get("inputs") or {})
95+
96+
97+def job_calls_claude(job: dict) -> bool:
98+ if contains_marker(job):
99+ return True
100+ for step in steps_of(job):
101+ if runs_claude_code_action(step):
102+ return True
103+ action = load_local_action(str(step.get("uses", "")))
104+ if action is not None and contains_marker(action):
105+ return True
106+ return False
107+
108+
109+def permission_mode_problem(step: dict, exempt: bool, inherited_env: dict) -> str | None:
110+ """The message for a step whose permission mode is wrong, or None if it is right.
111+
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'.
115+ """
116+ inputs = step.get("with") or {}
117+ lines = str(inputs.get("claude_args", "")).splitlines()
118+ text = " ".join(line for line in lines if not line.strip().startswith("#"))
119+ try:
120+ args = shlex.split(text, comments=True)
121+ except ValueError:
122+ return "'claude_args' has a quote that is never closed. Close it"
123+ modes = []
124+ for index, arg in enumerate(args):
125+ if arg == "--dangerously-skip-permissions":
126+ return (
127+ "remove '--dangerously-skip-permissions' from 'claude_args': "
128+ "it turns off permission checks"
129+ )
130+ if arg == "--permission-mode":
131+ modes.append(args[index + 1] if index + 1 < len(args) else "")
132+ elif arg.startswith("--permission-mode="):
133+ modes.append(arg.split("=", 1)[1])
134+ if exempt and modes:
135+ return (
136+ "remove '--permission-mode' from 'claude_args': this job is listed in "
137+ "EXEMPT_FROM_AUTO_MODE (.github/scripts/check_workflow_hardening.py), and a job listed "
138+ "there must not set a permission mode"
139+ )
140+ if not modes and not exempt:
141+ return "add '--permission-mode auto' to 'claude_args' under the step's 'with:'"
142+ for mode in modes:
143+ if mode == "":
144+ return (
145+ "'claude_args' has '--permission-mode' with nothing after it. "
146+ "Write '--permission-mode auto'"
147+ )
148+ if mode != "auto":
149+ return (
150+ f"'claude_args' has '--permission-mode {mode}'. "
151+ "Change it to '--permission-mode auto'"
152+ )
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)
162+ ]
163+ settings = [("the step's 'settings'", inputs.get("settings", ""))]
164+ settings += [
165+ ("'--settings' in 'claude_args'", value) for value in flag_values(args, "--settings")
166+ ]
167+ for where, value in settings:
168+ text = settings_text(value)
169+ if text is None:
170+ return (
171+ f"{where} names a file outside the repository, which this check cannot read. "
172+ "Use inline settings or a file inside the repository"
173+ )
174+ if "defaultMode" in text:
175+ 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+ )
189+ return None
190+
191+
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+
212+def settings_text(value) -> str | None:
213+ """The settings JSON a 'settings' value stands for: the value itself, or the contents of
214+ the file it names when it is a path inside the repository. None when it names a path
215+ outside the repository."""
216+ text = str(value or "").strip()
217+ if not text or text.startswith("{"):
218+ return text
219+ path = pathlib.Path(text)
220+ root = pathlib.Path.cwd().resolve()
221+ try:
222+ resolved = path.resolve()
223+ resolved.relative_to(root)
224+ except (OSError, ValueError):
225+ return None
226+ if resolved.is_file():
227+ return resolved.read_text(encoding="utf-8", errors="replace")
228+ return text
229+
230+
231+def check_job(file_name: str, job_id: str, job: dict, workflow_env: dict) -> list[str]:
232+ key = f"{file_name}:{job_id}"
233+ where = f".github/workflows/{file_name}: job '{job_id}'"
234+ errors = []
235+ runs_on = job.get("runs-on")
236+ if isinstance(runs_on, list) and len(runs_on) == 1:
237+ runs_on = runs_on[0]
238+ if key in EXEMPT_FROM_FIREWALL_RUNNER:
239+ print(
240+ f"The egress-firewall runner is not required for job '{job_id}' in {file_name}. "
241+ f"Reason: {EXEMPT_FROM_FIREWALL_RUNNER[key]}."
242+ )
243+ elif runs_on != FIREWALL_RUNNER:
244+ if "runs-on" not in job:
245+ has = "no 'runs-on'"
246+ elif isinstance(job["runs-on"], str):
247+ has = f"'runs-on: {job['runs-on']}'"
248+ else:
249+ has = "a 'runs-on' list or group"
250+ errors.append(
251+ f"{where} calls Claude, so it must have 'runs-on: {FIREWALL_RUNNER}'. "
252+ f"It has {has}. {HELP}"
253+ )
254+ exempt = key in EXEMPT_FROM_AUTO_MODE
255+ if exempt:
256+ print(
257+ f"Auto permission mode is not required for job '{job_id}' in {file_name}. "
258+ f"Reason: {EXEMPT_FROM_AUTO_MODE[key]}."
259+ )
260+ if "defaultMode" in json.dumps(job):
261+ # Catches a settings file that an earlier step of the job writes, which the
262+ # step-level check cannot read.
263+ errors.append(
264+ f"{where} mentions 'defaultMode': settings must not set a permission mode. {HELP}"
265+ )
266+ for index, step in enumerate(steps_of(job), start=1):
267+ if not runs_claude_code_action(step):
268+ continue
269+ inherited_env = {**workflow_env, **(job.get("env") or {})}
270+ problem = permission_mode_problem(step, exempt, inherited_env)
271+ if problem:
272+ step_label = f"step '{step['name']}'" if "name" in step else f"step {index}"
273+ errors.append(f"{where}, {step_label}: {problem}. {HELP}")
274+ return errors
275+
276+
277+def check_policy() -> list[str]:
278+ if not POLICY_PATH.is_file():
279+ return [
280+ f"{POLICY_PATH} is missing. Jobs on the egress-firewall runner need it "
281+ f"to limit outbound network access. {HELP}"
282+ ]
283+ policy = load_yaml(POLICY_PATH)
284+ if not isinstance(policy, dict):
285+ return [
286+ f"{POLICY_PATH} is empty or is not a set of 'name: value' lines. It needs 'mode: enforce' "
287+ f"and an 'allow:' list of hosts. {HELP}"
288+ ]
289+ errors = []
290+ if "mode" not in policy:
291+ errors.append(f"{POLICY_PATH}: 'mode' is missing. Add 'mode: enforce'. {HELP}")
292+ elif policy["mode"] != "enforce":
293+ errors.append(
294+ f"{POLICY_PATH}: 'mode' is '{policy['mode']}'. It must be 'enforce'. {HELP}"
295+ )
296+ allow = policy.get("allow")
297+ if not isinstance(allow, list) or not allow:
298+ errors.append(
299+ f"{POLICY_PATH}: the 'allow' list is missing or empty. List under 'allow:' "
300+ f"each host the jobs need. {HELP}"
301+ )
302+ else:
303+ for host in allow:
304+ if "*" in str(host):
305+ errors.append(
306+ f"{POLICY_PATH}: the 'allow' entry '{host}' contains '*'. "
307+ f"Name each host in full. {HELP}"
308+ )
309+ return errors
310+
311+
312+def main() -> int:
313+ if not WORKFLOW_DIR.is_dir():
314+ stop(f"{WORKFLOW_DIR} not found. Run this check from the repository root.")
315+ errors = []
316+ checked = 0
317+ for path in sorted([*WORKFLOW_DIR.glob("*.yml"), *WORKFLOW_DIR.glob("*.yaml")]):
318+ workflow = load_yaml(path)
319+ jobs = workflow.get("jobs") if isinstance(workflow, dict) else None
320+ for job_id, job in (jobs or {}).items():
321+ if not isinstance(job, dict) or not job_calls_claude(job):
322+ continue
323+ checked += 1
324+ errors.extend(check_job(path.name, job_id, job, workflow.get("env") or {}))
325+ if checked:
326+ errors.extend(check_policy())
327+ for error in errors:
328+ print(f"::error::{error}")
329+ if errors:
330+ return 1
331+ if checked == 0:
332+ print("OK: no workflow job calls Claude, so there was nothing to check.")
333+ else:
334+ print(f"OK: checked {checked} job(s) that call Claude and found no problems.")
335+ return 0
336+
337+
338+if __name__ == "__main__":
339+ sys.exit(main())
@@ -12,7 +12,9 @@
1212
1313 jobs:
1414 claude-dedupe-issues:
15- runs-on: ubuntu-latest
15+ # This job calls Claude, so it runs on GitHub's egress-firewall runner, which
16+ # filters the job's outbound network traffic (allow list: .github/egress-firewall.yaml).
17+ runs-on: ubuntu-24.04-firewall
1618 timeout-minutes: 10
1719 permissions:
1820 contents: read
@@ -40,7 +42,10 @@
4042 anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }}
4143 anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }}
4244 anthropic_workspace_id: ${{ vars.ANTHROPIC_WORKSPACE_ID }}
43- claude_args: "--model claude-sonnet-4-5-20250929"
45+ # --permission-mode auto: a tool call that needs permission and is outside the allowed-tools
46+ # list in .claude/commands/dedupe.md runs only if Claude Code's safety review passes it.
47+ # Tools in --disallowedTools never run.
48+ claude_args: '--model claude-sonnet-4-6 --permission-mode auto --disallowedTools "WebFetch,WebSearch,Write,Edit,MultiEdit,NotebookEdit"'
4449
4550 - name: Log duplicate comment event to Statsig
4651 if: always()
@@ -7,7 +7,9 @@
77
88 jobs:
99 triage-issue:
10- runs-on: ubuntu-latest
10+ # This job calls Claude, so it runs on GitHub's egress-firewall runner, which
11+ # filters the job's outbound network traffic (allow list: .github/egress-firewall.yaml).
12+ runs-on: ubuntu-24.04-firewall
1113 timeout-minutes: 10
1214 if: >-
1315 github.event_name == 'issues' ||
@@ -43,5 +45,10 @@
4345 anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }}
4446 anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }}
4547 anthropic_workspace_id: ${{ vars.ANTHROPIC_WORKSPACE_ID }}
48+ # --permission-mode auto: a tool call that needs permission and is outside the allowed-tools
49+ # list in .claude/commands/triage-issue.md runs only if Claude Code's safety review passes
50+ # it. Tools in --disallowedTools never run.
4651 claude_args: |
52+ --permission-mode auto
53+ --disallowedTools "WebFetch,WebSearch,Write,Edit,MultiEdit,NotebookEdit"
4754 --model claude-opus-4-6
@@ -17,7 +17,9 @@
1717 (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
1818 (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
1919 (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
20- runs-on: ubuntu-latest
20+ # This job calls Claude, so it runs on GitHub's egress-firewall runner, which
21+ # filters the job's outbound network traffic (allow list: .github/egress-firewall.yaml).
22+ runs-on: ubuntu-24.04-firewall
2123 permissions:
2224 contents: read
2325 pull-requests: read
@@ -40,5 +42,8 @@
4042 anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }}
4143 anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }}
4244 anthropic_workspace_id: ${{ vars.ANTHROPIC_WORKSPACE_ID }}
43- 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"
4449
@@ -1,0 +1,23 @@
1+# Fails when a workflow job that calls Claude lacks its security settings.
2+# The rules are in CLAUDE.md, "Security hardening for GitHub Actions".
3+name: Security check for workflows that call Claude
4+
5+on:
6+ pull_request:
7+ push:
8+ branches: [main]
9+
10+permissions:
11+ contents: read
12+
13+jobs:
14+ workflow-hardening:
15+ name: Check security settings
16+ runs-on: ubuntu-latest
17+ timeout-minutes: 5
18+ steps:
19+ - name: Checkout repository
20+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
21+
22+ - name: Check security settings of workflows that call Claude
23+ run: python3 .github/scripts/check_workflow_hardening.py
ACLAUDE.md+40 -0
@@ -1,0 +1,40 @@
1+# CLAUDE.md
2+
3+Guidance for Claude Code and other coding agents working in this repository.
4+
5+## Security hardening for GitHub Actions
6+
7+Workflow jobs in this repository that call Claude run with three protections.
8+Keep them when you add or edit a workflow.
9+
10+1. **Egress-firewall runner.** The job has `runs-on: ubuntu-24.04-firewall`,
11+ a GitHub-hosted runner that filters the job's outbound network traffic. Do
12+ not move a job that calls Claude to another runner.
13+2. **Network allow list.** `.github/egress-firewall.yaml` lists the hosts
14+ those jobs may reach, besides any that GitHub's firewall allows by default.
15+ Keep `mode: enforce`, which is what makes the firewall block the rest. Follow
16+ that file's header when you add a host.
17+3. **Auto permission mode.** Every step that runs the Claude Code action
18+ (`uses: anthropics/claude-code-action`, or a local action with a
19+ `claude_args` input) passes `--permission-mode auto` in `claude_args`. A tool
20+ call that needs permission and that the allowed tools do not cover then runs
21+ 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. Use `claude-opus-4-6`
23+ or a newer model: on an older one Claude Code falls back to its default
24+ permission mode.
25+
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.
29+
30+`.github/workflows/workflow-hardening.yml` fails when a job that runs the Claude
31+Code action or mentions `ANTHROPIC_FEDERATION_RULE_ID` breaks protection 1 or 3,
32+or when the allow list is missing, empty, not `mode: enforce`, or names a host
33+with `*`. It cannot see a job that calls Claude another way, so check new
34+workflows by hand too. If a job cannot meet protection 1 or 3, add it with the
35+reason to the matching exemption table in
36+`.github/scripts/check_workflow_hardening.py`. A job in `EXEMPT_FROM_AUTO_MODE`
37+must set no permission mode at all. Do not skip or weaken the check.
38+
39+Keep each workflow's `permissions:` block minimal, and never print tokens or
40+environment variables in workflow logs.