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

Add the network allow list, a security check and CLAUDE.md guidanceBrowse files
- .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.
Claude authored 28 days ago parent 592d541 · commit b1922f294d4a
4 changed files+342 -0compared with 592d541
A.github/egress-firewall.yaml
A.github/scripts/check_workflow_hardening.py
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,253 @@
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 shlex
10+import subprocess
11+import sys
12+
13+FIREWALL_RUNNER = "ubuntu-24.04-firewall"
14+WORKFLOW_DIR = pathlib.Path(".github/workflows")
15+POLICY_PATH = pathlib.Path(".github/egress-firewall.yaml")
16+SIGN_IN_MARKER = "anthropic_federation_rule_id"
17+CLAUDE_ACTIONS = ("anthropics/claude-code-action", "anthropics/claude-code-base-action")
18+HELP = 'See CLAUDE.md, "Security hardening for GitHub Actions".'
19+
20+# Key: "<workflow file name>:<job id>". Value: why that job is exempt from the table's rule.
21+EXEMPT_FROM_FIREWALL_RUNNER: dict[str, str] = {}
22+EXEMPT_FROM_AUTO_MODE: dict[str, str] = {
23+ "claude.yml:claude": "answers @claude mentions, and for those the Claude Code action sets --permission-mode acceptEdits itself",
24+}
25+
26+
27+def stop(message: str):
28+ sys.exit(f"::error::{message}")
29+
30+
31+def load_yaml(path: pathlib.Path):
32+ """Parse a YAML file with PyYAML, or with the yq command if PyYAML is absent."""
33+ try:
34+ import yaml
35+ except ImportError:
36+ try:
37+ result = subprocess.run(
38+ ["yq", "-o=json", ".", str(path)], check=True, capture_output=True, text=True
39+ )
40+ except FileNotFoundError:
41+ stop(
42+ f"Cannot read {path}: Python has no 'yaml' module and no 'yq' command was found. "
43+ "Add a step that runs 'pip install pyyaml' before this check."
44+ )
45+ except subprocess.CalledProcessError:
46+ stop(f"Cannot read {path}: 'yq' could not parse it. Check that the file is valid YAML.")
47+ return json.loads(result.stdout)
48+ try:
49+ with path.open(encoding="utf-8") as handle:
50+ return yaml.safe_load(handle)
51+ except yaml.YAMLError as error:
52+ stop(f"Cannot read {path}: it is not valid YAML ({error}).")
53+
54+
55+def contains_marker(node) -> bool:
56+ """Whether any key or string under node contains SIGN_IN_MARKER, ignoring case."""
57+ if isinstance(node, dict):
58+ return any(contains_marker(k) or contains_marker(v) for k, v in node.items())
59+ if isinstance(node, list):
60+ return any(contains_marker(item) for item in node)
61+ return isinstance(node, str) and SIGN_IN_MARKER in node.lower()
62+
63+
64+def load_local_action(uses: str):
65+ """The parsed action file of a local action (uses: ./path), or None."""
66+ if not uses.startswith("./"):
67+ return None
68+ for name in ("action.yml", "action.yaml"):
69+ action_file = pathlib.Path(uses) / name
70+ if action_file.is_file():
71+ action = load_yaml(action_file)
72+ return action if isinstance(action, dict) else {}
73+ return None
74+
75+
76+def steps_of(job: dict) -> list[dict]:
77+ return [step for step in job.get("steps") or [] if isinstance(step, dict)]
78+
79+
80+def runs_claude_code_action(step: dict) -> bool:
81+ """Whether the step runs the Claude Code action: the published action, or a
82+ local action that accepts a claude_args input."""
83+ uses = str(step.get("uses", ""))
84+ if uses.lower().startswith(CLAUDE_ACTIONS):
85+ return True
86+ action = load_local_action(uses)
87+ return action is not None and "claude_args" in (action.get("inputs") or {})
88+
89+
90+def job_calls_claude(job: dict) -> bool:
91+ if contains_marker(job):
92+ return True
93+ for step in steps_of(job):
94+ if runs_claude_code_action(step):
95+ return True
96+ action = load_local_action(str(step.get("uses", "")))
97+ if action is not None and contains_marker(action):
98+ return True
99+ return False
100+
101+
102+def permission_mode_problem(step: dict, exempt: bool) -> str | None:
103+ """The message for a step whose permission mode is wrong, or None if it is right.
104+
105+ A step must set auto mode. A step of a job in EXEMPT_FROM_AUTO_MODE must set no mode at all.
106+ """
107+ inputs = step.get("with") or {}
108+ lines = str(inputs.get("claude_args", "")).splitlines()
109+ text = " ".join(line for line in lines if not line.strip().startswith("#"))
110+ try:
111+ args = shlex.split(text, comments=True)
112+ except ValueError:
113+ return "'claude_args' has a quote that is never closed. Close it"
114+ modes = []
115+ for index, arg in enumerate(args):
116+ if arg == "--dangerously-skip-permissions":
117+ return (
118+ "remove '--dangerously-skip-permissions' from 'claude_args': "
119+ "it turns off permission checks"
120+ )
121+ if arg == "--permission-mode":
122+ modes.append(args[index + 1] if index + 1 < len(args) else "")
123+ elif arg.startswith("--permission-mode="):
124+ modes.append(arg.split("=", 1)[1])
125+ if exempt and modes:
126+ return (
127+ "remove '--permission-mode' from 'claude_args': this job is listed in "
128+ "EXEMPT_FROM_AUTO_MODE (.github/scripts/check_workflow_hardening.py), and a job listed "
129+ "there must not set a permission mode"
130+ )
131+ if not modes and not exempt:
132+ return "add '--permission-mode auto' to 'claude_args' under the step's 'with:'"
133+ for mode in modes:
134+ if mode == "":
135+ return (
136+ "'claude_args' has '--permission-mode' with nothing after it. "
137+ "Write '--permission-mode auto'"
138+ )
139+ if mode != "auto":
140+ return (
141+ f"'claude_args' has '--permission-mode {mode}'. "
142+ "Change it to '--permission-mode auto'"
143+ )
144+ if "defaultMode" in str(inputs.get("settings", "")):
145+ return (
146+ "remove 'defaultMode' from the step's 'settings': "
147+ "'settings' must not set a permission mode"
148+ )
149+ return None
150+
151+
152+def check_job(file_name: str, job_id: str, job: dict) -> list[str]:
153+ key = f"{file_name}:{job_id}"
154+ where = f".github/workflows/{file_name}: job '{job_id}'"
155+ errors = []
156+ runs_on = job.get("runs-on")
157+ if isinstance(runs_on, list) and len(runs_on) == 1:
158+ runs_on = runs_on[0]
159+ if key in EXEMPT_FROM_FIREWALL_RUNNER:
160+ print(
161+ f"The egress-firewall runner is not required for job '{job_id}' in {file_name}. "
162+ f"Reason: {EXEMPT_FROM_FIREWALL_RUNNER[key]}."
163+ )
164+ elif runs_on != FIREWALL_RUNNER:
165+ if "runs-on" not in job:
166+ has = "no 'runs-on'"
167+ elif isinstance(job["runs-on"], str):
168+ has = f"'runs-on: {job['runs-on']}'"
169+ else:
170+ has = "a 'runs-on' list or group"
171+ errors.append(
172+ f"{where} calls Claude, so it must have 'runs-on: {FIREWALL_RUNNER}'. "
173+ f"It has {has}. {HELP}"
174+ )
175+ exempt = key in EXEMPT_FROM_AUTO_MODE
176+ if exempt:
177+ print(
178+ f"Auto permission mode is not required for job '{job_id}' in {file_name}. "
179+ f"Reason: {EXEMPT_FROM_AUTO_MODE[key]}."
180+ )
181+ for index, step in enumerate(steps_of(job), start=1):
182+ if not runs_claude_code_action(step):
183+ continue
184+ problem = permission_mode_problem(step, exempt)
185+ if problem:
186+ step_label = f"step '{step['name']}'" if "name" in step else f"step {index}"
187+ errors.append(f"{where}, {step_label}: {problem}. {HELP}")
188+ return errors
189+
190+
191+def check_policy() -> list[str]:
192+ if not POLICY_PATH.is_file():
193+ return [
194+ f"{POLICY_PATH} is missing. Jobs on the egress-firewall runner need it "
195+ f"to limit outbound network access. {HELP}"
196+ ]
197+ policy = load_yaml(POLICY_PATH)
198+ if not isinstance(policy, dict):
199+ return [
200+ f"{POLICY_PATH} is empty or is not a set of 'name: value' lines. It needs 'mode: enforce' "
201+ f"and an 'allow:' list of hosts. {HELP}"
202+ ]
203+ errors = []
204+ if "mode" not in policy:
205+ errors.append(f"{POLICY_PATH}: 'mode' is missing. Add 'mode: enforce'. {HELP}")
206+ elif policy["mode"] != "enforce":
207+ errors.append(
208+ f"{POLICY_PATH}: 'mode' is '{policy['mode']}'. It must be 'enforce'. {HELP}"
209+ )
210+ allow = policy.get("allow")
211+ if not isinstance(allow, list) or not allow:
212+ errors.append(
213+ f"{POLICY_PATH}: the 'allow' list is missing or empty. List under 'allow:' "
214+ f"each host the jobs need. {HELP}"
215+ )
216+ else:
217+ for host in allow:
218+ if "*" in str(host):
219+ errors.append(
220+ f"{POLICY_PATH}: the 'allow' entry '{host}' contains '*'. "
221+ f"Name each host in full. {HELP}"
222+ )
223+ return errors
224+
225+
226+def main() -> int:
227+ if not WORKFLOW_DIR.is_dir():
228+ stop(f"{WORKFLOW_DIR} not found. Run this check from the repository root.")
229+ errors = []
230+ checked = 0
231+ for path in sorted([*WORKFLOW_DIR.glob("*.yml"), *WORKFLOW_DIR.glob("*.yaml")]):
232+ workflow = load_yaml(path)
233+ jobs = workflow.get("jobs") if isinstance(workflow, dict) else None
234+ for job_id, job in (jobs or {}).items():
235+ if not isinstance(job, dict) or not job_calls_claude(job):
236+ continue
237+ checked += 1
238+ errors.extend(check_job(path.name, job_id, job))
239+ if checked:
240+ errors.extend(check_policy())
241+ for error in errors:
242+ print(f"::error::{error}")
243+ if errors:
244+ return 1
245+ if checked == 0:
246+ print("OK: no workflow job calls Claude, so there was nothing to check.")
247+ else:
248+ print(f"OK: checked {checked} job(s) that call Claude and found no problems.")
249+ return 0
250+
251+
252+if __name__ == "__main__":
253+ sys.exit(main())
@@ -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+39 -0
@@ -1,0 +1,39 @@
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.
23+
24+Exception (listed with its reason in an exemption table in
25+`.github/scripts/check_workflow_hardening.py`): `claude.yml` answers `@claude`
26+mentions. For those the Claude Code action sets `--permission-mode acceptEdits`
27+itself. Do not add a `--permission-mode` there.
28+
29+`.github/workflows/workflow-hardening.yml` fails when a job that runs the Claude
30+Code action or mentions `ANTHROPIC_FEDERATION_RULE_ID` breaks protection 1 or 3,
31+or when the allow list is missing, empty, not `mode: enforce`, or names a host
32+with `*`. It cannot see a job that calls Claude another way, so check new
33+workflows by hand too. If a job cannot meet protection 1 or 3, add it with the
34+reason to the matching exemption table in
35+`.github/scripts/check_workflow_hardening.py`. A job in `EXEMPT_FROM_AUTO_MODE`
36+must set no permission mode at all. Do not skip or weaken the check.
37+
38+Keep each workflow's `permissions:` block minimal, and never print tokens or
39+environment variables in workflow logs.