claude-egress-guard 1.0.0

Fail-closed egress gate for Claude Code on Linux, packaged as an agent skill.

- binary-level gate at /usr/local/bin/claude and /usr/bin/claude with the real
  binary pinned under /opt/claude-real, so cron, Python, systemd and absolute
  paths are covered
- exit address and country checked before every start; mismatch or timeout
  refuses with exit 77 and never falls back to a direct connection
- optional kernel jail (systemd scope, IPAddressDeny=any) for the whole process
  tree, with loopback, proxy upstream, DNS resolvers and operator CIDRs allowed
- self-heal after package updates that rewrite /usr/bin/claude
- installer with dry-run, backups and uninstall; verify/verify --deep/status/drill
- sandboxed self-test suite, shellcheck and leak scanner in CI
This commit is contained in:
yinyues2 committed 2026-10-27 01:19:22 +08:00
commit c64ed98eb1
20 files changed
+2231

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[*.sh]
indent_style = tab
indent_size = 4
switch_case_indent = true
[*.py]
indent_style = space
indent_size = 4
[*.md]
trim_trailing_whitespace = false
+6
View File
@@ -0,0 +1,6 @@
* text=auto eol=lf
*.sh text eol=lf
LICENSE text eol=lf
*.md text eol=lf
*.py text eol=lf
+34
View File
@@ -0,0 +1,34 @@
name: ci
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
shell:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: shellcheck
run: |
sudo apt-get update -qq
sudo apt-get install -y shellcheck
shellcheck -x -S warning skills/claude-egress-guard/scripts/*
- name: syntax
run: |
for f in skills/claude-egress-guard/scripts/*; do bash -n "$f"; done
- name: self-test
run: bash skills/claude-egress-guard/scripts/selftest.sh
- name: leak scan
run: python3 tools/leak-scan.py
+24
View File
@@ -0,0 +1,24 @@
# Local policy files with real addresses never belong in the repository.
*.local.conf
claude-egress-guard.conf
!skills/claude-egress-guard/templates/claude-egress-guard.conf.example
# Operator artefacts
evidence/
*.evidence.json
drill-output/
*.log
# Editors and OS noise
.DS_Store
Thumbs.db
*.swp
.idea/
.vscode/
__pycache__/
*.pyc
# Local environment
.env
.env.*
!.env.example
+36
View File
@@ -0,0 +1,36 @@
# Changelog
All notable changes to this project are documented here. The format follows
Keep a Changelog; versions use semantic versioning.
## [1.0.0] - 2026-10-27
### Added
- Fail-closed gate for Claude Code: exit-address and country check before every
start, proxy environment injection, refusal with exit code 77 when anything is
missing or mismatched.
- Binary-level coverage: the gate is installed as both
`/usr/local/bin/claude` and `/usr/bin/claude`, with the real binary pinned at
`/opt/claude-real/claude`, so Python, cron, systemd and absolute-path callers
are covered.
- Optional kernel-level jail (`STRICT_NET`, default on): the whole Claude process
tree runs in a transient systemd scope with `IPAddressDeny=any` and an explicit
allow list (loopback, proxy upstream, DNS resolvers, operator-supplied CIDRs).
- Self-heal after package updates, which re-create `/usr/bin/claude` as a
symlink.
- Installer with `--dry-run`, timestamped backups of everything it replaces, and
an allow list that automatically includes loopback and the host's IPv4 DNS
resolvers.
- `verify` (static + live checks), `verify --deep` (proves the jail blocks direct
egress and still allows the proxy), `status` (credentials masked), `drill`
(stops and restarts the proxy unit, proves refusal and recovery), `uninstall`
(restores from backup), `show` (prints the gate for audit).
- Agent skill (`skills/claude-egress-guard`) with authorization tiers, an
operator interview limited to four facts, an evidence record template and a
troubleshooting table.
- Sandboxed self-test suite (31 assertions) using fake `curl`, `id` and
`systemd-run`; runs without root or network.
- Repository guard rails: shellcheck in CI, a leak scanner that rejects
credentials, personal identifiers and non-documentation IP addresses, and an
editor/git configuration that keeps line endings stable.
+54
View File
@@ -0,0 +1,54 @@
# Contributing
Thanks for taking a look. This is a security-adjacent tool, so the bar for
changes is "does not weaken the default behaviour".
## Ground rules
1. **No real infrastructure in the repository.** Use documentation address space
(`192.0.2.0/24`, `198.51.100.0/24`, `203.0.113.0/24`), `example.com`, and
`127.0.0.1` for local endpoints. Never commit production IPs, hostnames,
proxy credentials, subscription links, or account identifiers.
2. **Fail closed.** A new code path that can start Claude without a passing exit
check will not be merged. If a check cannot run, refuse.
3. **No new network calls.** The checker talks to the endpoints in the config and
nothing else.
4. **No policy shortcuts.** Do not add flags that silently disable a layer; make
the trade-off explicit and documented, as `--strict-net off` does.
## Style
- Bash, 4-space-tab indentation, `set -u`, no `eval`, no unquoted expansions of
paths.
- Every script must pass `shellcheck -x -S warning`.
- Prefer small functions with a single exit path; keep the gate readable, since
operators are expected to audit it.
- Comments explain *why*, not *what*.
## Testing
```bash
bash skills/claude-egress-guard/scripts/selftest.sh
shellcheck -x -S warning skills/claude-egress-guard/scripts/*
python3 tools/leak-scan.py
```
Add a case to `selftest.sh` for every behaviour change. The suite runs in a
temporary prefix with fake `curl`, `id` and `systemd-run`; it must stay
root-free and network-free.
## Pull requests
- One concern per PR.
- Describe the failure mode being fixed and the evidence that it is fixed.
- If you change the config schema, update the template, the example in the
README and the troubleshooting table in the same PR.
## Commit messages
Short imperative subject, wrapped body explaining the failure and the fix.
## Security issues
Do not open a public PR or issue for a bypass. Use the private reporting channel
described in [SECURITY.md](SECURITY.md).
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+60
View File
@@ -0,0 +1,60 @@
# Privacy
## Short version
This project collects nothing. There is no telemetry, no analytics, no crash
reporting, no update check, and no network call that is not the operator's own
proxy probe.
## What stays on your host
Everything. The installer writes only:
- `/etc/claude-egress-guard.conf` — proxy URL, expected exit address, country,
allow list. Mode `0600`, root-owned.
- `/usr/local/{bin,sbin,lib}` — the gate, the checker, the CLI.
- `/opt/claude-real/claude` — a symlink to the package binary.
- `/var/lib/claude-egress-guard/backup/` — copies of whatever the installer
replaced.
No file leaves the host. No file is uploaded by any component. The backslash
backup directory can contain a proxy URL with credentials if your proxy endpoint
embeds them — treat it as sensitive and remove it with `uninstall` when done.
## Network calls
The only outbound requests are made **by the host, through the proxy you
configure**, and only to the endpoints in your config:
- `TRACE_URL` — default `https://www.cloudflare.com/cdn-cgi/trace`, a generic
CDN edge trace used to read the public exit address. It does not reach any AI
provider.
- `FALLBACK_URL` — default `https://ipinfo.io/json`, used only when the trace
endpoint fails.
Both are plain GETs. They send no identifiers, no account data, and no request
bodies. You can point them at any equivalent endpoint.
`claude-egress-guard verify --deep` additionally requests `https://example.com/`
twice: once directly (it must fail, which proves the jail blocks egress) and once
through the proxy (it must succeed). Both requests are made by the host you run
it on, carry no identifiers, and go to a reserved documentation hostname.
## This repository and CI
- The repository contains no operator data. Examples use documentation address
space (RFC 5737) and `example.com`.
- CI runs shellcheck, the sandboxed self-test and a leak scan. It has no access
to any deployment and holds no secrets beyond the default `GITHUB_TOKEN` with
read-only permissions.
## Filing an issue
Redact before posting:
- public IP addresses (replace with `203.0.113.x`),
- proxy URLs and credentials,
- hostnames and account identifiers,
- full config files.
Paste the `verify` summary and the failing line only.
+182
View File
@@ -0,0 +1,182 @@
# claude-egress-guard
**A fail-closed egress gate for Claude Code on Linux hosts — packaged as an agent skill.**
Claude Code on a VPS is easy to set up and easy to leak. A shell alias or a
`claude()` function only covers interactive shells: Python, `sh -c`, cron,
systemd units and anything calling `/usr/bin/claude` walk straight past it, and
a proxy that dies mid-session silently turns into a direct connection from the
datacenter IP.
This project pins every Claude Code invocation to one proxy and one expected
exit address, refuses to start when that path is not available, and optionally
jails the whole process tree at the kernel level so child processes cannot
reach the public internet even if they ignore proxy variables.
```
claude (any entry point)
└─ /usr/local/bin/claude gate: egress check -> proxy env -> jail
├─ /usr/bin/claude same gate (npm updates rewrite this; the gate self-heals)
└─ /opt/claude-real/claude real binary (symlink, follows package updates)
└─ your proxy 127.0.0.1:1080 -> expected exit address
```
## What it guarantees
| Layer | Behaviour |
| --- | --- |
| Exit check | Before every start, the proxy is asked for its public address and country. Mismatch, timeout or HTTP error = **refuse to start** (exit 77). |
| Proxy environment | `ALL_PROXY`, `http_proxy`, `https_proxy` (upper and lower case) exported to the client and every child. |
| Kernel jail (optional, default on) | The Claude process tree runs in a transient systemd scope with `IPAddressDeny=any`; only loopback, the allow-listed networks (your proxy upstream, DNS resolvers, tailnet) stay reachable. |
| Self-heal | Package self-updates rewrite `/usr/bin/claude` back into a symlink; the gate restores itself on the next invocation. |
| Fail-closed | Missing config, missing checker, unreachable proxy, wrong exit, wrong country, unavailable `systemd-run`: all refuse. There is no "continue anyway" path. |
## What it does not do
- It does not create, configure or monitor the proxy itself. You supply an
HTTP/mixed endpoint that already exits where you want.
- It is not a VPN and does not hide traffic from the host's other services.
It gates Claude Code only.
- It cannot make an account safe, cannot prevent provider-side risk checks, and
does not help with anything that violates a provider's terms. Account
registration, payment and identity verification stay with the account owner.
- It does not rotate IPs, retry, or fall back to a direct connection.
## Requirements
- Linux with systemd 235+ (Debian 12/13, Ubuntu 22.04/24.04 tested patterns)
- `bash` 4.3+, `curl`, `install`, `readlink`
- root for a real install (`--prefix` allows a sandboxed install for tests)
- Claude Code already installed (npm-global or native installer)
## Quick start
```bash
git clone https://github.com/<owner>/claude-egress-guard.git
cd claude-egress-guard/skills/claude-egress-guard/scripts
# 1) show the plan first
sudo ./claude-egress-guard install \
--proxy http://127.0.0.1:1080 \
--exit-ip 203.0.113.10 \
--country JP \
--dry-run
# 2) install (backs up anything it replaces)
sudo ./claude-egress-guard install \
--proxy http://127.0.0.1:1080 \
--exit-ip 203.0.113.10 \
--country JP
# 3) verify, including a live jail test
sudo claude-egress-guard verify --deep
```
`203.0.113.10` is documentation address space (RFC 5737); replace it with the
address your proxy actually reports.
If your proxy upstream or a private network has to stay reachable inside the
jail, add it explicitly:
```bash
sudo claude-egress-guard install ... --allow 100.64.0.0/10,192.0.2.0/24
```
Loopback and the host's IPv4 DNS resolvers are added automatically.
## Verify and drill
```bash
sudo claude-egress-guard verify # static checks + live egress check
sudo claude-egress-guard verify --deep # also proves the jail blocks direct egress
sudo claude-egress-guard status # current policy, proxy credentials masked
sudo claude-egress-guard drill --service my-proxy.service
```
`drill` stops the proxy unit, proves the gate refuses to start, restarts the
unit and waits for recovery. It refuses to touch units whose name suggests SSH,
Tailscale or Docker, and restarts the unit on exit even if it fails midway.
Exit codes: `0` success, `1` verification failed, `2` usage, `77` gate refused.
## After a package update
Claude Code's self-update replaces the package files and re-creates the
`/usr/bin/claude` symlink. The gate detects that and reinstalls itself on the
next PATH invocation. After any manual `npm i -g`, run:
```bash
sudo claude-egress-guard verify --deep
```
## Changing the expected exit
Edit `/etc/claude-egress-guard.conf` (`EXPECTED_IP`, `EXPECTED_COUNTRY`,
`ALLOW_IPS`, `STRICT_NET`) and run `verify`. The file is mode 0600 and is the
only place policy lives.
To keep the egress check but drop the kernel jail:
```bash
sudo sed -i 's/^STRICT_NET=1/STRICT_NET=0/' /etc/claude-egress-guard.conf
```
## Uninstall
```bash
sudo claude-egress-guard uninstall
```
Restores the files replaced during install from
`/var/lib/claude-egress-guard/backup/<timestamp>/` and removes the gate. Backups
are kept so a mistake is recoverable.
## Use it as an agent skill
`skills/claude-egress-guard/SKILL.md` is written for an agent (Hermes, Codex or
any skill-capable runner) and tells it how to interview the operator, install,
verify, drill and record evidence without touching anything it does not own.
The reference files hold the decision points, the authorization tiers and the
troubleshooting table.
Copy `skills/claude-egress-guard/` into your skills directory, for example:
```bash
cp -R skills/claude-egress-guard ~/.hermes/skills/ # Hermes
cp -R skills/claude-egress-guard ~/.codex/skills/ # Codex
```
## Repository layout
```
skills/claude-egress-guard/
SKILL.md agent instructions
references/ playbook, troubleshooting
scripts/claude-egress-guard installer / verifier / drill
scripts/guard-claude.sh the gate itself (installed as `claude`)
scripts/egress-check.sh exit checker (also runnable standalone)
scripts/selftest.sh sandboxed test suite (no root, no network)
templates/ example configuration
tools/leak-scan.py blocks accidental secrets/PII before commit
```
## Development
```bash
bash skills/claude-egress-guard/scripts/selftest.sh
shellcheck -x -S warning skills/claude-egress-guard/scripts/*
python3 tools/leak-scan.py
```
CI runs the same checks on every push.
## License
Apache-2.0. See [LICENSE](LICENSE).
## Security and privacy
Read [SECURITY.md](SECURITY.md) for the threat model and reporting process, and
[PRIVACY.md](PRIVACY.md) for what this project does and does not collect (short
version: nothing — no telemetry, no analytics, no network calls except your own
proxy probe).
+104
View File
@@ -0,0 +1,104 @@
# claude-egress-guard(中文说明)
**给 Linux 上的 Claude Code 加一道「出口不对就不许启动」的闸门,打包成 agent 可用的 skill。**
在 VPS 上跑 Claude Code,最容易出问题的不是安装,而是**出口泄漏**:写在
`/etc/profile.d` 里的 `claude()` 函数只覆盖交互式 shell,Python、`sh -c`、
cron、systemd 和任何直接调用 `/usr/bin/claude` 的路径都会绕过去;代理中途挂掉时,
客户端还会悄悄从机房 IP 直连出去。
这个项目做三件事:
1. 每次启动前先核验代理的真实出口地址与国家,不符就**拒绝启动**(退出码 77);
2. 把代理变量注入给 Claude 及其所有子进程;
3. 可选(默认开启)用 systemd 的 `IPAddressDeny=any` 把整个 Claude 进程树关进
内核级网络白名单,只放行回环、代理上游、DNS 解析器和你显式允许的网段。
```
claude(任意入口)
└─ /usr/local/bin/claude 闸门:核验出口 -> 注入代理 -> 内核白名单
├─ /usr/bin/claude 同一份闸门(npm 更新会改回符号链接,闸门会自愈)
└─ /opt/claude-real/claude 真身(符号链接,跟随包更新)
└─ 你的代理 127.0.0.1:1080 -> 预期的出口地址
```
## 不做什么
- 不创建、不配置、不监控代理本身,你提供已经能在目标地区落地的 HTTP/mixed 入口;
- 不是 VPN,不影响主机上其他服务,只管 Claude Code;
- 不保证账号安全,也不提供任何绕过服务商条款的功能;注册、付款、身份核验都由账号本人完成;
- 不做 IP 轮换、不重试、不回落直连。
## 快速开始
```bash
git clone https://github.com/<owner>/claude-egress-guard.git
cd claude-egress-guard/skills/claude-egress-guard/scripts
sudo ./claude-egress-guard install \
--proxy http://127.0.0.1:1080 \
--exit-ip 203.0.113.10 \
--country JP \
--dry-run # 先看计划,不写任何东西
sudo ./claude-egress-guard install \
--proxy http://127.0.0.1:1080 \
--exit-ip 203.0.113.10 \
--country JP
sudo claude-egress-guard verify --deep
```
`203.0.113.10` 是文档专用地址(RFC 5737),请换成你代理实际报告的地址。
代理上游或内网需要保持可达时,用 `--allow 100.64.0.0/10,192.0.2.0/24` 显式放行;
回环和主机 IPv4 DNS 解析器会自动加入白名单。
## 常用命令
```bash
sudo claude-egress-guard verify # 静态检查 + 实时出口核验
sudo claude-egress-guard verify --deep # 额外验证「白名单确实挡住了直连」
sudo claude-egress-guard status # 查看当前策略(代理凭据打码)
sudo claude-egress-guard drill --service my-proxy.service # 故障演练
sudo claude-egress-guard uninstall # 从备份恢复并移除
```
`drill` 会停掉代理单元、证明闸门拒绝启动、再拉起并等待恢复;名字里带 ssh、
tailscale、docker 的单元一律拒绝操作,中途失败也会把单元拉回运行状态。
退出码:`0` 成功,`1` 校验失败,`2` 用法错误,`77` 闸门拒绝启动。
## 包更新之后
Claude Code 自更新会重建 `/usr/bin/claude` 符号链接。闸门会在下一次 PATH 调用时
自动补回;手动 `npm i -g` 之后跑一次 `verify --deep` 即可。
## 策略文件
所有策略都在 `/etc/claude-egress-guard.conf`(权限 0600):`PROXY`、`EXPECTED_IP`、
`EXPECTED_COUNTRY`、`STRICT_NET`、`ALLOW_IPS`、`REAL_BIN`、`CHECK_BIN`、`ALT_PATH`。
只想要出口核验、不要内核白名单:
```bash
sudo sed -i 's/^STRICT_NET=1/STRICT_NET=0/' /etc/claude-egress-guard.conf
```
## 作为 agent skill 使用
`skills/claude-egress-guard/SKILL.md` 是写给 agent 看的:如何只问 4 个必要事实、
如何按 A/B/C 三档授权推进、如何安装、验证、演练、留证,以及哪些操作绝对不碰。
把整个目录复制到你的 skills 目录即可(Hermes 用 `~/.hermes/skills/`,
Codex 用 `~/.codex/skills/`)。
## 开发与测试
```bash
bash skills/claude-egress-guard/scripts/selftest.sh # 沙箱内 31 项自测,不需要 root 和网络
shellcheck -x -S warning skills/claude-egress-guard/scripts/*
python3 tools/leak-scan.py # 提交前查隐私/凭据泄漏
```
## 许可
Apache-2.0,见 [LICENSE](LICENSE)。安全模型见 [SECURITY.md](SECURITY.md),
数据收集见 [PRIVACY.md](PRIVACY.md)(简短版:不收集任何东西)。
+64
View File
@@ -0,0 +1,64 @@
# Security policy
## Reporting
Use GitHub's private vulnerability reporting on this repository
("Security" → "Report a vulnerability"). Do not open a public issue for anything
that could weaken the gate for deployed users.
Please include: affected version, host distribution and systemd version, the
`verify` output (redact addresses), and the steps to reproduce. Expect an
acknowledgement within 7 days and a decision within 30.
## What this project protects against
1. **Accidental direct egress.** Claude Code or a child process reaching the
internet from the host's own address because a proxy died, a variable was not
exported, or the caller was not an interactive shell.
2. **Entry-point bypass.** Invocations from Python, cron, systemd, `sh -c`, or an
absolute `/usr/bin/claude` path, which a shell function never sees.
3. **Silent regression after package updates.** Claude Code's self-update
re-creates `/usr/bin/claude` as a symlink; the gate re-installs itself.
4. **Wide-open jail configuration.** The installer refuses empty allow lists and
the verifier proves direct egress is blocked before reporting success.
## What it does not protect against
- **Root on the host.** Anyone with root can set `STRICT_NET=0`, edit
`/etc/claude-egress-guard.conf`, run the real binary directly from
`/opt/claude-real/claude`, or stop the proxy. This is a guard rail, not a
mandatory access control boundary.
- **A second Claude installation.** Only the pinned binary is gated. If another
copy exists in a different prefix, it must be removed or gated separately.
- **Container workloads.** Docker/Kubernetes traffic does not pass through the
host's output path; the jail applies to the transient scope Claude runs in.
- **DNS content.** Name resolution uses the host resolver; the jail only limits
which resolver addresses are reachable. Use a resolver you trust (and consider
DNS-over-TLS) if that matters.
- **Proxy compromise or misconfiguration.** If the proxy exits somewhere else,
the gate refuses to start only when the address differs from `EXPECTED_IP`; it
cannot tell whether the exit is trustworthy.
- **Provider-side account decisions.** Nothing here affects, prevents, or
reverses account reviews, verification requests, or suspensions, and it must
not be used to evade a provider's terms.
## Hardening checklist
- Keep `/etc/claude-egress-guard.conf` at mode `0600` owned by root.
- Never allow `0.0.0.0/0`, `::/0`, or whole private ranges in `ALLOW_IPS`.
- Keep `STRICT_NET=1` unless the host genuinely needs process-tree egress.
- Run `verify --deep` after every package upgrade and after any proxy change.
- Prefer a proxy endpoint that is loopback-only, so the jail does not need a
remote relay address.
- Audit the gate before trusting it: `claude-egress-guard show guard`.
## Supported versions
The latest `1.x` release receives fixes. Older tags are not patched.
## Disclosure
We credit reporters by name or handle if they want, and we publish an advisory
after a fix is available. Reports that only show "root can disable it" are out of
scope; reports that show an unprivileged bypass, a jail escape, or a
check-that-always-passes are in scope.
+126
View File
@@ -0,0 +1,126 @@
---
name: claude-egress-guard
description: "Use when pinning Claude Code to one egress on Linux — installs a fail-closed gate, kernel net jail, and drills it."
version: 1.0.0
author: claude-egress-guard contributors
license: Apache-2.0
platforms: [linux]
metadata:
hermes:
tags: [claude, egress, network, systemd, fail-closed, security]
category: devops
---
# Claude Egress Guard
Install a fail-closed egress gate in front of Claude Code on a Linux host: every
invocation must pass an exit-address check, the client and its children get proxy
variables, and (by default) the whole process tree is jailed with a kernel-level
IP allow list. Anything unexpected refuses to start instead of falling back.
## When to use
- The operator runs Claude Code on a VPS or remote Linux box and wants its traffic
to leave through one known residential/proxy exit, not the datacenter IP.
- An existing guard is only a shell function or a `claude()` alias, so cron,
Python, systemd or absolute paths can bypass it.
- The proxy can die or change address and nobody notices until the IP history is
already polluted.
- The operator asks to "lock down", "pin", or "verify" the Claude egress, or to
prove it still works after a package update.
Do not use it to hide from a provider, to run multiple accounts, to rotate
identities, or as a substitute for the proxy itself. It gates one binary; it does
not configure networking.
## Facts to collect once
Ask only what cannot be read from the host. Do not ask again later.
1. **Proxy endpoint** — a local HTTP/mixed URL, e.g. `http://127.0.0.1:1080`.
2. **Expected exit address** — the address the proxy reports, ideally obtained by
running the probe once through it (`curl -x <proxy> https://www.cloudflare.com/cdn-cgi/trace`).
3. **Country code** — the ISO code that exit reports, if the operator wants it enforced.
4. **Kernel jail** — on (default) unless the host's Claude work needs unrestricted
egress, and which extra CIDRs must stay reachable (tailnet, internal APIs).
5. **Proxy unit name** — only needed for `drill`.
Never ask for credentials, subscription links, or the contents of the proxy config.
## Authorization tiers
| Tier | What it covers | How to proceed |
| --- | --- | --- |
| A — read-only | `--help`, `--dry-run`, `status`, `verify` without `--deep`, reading config | Do it without asking again once the scope is agreed |
| B — one approval for the plan | `install`, `verify --deep`, editing the allow list, `uninstall` | Present the plan (paths, backup, allow list, what it replaces), then execute |
| C — confirm on the spot | Stopping a service that carries the session, changing SSH, rebooting, anything that could lock the operator out | Write the exact command, wait for an explicit yes |
`drill` stops and starts the proxy unit. Treat it as B when the unit is unrelated
to the session, C when it is the only path to the host.
## Procedure
```bash
S=./skills/claude-egress-guard/scripts
# A: plan
sudo "$S/claude-egress-guard" install \
--proxy "$PROXY" --exit-ip "$EXIT_IP" [--country "$CC"] [--allow "$CIDRS"] --dry-run
# B: install (backs up anything it replaces)
sudo "$S/claude-egress-guard" install \
--proxy "$PROXY" --exit-ip "$EXIT_IP" [--country "$CC"] [--allow "$CIDRS"]
# B: prove it
sudo "$S/claude-egress-guard" verify --deep
# B/C: prove it under failure
sudo "$S/claude-egress-guard" drill --service "$PROXY_UNIT"
```
What the operator should see when it is working:
- `verify` prints one PASS line per layer and ends with `0 check(s) failed`.
- `verify --deep` additionally proves direct egress is blocked inside the jail and
the proxy path still works.
- `drill` prints `PASS gate refused to start with the proxy down` and
`PASS gate recovered after Ns`.
- A real Claude invocation still works afterwards (ask before spending quota).
## Reading failures
| Exit | Meaning | Next step |
| --- | --- | --- |
| 77 | Gate refused: config missing, proxy unreachable, exit/country mismatch, jail unavailable or not root | Run the checker directly to see the reason: `/usr/local/lib/claude-egress-guard/egress-check.sh` |
| 1 | `verify` found a failed layer | Fix the named layer; never silence it |
| 2 | Usage error | Re-read `--help` |
Do not "fix" a refusal by setting `STRICT_NET=0` unless the operator explicitly
asks for that trade-off. Do not widen `ALLOW_IPS` to `0.0.0.0/0`.
## Guardrails
- Never stop a unit whose name matches ssh, tailscale or docker; `drill` refuses
this itself, and so should you.
- Never edit `/etc/claude-egress-guard.conf` to make a test pass. Changing policy
is a B-tier decision with the operator.
- Do not claim a layer passed without its output. Quote the PASS/FAIL line.
- If the host has no systemd or is not root, install with `--strict-net off` only
after saying what protection is lost.
- Package self-updates re-create `/usr/bin/claude` as a symlink; the gate repairs
itself on the next invocation. After a manual upgrade, run `verify` instead of
assuming.
- Leave account work (registration, payment, identity checks) to the operator.
## Reporting template
```
Done: gate installed at <paths>, real binary pinned to <path>, jail <on/off>
Evidence: verify -> <n> layers PASS; exit check -> <ok/fail>; drill -> <pass/fail>
Left: <what the operator still has to do, if anything>
```
## References
- `references/agent-playbook.md` — full command sequence, allow-list rules, evidence record
- `references/troubleshooting.md` — symptom → cause → fix table
@@ -0,0 +1,149 @@
# Agent playbook
Concrete sequence for deploying `claude-egress-guard` on a Linux host. Follow it
in order; each step names what to check before moving on.
## 0. Read the host, do not assume it
```bash
uname -srm # Linux, kernel
id -u # 0 for a real install
command -v systemd-run systemctl curl install
systemctl --version | head -1 # 235+ for IPAddressDeny
readlink -f "$(command -v claude)" # real binary; empty = not installed
```
Record the current state before changing anything:
```bash
ls -l /usr/bin/claude /usr/local/bin/claude 2>/dev/null
systemctl list-units --type=service | grep -iE 'proxy|xray|sing-box|clash' | head
```
## 1. Learn the exit the proxy really gives
```bash
curl -4 -fsS --max-time 8 -x "$PROXY" https://www.cloudflare.com/cdn-cgi/trace
```
Take `ip=` as `--exit-ip` and `loc=` as `--country`. If the proxy needs a
hostname that only resolves on the host, run it on the host, not from your own
machine.
Two independent sources are better than one. Cross-check with a second endpoint
(e.g. `https://ipinfo.io/json` through the same proxy). Geo databases disagree
often enough that a single outlier should not be treated as the truth.
## 2. Decide the allow list
Inside the jail only these stay reachable:
- `127.0.0.0/8` and `::1/128` — the local proxy listener, local sockets and
`systemd-resolved` on `127.0.0.53`. A `/32` would break DNS on resolved hosts.
- the host's IPv4 DNS resolvers (the installer adds them automatically).
- anything passed with `--allow`:
- the **proxy upstream** when it is not loopback (a remote relay address);
- a **tailnet** range when other agents reach the host that way;
- internal API endpoints the operator's work depends on.
Rules of thumb:
- Never use `0.0.0.0/0` or a whole `10.0.0.0/8` "to be safe".
- If `verify --deep` reports `jail blocks the proxy path`, the proxy upstream is
missing from the list — add the exact address, not a wide range.
- If DNS breaks inside the jail (`could not resolve host`), `/etc/resolv.conf`
points at resolvers that are not allow-listed. Re-run `install` after fixing
resolvers, or add them with `--allow`.
## 3. Install
```bash
sudo claude-egress-guard install \
--proxy "$PROXY" \
--exit-ip "$EXIT_IP" \
--country "$CC" \
--allow "$EXTRA_CIDRS"
```
What it writes (all under `--prefix`, default `/`):
```
/etc/claude-egress-guard.conf mode 0600, the only policy file
/usr/local/lib/claude-egress-guard/guard-claude.sh the gate source
/usr/local/lib/claude-egress-guard/egress-check.sh the exit checker
/usr/local/bin/claude gate
/usr/bin/claude gate (disable with --no-alt)
/usr/local/sbin/claude-egress-guard this CLI
/opt/claude-real/claude symlink -> real package binary
/var/lib/claude-egress-guard/backup/<timestamp>/ whatever was replaced
```
If `/usr/bin/claude` was a symlink (npm-global install), it is backed up as a
symlink and restored by `uninstall`.
## 4. Verify
```bash
sudo claude-egress-guard verify --deep
```
Pass criteria, one line each: config present, real binary executable, gate
installed, alternative path is the gate and not a symlink, egress check `ok`,
jail enabled with a non-empty allow list, `systemd-run` present, jail blocks
direct egress, jail allows the proxy path.
Then prove it end to end **without spending quota**:
```bash
sudo -u <operator> claude --version # passthrough, no network
CEG_CONF=/etc/claude-egress-guard.conf claude --ceg-check # gate check only
```
Only run a real prompt if the operator approves it.
## 5. Drill
```bash
sudo claude-egress-guard drill --service "$PROXY_UNIT" --wait 30
```
Expected output: `PASS gate refused to start with the proxy down`, then
`PASS gate recovered after Ns`. The trap restarts the unit even on failure.
Do not run the drill when the proxy unit is also the path you are connected
through. `drill` refuses names containing ssh/tailscale/docker, but a custom unit
name can still be load-bearing — ask.
## 6. Record evidence
Write a short record next to the deployment (this is for the operator, not a
public file):
```
host: <hostname or alias>
date: <UTC timestamp>
proxy: <endpoint, credentials stripped>
exit: <address> / <country>
jail: on, allow=<cidrs>
verify: <n> PASS, 0 FAIL (deep: yes/no)
drill: refused-with-proxy-down=yes, recovered-after=<n>s
package: <claude --version output>
```
## 7. Maintenance
| Event | Action |
| --- | --- |
| `npm i -g @anthropic-ai/claude-code` or built-in update | `verify --deep`; the gate self-heals `/usr/bin/claude` |
| Proxy exit address changed | update `EXPECTED_IP`, run `verify` |
| Proxy moved to a remote relay | add the relay address to `ALLOW_IPS` |
| Operator wants no kernel jail | `STRICT_NET=0`, then `verify` (expect a WARN) |
| Suspected bypass | `claude-egress-guard status`, then list processes containing `claude` and check their cgroup: `cat /proc/<pid>/cgroup` should show a `run-r...scope` |
## Failure handling
- A failed layer is never resolved by disabling the layer. Fix the layer or
record why it cannot be met and tell the operator.
- If the host has no systemd, install with `--strict-net off` **and say** that
process-tree egress is no longer enforced.
- If the real binary cannot be found, stop and ask for its path; do not guess.
@@ -0,0 +1,35 @@
# Troubleshooting
| Symptom | Cause | Fix |
| --- | --- | --- |
| Claude exits with `77` right after install | Exit check failed (proxy down, wrong address, wrong country) or config missing | Run `/usr/local/lib/claude-egress-guard/egress-check.sh` for the reason; fix the proxy or the policy |
| `claude-egress-guard: STRICT_NET is on but this process is not root` | The jail needs root to create a system scope | Run as root, or set `STRICT_NET=0` and accept that process-tree egress is unenforced |
| `Failed to parse IP address prefix: localhost` | `IPAddressAllow=` accepts CIDR only, not names | Use `127.0.0.0/8 ::1/128 ...` |
| Everything fails with `could not resolve host` inside a Claude session | The jail blocks the DNS resolvers in `/etc/resolv.conf` | Add them: `--allow 192.0.2.53/32` (or fix resolvers and re-run `install`) |
| `verify --deep` says `jail blocks the proxy path` | The proxy upstream is not loopback and is not allow-listed | Add the exact upstream address to `ALLOW_IPS` |
| `verify --deep` says `jail did NOT block direct egress` | `ALLOW_IPS` is too wide (`0.0.0.0/0`, whole private ranges), or the host has no cgroup BPF support | Narrow the allow list; check `systemd --version` and kernel BPF support |
| `verify` says `alternative path is still a symlink` | A package update re-created `/usr/bin/claude` and nothing invoked the gate since | Invoke `claude --version` once (self-heal) or run `install` again |
| `curl` works but Claude reports network errors | Not every client honours proxy variables; some tools open raw sockets | That is exactly what the jail is for — check `ALLOW_IPS`, or route the tool through the proxy explicitly |
| Exit check passes but a second egress exists | Multiple proxies or a second route on the host | `claude-egress-guard status`, then confirm the process tree is inside a `run-r...scope` |
| `drill` refuses to stop a unit | Unit name matches ssh/tailscale/docker | Use a proxy unit name that does not collide, and confirm stopping it is safe |
| `drill` says the gate recovered but Claude still fails | The client, not the gate, is broken (auth, quota, version) | Run `claude --version` and `claude auth status` outside the gate scope to compare |
| `npm i -g` re-created the symlink and you want certainty | Self-heal is best-effort on the next invocation | `sudo claude-egress-guard verify --deep` after every manual upgrade |
| Two Claude installations (`npm-global` and native) | The gate pins one real binary; the other stays callable by absolute path | Remove the unused install, or add a second gate for it and verify both |
## Reading `verify` output
- `PASS` — layer observed working.
- `WARN` — layer not applicable or intentionally relaxed (jail off, IPv6 enabled). Not a failure, but say so in the report.
- `FAIL` — the deployment is not doing what it claims; do not report success.
## Recovering from a broken install
```bash
sudo claude-egress-guard uninstall # restores the newest backup
# or, if the CLI itself is gone:
ls /var/lib/claude-egress-guard/backup/
# /usr/bin/claude is usually a symlink to the package binary:
ln -sfn /path/to/package/bin/claude /usr/bin/claude
```
Backups are never deleted automatically.
+569
View File
@@ -0,0 +1,569 @@
#!/usr/bin/env bash
# claude-egress-guard — install, verify and drill a fail-closed egress gate for
# Claude Code on a Linux host.
#
# This program writes only these locations (all rooted at --prefix, default "/"):
# <prefix>/etc/claude-egress-guard.conf
# <prefix>/usr/local/lib/claude-egress-guard/{guard-claude.sh,egress-check.sh}
# <prefix>/usr/local/bin/claude
# <prefix>/usr/bin/claude (unless --no-alt)
# <prefix>/usr/local/sbin/claude-egress-guard
# <prefix>/opt/claude-real/claude (symlink to the real package binary)
# <prefix>/var/lib/claude-egress-guard/backup/<timestamp>/...
#
# Everything it replaces is copied to the backup directory first.
set -u
VERSION="1.0.0"
MARKER="claude-egress-guard"
CONF_REL="/etc/claude-egress-guard.conf"
LIB_REL="/usr/local/lib/claude-egress-guard"
BIN_REL="/usr/local/bin"
SBIN_REL="/usr/local/sbin"
REAL_REL="/opt/claude-real"
STATE_REL="/var/lib/claude-egress-guard"
PREFIX=""
SELF_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P)
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
say() { printf '%s\n' "$*"; }
err() { printf 'error: %s\n' "$*" >&2; }
die() {
err "$*"
exit 1
}
warn() { printf 'WARN %s\n' "$*"; }
pass() { printf 'PASS %s\n' "$*"; }
fail() {
printf 'FAIL %s\n' "$*"
FAILED=$((FAILED + 1))
}
p() { printf '%s%s' "$PREFIX" "$1"; }
in_sandbox() { [ -n "$PREFIX" ]; }
need_root() {
if ! in_sandbox && [ "$(id -u)" -ne 0 ]; then
die "must run as root (or use --prefix for a sandboxed install)"
fi
}
source_file() {
# Locate a shipped script next to this one, or in the installed lib dir.
local name="$1" dir
for dir in "$SELF_DIR" "$(p "$LIB_REL")"; do
if [ -r "$dir/$name" ]; then
printf '%s\n' "$dir/$name"
return 0
fi
done
return 1
}
is_gate() {
head -c 200 -- "$1" 2>/dev/null | grep -q -- "$MARKER"
}
valid_ipv4() {
local ip="$1" o
case "$ip" in
*[!0-9.]* | .* | *. | *..*) return 1 ;;
esac
[ "$(printf '%s' "$ip" | awk -F. '{print NF}')" = "4" ] || return 1
IFS=.
for o in $ip; do
[ "$o" -ge 0 ] 2>/dev/null && [ "$o" -le 255 ] 2>/dev/null || return 1
done
unset IFS
return 0
}
usage() {
cat <<'EOF'
claude-egress-guard - fail-closed egress gate for Claude Code (Linux)
USAGE
claude-egress-guard install --proxy URL --exit-ip ADDR [options]
claude-egress-guard verify [--deep] [--prefix DIR]
claude-egress-guard status [--prefix DIR]
claude-egress-guard drill --service UNIT [--prefix DIR]
claude-egress-guard uninstall [--prefix DIR]
claude-egress-guard show <guard|check|config>
claude-egress-guard version
INSTALL OPTIONS
--proxy URL local HTTP/mixed proxy the client must use (required)
e.g. http://127.0.0.1:1080
--exit-ip ADDR public IPv4 the proxy must exit from (required)
--country CC two-letter country the exit must report (optional)
--allow LIST extra CIDR(s) reachable inside the jail, comma separated;
loopback and the host resolver are always added
--strict-net MODE on|off kernel jail for the Claude process tree (default on)
--real PATH real Claude binary (auto-detected when omitted)
--no-alt do not mirror the gate to <prefix>/usr/bin/claude
--prefix DIR install under DIR instead of / (used by tests)
--dry-run print the plan without writing anything
VERIFY OPTIONS
--deep also prove the jail blocks direct egress and allows the proxy
DRILL OPTIONS
--service UNIT systemd unit of the proxy core (stopped and restarted)
--wait SECONDS how long to wait for recovery (default 30)
EXIT CODES
0 success | 1 check failed | 2 usage | 77 gate refused to start
EOF
}
# ---------------------------------------------------------------------------
# install
# ---------------------------------------------------------------------------
discover_real() {
local explicit="$1" alt_conf="$2" dir cand
if [ -n "$explicit" ]; then
[ -x "$explicit" ] || die "--real is not executable: $explicit"
readlink -f -- "$explicit"
return 0
fi
if [ -n "$alt_conf" ] && [ -L "$alt_conf" ]; then
cand=$(readlink -f -- "$alt_conf" 2>/dev/null || true)
if [ -n "$cand" ] && [ -x "$cand" ] && ! is_gate "$cand"; then
printf '%s\n' "$cand"
return 0
fi
fi
while IFS= read -r dir; do
[ -n "$dir" ] || continue
cand="$dir/claude"
[ -x "$cand" ] || continue
is_gate "$cand" && continue
readlink -f -- "$cand"
return 0
done < <(printf '%s' "$PATH" | tr ':' '\n')
return 1
}
resolver_allow() {
local ns out=""
[ -r /etc/resolv.conf ] || {
printf '%s' ""
return 0
}
for ns in $(awk '/^[[:space:]]*nameserver[[:space:]]+/ {print $2}' /etc/resolv.conf); do
case "$ns" in
*:*) continue ;; # IPv6 resolvers: skipped, CIDR formatting differs
127.*) continue ;;
esac
valid_ipv4 "$ns" || continue
out="$out $ns/32"
done
printf '%s' "$out"
}
cmd_install() {
local proxy="" exit_ip="" country="" strict="on" real="" no_alt="0" dry="0"
local allow_extra="" alt_conf=""
while [ $# -gt 0 ]; do
case "$1" in
--proxy) proxy="${2:-}"; shift 2 ;;
--exit-ip) exit_ip="${2:-}"; shift 2 ;;
--country) country="${2:-}"; shift 2 ;;
--allow) allow_extra="$allow_extra ${2:-}"; shift 2 ;;
--strict-net) strict="${2:-}"; shift 2 ;;
--real) real="${2:-}"; shift 2 ;;
--no-alt) no_alt="1"; shift ;;
--prefix) PREFIX="${2:-}"; shift 2 ;;
--dry-run) dry="1"; shift ;;
-h | --help) usage; exit 0 ;;
*) die "unknown install option: $1" ;;
esac
done
[ -n "$proxy" ] || die "--proxy is required"
[ -n "$exit_ip" ] || die "--exit-ip is required"
case "$proxy" in
http://* | https://*) ;;
*) die "--proxy must be an http:// or https:// (mixed) endpoint" ;;
esac
valid_ipv4 "$exit_ip" || die "--exit-ip must be a valid IPv4 address"
case "$country" in
"" | [A-Za-z][A-Za-z]) ;;
*) die "--country must be a two-letter code" ;;
esac
case "$strict" in
on | off) ;;
*) die "--strict-net must be on or off" ;;
esac
if [ "$strict" = "on" ] && ! in_sandbox; then
command -v systemd-run >/dev/null 2>&1 || die "--strict-net on needs systemd-run"
fi
need_root
local conf lib bindir sbin realdir state ts real_bin allow_all backup
conf=$(p "$CONF_REL")
lib=$(p "$LIB_REL")
bindir=$(p "$BIN_REL")
sbin=$(p "$SBIN_REL")
realdir=$(p "$REAL_REL")
state=$(p "$STATE_REL")
allow_all="127.0.0.0/8 ::1/128$(resolver_allow)"
if [ -n "$allow_extra" ]; then
allow_all="$allow_all $(printf '%s' "$allow_extra" | tr ',' ' ')"
fi
allow_all=$(printf '%s' "$allow_all" | tr -s ' ' | sed 's/^ //;s/ $//')
[ "$no_alt" = "1" ] && alt_conf="" || alt_conf=$(p "/usr/bin/claude")
real_bin=$(discover_real "$real" "$alt_conf") ||
die "no real claude binary found; pass --real /path/to/claude"
local guard_src check_src
guard_src=$(source_file guard-claude.sh) || die "guard-claude.sh not found next to this script"
check_src=$(source_file egress-check.sh) || die "egress-check.sh not found next to this script"
say "claude-egress-guard $VERSION - install plan"
say " prefix : ${PREFIX:-/}"
say " proxy : $proxy"
say " expected exit : $exit_ip${country:+ ($country)}"
say " real binary : $real_bin"
say " strict jail : $strict"
say " jail allow : $allow_all"
say " gate paths : $bindir/claude${alt_conf:+, $alt_conf}"
say " config : $conf"
if [ "$dry" = "1" ]; then
say "dry run - nothing written"
return 0
fi
mkdir -p "$lib" "$bindir" "$sbin" "$realdir" "$(dirname -- "$conf")" "$state/backup"
ts=$(date -u +%Y%m%dT%H%M%SZ)
backup="$state/backup/$ts"
mkdir -p "$backup"
# Back up whatever the gate is about to replace.
local target
for target in "$bindir/claude" "$alt_conf"; do
[ -n "$target" ] || continue
[ -e "$target" ] || [ -L "$target" ] || continue
is_gate "$target" && continue
mkdir -p "$backup$(dirname -- "${target#"$PREFIX"}")"
cp -a -- "$target" "$backup${target#"$PREFIX"}" || die "backup failed for $target"
say " backup : $target -> $backup${target#"$PREFIX"}"
done
install -m 0755 -- "$guard_src" "$lib/guard-claude.sh"
install -m 0755 -- "$check_src" "$lib/egress-check.sh"
install -m 0755 -- "$lib/guard-claude.sh" "$bindir/claude"
if [ -n "$alt_conf" ]; then
mkdir -p -- "$(dirname -- "$alt_conf")"
rm -f -- "$alt_conf"
install -m 0755 -- "$lib/guard-claude.sh" "$alt_conf"
fi
install -m 0755 -- "$0" "$sbin/claude-egress-guard"
ln -sfn -- "$real_bin" "$realdir/claude"
umask 077
{
printf '# claude-egress-guard configuration - keep this file mode 0600\n'
printf '# Written by claude-egress-guard %s on %s\n' "$VERSION" "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
printf 'PROXY=%q\n' "$proxy"
printf 'EXPECTED_IP=%q\n' "$exit_ip"
printf 'EXPECTED_COUNTRY=%q\n' "$country"
printf 'STRICT_NET=%q\n' "$([ "$strict" = "on" ] && printf 1 || printf 0)"
printf 'ALLOW_IPS=%q\n' "$allow_all"
printf 'REAL_BIN=%q\n' "$realdir/claude"
printf 'CHECK_BIN=%q\n' "$lib/egress-check.sh"
printf 'ALT_PATH=%q\n' "$alt_conf"
printf 'CHECK_TIMEOUT=6\n'
printf 'TRACE_URL="https://www.cloudflare.com/cdn-cgi/trace"\n'
printf 'FALLBACK_URL="https://ipinfo.io/json"\n'
} >"$conf.tmp"
mv -f -- "$conf.tmp" "$conf"
chmod 600 "$conf"
say "installed."
say "next: $(p "$SBIN_REL")/claude-egress-guard verify --deep"
}
# ---------------------------------------------------------------------------
# verify
# ---------------------------------------------------------------------------
cmd_verify() {
local deep="0"
while [ $# -gt 0 ]; do
case "$1" in
--deep) deep="1"; shift ;;
--prefix) PREFIX="${2:-}"; shift 2 ;;
-h | --help) usage; exit 0 ;;
*) die "unknown verify option: $1" ;;
esac
done
FAILED=0
local conf bindir
conf=$(p "$CONF_REL")
lib=$(p "$LIB_REL")
bindir=$(p "$BIN_REL")
[ -r "$conf" ] && pass "config present: $conf" || fail "config missing: $conf"
# shellcheck source=/dev/null
[ -r "$conf" ] && . "$conf"
if [ -x "${REAL_BIN:-/nonexistent}" ]; then
pass "real binary executable"
else
fail "real binary not executable: ${REAL_BIN:-unset}"
fi
is_gate "$bindir/claude" && pass "gate installed: $bindir/claude" || fail "gate missing: $bindir/claude"
if [ -n "${ALT_PATH:-}" ]; then
if [ -L "$ALT_PATH" ]; then
fail "alternative path is still a symlink (absolute-path callers bypass the gate): $ALT_PATH"
elif is_gate "$ALT_PATH"; then
pass "gate mirrored: $ALT_PATH"
else
fail "alternative path is not the gate: $ALT_PATH"
fi
fi
if [ -x "${CHECK_BIN:-/nonexistent}" ]; then
if OUT=$(CEG_CONF="$conf" "$CHECK_BIN" 2>&1); then
pass "egress check: $OUT"
else
fail "egress check: $OUT"
fi
else
fail "egress checker missing: ${CHECK_BIN:-unset}"
fi
case "${STRICT_NET:-0}" in
1)
if [ -n "${ALLOW_IPS:-}" ] && [ -n "${ALLOW_IPS// /}" ]; then
pass "kernel jail enabled, allow list: $ALLOW_IPS"
else
fail "kernel jail enabled but ALLOW_IPS is empty"
fi
command -v systemd-run >/dev/null 2>&1 && pass "systemd-run available" || fail "systemd-run missing"
;;
*)
warn "kernel jail disabled (STRICT_NET=0) - process-tree egress is not enforced"
;;
esac
if [ -r /proc/sys/net/ipv6/conf/all/disable_ipv6 ]; then
if [ "$(cat /proc/sys/net/ipv6/conf/all/disable_ipv6)" = "1" ]; then
pass "IPv6 disabled system-wide"
else
warn "IPv6 is enabled; make sure the proxy and the jail cover it"
fi
fi
if [ "$(stat -c '%a' "$conf" 2>/dev/null || printf '')" != "600" ]; then
warn "config file mode is not 0600"
fi
if [ "$deep" = "1" ] && [ "${STRICT_NET:-0}" = "1" ]; then
local curl_bin="${CEG_CURL:-curl}" direct_rc proxy_rc
if systemd-run --scope --quiet --collect -p IPAddressDeny=any \
-p "IPAddressAllow=$ALLOW_IPS" -- \
"$curl_bin" -4 -fsS --max-time 4 https://example.com/ >/dev/null 2>&1; then
direct_rc=0
else
direct_rc=1
fi
[ "$direct_rc" -ne 0 ] && pass "jail blocks direct egress" ||
fail "jail did NOT block direct egress - check ALLOW_IPS and systemd version"
if systemd-run --scope --quiet --collect -p IPAddressDeny=any \
-p "IPAddressAllow=$ALLOW_IPS" -- \
"$curl_bin" -4 -fsS --max-time 6 \
-x "${PROXY:-}" "${TRACE_URL:-https://www.cloudflare.com/cdn-cgi/trace}" >/dev/null 2>&1; then
proxy_rc=0
else
proxy_rc=1
fi
[ "$proxy_rc" -eq 0 ] && pass "jail still allows the proxy path" ||
fail "jail blocks the proxy path - add the proxy upstream to ALLOW_IPS"
fi
say "---- ${FAILED} check(s) failed"
[ "$FAILED" -eq 0 ]
}
# ---------------------------------------------------------------------------
# status
# ---------------------------------------------------------------------------
mask_proxy() {
printf '%s' "$1" | sed -E 's#(://)[^/@]*@#\1***@#'
}
cmd_status() {
while [ $# -gt 0 ]; do
case "$1" in
--prefix) PREFIX="${2:-}"; shift 2 ;;
-h | --help) usage; exit 0 ;;
*) die "unknown status option: $1" ;;
esac
done
local conf
conf=$(p "$CONF_REL")
[ -r "$conf" ] || die "config not found: $conf"
# shellcheck source=/dev/null
. "$conf"
say "config : $conf"
say "proxy : $(mask_proxy "${PROXY:-}")"
say "expected exit : ${EXPECTED_IP:-unset}${EXPECTED_COUNTRY:+ (${EXPECTED_COUNTRY})}"
say "strict jail : ${STRICT_NET:-0}"
say "jail allow : ${ALLOW_IPS:-}"
say "real binary : ${REAL_BIN:-unset}"
say "gate : $(p "$BIN_REL")/claude"
say "alt path : ${ALT_PATH:-none}"
if [ -x "${CHECK_BIN:-/nonexistent}" ]; then
say "egress : $(CEG_CONF="$conf" "$CHECK_BIN" 2>&1 || true)"
fi
}
# ---------------------------------------------------------------------------
# drill
# ---------------------------------------------------------------------------
cmd_drill() {
local service="" wait_s="30"
while [ $# -gt 0 ]; do
case "$1" in
--service) service="${2:-}"; shift 2 ;;
--wait) wait_s="${2:-}"; shift 2 ;;
--prefix) PREFIX="${2:-}"; shift 2 ;;
-h | --help) usage; exit 0 ;;
*) die "unknown drill option: $1" ;;
esac
done
[ -n "$service" ] || die "--service is required (the proxy core unit)"
case "$service" in
*ssh* | *tailscale* | docker*) die "refusing to stop $service: it can cut this session or unrelated services" ;;
esac
need_root
command -v systemctl >/dev/null 2>&1 || die "systemctl not found"
local gate conf state_before
gate=$(p "$BIN_REL")/claude
conf=$(p "$CONF_REL")
[ -r "$conf" ] || die "config not found: $conf"
[ -x "$gate" ] || die "gate not installed: $gate"
state_before=$(systemctl is-active "$service" 2>/dev/null || true)
[ "$state_before" = "active" ] || die "$service is not active (state: ${state_before:-unknown})"
restore() {
if [ "$(systemctl is-active "$service" 2>/dev/null || true)" != "active" ]; then
systemctl start "$service" >/dev/null 2>&1 || true
fi
}
trap restore EXIT
CEG_CONF="$conf" "$gate" --ceg-check >/dev/null 2>&1 ||
die "gate refuses while the proxy is up - fix verify first"
say "stopping $service ..."
systemctl stop "$service" >/dev/null 2>&1 || die "could not stop $service"
sleep 2
if CEG_CONF="$conf" "$gate" --ceg-check >/dev/null 2>&1; then
restore
die "FAIL: gate still allowed a start while the proxy was down"
fi
say "PASS gate refused to start with the proxy down"
say "starting $service ..."
systemctl start "$service" >/dev/null 2>&1 || die "could not start $service"
local i=0
while [ "$i" -lt "$wait_s" ]; do
if CEG_CONF="$conf" "$gate" --ceg-check >/dev/null 2>&1; then
say "PASS gate recovered after ${i}s"
trap - EXIT
return 0
fi
sleep 1
i=$((i + 1))
done
die "FAIL: gate did not recover within ${wait_s}s after restarting $service"
}
# ---------------------------------------------------------------------------
# uninstall
# ---------------------------------------------------------------------------
cmd_uninstall() {
while [ $# -gt 0 ]; do
case "$1" in
--prefix) PREFIX="${2:-}"; shift 2 ;;
-h | --help) usage; exit 0 ;;
*) die "unknown uninstall option: $1" ;;
esac
done
need_root
local state latest rel dest f
state=$(p "$STATE_REL")
latest=$(ls -1d "$state"/backup/*/ 2>/dev/null | sort | tail -n 1 || true)
if [ -n "$latest" ]; then
say "restoring files from $latest"
while IFS= read -r f; do
[ -n "$f" ] || continue
rel="${f#"$latest"}"
case "$rel" in
usr/local/bin/claude | usr/bin/claude) ;;
*) continue ;;
esac
dest="${PREFIX}/${rel}"
mkdir -p -- "$(dirname -- "$dest")"
cp -a -- "$f" "$dest" 2>/dev/null || warn "could not restore $dest"
say " restored $dest"
done < <(find "$latest" -mindepth 1 \( -type f -o -type l \) 2>/dev/null)
fi
rm -f -- "$(p "$BIN_REL")/claude"
rm -f -- "$(p "$SBIN_REL")/claude-egress-guard"
rm -f -- "$(p "$CONF_REL")"
rm -rf -- "$(p "$LIB_REL")"
rm -f -- "$(p "$REAL_REL")/claude"
say "gate removed; backups kept in $state"
}
# ---------------------------------------------------------------------------
# show
# ---------------------------------------------------------------------------
cmd_show() {
local what="${1:-}" src
case "$what" in
guard) src=$(source_file guard-claude.sh) || die "guard-claude.sh not found" ;;
check) src=$(source_file egress-check.sh) || die "egress-check.sh not found" ;;
config)
src=$(p "$CONF_REL")
[ -r "$src" ] || die "config not found: $src"
;;
*) die "usage: claude-egress-guard show <guard|check|config>" ;;
esac
cat -- "$src"
}
# ---------------------------------------------------------------------------
# main
# ---------------------------------------------------------------------------
FAILED=0
cmd="${1:-}"
[ $# -gt 0 ] && shift
case "$cmd" in
install) cmd_install "$@" ;;
verify) cmd_verify "$@" ;;
status) cmd_status "$@" ;;
drill) cmd_drill "$@" ;;
uninstall) cmd_uninstall "$@" ;;
show) cmd_show "$@" ;;
version | --version) say "claude-egress-guard $VERSION" ;;
help | --help | -h | "") usage ;;
*) err "unknown command: $cmd"; usage; exit 2 ;;
esac
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# claude-egress-guard — verify that the configured proxy really exits through
# the expected address and country.
#
# Prints one line on stdout:
# ok exit=<address> country=<cc>
# fail reason=<why> [detail=...]
# Never writes state, never retries, never falls back to a direct connection.
set -u
CONF="${CEG_CONF:-/etc/claude-egress-guard.conf}"
[ -r "$CONF" ] || { echo "fail reason=config_missing"; exit 1; }
# shellcheck source=/dev/null
. "$CONF" || { echo "fail reason=config_unreadable"; exit 1; }
: "${PROXY:?}" 2>/dev/null || { echo "fail reason=proxy_missing"; exit 1; }
: "${EXPECTED_IP:?}" 2>/dev/null || { echo "fail reason=expected_ip_missing"; exit 1; }
TIMEOUT="${CHECK_TIMEOUT:-6}"
TRACE_URL="${TRACE_URL:-https://www.cloudflare.com/cdn-cgi/trace}"
FALLBACK_URL="${FALLBACK_URL:-https://ipinfo.io/json}"
CURL_BIN="${CEG_CURL:-curl}"
fetch() {
# -4 keeps the probe itself on IPv4; -f turns HTTP errors into failures.
"$CURL_BIN" -4 -fsS --max-time "$TIMEOUT" -x "$PROXY" "$1" 2>/dev/null
}
BODY=$(fetch "$TRACE_URL")
IP=$(printf '%s\n' "$BODY" | sed -n 's/^ip=//p' | head -n 1)
COUNTRY=$(printf '%s\n' "$BODY" | sed -n 's/^loc=//p' | head -n 1)
if [ -z "$IP" ]; then
BODY=$(fetch "$FALLBACK_URL")
IP=$(printf '%s' "$BODY" | sed -n 's/.*"ip":[[:space:]]*"\([^"]*\)".*/\1/p')
COUNTRY=$(printf '%s' "$BODY" | sed -n 's/.*"country":[[:space:]]*"\([^"]*\)".*/\1/p')
fi
[ -n "$IP" ] || { echo "fail reason=proxy_unreachable"; exit 1; }
[ "$IP" = "$EXPECTED_IP" ] || { echo "fail reason=exit_ip_mismatch exit=$IP"; exit 1; }
EXPECTED_COUNTRY="${EXPECTED_COUNTRY:-}"
if [ -n "$EXPECTED_COUNTRY" ] && [ "$COUNTRY" != "$EXPECTED_COUNTRY" ]; then
echo "fail reason=country_mismatch country=${COUNTRY:-unknown}"
exit 1
fi
echo "ok exit=$IP country=${COUNTRY:-unknown}"
exit 0
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
# claude-egress-guard — fail-closed launcher for Claude Code.
#
# Installed as <prefix>/usr/local/bin/claude and mirrored to <prefix>/usr/bin/claude.
# Every policy value lives in /etc/claude-egress-guard.conf. If the config is
# missing, the egress check fails, or the strict network jail cannot be applied,
# this script refuses to start Claude at all.
#
# Exit codes: 77 = refused by the gate, 78 = config problem.
set -u
MARKER="claude-egress-guard"
CONF="${CEG_CONF:-/etc/claude-egress-guard.conf}"
REFUSE=77
refuse() {
printf 'claude-egress-guard: %s\n' "$*" >&2
exit "$REFUSE"
}
[ -r "$CONF" ] || refuse "missing or unreadable $CONF - refusing to start"
# shellcheck source=/dev/null
. "$CONF" || refuse "cannot load $CONF - refusing to start"
# The checker must read the same policy file, whatever the entry point was.
export CEG_CONF="$CONF"
for _var in CHECK_BIN REAL_BIN PROXY EXPECTED_IP; do
[ -n "${!_var:-}" ] || refuse "config value $_var is empty in $CONF"
done
unset _var
# Checked before every diagnostic path too, so a broken pin reports the real
# reason instead of "command not found" from exec.
[ -x "$REAL_BIN" ] || refuse "real Claude binary missing or not executable: $REAL_BIN"
# ---------------------------------------------------------------------------
# 1. Diagnostic paths that must never depend on the network.
# ---------------------------------------------------------------------------
case "${1:-}" in
--ceg-check)
# Gate self-test used by drift checks and drill scripts.
exec "$CHECK_BIN"
;;
--version | -v | --help | -h)
exec "$REAL_BIN" "$@"
;;
esac
# ---------------------------------------------------------------------------
# 2. Self-heal: package updates rewrite the /usr/bin symlink back to the real
# binary. Restore the gate copy so absolute-path callers stay covered.
# ---------------------------------------------------------------------------
ALT="${ALT_PATH:-}"
if [ -n "$ALT" ]; then
SELF_REAL=$(readlink -f -- "$0" 2>/dev/null || printf '%s' "$0")
ALT_REAL=$(readlink -f -- "$ALT" 2>/dev/null || printf '%s' "$ALT")
if [ "$SELF_REAL" != "$ALT_REAL" ] && [ -w "$(dirname -- "$ALT")" ]; then
if ! head -c 200 -- "$ALT" 2>/dev/null | grep -q -- "$MARKER"; then
# rm first: cp through an npm symlink would overwrite the real binary.
rm -f -- "$ALT" 2>/dev/null || true
install -m 0755 -- "$0" "$ALT" 2>/dev/null || true
fi
fi
fi
# ---------------------------------------------------------------------------
# 3. Egress check. Runs before every start; failure is fatal, never a warning.
# ---------------------------------------------------------------------------
if [ ! -x "$CHECK_BIN" ]; then
refuse "egress checker missing or not executable: $CHECK_BIN"
fi
if ! CHECK_OUT=$("$CHECK_BIN" 2>&1); then
printf '%s\n' "$CHECK_OUT" >&2
refuse "egress check failed - refusing to start (fail-closed)"
fi
# ---------------------------------------------------------------------------
# 4. Proxy environment for the client itself and every child process.
# ---------------------------------------------------------------------------
export ALL_PROXY="$PROXY" all_proxy="$PROXY"
export http_proxy="$PROXY" https_proxy="$PROXY"
export HTTP_PROXY="$PROXY" HTTPS_PROXY="$PROXY"
export NO_PROXY="127.0.0.1,localhost,::1" no_proxy="127.0.0.1,localhost,::1"
# ---------------------------------------------------------------------------
# 5. Optional kernel-level jail: only loopback, the proxy upstream and the
# allow-listed networks are reachable from the whole Claude process tree.
# ---------------------------------------------------------------------------
case "${STRICT_NET:-0}" in
1 | yes | true)
[ -n "${ALLOW_IPS:-}" ] || refuse "STRICT_NET is on but ALLOW_IPS is empty"
command -v systemd-run >/dev/null 2>&1 || refuse "STRICT_NET is on but systemd-run is unavailable"
[ "$(id -u)" -eq 0 ] || refuse "STRICT_NET is on but this process is not root"
exec systemd-run --scope --quiet --collect \
-p IPAddressDeny=any \
-p "IPAddressAllow=$ALLOW_IPS" \
-- "$REAL_BIN" "$@"
;;
esac
exec "$REAL_BIN" "$@"
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env bash
# Self-test for claude-egress-guard. Runs entirely inside a throwaway prefix
# with fake curl / systemd-run / claude binaries. No root, no network.
set -u
HERE=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P)
CLI="$HERE/claude-egress-guard"
WORK=$(mktemp -d "${TMPDIR:-/tmp}/ceg-test.XXXXXX")
PREFIX="$WORK/prefix"
BIN="$PREFIX/bin"
SRC="$WORK/src"
PASSED=0
FAILED=0
cleanup() { rm -rf "$WORK"; }
trap cleanup EXIT
ok() {
printf ' ok %s\n' "$1"
PASSED=$((PASSED + 1))
}
no() {
printf ' FAIL %s\n' "$1"
FAILED=$((FAILED + 1))
}
expect_rc() { # name expected actual
if [ "$2" = "$3" ]; then ok "$1"; else no "$1 (expected rc=$2, got rc=$3)"; fi
}
# ---------------------------------------------------------------------------
# fixtures: fake claude, fake curl, fake systemd-run
# ---------------------------------------------------------------------------
mkdir -p "$BIN" "$SRC" "$PREFIX/etc" "$PREFIX/usr/bin" "$PREFIX/usr/local/bin"
cat >"$SRC/claude" <<'EOF'
#!/bin/sh
printf 'REAL_CLAUDE'
for a in "$@"; do printf ' %s' "$a"; done
printf '\n'
EOF
chmod 0755 "$SRC/claude"
cat >"$BIN/curl" <<'EOF'
#!/bin/sh
# Emulates `curl -x PROXY URL` for the egress checker.
[ "${FAKE_CURL_FAIL:-0}" = "1" ] && exit 7
printf 'ip=%s\nloc=%s\nhttp=http/2\n' "${FAKE_IP:-203.0.113.10}" "${FAKE_LOC:-JP}"
EOF
chmod 0755 "$BIN/curl"
cat >"$BIN/systemd-run" <<'EOF'
#!/bin/bash
# Records the properties, then runs the command after `--`.
: >"${FAKE_SYSTEMD_LOG:-/dev/null}"
args=()
while [ $# -gt 0 ]; do
case "$1" in
--scope | --quiet | --collect) shift ;;
-p)
printf '%s\n' "$2" >>"${FAKE_SYSTEMD_LOG:-/dev/null}"
shift 2
;;
--)
shift
break
;;
*) shift ;;
esac
done
exec "$@"
EOF
chmod 0755 "$BIN/systemd-run"
# The gate demands root for the jail; pretend to be root inside the sandbox.
cat >"$BIN/id" <<'EOF'
#!/bin/sh
echo 0
EOF
chmod 0755 "$BIN/id"
export PATH="$BIN:$PATH"
export CEG_CURL="$BIN/curl"
# Pre-existing npm-style symlink that the installer must back up.
ln -sfn "$SRC/claude" "$PREFIX/usr/bin/claude"
# ---------------------------------------------------------------------------
printf 'install\n'
# ---------------------------------------------------------------------------
"$CLI" install --prefix "$PREFIX" --proxy http://127.0.0.1:1080 \
--exit-ip 203.0.113.10 --country JP --real "$SRC/claude" --allow 100.64.0.0/10 \
--dry-run >/dev/null 2>&1
expect_rc "dry-run exits 0" 0 $?
[ ! -e "$PREFIX/etc/claude-egress-guard.conf" ] && ok "dry-run writes nothing" || no "dry-run wrote the config"
"$CLI" install --prefix "$PREFIX" --proxy http://127.0.0.1:1080 \
--exit-ip 203.0.113.10 --country JP --real "$SRC/claude" --allow 100.64.0.0/10 >/dev/null 2>&1
expect_rc "install exits 0" 0 $?
CONF="$PREFIX/etc/claude-egress-guard.conf"
[ -r "$CONF" ] && ok "config written" || no "config missing"
[ "$(stat -c '%a' "$CONF")" = "600" ] && ok "config mode 0600" || no "config mode is $(stat -c '%a' "$CONF")"
[ -x "$PREFIX/usr/local/bin/claude" ] && ok "gate installed" || no "gate missing"
[ -x "$PREFIX/usr/bin/claude" ] && ok "alt path installed" || no "alt path missing"
grep -q '^STRICT_NET=1' "$CONF" && ok "strict net defaults to on" || no "strict net not enabled"
grep -q '127.0.0.0/8' "$CONF" && ok "loopback allow-listed" || no "loopback missing from allow list"
grep -q '100.64.0.0/10' "$CONF" && ok "extra --allow honoured" || no "extra --allow missing"
[ -L "$PREFIX/opt/claude-real/claude" ] && ok "real binary pinned" || no "real binary not pinned"
BK=$(find "$PREFIX/var/lib/claude-egress-guard/backup" -name claude 2>/dev/null | head -n 1)
[ -n "$BK" ] && [ -L "$BK" ] && ok "original symlink backed up" || no "no backup of the original symlink"
# ---------------------------------------------------------------------------
printf 'gate behaviour\n'
# ---------------------------------------------------------------------------
run_gate() { CEG_CONF="$CONF" "$PREFIX/usr/local/bin/claude" "$@"; }
OUT=$(FAKE_IP=203.0.113.10 run_gate --print-config 2>&1)
expect_rc "gate starts with the expected exit" 0 $?
case "$OUT" in
REAL_CLAUDE*) ok "gate execs the real binary" ;;
*) no "gate did not exec the real binary: $OUT" ;;
esac
OUT=$(CEG_CONF="$CONF" "$PREFIX/usr/local/bin/claude" --version 2>&1)
expect_rc "--version passthrough" 0 $?
case "$OUT" in
REAL_CLAUDE*) ok "--version skips the egress check" ;;
*) no "--version did not reach the real binary" ;;
esac
FAKE_IP=198.51.100.7 run_gate --print-config >/dev/null 2>&1
expect_rc "gate refuses on exit mismatch" 77 $?
FAKE_LOC=US run_gate --print-config >/dev/null 2>&1
expect_rc "gate refuses on wrong country" 77 $?
FAKE_CURL_FAIL=1 run_gate --print-config >/dev/null 2>&1
expect_rc "gate refuses when the proxy is unreachable" 77 $?
CEG_CONF="$WORK/missing.conf" "$PREFIX/usr/local/bin/claude" --print-config >/dev/null 2>&1
expect_rc "gate refuses without config" 77 $?
# ---------------------------------------------------------------------------
printf 'self-heal\n'
# ---------------------------------------------------------------------------
ln -sfn "$SRC/claude" "$PREFIX/usr/bin/claude" # simulate an npm update
FAKE_IP=203.0.113.10 run_gate --print-config >/dev/null 2>&1
if head -c 200 "$PREFIX/usr/bin/claude" | grep -q claude-egress-guard; then
ok "alt path restored to the gate after an update"
else
no "alt path was not restored"
fi
# ---------------------------------------------------------------------------
printf 'strict jail\n'
# ---------------------------------------------------------------------------
: >"$WORK/systemd.log"
FAKE_IP=203.0.113.10 FAKE_SYSTEMD_LOG="$WORK/systemd.log" run_gate --print-config >/dev/null 2>&1
expect_rc "gate runs inside the scope" 0 $?
grep -q 'IPAddressDeny=any' "$WORK/systemd.log" && ok "jail denies by default" || no "IPAddressDeny missing"
grep -q 'IPAddressAllow=.*127.0.0.0/8' "$WORK/systemd.log" && ok "jail allow list forwarded" || no "IPAddressAllow malformed"
# ---------------------------------------------------------------------------
printf 'verify / status / uninstall\n'
# ---------------------------------------------------------------------------
FAKE_IP=203.0.113.10 "$CLI" verify --prefix "$PREFIX" >/dev/null 2>&1
expect_rc "verify passes when everything is wired" 0 $?
FAKE_IP=198.51.100.7 "$CLI" verify --prefix "$PREFIX" >/dev/null 2>&1
expect_rc "verify fails on exit mismatch" 1 $?
"$CLI" status --prefix "$PREFIX" >/dev/null 2>&1
expect_rc "status exits 0" 0 $?
"$CLI" uninstall --prefix "$PREFIX" >/dev/null 2>&1
expect_rc "uninstall exits 0" 0 $?
[ -L "$PREFIX/usr/bin/claude" ] && ok "original alt symlink restored" || no "alt symlink not restored"
[ ! -e "$CONF" ] && ok "config removed" || no "config left behind"
[ ! -e "$PREFIX/usr/local/bin/claude" ] && ok "gate removed" || no "gate left behind"
printf '\n%d passed, %d failed\n' "$PASSED" "$FAILED"
[ "$FAILED" -eq 0 ]
@@ -0,0 +1,43 @@
# claude-egress-guard configuration example
#
# Installed to /etc/claude-egress-guard.conf with mode 0600 by
# `claude-egress-guard install`. Every value below is plain shell assignment;
# the file is sourced by the gate and the checker.
#
# Changing policy here is an operator decision: edit, then run
# `claude-egress-guard verify --deep`.
# Local HTTP/mixed proxy the client must use.
PROXY="http://127.0.0.1:1080"
# Public IPv4 address the proxy must exit from (required).
EXPECTED_IP="203.0.113.10"
# Two-letter country the exit must report. Empty = not enforced.
EXPECTED_COUNTRY=""
# Kernel jail for the whole Claude process tree: 1 = on, 0 = off.
STRICT_NET=1
# CIDRs reachable inside the jail. Always keep loopback; add the proxy upstream,
# the host's DNS resolvers and any private network the work depends on.
ALLOW_IPS="127.0.0.0/8 ::1/128 192.0.2.53/32"
# Real Claude binary, pinned through a stable symlink so package updates are
# followed without re-installing the gate.
REAL_BIN="/opt/claude-real/claude"
# Exit checker used by the gate.
CHECK_BIN="/usr/local/lib/claude-egress-guard/egress-check.sh"
# Mirror path: the gate repairs this file if a package update replaces it.
ALT_PATH="/usr/bin/claude"
# Seconds allowed for each probe.
CHECK_TIMEOUT=6
# Endpoints used by the checker, through the proxy only. The first one is a
# Cloudflare edge trace (no request reaches an AI provider); the second is a
# JSON fallback.
TRACE_URL="https://www.cloudflare.com/cdn-cgi/trace"
FALLBACK_URL="https://ipinfo.io/json"
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""Privacy and secret leak scanner for this repository.
Fails when a tracked file contains something that should never be published:
credentials, personal identifiers, or infrastructure addresses outside the
documentation ranges.
Usage:
python3 tools/leak-scan.py [path ...]
Exit status: 0 clean, 1 findings, 2 usage error.
"""
from __future__ import annotations
import ipaddress
import re
import sys
from dataclasses import dataclass
from pathlib import Path
ALLOWED_EMAIL_DOMAINS = {
"example.com",
"example.org",
"example.net",
"users.noreply.github.com",
"noreply.github.com",
}
# Ranges that are safe to publish: loopback, link-local, RFC 1918, CGNAT and the
# three documentation blocks.
ALLOWED_NETS = [
ipaddress.ip_network(n)
for n in (
"0.0.0.0/8",
"10.0.0.0/8",
"100.64.0.0/10",
"127.0.0.0/8",
"169.254.0.0/16",
"172.16.0.0/12",
"192.0.2.0/24",
"192.168.0.0/16",
"198.51.100.0/24",
"203.0.113.0/24",
"224.0.0.0/4",
"255.255.255.255/32",
)
]
ALLOWED_HOME_USER = {"user", "runner", "example", "name"}
UUID_RE = re.compile(r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b")
IPV4_RE = re.compile(r"(?<![\d.])(?:\d{1,3}\.){3}\d{1,3}(?![\d.])")
EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+-]+@([A-Za-z0-9.-]+\.[A-Za-z]{2,})\b")
HOME_RE = re.compile(r"(?:/home/|/Users/)([A-Za-z0-9._-]+)")
WIN_HOME_RE = re.compile(r"[Cc]:\\Users\\([A-Za-z0-9._-]+)")
LONG_HEX_RE = re.compile(r"\b[0-9a-fA-F]{64,}\b")
CN_MOBILE_RE = re.compile(r"(?<!\d)1[3-9]\d{9}(?!\d)")
TG_TOKEN_RE = re.compile(r"\b\d{8,10}:[A-Za-z0-9_-]{35}\b")
TOKENS = {
"private-key": re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----"),
"github-token": re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})"),
"aws-key": re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"),
"slack-token": re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}"),
"openai-key": re.compile(r"\bsk-[A-Za-z0-9]{32,}"),
"google-key": re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b"),
"huggingface-token": re.compile(r"\bhf_[A-Za-z0-9]{30,}"),
}
@dataclass
class Finding:
path: str
line: int
rule: str
preview: str
def redact(value: str, keep: int = 3) -> str:
if len(value) <= keep:
return "***"
return value[:keep] + "***"
def iter_files(paths: list[str]) -> list[Path]:
roots = [Path(p) for p in paths] or [Path(".")]
out: list[Path] = []
for root in roots:
if root.is_file():
out.append(root)
continue
for path in sorted(root.rglob("*")):
if not path.is_file():
continue
if any(part in {".git", "node_modules", "__pycache__"} for part in path.parts):
continue
out.append(path)
return out
def is_binary(path: Path) -> bool:
try:
chunk = path.read_bytes()[:4096]
except OSError:
return True
return b"\x00" in chunk
def check_line(rule: str, text: str, match: re.Match[str]) -> Finding | None:
value = match.group(0)
if rule == "email":
if match.group(1).lower() in ALLOWED_EMAIL_DOMAINS:
return None
return Finding("", 0, rule, redact(value))
if rule == "ipv4":
try:
addr = ipaddress.ip_address(value)
except ValueError:
return None
if any(addr in net for net in ALLOWED_NETS):
return None
return Finding("", 0, rule, redact(value, keep=6))
if rule == "home-path":
if value.split("/")[-1].lower() in ALLOWED_HOME_USER:
return None
return Finding("", 0, rule, value.rsplit("/", 1)[0] + "/***")
if rule == "win-home-path":
if value.split("\\")[-1].lower() in ALLOWED_HOME_USER:
return None
return Finding("", 0, rule, value.rsplit("\\", 1)[0] + "\\***")
if rule in TOKENS or rule in {"uuid", "long-hex", "cn-mobile", "telegram-token"}:
return Finding("", 0, rule, redact(value))
return Finding("", 0, rule, redact(value))
def scan(path: Path) -> list[Finding]:
if is_binary(path):
return []
findings: list[Finding] = []
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return findings
for lineno, line in enumerate(text.splitlines(), start=1):
for rule, regex in TOKENS.items():
for match in regex.finditer(line):
found = check_line(rule, line, match)
if found:
found.path, found.line = str(path), lineno
findings.append(found)
for rule, regex in (
("uuid", UUID_RE),
("long-hex", LONG_HEX_RE),
("email", EMAIL_RE),
("ipv4", IPV4_RE),
("home-path", HOME_RE),
("win-home-path", WIN_HOME_RE),
("cn-mobile", CN_MOBILE_RE),
("telegram-token", TG_TOKEN_RE),
):
for match in regex.finditer(line):
found = check_line(rule, line, match)
if found:
found.path, found.line = str(path), lineno
findings.append(found)
return findings
def main(argv: list[str]) -> int:
if argv and argv[0] in {"-h", "--help"}:
print(__doc__)
return 0
findings: list[Finding] = []
for path in iter_files(argv):
if path.name == "leak-scan.py":
continue
findings.extend(scan(path))
if not findings:
print("leak-scan: clean")
return 0
for item in findings:
print(f"{item.path}:{item.line}: {item.rule}: {item.preview}")
print(f"leak-scan: {len(findings)} finding(s)")
return 1
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))