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:
commit
c64ed98eb1
20 files changed
+2231
No files matched your search
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.sh text eol=lf
|
||||
LICENSE text eol=lf
|
||||
*.md text eol=lf
|
||||
*.py text eol=lf
|
||||
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
@@ -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:]))
|
||||
Reference in new issue
Block a user