diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6802042..56678d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..28e4e24 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 79b0f14..6450377 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a328827 --- /dev/null +++ b/Makefile @@ -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}" diff --git a/README.md b/README.md index fae2e09..9482d11 100644 --- a/README.md +++ b/README.md @@ -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=`, 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//`; `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 ` | 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: `. 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//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//` 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. diff --git a/README.zh-CN.md b/README.zh-CN.md index c1aa8dd..33aa84c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 ` | 打印已安装的闸门/检查器/策略,便于审计 | +| `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//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)(简短版:不收集任何东西,只探测你自己的代理)。 diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..b7ff068 --- /dev/null +++ b/install.sh @@ -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 " +say " journalctl -t claude-egress-guard -f # when LOG_REFUSALS=1" diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..396e17c --- /dev/null +++ b/llms.txt @@ -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. diff --git a/skills/claude-egress-guard/SKILL.md b/skills/claude-egress-guard/SKILL.md index b777af9..4140584 100644 --- a/skills/claude-egress-guard/SKILL.md +++ b/skills/claude-egress-guard/SKILL.md @@ -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 diff --git a/skills/claude-egress-guard/references/agent-playbook.md b/skills/claude-egress-guard/references/agent-playbook.md index bde0b23..f18cf8e 100644 --- a/skills/claude-egress-guard/references/agent-playbook.md +++ b/skills/claude-egress-guard/references/agent-playbook.md @@ -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: | 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//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 diff --git a/skills/claude-egress-guard/references/troubleshooting.md b/skills/claude-egress-guard/references/troubleshooting.md index 4492a26..312164d 100644 --- a/skills/claude-egress-guard/references/troubleshooting.md +++ b/skills/claude-egress-guard/references/troubleshooting.md @@ -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 diff --git a/skills/claude-egress-guard/scripts/claude-egress-guard b/skills/claude-egress-guard/scripts/claude-egress-guard index c30b9d9..7d66ed0 100755 --- a/skills/claude-egress-guard/scripts/claude-egress-guard +++ b/skills/claude-egress-guard/scripts/claude-egress-guard @@ -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 @@ -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 "$@" ;; diff --git a/skills/claude-egress-guard/scripts/guard-claude.sh b/skills/claude-egress-guard/scripts/guard-claude.sh index 9b5a9af..11dddee 100755 --- a/skills/claude-egress-guard/scripts/guard-claude.sh +++ b/skills/claude-egress-guard/scripts/guard-claude.sh @@ -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" diff --git a/skills/claude-egress-guard/scripts/selftest.sh b/skills/claude-egress-guard/scripts/selftest.sh index 8e5e96c..bf6b61a 100755 --- a/skills/claude-egress-guard/scripts/selftest.sh +++ b/skills/claude-egress-guard/scripts/selftest.sh @@ -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 ] diff --git a/skills/claude-egress-guard/templates/claude-egress-guard.conf.example b/skills/claude-egress-guard/templates/claude-egress-guard.conf.example index 9a2379e..5ca7204 100644 --- a/skills/claude-egress-guard/templates/claude-egress-guard.conf.example +++ b/skills/claude-egress-guard/templates/claude-egress-guard.conf.example @@ -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