Skip to content

Commit b1922f2

Browse files
committed
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.
1 parent 592d541 commit b1922f2

4 files changed

Lines changed: 342 additions & 0 deletions

File tree

‎.github/egress-firewall.yaml‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,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
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,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())
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,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

‎CLAUDE.md‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,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.

0 commit comments

Comments
 (0)