feat: one-command install, set-exit, refusal logging, agent-first docs

- install.sh: install from a checkout or via curl|bash, tag-pinned download,
  --skill-only / --uninstall / --dry-run / --yes, ends with verify --deep
- set-exit: follow a rotating exit, probe first, rewrite policy only when the
  proxy serves the candidate address, then re-verify
- LOG_REFUSALS=1: refusals go to journald under claude-egress-guard
- AGENTS.md, llms.txt, Makefile: machine entry points and make check
- README/README.zh-CN rewritten for two audiences (agents, humans) with a real
  verify sample, compatibility table, per-start cost, rotation, logging and an
  explicit out-of-scope list (npx, second installs, containers, root, SOCKS)
- default allow list is 127.0.0.0/8 so systemd-resolved keeps working
- 11 new self-tests, 42 total
This commit is contained in:
yinyues2 committed 2026-10-27 01:29:59 +08:00
1 parent 3693442954
commit 3fe43b7ddc
15 files changed
+1122 -210

No files matched your search

+8 -1
View File
@@ -21,12 +21,19 @@ jobs:
run: |
sudo apt-get update -qq
sudo apt-get install -y shellcheck
shellcheck -x -S warning skills/claude-egress-guard/scripts/*
shellcheck -x -S warning install.sh skills/claude-egress-guard/scripts/*
- name: syntax
run: |
bash -n install.sh
for f in skills/claude-egress-guard/scripts/*; do bash -n "$f"; done
- name: installer surface
run: |
./install.sh --help >/dev/null
./install.sh --proxy http://127.0.0.1:1080 >/dev/null 2>&1 && exit 1 || true
make --version >/dev/null
- name: self-test
run: bash skills/claude-egress-guard/scripts/selftest.sh
+56
View File
@@ -0,0 +1,56 @@
# AGENTS.md
Machine entry point for agent runners. If you are an agent asked to "lock down",
"pin", "verify" or "audit" the egress of Claude Code on a Linux host, read this
file, then load [`skills/claude-egress-guard/SKILL.md`](skills/claude-egress-guard/SKILL.md).
## What this repository is
A deployment skill plus an installer for a fail-closed egress gate in front of
Claude Code. It is not a proxy and not a VPN: it makes the `claude` binary refuse
to start unless traffic leaves through one verified exit.
## Read order
1. `skills/claude-egress-guard/SKILL.md` — the contract (facts, tiers, guardrails).
2. `skills/claude-egress-guard/references/agent-playbook.md` — commands and decision points.
3. `skills/claude-egress-guard/references/troubleshooting.md` — when a check fails.
4. `SECURITY.md` — threat model, known limitations, what is out of scope.
## Operating contract
- Ask the operator for exactly four things: proxy endpoint, expected exit address,
country to enforce, extra CIDRs. Everything else is read from the host.
- Read-only diagnosis (`--help`, `--dry-run`, `status`, `verify`) needs no extra
approval once the scope is agreed. One approval covers install, `verify --deep`,
policy edits and uninstall. Confirm on the spot before anything that can cut the
operator's own access (ssh, tailscale, the session's network path, reboots).
- Quote the PASS/FAIL line for every claim. A layer without output is not verified.
- Never stop a unit whose name contains ssh, tailscale or docker.
- Never widen `ALLOW_IPS` to `0.0.0.0/0` or a whole private range.
- Never set `STRICT_NET=0` to make a check pass; that is an operator decision with
a stated trade-off.
- Never touch account registration, payment, identity verification or provider
decisions.
## Entry points
```bash
# install the skill only
./install.sh --skill-only --dest ~/.hermes/skills
# full install, unattended
sudo ./install.sh --proxy "$PROXY" --exit-ip "$EXIT_IP" --country "$CC" --yes
# proof
sudo claude-egress-guard verify --deep
sudo claude-egress-guard drill --service "$PROXY_UNIT"
```
Exit codes: `0` ok, `1` verification failed, `2` usage, `77` gate refused.
## What "done" means
`verify --deep` reports `0 check(s) failed`, the drill reports refusal and
recovery, the operator's Claude invocation still works, and you have written down
the version, the exit address, the allow list and the drill result.
+33
View File
@@ -3,6 +3,39 @@
All notable changes to this project are documented here. The format follows
Keep a Changelog; versions use semantic versioning.
## [1.1.0] - 2026-10-27
### Added
- `install.sh`: one-command installer, usable from a checkout or piped from
`raw.githubusercontent.com`. Downloads a tag-pinned release when it is not run
from a checkout, prints the plan, backs up replaced files, then runs
`verify --deep`; `--skill-only`, `--uninstall`, `--dry-run`, `--yes`, `--ref`.
- `claude-egress-guard set-exit --ip ADDR [--country CC]`: follows a rotating
residential exit. Probes the proxy with the candidate address, refuses if the
proxy does not serve it, rewrites the policy atomically (mode 0600) and
re-verifies.
- `LOG_REFUSALS=1`: refusals are written to journald under the
`claude-egress-guard` tag, so "did it ever refuse?" is a query.
- `AGENTS.md`, `llms.txt` and a `Makefile` (`make check`), so agent runners and
contributors have machine entry points.
- README restructured around two audiences: agents installing the skill, and
humans running one command. Adds a real `verify --deep` sample, a compatibility
table, per-start cost, refusal logging, exit rotation and an explicit
"what it does not do" section (`npx`, second installs, containers, root,
SOCKS-only proxies).
- Eleven new self-tests (42 total): set-exit accept/refuse/restore, refusal logging,
and the installer's `--help`, `--skill-only` and missing-argument behaviour.
### Changed
- The default allow list uses `127.0.0.0/8` instead of `127.0.0.1/32`, so
`systemd-resolved` on `127.0.0.53` keeps working inside the jail.
- `verify --deep` no longer aborts on unset proxy variables when the config is
missing; it reports the failed layer.
- The real binary is checked before the diagnostic passthrough, so a broken pin
reports the actual reason instead of a shell `command not found`.
## [1.0.0] - 2026-10-27
### Added
+31
View File
@@ -0,0 +1,31 @@
SHELL := /bin/bash
SCRIPTS := skills/claude-egress-guard/scripts
SHELLCHECK ?= shellcheck
SHELLCHECK_FLAGS ?= -x -S warning
.PHONY: help lint test scan check install skill
help:
@printf '%s\n' \
'make check shellcheck + self-tests + leak scan (what CI runs)' \
'make lint shellcheck on every script' \
'make test 42 sandboxed assertions, no root and no network' \
'make scan privacy/secret scan' \
'make skill copy the skill into ~/.hermes/skills'
lint:
$(SHELLCHECK) $(SHELLCHECK_FLAGS) install.sh $(wildcard $(SCRIPTS)/*)
test:
@bash $(SCRIPTS)/selftest.sh
scan:
@python3 tools/leak-scan.py
check: lint test scan
install:
@sudo ./install.sh --help >/dev/null && echo 'run: sudo ./install.sh --proxy URL --exit-ip ADDR'
skill:
@./install.sh --skill-only --dest "$${DEST:-$$HOME/.hermes/skills}"
+291 -143
View File
@@ -2,183 +2,331 @@
[![ci](https://github.com/yinyues2/claude-egress-guard/actions/workflows/ci.yml/badge.svg)](https://github.com/yinyues2/claude-egress-guard/actions/workflows/ci.yml)
**A fail-closed egress gate for Claude Code on Linux hosts — packaged as an agent skill.**
**A deployment skill for agents, plus a one-command installer for humans, that
pins Claude Code on a Linux host to one egress and refuses to start when that
path is unavailable.**
Claude Code on a VPS is easy to set up and easy to leak. A shell alias or a
Claude Code on a VPS is easy to install 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
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.
This project closes that gap at the binary level: the `claude` command itself
becomes a gate that verifies the proxy's real exit address, injects the proxy
environment, and optionally jails the whole process tree with a kernel IP allow
list. Anything unexpected refuses to start. There is no fallback.
---
## For agents — start here
This repository is written to be consumed by an agent (Hermes, Codex, Claude
Code, or any skill-capable runner), not only by a human reading a README.
```bash
# give the agent the skill (no root, no changes to the host)
curl -fsSL https://raw.githubusercontent.com/yinyues2/claude-egress-guard/main/install.sh \
| bash -s -- --skill-only --dest ~/.hermes/skills
```
Then say:
> Use the `claude-egress-guard` skill. Interview me for the proxy endpoint, the
> expected exit address, the country and any extra CIDRs; then install, run
> `verify --deep`, drill the failure path and report the PASS/FAIL lines.
What the agent is told, in [`skills/claude-egress-guard/SKILL.md`](skills/claude-egress-guard/SKILL.md):
- **Four facts only** — proxy endpoint, expected exit address, country, allow
list. Everything else is read from the host instead of asked again.
- **Three authorization tiers** — read-only diagnosis is free; one approval
covers the plan (install/verify/drill/uninstall); anything that can cut the
operator's own access is confirmed on the spot.
- **Evidence, not adjectives** — quote the PASS line for every claim, record the
drill result, never report a layer as working without its output.
- **Hard stops** — never stop ssh/tailscale/docker units, never widen
`ALLOW_IPS` to `0.0.0.0/0`, never disable a layer to make a check pass, never
touch account, payment or identity work.
Supporting files: [`AGENTS.md`](AGENTS.md) (machine entry point),
[`references/agent-playbook.md`](skills/claude-egress-guard/references/agent-playbook.md)
(step-by-step with decision points), [`llms.txt`](llms.txt) (link index for
LLM tooling), [`references/troubleshooting.md`](skills/claude-egress-guard/references/troubleshooting.md).
---
## One-command install
```bash
curl -fsSL https://raw.githubusercontent.com/yinyues2/claude-egress-guard/main/install.sh \
| sudo bash -s -- --proxy http://127.0.0.1:1080 --exit-ip 203.0.113.10 --country JP
```
That command downloads the pinned release, prints the plan, installs the gate,
backs up whatever it replaces, and finishes with `verify --deep`. If any layer
fails it stops and names the layer; it never reports success for a gate that is
not working.
| Flag | Meaning |
| --- | --- |
| `--proxy URL` | local HTTP/mixed proxy the client must use (required) |
| `--exit-ip ADDR` | public IPv4 the proxy must exit from (required) |
| `--country CC` | two-letter country to enforce (optional) |
| `--allow LIST` | extra CIDRs reachable inside the jail, comma separated |
| `--strict-net on\|off` | kernel jail for the process tree (default `on`) |
| `--dry-run` | print the plan, write nothing |
| `--yes` | skip the confirmation prompt (for automation) |
| `--ref REF` | release tag or branch to download (default: pinned tag) |
| `--uninstall` | remove the gate and restore the previous binaries |
| `--skill-only` | copy just the skill into a skills directory |
`203.0.113.10` is documentation address space (RFC 5737); replace it with the
address your proxy actually reports. Loopback and the host's IPv4 DNS resolvers
are allow-listed automatically; add `--allow` for a remote proxy upstream, a
tailnet range, or an internal API.
**Read before you pipe to root.** `install.sh` is a plain shell script with no
obfuscation, and it downloads a tag-pinned tarball rather than a moving branch.
Read it, or clone the repository and run `sudo ./install.sh ...`.
Uninstall, same channel:
```bash
curl -fsSL https://raw.githubusercontent.com/yinyues2/claude-egress-guard/main/install.sh \
| sudo bash -s -- --uninstall
```
---
## What you get
```
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)
$ sudo claude-egress-guard verify --deep
PASS config present: /etc/claude-egress-guard.conf
PASS real binary executable
PASS gate installed: /usr/local/bin/claude
PASS gate mirrored: /usr/bin/claude
PASS egress check: ok exit=203.0.113.10 country=JP
PASS kernel jail enabled, allow list: 127.0.0.0/8 ::1/128 192.0.2.53/32
PASS systemd-run available
PASS IPv6 disabled system-wide
PASS jail blocks direct egress
PASS jail still allows the proxy path
---- 0 check(s) failed
```
Every line is an observation rather than a configuration claim: the last two
issue real requests from inside the jail.
```
claude (any entry point: shell, Python, cron, systemd, absolute path)
└─ /usr/local/bin/claude gate: exit check -> proxy env -> jail
├─ /usr/bin/claude same gate (npm updates rewrite this; it self-heals)
└─ /opt/claude-real/claude real binary (symlink, follows package updates)
└─ your proxy 127.0.0.1:1080 -> expected exit address
```
Order of operations on every start:
1. Load `/etc/claude-egress-guard.conf`; missing or unreadable = refuse (77).
2. Restore `/usr/bin/claude` if a package update replaced it with a symlink.
3. Ask the proxy for its exit address and country; mismatch, timeout or HTTP
error = refuse (77), with no retry and no direct fallback.
4. Export `ALL_PROXY`/`http_proxy`/`https_proxy` (and the lowercase forms) to the
client and every child, with `NO_PROXY` limited to loopback.
5. If `STRICT_NET=1`, exec inside a transient systemd scope with
`IPAddressDeny=any` and `IPAddressAllow=<allow list>`, so children inherit a
kernel-level allow list instead of a promise.
Diagnostics never touch the network: `--version`, `--help` and `--ceg-check`
short-circuit before the probe.
---
## 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. |
| Exit check | Address and country verified before every start; any mismatch, timeout or error refuses with exit 77 |
| Proxy environment | All six proxy variables exported to the client and every descendant |
| Kernel jail | `IPAddressDeny=any` for the whole process tree; only loopback, the allow list and explicitly allowed CIDRs stay reachable |
| Entry-point coverage | `/usr/local/bin/claude` and `/usr/bin/claude` are both the gate; the real binary lives under `/opt/claude-real` |
| Self-heal | Package self-updates that rewrite `/usr/bin/claude` are repaired on the next invocation |
| Fail-closed | Missing config, missing checker, proxy down, wrong exit, wrong country, no `systemd-run`, not root with `STRICT_NET=1`: all refuse |
| Audit trail | With `LOG_REFUSALS=1`, every refusal goes to journald under the `claude-egress-guard` tag |
| Reversibility | Every replaced file is backed up under `/var/lib/claude-egress-guard/backup/<timestamp>/`; `uninstall` restores it |
---
## Requirements and compatibility
| Item | Requirement |
| --- | --- |
| OS | Linux with systemd 235+ (needed for `IPAddressDeny=`) |
| Shell | bash 4.3+ |
| Tools | `curl`, `install`, `readlink`, `mktemp`, `awk`, `sed` |
| Privileges | root for a real install; `--prefix` for an unprivileged sandbox |
| Claude Code | already installed, npm-global or native installer |
| Proxy | local HTTP or mixed endpoint (SOCKS-only is not usable through the client's proxy variables) |
Verified against: Debian 13 / systemd 257 / Claude Code 2.1.296 (npm-global),
with a residential HTTP proxy on loopback. CI runs the suite on Ubuntu 24.04.
Not verified, not supported: macOS and BSD, WSL, systemd older than 235, rootless
user scopes with `STRICT_NET=1`, Claude running inside containers.
**Cost per start:** one HTTPS round trip through the proxy (roughly 100-400 ms
depending on distance). `--version` and `--help` skip it.
---
## Commands
| Command | Purpose |
| --- | --- |
| `claude-egress-guard install ...` | plan, back up, install, print the next step (`--dry-run` supported) |
| `claude-egress-guard verify` | static checks + live exit check |
| `claude-egress-guard verify --deep` | adds proof that the jail blocks direct egress and still allows the proxy |
| `claude-egress-guard status` | current policy with proxy credentials masked |
| `claude-egress-guard set-exit --ip ADDR [--country CC]` | follow a rotating exit: probe first, rewrite policy only if the proxy serves that address, then re-verify |
| `claude-egress-guard drill --service UNIT` | stop the proxy unit, prove the gate refuses, restart, wait for recovery |
| `claude-egress-guard show <guard\|check\|config>` | print the installed gate, checker or policy for audit |
| `claude-egress-guard uninstall` | restore the newest backup and remove the gate |
Exit codes: `0` success, `1` verification failed, `2` usage error, `77` the gate
refused to start.
---
## Rotating or changing the exit
Residential exits move. Do not hand-edit the config; ask the gate to follow:
```bash
sudo claude-egress-guard set-exit --ip 198.51.100.23 --country JP
```
It probes the proxy with the candidate address first, refuses if the proxy does
not serve it, rewrites `EXPECTED_IP`/`EXPECTED_COUNTRY` atomically with mode
0600, then runs `verify`.
If the exit moved and you do not know the new address:
```bash
curl -4 -fsS --max-time 8 -x http://127.0.0.1:1080 https://www.cloudflare.com/cdn-cgi/trace
```
Take `ip=` and `loc=` from that output.
---
## Refusal logging
Refusals are otherwise visible only to whoever is watching the terminal. Turn on
journald logging once:
```bash
sudo sed -i 's/^LOG_REFUSALS=0/LOG_REFUSALS=1/' /etc/claude-egress-guard.conf
```
Then:
```bash
journalctl -t claude-egress-guard --since -24h
```
Each entry is `refused: <reason>`. This is the difference between "I think it
never refused" and "here are the eleven times it did".
---
## Everyday operations
| Event | Action |
| --- | --- |
| `npm i -g` or Claude Code self-update | `sudo claude-egress-guard verify --deep` (the alt path is repaired automatically) |
| Proxy exit changed | `sudo claude-egress-guard set-exit --ip NEW --country CC` |
| Proxy moved to a remote relay | add the relay address with `--allow`, then `verify --deep` |
| Want no kernel jail | set `STRICT_NET=0`, then `verify` (expect a WARN); process-tree egress is no longer enforced |
| Suspect a bypass | `status`, then inspect `/proc/<pid>/cgroup` for a `run-r...scope` |
---
## 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.
- **It does not create or manage the proxy.** You supply an endpoint that
already exits where you want, and you keep it alive.
- **It gates one binary.** `npx @anthropic-ai/claude-code` runs the package
without touching `/usr/local/bin/claude` and is therefore not gated; remove
that habit or wrap it yourself. A second Claude installation in another prefix
is also ungated until you gate or remove it.
- **It is not a VPN.** Host services, package managers and other users are
unaffected; the jail applies only to the Claude process tree.
- **It cannot stop root.** Anyone with root can set `STRICT_NET=0`, edit the
config or run `/opt/claude-real/claude` directly. This is a guard rail, not a
mandatory access control boundary.
- **It does not cover containers.** Docker and Kubernetes traffic does not pass
through the host's output path.
- **It does not make an account safe.** Registration, payment, identity checks
and provider decisions stay with the account owner; nothing here is meant to
evade a provider's terms.
## Requirements
More detail, including the full threat model: [SECURITY.md](SECURITY.md).
- 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
## Manual install from a checkout
```bash
git clone https://github.com/yinyues2/claude-egress-guard.git
cd claude-egress-guard/skills/claude-egress-guard/scripts
cd claude-egress-guard
# 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
# one command, same flags as the curl form
sudo ./install.sh --proxy http://127.0.0.1:1080 --exit-ip 203.0.113.10 --country JP
# 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
# or drive the CLI directly
sudo skills/claude-egress-guard/scripts/claude-egress-guard install \
--proxy http://127.0.0.1:1080 --exit-ip 203.0.113.10 --country JP --dry-run
```
`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
make check # shellcheck + 42 self-tests + leak scan
make test # sandboxed suite only (no root, no network)
make lint
make scan
```
CI runs the same checks on every push.
The suite installs into a throwaway prefix with fake `curl`, `id`, `logger` and
`systemd-run`, so it neither needs root nor touches the host.
## License
---
Apache-2.0. See [LICENSE](LICENSE).
## Repository layout
## Security and privacy
```
install.sh one-command installer (tag-pinned download)
AGENTS.md machine entry point for agent runners
llms.txt link index for LLM tooling
skills/claude-egress-guard/
SKILL.md the deployment skill
references/ playbook and troubleshooting
scripts/claude-egress-guard installer/verifier/drill/uninstall CLI
scripts/guard-claude.sh the gate (installed as `claude`)
scripts/egress-check.sh exit checker (standalone runnable)
scripts/selftest.sh 42 sandboxed assertions
templates/ example policy file
tools/leak-scan.py blocks credentials, PII and non-documentation IPs
```
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).
---
## License, security, privacy
Apache-2.0 — see [LICENSE](LICENSE). Security model and reporting:
[SECURITY.md](SECURITY.md). Data collection: [PRIVACY.md](PRIVACY.md) — short
version: none, no telemetry, no network calls other than your own proxy probe.
+256 -63
View File
@@ -1,104 +1,297 @@
# claude-egress-guard(中文说明)
**给 Linux 上的 Claude Code 加一道「出口不对就不许启动」的闸门,打包成 agent 可用的 skill。**
**给 agent 用的部署 skill,也是给人用的一键安装脚本:把 Linux 上的 Claude Code
钉死在一个出口上,出口不对就拒绝启动。**
[![ci](https://github.com/yinyues2/claude-egress-guard/actions/workflows/ci.yml/badge.svg)](https://github.com/yinyues2/claude-egress-guard/actions/workflows/ci.yml)
在 VPS 上跑 Claude Code,最容易出问题的不是安装,而是**出口泄漏**:写在
`/etc/profile.d` 里的 `claude()` 函数只覆盖交互式 shell,Python、`sh -c`、
cron、systemd 和任何直接调用 `/usr/bin/claude` 的路径都会绕过去;代理中途挂掉时,
客户端还会悄悄从机房 IP 直连出去。
这个项目做三件事:
这个项目在**二进制层**补上这个洞:`claude` 命令本身变成闸门,启动前核验代理的真实
出口地址,注入代理变量,并可选地把整个进程树关进内核级 IP 白名单。任何异常都拒绝
启动,没有「继续执行」的分支。
1. 每次启动前先核验代理的真实出口地址与国家,不符就**拒绝启动**(退出码 77);
2. 把代理变量注入给 Claude 及其所有子进程;
3. 可选(默认开启)用 systemd 的 `IPAddressDeny=any` 把整个 Claude 进程树关进
内核级网络白名单,只放行回环、代理上游、DNS 解析器和你显式允许的网段。
---
## 给 agent:从这里开始
这个仓库是给 agent(Hermes、Codex、Claude Code 或任何支持 skill 的运行器)消费的,
不只是给人读的 README。
```bash
# 把 skill 装进 agent 的 skills 目录(不需要 root,不改主机)
curl -fsSL https://raw.githubusercontent.com/yinyues2/claude-egress-guard/main/install.sh \
| bash -s -- --skill-only --dest ~/.hermes/skills
```
然后对 agent 说:
> 用 `claude-egress-guard` 这个 skill:先问我代理入口、预期出口地址、国家、额外网段,
> 然后安装、跑 `verify --deep`、做一次故障演练,最后把 PASS/FAIL 行贴给我。
skill 里写清的约束([`SKILL.md`](skills/claude-egress-guard/SKILL.md)):
- **只问 4 个事实**——代理入口、预期出口地址、国家、额外白名单;其余从主机自己读,不重复追问;
- **三档授权**——只读诊断直接做;安装/验证/演练/卸载整计划批一次;可能切断操作者自身访问的操作当场确认;
- **证据而非形容词**——每条结论都要贴对应的 PASS 行,演练结果要记录,没输出不算通过;
- **硬性禁止**——不停 ssh/tailscale/docker 单元,不把 `ALLOW_IPS` 放大到 `0.0.0.0/0`,不为通过检查而关闭某一层,不碰账号/付款/身份核验。
配套文件:[`AGENTS.md`](AGENTS.md)(机器入口)、
[`references/agent-playbook.md`](skills/claude-egress-guard/references/agent-playbook.md)
(带判断点的分步手册)、[`llms.txt`](llms.txt)(给 LLM 工具用的链接索引)、
[`references/troubleshooting.md`](skills/claude-egress-guard/references/troubleshooting.md)。
---
## 一键安装
```bash
curl -fsSL https://raw.githubusercontent.com/yinyues2/claude-egress-guard/main/install.sh \
| sudo bash -s -- --proxy http://127.0.0.1:1080 --exit-ip 203.0.113.10 --country JP
```
这条命令会下载固定版本的发布包、打印安装计划、安装闸门、备份被替换的文件,最后自动跑
`verify --deep`。任何一层失败就停下并指出是哪一层,不会把装了一半的闸门当成功。
| 参数 | 含义 |
| --- | --- |
| `--proxy URL` | 本地 HTTP/mixed 代理入口(必填) |
| `--exit-ip ADDR` | 代理出口的公网 IPv4(必填) |
| `--country CC` | 要强制的两位国家码(可选) |
| `--allow LIST` | 内核白名单里额外放行的网段,逗号分隔 |
| `--strict-net on\|off` | 是否给进程树加内核白名单(默认 on) |
| `--dry-run` | 只打印计划,不写任何东西 |
| `--yes` | 跳过确认提示(自动化用) |
| `--ref REF` | 指定 tag 或分支(默认固定 tag) |
| `--uninstall` | 移除闸门并从备份恢复 |
| `--skill-only` | 只把 skill 复制到 skills 目录 |
`203.0.113.10` 是文档专用地址(RFC 5737),请换成你代理实际报告的地址。回环和主机
IPv4 DNS 解析器会自动放行;代理上游、Tailscale 网段、内网 API 用 `--allow` 显式加。
**给 root 之前先读一遍。** `install.sh` 是不混淆的纯 shell 脚本,下载的是固定 tag 的
压缩包,不是移动的分支。也可以先 clone 再 `sudo ./install.sh ...`。
卸载走同一条通道:
```bash
curl -fsSL https://raw.githubusercontent.com/yinyues2/claude-egress-guard/main/install.sh \
| sudo bash -s -- --uninstall
```
---
## 装好之后是什么样
```
claude(任意入口)
└─ /usr/local/bin/claude 闸门:核验出口 -> 注入代理 -> 内核白名单
├─ /usr/bin/claude 同一份闸门(npm 更新会改回符号链接,闸门会自愈)
$ sudo claude-egress-guard verify --deep
PASS config present: /etc/claude-egress-guard.conf
PASS real binary executable
PASS gate installed: /usr/local/bin/claude
PASS gate mirrored: /usr/bin/claude
PASS egress check: ok exit=203.0.113.10 country=JP
PASS kernel jail enabled, allow list: 127.0.0.0/8 ::1/128 192.0.2.53/32
PASS systemd-run available
PASS IPv6 disabled system-wide
PASS jail blocks direct egress
PASS jail still allows the proxy path
---- 0 check(s) failed
```
每一行都是实测观察,不是「配置里写了」:最后两行是真的在白名单里发请求验证的。
```
claude(任意入口:shell、Python、cron、systemd、绝对路径)
└─ /usr/local/bin/claude 闸门:核验出口 -> 注入代理 -> 内核白名单
├─ /usr/bin/claude 同一份闸门(npm 更新会改回符号链接,闸门自愈)
└─ /opt/claude-real/claude 真身(符号链接,跟随包更新)
└─ 你的代理 127.0.0.1:1080 -> 预期的出口地址
└─ 你的代理 127.0.0.1:1080 -> 预期出口地址
```
## 不做什么
每次启动的顺序:
- 不创建、不配置、不监控代理本身,你提供已经能在目标地区落地的 HTTP/mixed 入口;
- 不是 VPN,不影响主机上其他服务,只管 Claude Code;
- 不保证账号安全,也不提供任何绕过服务商条款的功能;注册、付款、身份核验都由账号本人完成;
- 不做 IP 轮换、不重试、不回落直连。
1. 读 `/etc/claude-egress-guard.conf`,缺失或不可读 → 拒绝(77);
2. 如果包更新把 `/usr/bin/claude` 改回符号链接,先补回闸门;
3. 问代理要真实出口地址和国家,不符/超时/HTTP 错误 → 拒绝(77),不重试、不回落直连;
4. 把 `ALL_PROXY`/`http_proxy`/`https_proxy`(大小写)导出给客户端和所有子进程,`NO_PROXY` 只留回环;
5. `STRICT_NET=1` 时,在临时 systemd scope 里以 `IPAddressDeny=any` + 白名单 exec,
子进程继承的是内核级限制,不是口头承诺。
## 快速开始
诊断路径不碰网络:`--version`、`--help`、`--ceg-check` 在探测之前就短路返回。
```bash
git clone https://github.com/yinyues2/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
| 层 | 行为 |
| --- | --- |
| 出口核验 | 每次启动前核验地址和国家;不符、超时、报错一律退出码 77 |
| 代理环境 | 六个代理变量导出给客户端和全部子进程 |
| 内核白名单 | 整个进程树 `IPAddressDeny=any`,只放行回环、白名单和显式允许的网段 |
| 入口覆盖 | `/usr/local/bin/claude` 和 `/usr/bin/claude` 都是闸门,真身放在 `/opt/claude-real` |
| 自愈 | 包自更新改写的 `/usr/bin/claude` 会在下一次调用时补回 |
| fail-closed | 配置缺失、检查器缺失、代理不通、出口不符、国家不符、没有 `systemd-run`、非 root 且 `STRICT_NET=1`:全部拒绝 |
| 审计线索 | `LOG_REFUSALS=1` 时每次拒绝写入 journald,tag `claude-egress-guard` |
| 可回滚 | 被替换的文件都在 `/var/lib/claude-egress-guard/backup/<时间戳>/`,`uninstall` 恢复 |
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 解析器会自动加入白名单。
## 环境要求与兼容性
## 常用命令
| 项目 | 要求 |
| --- | --- |
| 系统 | Linux + systemd 235+(`IPAddressDeny=` 需要) |
| Shell | bash 4.3+ |
| 工具 | `curl`、`install`、`readlink`、`mktemp`、`awk`、`sed` |
| 权限 | 正式安装要 root;`--prefix` 可做无特权沙箱 |
| Claude Code | 已安装,npm 全局或官方安装器均可 |
| 代理 | 本地 HTTP 或 mixed 入口(只有 SOCKS 的话,客户端代理变量用不了) |
```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 # 从备份恢复并移除
```
已实测:Debian 13 / systemd 257 / Claude Code 2.1.296(npm 全局),代理在回环上的
住宅 HTTP 入口。CI 在 Ubuntu 24.04 上跑全套。
`drill` 会停掉代理单元、证明闸门拒绝启动、再拉起并等待恢复;名字里带 ssh、
tailscale、docker 的单元一律拒绝操作,中途失败也会把单元拉回运行状态。
未验证、不支持:macOS/BSD、WSL、systemd 235 以下、非 root 用户 scope 下开
`STRICT_NET=1`、容器内的 Claude。
**每次启动成本**:一次经代理的 HTTPS 往返(视距离约 100-400 ms);`--version`/`--help` 不探测。
---
## 命令
| 命令 | 用途 |
| --- | --- |
| `install ...` | 打印计划、备份、安装(支持 `--dry-run`) |
| `verify` | 静态检查 + 实时出口核验 |
| `verify --deep` | 额外证明「白名单挡住直连、仍放行代理」 |
| `status` | 查看当前策略(代理凭据打码) |
| `set-exit --ip ADDR [--country CC]` | 跟随变化的出口:先探测,代理确实在该地址才改策略,再自动 verify |
| `drill --service UNIT` | 停代理单元,证明闸门拒绝,再拉起并等待恢复 |
| `show <guard\|check\|config>` | 打印已安装的闸门/检查器/策略,便于审计 |
| `uninstall` | 从最新备份恢复并移除闸门 |
退出码:`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
sudo claude-egress-guard set-exit --ip 198.51.100.23 --country JP
```
## 作为 agent skill 使用
它先用候选地址探测代理,代理确实在该地址才原子改写 `EXPECTED_IP`/`EXPECTED_COUNTRY`
(权限保持 0600),然后自动跑 `verify`;不爆料就拒绝,不会把没验证过的地址写进策略。
`skills/claude-egress-guard/SKILL.md` 是写给 agent 看的:如何只问 4 个必要事实、
如何按 A/B/C 三档授权推进、如何安装、验证、演练、留证,以及哪些操作绝对不碰。
把整个目录复制到你的 skills 目录即可(Hermes 用 `~/.hermes/skills/`,
Codex 用 `~/.codex/skills/`)。
不知道新地址时先看一眼:
```bash
curl -4 -fsS --max-time 8 -x http://127.0.0.1:1080 https://www.cloudflare.com/cdn-cgi/trace
```
取输出里的 `ip=` 和 `loc=`。
---
## 拒绝日志
拒绝默认只出现在终端,没人盯着就看不见。开一次 journald 记录:
```bash
sudo sed -i 's/^LOG_REFUSALS=0/LOG_REFUSALS=1/' /etc/claude-egress-guard.conf
```
之后:
```bash
journalctl -t claude-egress-guard --since -24h
```
每条是 `refused: <原因>`。这决定了你是「我觉得没拒绝过」还是「它拒绝过 11 次,都在这」。
---
## 日常维护
| 事件 | 动作 |
| --- | --- |
| `npm i -g` 或 Claude Code 自更新 | `sudo claude-egress-guard verify --deep`(备用路径会自动补回) |
| 代理出口变了 | `sudo claude-egress-guard set-exit --ip 新地址 --country CC` |
| 代理改走远程中转 | 用 `--allow` 加中转地址,再 `verify --deep` |
| 不要内核白名单 | 设 `STRICT_NET=0` 再 `verify`(会出现 WARN),进程树出口不再强制 |
| 怀疑有绕过 | `status`,然后看 `/proc/<pid>/cgroup` 里有没有 `run-r...scope` |
---
## 不做什么
- **不创建、不管理代理。** 入口要你自己提供并保持存活。
- **只闸一个二进制。** `npx @anthropic-ai/claude-code` 不经过 `/usr/local/bin/claude`,
因此不受保护;要么别这么用,要么自己再包一层。另一份装在别的前缀里的 Claude 同样不受保护。
- **不是 VPN。** 主机上其他服务、包管理器、其他用户不受影响,白名单只管 Claude 进程树。
- **挡不住 root。** 有 root 就能设 `STRICT_NET=0`、改配置或直接跑 `/opt/claude-real/claude`。
这是护栏,不是强制访问控制边界。
- **不覆盖容器。** Docker/Kubernetes 的流量不经过主机的出口路径。
- **不保证账号安全。** 注册、付款、身份核验和平台判定都由账号本人承担;这个项目不用于规避服务商条款。
完整威胁模型见 [SECURITY.md](SECURITY.md)。
---
## 从源码手动安装
```bash
git clone https://github.com/yinyues2/claude-egress-guard.git
cd claude-egress-guard
# 一条命令,参数和 curl 版一致
sudo ./install.sh --proxy http://127.0.0.1:1080 --exit-ip 203.0.113.10 --country JP
# 或者直接驱动 CLI
sudo skills/claude-egress-guard/scripts/claude-egress-guard install \
--proxy http://127.0.0.1:1080 --exit-ip 203.0.113.10 --country JP --dry-run
```
---
## 开发与测试
```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 # 提交前查隐私/凭据泄漏
make check # shellcheck + 42 项自测 + 泄漏扫描
make test # 只跑沙箱自测(不需要 root,不联网)
make lint
make scan
```
## 许可
自测在临时前缀里用假的 `curl`/`id`/`logger`/`systemd-run` 跑,不碰主机。
Apache-2.0,见 [LICENSE](LICENSE)。安全模型见 [SECURITY.md](SECURITY.md),
数据收集见 [PRIVACY.md](PRIVACY.md)(简短版:不收集任何东西)。
---
## 仓库结构
```
install.sh 一键安装(下载固定 tag)
AGENTS.md agent 运行器的机器入口
llms.txt 给 LLM 工具的链接索引
skills/claude-egress-guard/
SKILL.md 部署 skill 本体
references/ playbook 与故障排查
scripts/claude-egress-guard 安装/验证/演练/卸载 CLI
scripts/guard-claude.sh 闸门本体(安装为 `claude`)
scripts/egress-check.sh 出口检查器(可单独运行)
scripts/selftest.sh 42 项沙箱断言
templates/ 策略文件示例
tools/leak-scan.py 拦截凭据、个人标识和非文档网段
```
---
## 许可、安全、隐私
Apache-2.0,见 [LICENSE](LICENSE)。安全模型与漏洞报告见 [SECURITY.md](SECURITY.md),
数据收集见 [PRIVACY.md](PRIVACY.md)(简短版:不收集任何东西,只探测你自己的代理)。
Executable
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env bash
# claude-egress-guard — one-shot installer.
#
# From a checkout: sudo ./install.sh --proxy URL --exit-ip ADDR
# From the network: curl -fsSL https://github.com/yinyues2/claude-egress-guard/raw/main/install.sh \
# | sudo bash -s -- --proxy URL --exit-ip ADDR
#
# The script downloads a pinned release tarball when it is not run from a
# checkout, verifies the expected layout, then hands over to the project CLI.
# It never edits system files itself.
set -u
REPO="yinyues2/claude-egress-guard"
PINNED_REF="v1.1.0"
SELF_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P)
WORK=""
cleanup() { [ -n "$WORK" ] && rm -rf "$WORK"; }
trap cleanup EXIT
say() { printf '%s\n' "$*"; }
err() { printf 'error: %s\n' "$*" >&2; }
die() {
err "$*"
exit 1
}
usage() {
cat <<'EOF'
claude-egress-guard installer
USAGE
install.sh --proxy URL --exit-ip ADDR [options]
install.sh --skill-only [--dest DIR]
install.sh --uninstall [--prefix DIR]
OPTIONS
--proxy URL local HTTP/mixed proxy endpoint (required to install)
--exit-ip ADDR public IPv4 the proxy must exit from (required)
--country CC two-letter country to enforce (optional)
--allow LIST extra CIDRs inside the kernel jail, comma separated
--strict-net MODE on|off kernel jail (default on)
--prefix DIR install under DIR instead of / (testing)
--ref REF release tag or branch to download (default: v1.1.0)
--dry-run show the install plan only
--yes do not prompt
--skill-only copy the agent skill into a skills directory instead
--dest DIR target for --skill-only (default: ~/.hermes/skills)
--uninstall remove the gate and restore the previous files
-h, --help this text
NOTES
The installer needs root for a real install because the gate writes to
/usr/local and /etc. --prefix works unprivileged and is what the test suite
uses. Nothing on the host is modified before the CLI prints its plan.
EOF
}
PROXY=""
EXIT_IP=""
COUNTRY=""
ALLOW=""
STRICT=""
PREFIX=""
REF="$PINNED_REF"
DRY="0"
ASSUME_YES="0"
SKILL_ONLY="0"
DEST="${HOME}/.hermes/skills"
UNINSTALL="0"
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="${2:-}"; shift 2 ;;
--strict-net) STRICT="${2:-}"; shift 2 ;;
--prefix) PREFIX="${2:-}"; shift 2 ;;
--ref) REF="${2:-}"; shift 2 ;;
--dry-run) DRY="1"; shift ;;
--yes | -y) ASSUME_YES="1"; shift ;;
--skill-only) SKILL_ONLY="1"; shift ;;
--dest) DEST="${2:-}"; shift 2 ;;
--uninstall) UNINSTALL="1"; shift ;;
-h | --help) usage; exit 0 ;;
*) die "unknown option: $1 (try --help)" ;;
esac
done
# ---------------------------------------------------------------------------
# Locate the project: a checkout next to this script, or a downloaded tarball.
# ---------------------------------------------------------------------------
project_root() {
if [ -x "$SELF_DIR/skills/claude-egress-guard/scripts/claude-egress-guard" ]; then
printf '%s\n' "$SELF_DIR"
return 0
fi
command -v curl >/dev/null 2>&1 || die "curl is required to download the project"
command -v tar >/dev/null 2>&1 || die "tar is required to unpack the project"
WORK=$(mktemp -d "${TMPDIR:-/tmp}/ceg-install.XXXXXX") || die "cannot create a temp dir"
local url
case "$REF" in
main | master | [0-9]*) url="https://codeload.github.com/$REPO/tar.gz/refs/heads/$REF" ;;
*) url="https://codeload.github.com/$REPO/tar.gz/refs/tags/$REF" ;;
esac
say "downloading $REPO@$REF"
curl -fsSL --max-time 120 --retry 2 "$url" -o "$WORK/src.tar.gz" ||
die "download failed: $url"
tar -xzf "$WORK/src.tar.gz" -C "$WORK" || die "cannot unpack the release tarball"
local root
root=$(find "$WORK" -maxdepth 1 -mindepth 1 -type d -name 'claude-egress-guard-*' | head -n 1)
[ -n "$root" ] || die "unexpected archive layout"
[ -x "$root/skills/claude-egress-guard/scripts/claude-egress-guard" ] ||
die "archive does not contain the expected scripts"
printf '%s\n' "$root"
}
confirm() {
[ "$ASSUME_YES" = "1" ] && return 0
if [ -r /dev/tty ]; then
local answer=""
printf '%s [y/N] ' "$1" >/dev/tty
read -r answer </dev/tty || answer=""
case "$answer" in
y | Y | yes | YES) return 0 ;;
*) die "aborted" ;;
esac
fi
die "no terminal for confirmation; re-run with --yes"
}
# ---------------------------------------------------------------------------
# --skill-only: copy the skill into an agent's skills directory.
# ---------------------------------------------------------------------------
if [ "$SKILL_ONLY" = "1" ]; then
root=$(project_root)
mkdir -p "$DEST" || die "cannot create $DEST"
rm -rf "${DEST:?}/claude-egress-guard"
cp -R "$root/skills/claude-egress-guard" "$DEST/" || die "copy failed"
say "skill installed: $DEST/claude-egress-guard"
say "ask your agent to load the 'claude-egress-guard' skill and follow SKILL.md"
exit 0
fi
# ---------------------------------------------------------------------------
# --uninstall
# ---------------------------------------------------------------------------
if [ "$UNINSTALL" = "1" ]; then
cli=""
if [ -n "$PREFIX" ]; then
[ -x "$PREFIX/usr/local/sbin/claude-egress-guard" ] && cli="$PREFIX/usr/local/sbin/claude-egress-guard"
else
[ -x /usr/local/sbin/claude-egress-guard ] && cli="/usr/local/sbin/claude-egress-guard"
fi
if [ -z "$cli" ]; then
root=$(project_root)
cli="$root/skills/claude-egress-guard/scripts/claude-egress-guard"
fi
confirm "Remove the gate and restore the previous binaries?"
if [ -n "$PREFIX" ]; then
exec "$cli" uninstall --prefix "$PREFIX"
fi
exec "$cli" uninstall
fi
# ---------------------------------------------------------------------------
# install
# ---------------------------------------------------------------------------
[ -n "$PROXY" ] || die "--proxy is required (try --help)"
[ -n "$EXIT_IP" ] || die "--exit-ip is required (try --help)"
case "$PROXY" in
http://* | https://*) ;;
*) die "--proxy must start with http:// or https://" ;;
esac
if [ -z "$PREFIX" ] && [ "$(id -u)" -ne 0 ]; then
die "a real install needs root; re-run with sudo, or use --prefix for a sandbox"
fi
root=$(project_root)
cli="$root/skills/claude-egress-guard/scripts/claude-egress-guard"
args=(install --proxy "$PROXY" --exit-ip "$EXIT_IP")
[ -n "$COUNTRY" ] && args+=(--country "$COUNTRY")
[ -n "$ALLOW" ] && args+=(--allow "$ALLOW")
[ -n "$STRICT" ] && args+=(--strict-net "$STRICT")
[ -n "$PREFIX" ] && args+=(--prefix "$PREFIX")
[ "$DRY" = "1" ] && args+=(--dry-run)
"$cli" "${args[@]}" || die "install failed"
if [ "$DRY" = "1" ]; then
say "dry run only; re-run without --dry-run to apply"
exit 0
fi
say ""
say "running verify --deep ..."
if [ -n "$PREFIX" ]; then
"$cli" verify --deep --prefix "$PREFIX" || die "verify failed - do not use the gate until it passes"
else
"$cli" verify --deep || die "verify failed - do not use the gate until it passes"
fi
say ""
say "installed. next:"
say " claude-egress-guard status"
say " claude-egress-guard drill --service <your-proxy-unit>"
say " journalctl -t claude-egress-guard -f # when LOG_REFUSALS=1"
+28
View File
@@ -0,0 +1,28 @@
# claude-egress-guard
> A deployment skill for agents, plus a one-command installer, that pins Claude
> Code on a Linux host to one verified egress and refuses to start otherwise.
## Docs
- [Skill](https://github.com/yinyues2/claude-egress-guard/blob/main/skills/claude-egress-guard/SKILL.md): deployment contract — four facts, three authorization tiers, install/verify/drill, guardrails
- [Agent playbook](https://github.com/yinyues2/claude-egress-guard/blob/main/skills/claude-egress-guard/references/agent-playbook.md): step-by-step commands, allow-list rules, evidence record
- [Troubleshooting](https://github.com/yinyues2/claude-egress-guard/blob/main/skills/claude-egress-guard/references/troubleshooting.md): symptom → cause → fix
- [AGENTS.md](https://github.com/yinyues2/claude-egress-guard/blob/main/AGENTS.md): machine entry point
- [README](https://github.com/yinyues2/claude-egress-guard/blob/main/README.md): overview, one-command install, compatibility, limitations
- [SECURITY.md](https://github.com/yinyues2/claude-egress-guard/blob/main/SECURITY.md): threat model and scope
- [PRIVACY.md](https://github.com/yinyues2/claude-egress-guard/blob/main/PRIVACY.md): no telemetry, no data collection
## Install
- Skill only: `curl -fsSL https://raw.githubusercontent.com/yinyues2/claude-egress-guard/main/install.sh | bash -s -- --skill-only --dest ~/.hermes/skills`
- Full install: `curl -fsSL https://raw.githubusercontent.com/yinyues2/claude-egress-guard/main/install.sh | sudo bash -s -- --proxy URL --exit-ip ADDR [--country CC]`
- Verify: `sudo claude-egress-guard verify --deep`
## Facts
- Requires Linux with systemd 235+, bash 4.3+, root for a real install.
- Gated paths: `/usr/local/bin/claude` and `/usr/bin/claude`; real binary at `/opt/claude-real/claude`.
- Exit codes: 0 ok, 1 verification failed, 2 usage, 77 refused.
- Not covered: `npx` invocations, other installs, containers, root.
- License: Apache-2.0.
+35 -1
View File
@@ -1,7 +1,7 @@
---
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
version: 1.1.0
author: claude-egress-guard contributors
license: Apache-2.0
platforms: [linux]
@@ -60,6 +60,16 @@ to the session, C when it is the only path to the host.
## Procedure
If you have the full repository, `install.sh` wraps the same sequence and is the
preferred entry point. It prints the plan, installs, backs up, then runs
`verify --deep` and stops on the first failed layer:
```bash
sudo ./install.sh --proxy "$PROXY" --exit-ip "$EXIT_IP" [--country "$CC"] [--allow "$CIDRS"]
```
With only the skill directory installed, drive the CLI directly:
```bash
S=./skills/claude-egress-guard/scripts
@@ -98,6 +108,30 @@ What the operator should see when it is working:
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`.
## Changing policy
- **Rotating exit.** `sudo claude-egress-guard set-exit --ip NEW [--country CC]`
probes the proxy with the candidate address first, refuses if the proxy does
not serve it, rewrites the policy atomically (mode 0600) and re-runs `verify`.
Never hand-edit `EXPECTED_IP` from a number the operator guessed.
- **Audit trail.** Set `LOG_REFUSALS=1` in `/etc/claude-egress-guard.conf`, then
read `journalctl -t claude-egress-guard`. Prefer this over asking the operator
"did it ever refuse?".
- **Anything else** (allow list, jail on/off) is a B-tier edit followed by
`verify --deep`.
## Out of scope
State these plainly instead of implying coverage:
- `npx @anthropic-ai/claude-code`, and any second Claude installation outside the
gated paths.
- Containers whose traffic does not use the host's output path.
- Root on the host: root can disable the gate, edit the policy or run the real
binary directly.
- macOS, BSD, WSL, systemd older than 235.
- SOCKS-only proxies: the client's proxy variables need an HTTP/mixed endpoint.
## Guardrails
- Never stop a unit whose name matches ssh, tailscale or docker; `drill` refuses
@@ -57,6 +57,16 @@ Rules of thumb:
## 3. Install
If you have the whole repository, `install.sh` is the shortest path (it fetches a
tag-pinned release when run outside a checkout, then calls the CLI and
`verify --deep`):
```bash
sudo ./install.sh --proxy "$PROXY" --exit-ip "$EXIT_IP" --country "$CC" --allow "$EXTRA_CIDRS"
```
Otherwise drive the CLI directly:
```bash
sudo claude-egress-guard install \
--proxy "$PROXY" \
@@ -135,11 +145,35 @@ package: <claude --version output>
| 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 exit address changed | `set-exit --ip NEW [--country CC]`; it refuses an address the proxy does not serve |
| 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` |
## 8. Follow a rotating exit
Residential exits change without warning. Never hand-edit `EXPECTED_IP` with a
number the operator guessed:
```bash
sudo claude-egress-guard set-exit --ip "$NEW_IP" --country "$CC"
```
The command copies the policy to a temporary file with the candidate address,
runs the checker against it, and only rewrites the real file when the proxy
actually serves that address. It then re-runs `verify`. If it refuses, the exit
did not move — check the proxy, not the policy.
## 9. Turn on refusal logging
```bash
sudo sed -i 's/^LOG_REFUSALS=0/LOG_REFUSALS=1/' /etc/claude-egress-guard.conf
sudo journalctl -t claude-egress-guard --since -24h
```
Report the count of refusals in the evidence record. A gate that refuses often
is telling you the proxy is unstable, not that the gate is broken.
## Failure handling
- A failed layer is never resolved by disabling the layer. Fix the layer or
@@ -15,6 +15,12 @@
| `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 |
| `npx @anthropic-ai/claude-code` works and still leaves from the host address | `npx` runs the package directly, never `/usr/local/bin/claude` | Stop using `npx` on that host, or wrap it with its own egress check |
| A proxy in `~/.claude/settings.json` seems to win | Client settings are applied after the gate exports its variables | Remove the proxy from the client settings; the gate owns egress |
| Claude in Docker/Kubernetes is not gated | Container traffic does not use the host's output path | Run Claude on the host, or gate inside the container |
| `set-exit` refuses with `proxy does not serve ...` | The proxy is not exiting from that address right now | Check the proxy; never force the value into the config by hand |
| `journalctl -t claude-egress-guard` is empty | `LOG_REFUSALS=0`, or nothing has been refused | Set it to 1; entries only appear when a refusal happens |
| `install.sh` says `no terminal for confirmation` | Piped stdin cannot answer the prompt | Re-run with `--yes` |
## Reading `verify` output
@@ -14,7 +14,7 @@
# Everything it replaces is copied to the backup directory first.
set -u
VERSION="1.0.0"
VERSION="1.1.0"
MARKER="claude-egress-guard"
CONF_REL="/etc/claude-egress-guard.conf"
@@ -91,6 +91,7 @@ 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 set-exit --ip ADDR [--country CC] [--prefix DIR]
claude-egress-guard drill --service UNIT [--prefix DIR]
claude-egress-guard uninstall [--prefix DIR]
claude-egress-guard show <guard|check|config>
@@ -112,6 +113,10 @@ INSTALL OPTIONS
VERIFY OPTIONS
--deep also prove the jail blocks direct egress and allows the proxy
SET-EXIT OPTIONS
--ip ADDR new expected exit address; must match what the proxy reports now
--country CC new country code (optional)
DRILL OPTIONS
--service UNIT systemd unit of the proxy core (stopped and restarted)
--wait SECONDS how long to wait for recovery (default 30)
@@ -281,6 +286,7 @@ cmd_install() {
printf 'REAL_BIN=%q\n' "$realdir/claude"
printf 'CHECK_BIN=%q\n' "$lib/egress-check.sh"
printf 'ALT_PATH=%q\n' "$alt_conf"
printf 'LOG_REFUSALS=0\n'
printf 'CHECK_TIMEOUT=6\n'
printf 'TRACE_URL="https://www.cloudflare.com/cdn-cgi/trace"\n'
printf 'FALLBACK_URL="https://ipinfo.io/json"\n'
@@ -424,11 +430,80 @@ cmd_status() {
say "real binary : ${REAL_BIN:-unset}"
say "gate : $(p "$BIN_REL")/claude"
say "alt path : ${ALT_PATH:-none}"
say "refusal log : ${LOG_REFUSALS:-0} (1 = journald, tag claude-egress-guard)"
if [ -x "${CHECK_BIN:-/nonexistent}" ]; then
say "egress : $(CEG_CONF="$conf" "$CHECK_BIN" 2>&1 || true)"
fi
}
# ---------------------------------------------------------------------------
# set-exit — follow a rotating proxy exit without hand-editing policy
# ---------------------------------------------------------------------------
cmd_set_exit() {
local ip="" country=""
while [ $# -gt 0 ]; do
case "$1" in
--ip) ip="${2:-}"; shift 2 ;;
--country) country="${2:-}"; shift 2 ;;
--prefix) PREFIX="${2:-}"; shift 2 ;;
-h | --help) usage; exit 0 ;;
*) die "unknown set-exit option: $1" ;;
esac
done
[ -n "$ip" ] || die "--ip is required"
valid_ipv4 "$ip" || die "--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
local conf out tmp probe
conf=$(p "$CONF_REL")
[ -r "$conf" ] || die "config not found: $conf"
# shellcheck source=/dev/null
. "$conf"
[ -x "${CHECK_BIN:-/nonexistent}" ] || die "checker missing: ${CHECK_BIN:-unset}"
# The checker validates against the current policy, so probe with a temporary
# copy carrying the candidate address: set-exit follows a rotating exit, it
# does not whitelist an address nobody has verified.
probe=$(mktemp "${TMPDIR:-/tmp}/ceg-probe.XXXXXX") || die "cannot create a probe file"
awk -v ip="$ip" -v cc="$country" '
/^EXPECTED_IP=/ { print "EXPECTED_IP=\"" ip "\""; next }
/^EXPECTED_COUNTRY=/ && cc != "" { print "EXPECTED_COUNTRY=\"" cc "\""; next }
{ print }
' "$conf" >"$probe" || {
rm -f "$probe"
die "cannot prepare the probe"
}
if ! out=$(CEG_CONF="$probe" "$CHECK_BIN" 2>&1); then
rm -f "$probe"
die "proxy does not serve $ip right now: $out"
fi
rm -f "$probe"
say "probe: $out"
tmp="$conf.tmp"
awk -v ip="$ip" -v cc="$country" '
BEGIN { ipseen = 0; ccseen = 0 }
/^EXPECTED_IP=/ { print "EXPECTED_IP=\"" ip "\""; ipseen = 1; next }
/^EXPECTED_COUNTRY=/ {
ccseen = 1
if (cc != "") { print "EXPECTED_COUNTRY=\"" cc "\""; next }
}
{ print }
END {
if (!ipseen) print "EXPECTED_IP=\"" ip "\""
if (cc != "" && !ccseen) print "EXPECTED_COUNTRY=\"" cc "\""
}
' "$conf" >"$tmp" || die "cannot rewrite $conf"
chmod 600 "$tmp"
mv -f -- "$tmp" "$conf"
say "policy updated: EXPECTED_IP=$ip${country:+ EXPECTED_COUNTRY=$country}"
cmd_verify
}
# ---------------------------------------------------------------------------
# drill
# ---------------------------------------------------------------------------
@@ -560,6 +635,7 @@ case "$cmd" in
install) cmd_install "$@" ;;
verify) cmd_verify "$@" ;;
status) cmd_status "$@" ;;
set-exit) cmd_set_exit "$@" ;;
drill) cmd_drill "$@" ;;
uninstall) cmd_uninstall "$@" ;;
show) cmd_show "$@" ;;
@@ -15,9 +15,19 @@ REFUSE=77
refuse() {
printf 'claude-egress-guard: %s\n' "$*" >&2
log_refusal "$*"
exit "$REFUSE"
}
# Refusals are silent to anyone not watching the terminal. With LOG_REFUSALS=1
# they also land in the journal, which is what turns "did it ever refuse?"
# into a query instead of a guess.
log_refusal() {
[ "${LOG_REFUSALS:-0}" = "1" ] || return 0
command -v logger >/dev/null 2>&1 || return 0
logger -t "$MARKER" -p daemon.warning -- "refused: $*" 2>/dev/null || true
}
[ -r "$CONF" ] || refuse "missing or unreadable $CONF - refusing to start"
# shellcheck source=/dev/null
. "$CONF" || refuse "cannot load $CONF - refusing to start"
@@ -161,6 +161,33 @@ 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 'set-exit / refusal logging\n'
# ---------------------------------------------------------------------------
FAKE_IP=198.51.100.9 "$CLI" set-exit --prefix "$PREFIX" --ip 198.51.100.9 >/dev/null 2>&1
expect_rc "set-exit accepts an address the proxy serves" 0 $?
grep -q 'EXPECTED_IP="198.51.100.9"' "$CONF" && ok "policy rewritten" || no "policy not rewritten"
FAKE_IP=203.0.113.10 "$CLI" set-exit --prefix "$PREFIX" --ip 192.0.2.77 >/dev/null 2>&1
expect_rc "set-exit refuses an address the proxy does not serve" 1 $?
grep -q 'EXPECTED_IP="198.51.100.9"' "$CONF" && ok "policy untouched after a refusal" || no "policy changed on a failed set-exit"
FAKE_IP=203.0.113.10 "$CLI" set-exit --prefix "$PREFIX" --ip 203.0.113.10 >/dev/null 2>&1
expect_rc "set-exit returns to the original exit" 0 $?
cat >"$BIN/logger" <<'EOF'
#!/bin/sh
printf '%s\n' "$*" >>"${FAKE_LOGGER_LOG:-/dev/null}"
EOF
chmod 0755 "$BIN/logger"
sed -i 's/^LOG_REFUSALS=0/LOG_REFUSALS=1/' "$CONF"
: >"$WORK/logger.log"
FAKE_IP=198.51.100.7 FAKE_LOGGER_LOG="$WORK/logger.log" run_gate --print-config >/dev/null 2>&1
expect_rc "refusal still exits 77 with logging on" 77 $?
grep -q 'refused:' "$WORK/logger.log" && ok "refusal written to the journald target" || no "refusal not logged"
sed -i 's/^LOG_REFUSALS=1/LOG_REFUSALS=0/' "$CONF"
# ---------------------------------------------------------------------------
printf 'verify / status / uninstall\n'
# ---------------------------------------------------------------------------
@@ -179,5 +206,21 @@ expect_rc "uninstall exits 0" 0 $?
[ ! -e "$CONF" ] && ok "config removed" || no "config left behind"
[ ! -e "$PREFIX/usr/local/bin/claude" ] && ok "gate removed" || no "gate left behind"
# ---------------------------------------------------------------------------
printf 'installer\n'
# ---------------------------------------------------------------------------
ROOT=$(CDPATH='' cd -- "$HERE/../../.." && pwd -P)
if [ -f "$ROOT/install.sh" ]; then
"$ROOT/install.sh" --help >/dev/null 2>&1
expect_rc "install.sh --help" 0 $?
"$ROOT/install.sh" --skill-only --dest "$WORK/skills" >/dev/null 2>&1
expect_rc "install.sh --skill-only" 0 $?
[ -f "$WORK/skills/claude-egress-guard/SKILL.md" ] && ok "skill copied to the target directory" || no "skill not copied"
"$ROOT/install.sh" --proxy http://127.0.0.1:1080 >/dev/null 2>&1
expect_rc "install.sh refuses a missing --exit-ip" 1 $?
else
printf ' skip install.sh (not a checkout)\n'
fi
printf '\n%d passed, %d failed\n' "$PASSED" "$FAILED"
[ "$FAILED" -eq 0 ]
@@ -33,6 +33,9 @@ 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"
# 1 = every refusal is also written to journald (tag: claude-egress-guard).
LOG_REFUSALS=0
# Seconds allowed for each probe.
CHECK_TIMEOUT=6