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:
1 parent
3693442954
commit
3fe43b7ddc
15 files changed
+1122
-210
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
@@ -2,183 +2,331 @@
|
||||
|
||||
[](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
@@ -1,104 +1,297 @@
|
||||
# claude-egress-guard(中文说明)
|
||||
|
||||
**给 Linux 上的 Claude Code 加一道「出口不对就不许启动」的闸门,打包成 agent 可用的 skill。**
|
||||
**给 agent 用的部署 skill,也是给人用的一键安装脚本:把 Linux 上的 Claude Code
|
||||
钉死在一个出口上,出口不对就拒绝启动。**
|
||||
|
||||
[](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
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user