#!/usr/bin/env bash
# claude-egress-guard — install, verify and drill a fail-closed egress gate for
# Claude Code on a Linux host.
#
# This program writes only these locations (all rooted at --prefix, default "/"):
#   <prefix>/etc/claude-egress-guard.conf
#   <prefix>/usr/local/lib/claude-egress-guard/{guard-claude.sh,egress-check.sh}
#   <prefix>/usr/local/bin/claude
#   <prefix>/usr/bin/claude              (unless --no-alt)
#   <prefix>/usr/local/sbin/claude-egress-guard
#   <prefix>/opt/claude-real/claude      (symlink to the real package binary)
#   <prefix>/var/lib/claude-egress-guard/backup/<timestamp>/...
#
# Everything it replaces is copied to the backup directory first.
set -u

VERSION="1.4.0"
MARKER="claude-egress-guard"

CONF_REL="/etc/claude-egress-guard.conf"
LIB_REL="/usr/local/lib/claude-egress-guard"
BIN_REL="/usr/local/bin"
SBIN_REL="/usr/local/sbin"
REAL_REL="/opt/claude-real"
STATE_REL="/var/lib/claude-egress-guard"

PREFIX=""
SELF_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P)

# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
say() { printf '%s\n' "$*"; }
err() { printf 'error: %s\n' "$*" >&2; }
die() {
	err "$*"
	exit 1
}
warn() { printf 'WARN   %s\n' "$*"; }
pass() { printf 'PASS   %s\n' "$*"; }
fail() {
	printf 'FAIL   %s\n' "$*"
	FAILED=$((FAILED + 1))
}

p() { printf '%s%s' "$PREFIX" "$1"; }

in_sandbox() { [ -n "$PREFIX" ]; }

need_root() {
	if ! in_sandbox && [ "$(id -u)" -ne 0 ]; then
		die "must run as root (or use --prefix for a sandboxed install)"
	fi
}

source_file() {
	# Locate a shipped script next to this one, or in the installed lib dir.
	local name="$1" dir
	for dir in "$SELF_DIR" "$(p "$LIB_REL")"; do
		if [ -r "$dir/$name" ]; then
			printf '%s\n' "$dir/$name"
			return 0
		fi
	done
	return 1
}

is_gate() {
	head -c 200 -- "$1" 2>/dev/null | grep -q -- "$MARKER"
}

valid_ipv4() {
	local ip="$1" o
	case "$ip" in
		*[!0-9.]* | .* | *. | *..*) return 1 ;;
	esac
	[ "$(printf '%s' "$ip" | awk -F. '{print NF}')" = "4" ] || return 1
	IFS=.
	for o in $ip; do
		[ "$o" -ge 0 ] 2>/dev/null && [ "$o" -le 255 ] 2>/dev/null || return 1
	done
	unset IFS
	return 0
}

usage() {
	cat <<'EOF'
claude-egress-guard - fail-closed egress gate for Claude Code (Linux)

USAGE
  claude-egress-guard install --proxy URL --exit-ip ADDR [options]
  claude-egress-guard verify [--deep] [--prefix DIR]
  claude-egress-guard status [--prefix DIR]
  claude-egress-guard preflight [--proxy URL] [--exit-ip ADDR] [--country CC] [--json]
  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>
  claude-egress-guard version

INSTALL OPTIONS
  --proxy URL        local HTTP/mixed proxy the client must use (required)
                     e.g. http://127.0.0.1:1080
  --exit-ip ADDR     public IPv4 the proxy must exit from (required)
  --country CC       two-letter country the exit must report (optional)
  --allow LIST       extra CIDR(s) reachable inside the jail, comma separated;
                     loopback and the host resolver are always added
  --strict-net MODE  on|off kernel jail for the Claude process tree (default on)
  --real PATH        real Claude binary (auto-detected when omitted)
  --native-path PATH gate the vendor installer's binary too, e.g.
                     ~/.local/bin/claude (it sits earlier in PATH than
                     /usr/local/bin, so an ungated copy defeats the gate)
  --no-alt           do not mirror the gate to <prefix>/usr/bin/claude
  --prefix DIR       install under DIR instead of / (used by tests)
  --dry-run          print the plan without writing anything

VERIFY OPTIONS
  --deep             also prove the jail blocks direct egress and allows the proxy

PREFLIGHT OPTIONS
  --proxy URL        also prove the proxy answers and report its exit
  --exit-ip ADDR     expected exit to compare against
  --country CC       expected country to compare against
  --json             machine-readable output

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)

EXIT CODES
  0 success | 1 check failed | 2 usage | 77 gate refused to start
EOF
}

# ---------------------------------------------------------------------------
# install
# ---------------------------------------------------------------------------
discover_real() {
	local explicit="$1" alt_conf="$2" native_conf="${3:-}" dir cand
	if [ -n "$explicit" ]; then
		[ -x "$explicit" ] || die "--real is not executable: $explicit"
		readlink -f -- "$explicit"
		return 0
	fi
	# The vendor installer's binary: ~/.local/bin/claude -> .../versions/<ver>
	if [ -n "$native_conf" ] && [ -L "$native_conf" ]; then
		cand=$(readlink -f -- "$native_conf" 2>/dev/null || true)
		if [ -n "$cand" ] && [ -x "$cand" ] && ! is_gate "$cand"; then
			printf '%s\n' "$cand"
			return 0
		fi
	fi
	if [ -n "$alt_conf" ] && [ -L "$alt_conf" ]; then
		cand=$(readlink -f -- "$alt_conf" 2>/dev/null || true)
		if [ -n "$cand" ] && [ -x "$cand" ] && ! is_gate "$cand"; then
			printf '%s\n' "$cand"
			return 0
		fi
	fi
	while IFS= read -r dir; do
		[ -n "$dir" ] || continue
		cand="$dir/claude"
		[ -x "$cand" ] || continue
		is_gate "$cand" && continue
		readlink -f -- "$cand"
		return 0
	done < <(printf '%s' "$PATH" | tr ':' '\n')
	return 1
}

resolver_allow() {
	local ns out=""
	[ -r /etc/resolv.conf ] || {
		printf '%s' ""
		return 0
	}
	for ns in $(awk '/^[[:space:]]*nameserver[[:space:]]+/ {print $2}' /etc/resolv.conf); do
		case "$ns" in
			*:*) continue ;; # IPv6 resolvers: skipped, CIDR formatting differs
			127.*) continue ;;
		esac
		valid_ipv4 "$ns" || continue
		out="$out $ns/32"
	done
	printf '%s' "$out"
}

cmd_install() {
	local proxy="" exit_ip="" country="" strict="on" real="" no_alt="0" dry="0"
	local allow_extra="" alt_conf="" native_path="" native_conf=""

	while [ $# -gt 0 ]; do
		case "$1" in
			--proxy) proxy="${2:-}"; shift 2 ;;
			--exit-ip) exit_ip="${2:-}"; shift 2 ;;
			--country) country="${2:-}"; shift 2 ;;
			--allow) allow_extra="$allow_extra ${2:-}"; shift 2 ;;
			--strict-net) strict="${2:-}"; shift 2 ;;
			--real) real="${2:-}"; shift 2 ;;
			--native-path) native_path="${2:-}"; shift 2 ;;
			--no-alt) no_alt="1"; shift ;;
			--prefix) PREFIX="${2:-}"; shift 2 ;;
			--dry-run) dry="1"; shift ;;
			-h | --help) usage; exit 0 ;;
			*) die "unknown install option: $1" ;;
		esac
	done

	[ -n "$proxy" ] || die "--proxy is required"
	[ -n "$exit_ip" ] || die "--exit-ip is required"
	case "$proxy" in
		http://* | https://*) ;;
		*) die "--proxy must be an http:// or https:// (mixed) endpoint" ;;
	esac
	valid_ipv4 "$exit_ip" || die "--exit-ip must be a valid IPv4 address"
	case "$country" in
		"" | [A-Za-z][A-Za-z]) ;;
		*) die "--country must be a two-letter code" ;;
	esac
	case "$strict" in
		on | off) ;;
		*) die "--strict-net must be on or off" ;;
	esac
	if [ "$strict" = "on" ] && ! in_sandbox; then
		command -v systemd-run >/dev/null 2>&1 || die "--strict-net on needs systemd-run"
	fi
	need_root

	local conf lib bindir sbin realdir state ts real_bin allow_all backup
	conf=$(p "$CONF_REL")
	lib=$(p "$LIB_REL")
	bindir=$(p "$BIN_REL")
	sbin=$(p "$SBIN_REL")
	realdir=$(p "$REAL_REL")
	state=$(p "$STATE_REL")
	allow_all="127.0.0.0/8 ::1/128$(resolver_allow)"
	if [ -n "$allow_extra" ]; then
		allow_all="$allow_all $(printf '%s' "$allow_extra" | tr ',' ' ')"
	fi
	allow_all=$(printf '%s' "$allow_all" | tr -s ' ' | sed 's/^ //;s/ $//')

	[ "$no_alt" = "1" ] && alt_conf="" || alt_conf=$(p "/usr/bin/claude")
	if [ -n "$native_path" ]; then
		native_conf=$(p "$native_path")
		[ -e "$native_conf" ] || [ -L "$native_conf" ] ||
			die "--native-path does not exist: $native_conf"
	fi

	real_bin=$(discover_real "$real" "$alt_conf" "$native_conf") ||
		die "no real claude binary found; pass --real /path/to/claude"

	local guard_src check_src
	guard_src=$(source_file guard-claude.sh) || die "guard-claude.sh not found next to this script"
	check_src=$(source_file egress-check.sh) || die "egress-check.sh not found next to this script"

	say "claude-egress-guard $VERSION - install plan"
	say "  prefix        : ${PREFIX:-/}"
	say "  proxy         : $proxy"
	say "  expected exit : $exit_ip${country:+ ($country)}"
	say "  real binary   : $real_bin"
	say "  strict jail   : $strict"
	say "  jail allow    : $allow_all"
	say "  gate paths    : $bindir/claude${alt_conf:+, $alt_conf}${native_conf:+, $native_conf}"
	say "  config        : $conf"
	if [ "$dry" = "1" ]; then
		say "dry run - nothing written"
		return 0
	fi

	mkdir -p "$lib" "$bindir" "$sbin" "$realdir" "$(dirname -- "$conf")" "$state/backup"
	ts=$(date -u +%Y%m%dT%H%M%SZ)
	backup="$state/backup/$ts"
	mkdir -p "$backup"

	# Back up whatever the gate is about to replace.
	local target
	for target in "$bindir/claude" "$alt_conf" "$native_conf"; do
		[ -n "$target" ] || continue
		[ -e "$target" ] || [ -L "$target" ] || continue
		is_gate "$target" && continue
		mkdir -p "$backup$(dirname -- "${target#"$PREFIX"}")"
		cp -a -- "$target" "$backup${target#"$PREFIX"}" || die "backup failed for $target"
		say "  backup        : $target -> $backup${target#"$PREFIX"}"
	done

	install -m 0755 -- "$guard_src" "$lib/guard-claude.sh"
	install -m 0755 -- "$check_src" "$lib/egress-check.sh"
	install -m 0755 -- "$lib/guard-claude.sh" "$bindir/claude"
	if [ -n "$alt_conf" ]; then
		mkdir -p -- "$(dirname -- "$alt_conf")"
		rm -f -- "$alt_conf"
		install -m 0755 -- "$lib/guard-claude.sh" "$alt_conf"
	fi
	if [ -n "$native_conf" ]; then
		mkdir -p -- "$(dirname -- "$native_conf")"
		rm -f -- "$native_conf"
		install -m 0755 -- "$lib/guard-claude.sh" "$native_conf"
	fi
	install -m 0755 -- "$0" "$sbin/claude-egress-guard"
	ln -sfn -- "$real_bin" "$realdir/claude"

	umask 077
	{
		printf '# claude-egress-guard configuration - keep this file mode 0600\n'
		printf '# Written by claude-egress-guard %s on %s\n' "$VERSION" "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
		printf 'PROXY=%q\n' "$proxy"
		printf 'EXPECTED_IP=%q\n' "$exit_ip"
		printf 'EXPECTED_COUNTRY=%q\n' "$country"
		printf 'STRICT_NET=%q\n' "$([ "$strict" = "on" ] && printf 1 || printf 0)"
		printf 'ALLOW_IPS=%q\n' "$allow_all"
		printf 'REAL_BIN=%q\n' "$realdir/claude"
		printf 'CHECK_BIN=%q\n' "$lib/egress-check.sh"
		printf 'ALT_PATH=%q\n' "$alt_conf"
		printf 'NATIVE_PATH=%q\n' "$native_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'
	} >"$conf.tmp"
	mv -f -- "$conf.tmp" "$conf"
	chmod 600 "$conf"

	say "installed."
	say "next: $(p "$SBIN_REL")/claude-egress-guard verify --deep"
}

# ---------------------------------------------------------------------------
# verify
# ---------------------------------------------------------------------------
cmd_verify() {
	local deep="0"
	while [ $# -gt 0 ]; do
		case "$1" in
			--deep) deep="1"; shift ;;
			--prefix) PREFIX="${2:-}"; shift 2 ;;
			-h | --help) usage; exit 0 ;;
			*) die "unknown verify option: $1" ;;
		esac
	done

	FAILED=0
	local conf bindir
	conf=$(p "$CONF_REL")
	lib=$(p "$LIB_REL")
	bindir=$(p "$BIN_REL")

	[ -r "$conf" ] && pass "config present: $conf" || fail "config missing: $conf"
	# shellcheck source=/dev/null
	[ -r "$conf" ] && . "$conf"

	if [ -x "${REAL_BIN:-/nonexistent}" ]; then
		pass "real binary executable"
	else
		fail "real binary not executable: ${REAL_BIN:-unset}"
	fi

	is_gate "$bindir/claude" && pass "gate installed: $bindir/claude" || fail "gate missing: $bindir/claude"
	if [ -n "${ALT_PATH:-}" ]; then
		if [ -L "$ALT_PATH" ]; then
			fail "alternative path is still a symlink (absolute-path callers bypass the gate): $ALT_PATH"
		elif is_gate "$ALT_PATH"; then
			pass "gate mirrored: $ALT_PATH"
		else
			fail "alternative path is not the gate: $ALT_PATH"
		fi
	fi
	if [ -n "${NATIVE_PATH:-}" ]; then
		if is_gate "$NATIVE_PATH"; then
			pass "vendor path gated: $NATIVE_PATH"
		elif [ -L "$NATIVE_PATH" ]; then
			fail "vendor path reverted to a symlink after an update: $NATIVE_PATH (start claude once to heal)"
		else
			fail "vendor path is not the gate: $NATIVE_PATH"
		fi
	fi

	# PATH precedence is the quiet failure: an ungated ~/.local/bin/claude wins
	# over the gate in every interactive shell, and nothing else notices.
	if [ -z "$PREFIX" ]; then
		local res
		if res=$(command -v claude 2>/dev/null) && [ -n "$res" ]; then
			if is_gate "$res"; then
				pass "PATH resolves to the gate: $res"
			else
				fail "PATH resolves to an ungated claude: $res (gate it with --native-path, or remove it)"
			fi
		else
			warn "claude is not on PATH"
		fi
	fi

	if [ -x "${CHECK_BIN:-/nonexistent}" ]; then
		if OUT=$(CEG_CONF="$conf" "$CHECK_BIN" 2>&1); then
			pass "egress check: $OUT"
		else
			fail "egress check: $OUT"
		fi
	else
		fail "egress checker missing: ${CHECK_BIN:-unset}"
	fi

	case "${STRICT_NET:-0}" in
		1)
			if [ -n "${ALLOW_IPS:-}" ] && [ -n "${ALLOW_IPS// /}" ]; then
				pass "kernel jail enabled, allow list: $ALLOW_IPS"
			else
				fail "kernel jail enabled but ALLOW_IPS is empty"
			fi
			command -v systemd-run >/dev/null 2>&1 && pass "systemd-run available" || fail "systemd-run missing"
			;;
		*)
			warn "kernel jail disabled (STRICT_NET=0) - process-tree egress is not enforced"
			;;
	esac

	if [ -r /proc/sys/net/ipv6/conf/all/disable_ipv6 ]; then
		if [ "$(cat /proc/sys/net/ipv6/conf/all/disable_ipv6)" = "1" ]; then
			pass "IPv6 disabled system-wide"
		else
			warn "IPv6 is enabled; make sure the proxy and the jail cover it"
		fi
	fi

	if [ "$(stat -c '%a' "$conf" 2>/dev/null || printf '')" != "600" ]; then
		warn "config file mode is not 0600"
	fi

	if [ "$deep" = "1" ] && [ "${STRICT_NET:-0}" = "1" ]; then
		local curl_bin="${CEG_CURL:-curl}" direct_rc proxy_rc
		if systemd-run --scope --quiet --collect -p IPAddressDeny=any \
			-p "IPAddressAllow=$ALLOW_IPS" -- \
			"$curl_bin" -4 -fsS --max-time 4 https://example.com/ >/dev/null 2>&1; then
			direct_rc=0
		else
			direct_rc=1
		fi
		[ "$direct_rc" -ne 0 ] && pass "jail blocks direct egress" ||
			fail "jail did NOT block direct egress - check ALLOW_IPS and systemd version"
		if systemd-run --scope --quiet --collect -p IPAddressDeny=any \
			-p "IPAddressAllow=$ALLOW_IPS" -- \
			"$curl_bin" -4 -fsS --max-time 6 \
			-x "${PROXY:-}" "${TRACE_URL:-https://www.cloudflare.com/cdn-cgi/trace}" >/dev/null 2>&1; then
			proxy_rc=0
		else
			proxy_rc=1
		fi
		[ "$proxy_rc" -eq 0 ] && pass "jail still allows the proxy path" ||
			fail "jail blocks the proxy path - add the proxy upstream to ALLOW_IPS"
	fi

	say "---- ${FAILED} check(s) failed"
	[ "$FAILED" -eq 0 ]
}

# ---------------------------------------------------------------------------
# status
# ---------------------------------------------------------------------------
mask_proxy() {
	printf '%s' "$1" | sed -E 's#(://)[^/@]*@#\1***@#'
}

cmd_status() {
	while [ $# -gt 0 ]; do
		case "$1" in
			--prefix) PREFIX="${2:-}"; shift 2 ;;
			-h | --help) usage; exit 0 ;;
			*) die "unknown status option: $1" ;;
		esac
	done
	local conf
	conf=$(p "$CONF_REL")
	[ -r "$conf" ] || die "config not found: $conf"
	# shellcheck source=/dev/null
	. "$conf"
	say "config        : $conf"
	say "proxy         : $(mask_proxy "${PROXY:-}")"
	say "expected exit : ${EXPECTED_IP:-unset}${EXPECTED_COUNTRY:+ (${EXPECTED_COUNTRY})}"
	say "strict jail   : ${STRICT_NET:-0}"
	say "jail allow    : ${ALLOW_IPS:-}"
	say "real binary   : ${REAL_BIN:-unset}"
	say "gate          : $(p "$BIN_REL")/claude"
	say "alt path      : ${ALT_PATH:-none}"
	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
}

# ---------------------------------------------------------------------------
# preflight — read-only readiness check, safe on a host with nothing installed
# ---------------------------------------------------------------------------
cmd_preflight() {
	local proxy="" expect="" country="" json="0" timeout="6"
	while [ $# -gt 0 ]; do
		case "$1" in
			--proxy) proxy="${2:-}"; shift 2 ;;
			--exit-ip) expect="${2:-}"; shift 2 ;;
			--country) country="${2:-}"; shift 2 ;;
			--json) json="1"; shift ;;
			--timeout) timeout="${2:-}"; shift 2 ;;
			-h | --help) usage; exit 0 ;;
			*) die "unknown preflight option: $1" ;;
		esac
	done

	PF_BLOCKERS=0
	PF_JSON=""
	pf_add() { # name status detail
		local name="$1" status="$2" detail="${3//\"/\'}"
		detail="${detail//$'\n'/ }"
		[ "$json" = "1" ] || printf '%-12s %-5s %s\n' "$name" "$status" "$detail"
		PF_JSON="$PF_JSON{\"name\":\"$name\",\"status\":\"$status\",\"detail\":\"$detail\"},"
		[ "$status" = "FAIL" ] && PF_BLOCKERS=$((PF_BLOCKERS + 1))
		return 0
	}

	[ "$json" = "1" ] || printf '%-12s %-5s %s\n' "check" "state" "detail"

	# Platform and privilege model.
	if [ "$(uname -s)" = "Linux" ]; then
		pf_add platform PASS "$(uname -sr)"
	else
		pf_add platform FAIL "$(uname -s) is not supported"
	fi
	if [ "$(id -u)" -eq 0 ]; then
		pf_add privileges PASS "root"
	else
		pf_add privileges WARN "not root: install and STRICT_NET=1 need root"
	fi

	# systemd 235+ is what provides IPAddressDeny=.
	if command -v systemctl >/dev/null 2>&1; then
		local sdv
		sdv=$(systemctl --version 2>/dev/null | awk 'NR==1 {print $2}')
		case "$sdv" in
			'' | *[!0-9]*) pf_add systemd FAIL "version unknown" ;;
			*)
				if [ "$sdv" -ge 235 ]; then
					pf_add systemd PASS "$sdv"
				else
					pf_add systemd FAIL "$sdv (need 235+)"
				fi
				;;
		esac
	else
		pf_add systemd FAIL "systemctl not found"
	fi

	if [ "${BASH_VERSINFO[0]:-0}" -gt 4 ] ||
		{ [ "${BASH_VERSINFO[0]:-0}" -eq 4 ] && [ "${BASH_VERSINFO[1]:-0}" -ge 3 ]; }; then
		pf_add bash PASS "${BASH_VERSION%%(*}"
	else
		pf_add bash FAIL "${BASH_VERSION%%(*} (need 4.3+)"
	fi

	# Clock and timezone: a wrong clock breaks OAuth codes and TLS at once.
	local tz ntp
	tz=$(timedatectl show -p Timezone --value 2>/dev/null || cat /etc/timezone 2>/dev/null || true)
	[ -n "$tz" ] && pf_add timezone PASS "$tz" || pf_add timezone WARN "unknown"
	ntp=$(timedatectl show -p NTPSynchronized --value 2>/dev/null || true)
	case "$ntp" in
		yes) pf_add clock PASS "synchronised" ;;
		no) pf_add clock WARN "not synchronised; enable NTP before logging in" ;;
		*) pf_add clock WARN "cannot read NTP state" ;;
	esac

	# IPv6 is not required to be off, but it must be covered if it is on.
	if [ -r /proc/sys/net/ipv6/conf/all/disable_ipv6 ]; then
		if [ "$(cat /proc/sys/net/ipv6/conf/all/disable_ipv6)" = "1" ]; then
			pf_add ipv6 PASS "disabled on all interfaces"
		else
			pf_add ipv6 WARN "enabled; make sure the proxy and jail cover it"
		fi
	else
		pf_add ipv6 PASS "no IPv6 stack"
	fi

	local free_kb
	free_kb=$(df -Pk /var 2>/dev/null | awk 'NR==2 {print $4}')
	case "$free_kb" in
		'' | *[!0-9]*) pf_add disk WARN "cannot read free space on /var" ;;
		*)
			if [ "$free_kb" -ge 2097152 ]; then
				pf_add disk PASS "$((free_kb / 1048576))G free on /var"
			else
				pf_add disk FAIL "$((free_kb / 1048576))G free on /var (need 2G)"
			fi
			;;
	esac

	# Node and Claude Code: absent is fine before phase 3, but say so.
	if command -v node >/dev/null 2>&1; then
		local nv nmaj
		nv=$(node -v 2>/dev/null)
		nmaj="${nv#v}"
		nmaj="${nmaj%%.*}"
		case "$nmaj" in
			'' | *[!0-9]*) pf_add node WARN "$nv" ;;
			*) [ "$nmaj" -ge 18 ] && pf_add node PASS "$nv" || pf_add node WARN "$nv (use a current LTS)" ;;
		esac
	else
		pf_add node WARN "not installed (needed for the npm install path)"
	fi

	local gate_path real res
	gate_path="$(p "$BIN_REL")/claude"
	if [ -x "$gate_path" ] && is_gate "$gate_path"; then
		real=$(readlink -f -- "$(p "$REAL_REL")/claude" 2>/dev/null || true)
		pf_add claude PASS "gated: ${real:-$gate_path}"
		res=$(command -v claude 2>/dev/null || true)
		if [ -n "$res" ] && ! is_gate "$res"; then
			pf_add claude-path FAIL "PATH resolves to an ungated binary: $res"
		elif [ -n "$res" ]; then
			pf_add claude-path PASS "$res"
		else
			pf_add claude-path WARN "gate installed but claude is not on PATH"
		fi
	elif real=$(discover_real "" "") && [ -n "$real" ]; then
		pf_add claude WARN "installed but not gated yet: $real"
	else
		pf_add claude WARN "not installed yet; install it in phase 3"
	fi

	# Egress: only checked when a proxy is supplied. Uses the trace endpoint so
	# the operator learns the real exit before creating anything.
	if [ -n "$proxy" ]; then
		case "$proxy" in
			http://* | https://*) ;;
			*) die "--proxy must start with http:// or https://" ;;
		esac
		local trace ip loc
		trace=$(curl -4 -fsS --max-time "$timeout" -x "$proxy" \
			"${TRACE_URL:-https://www.cloudflare.com/cdn-cgi/trace}" 2>/dev/null || true)
		ip=$(printf '%s\n' "$trace" | sed -n 's/^ip=//p' | head -n 1)
		loc=$(printf '%s\n' "$trace" | sed -n 's/^loc=//p' | head -n 1)
		if [ -n "$ip" ]; then
			pf_add egress PASS "exit=$ip country=${loc:-?}"
			if [ -n "$expect" ]; then
				[ "$ip" = "$expect" ] && pf_add exit PASS "$ip" ||
					pf_add exit FAIL "expected $expect, proxy reports $ip"
			fi
			if [ -n "$country" ]; then
				[ "$loc" = "$country" ] && pf_add country PASS "$loc" ||
					pf_add country FAIL "expected $country, proxy reports ${loc:-?}"
			fi
		else
			pf_add egress FAIL "no reply through $proxy"
		fi
	else
		pf_add egress WARN "no --proxy given; the exit was not checked"
	fi

	if [ "$json" = "1" ]; then
		printf '{"checks":[%s],"blockers":%d}\n' "${PF_JSON%,}" "$PF_BLOCKERS"
	else
		say ""
		say "---- blockers: $PF_BLOCKERS"
		[ "$PF_BLOCKERS" -eq 0 ] && say "next: references/zero-to-deployed.md (phases 2-8)"
	fi
	[ "$PF_BLOCKERS" -eq 0 ]
}

# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
cmd_drill() {
	local service="" wait_s="30"
	while [ $# -gt 0 ]; do
		case "$1" in
			--service) service="${2:-}"; shift 2 ;;
			--wait) wait_s="${2:-}"; shift 2 ;;
			--prefix) PREFIX="${2:-}"; shift 2 ;;
			-h | --help) usage; exit 0 ;;
			*) die "unknown drill option: $1" ;;
		esac
	done
	[ -n "$service" ] || die "--service is required (the proxy core unit)"
	case "$service" in
		*ssh* | *tailscale* | docker*) die "refusing to stop $service: it can cut this session or unrelated services" ;;
	esac
	need_root
	command -v systemctl >/dev/null 2>&1 || die "systemctl not found"

	local gate conf state_before
	gate=$(p "$BIN_REL")/claude
	conf=$(p "$CONF_REL")
	[ -r "$conf" ] || die "config not found: $conf"
	[ -x "$gate" ] || die "gate not installed: $gate"

	state_before=$(systemctl is-active "$service" 2>/dev/null || true)
	[ "$state_before" = "active" ] || die "$service is not active (state: ${state_before:-unknown})"

	restore() {
		if [ "$(systemctl is-active "$service" 2>/dev/null || true)" != "active" ]; then
			systemctl start "$service" >/dev/null 2>&1 || true
		fi
	}
	trap restore EXIT

	CEG_CONF="$conf" "$gate" --ceg-check >/dev/null 2>&1 ||
		die "gate refuses while the proxy is up - fix verify first"

	say "stopping $service ..."
	systemctl stop "$service" >/dev/null 2>&1 || die "could not stop $service"
	sleep 2
	if CEG_CONF="$conf" "$gate" --ceg-check >/dev/null 2>&1; then
		restore
		die "FAIL: gate still allowed a start while the proxy was down"
	fi
	say "PASS   gate refused to start with the proxy down"

	say "starting $service ..."
	systemctl start "$service" >/dev/null 2>&1 || die "could not start $service"
	local i=0
	while [ "$i" -lt "$wait_s" ]; do
		if CEG_CONF="$conf" "$gate" --ceg-check >/dev/null 2>&1; then
			say "PASS   gate recovered after ${i}s"
			trap - EXIT
			return 0
		fi
		sleep 1
		i=$((i + 1))
	done
	die "FAIL: gate did not recover within ${wait_s}s after restarting $service"
}

# ---------------------------------------------------------------------------
# uninstall
# ---------------------------------------------------------------------------
cmd_uninstall() {
	while [ $# -gt 0 ]; do
		case "$1" in
			--prefix) PREFIX="${2:-}"; shift 2 ;;
			-h | --help) usage; exit 0 ;;
			*) die "unknown uninstall option: $1" ;;
		esac
	done
	need_root
	local state latest rel dest f
	state=$(p "$STATE_REL")
	latest=$(ls -1d "$state"/backup/*/ 2>/dev/null | sort | tail -n 1 || true)
	# Read the policy so the vendor path and mirror are removed too, not just
	# the two default locations.
	if [ -r "$(p "$CONF_REL")" ]; then
		# shellcheck source=/dev/null
		. "$(p "$CONF_REL")"
	fi

	if [ -n "$latest" ]; then
		say "restoring files from $latest"
		while IFS= read -r f; do
			[ -n "$f" ] || continue
			rel="${f#"$latest"}"
			case "$rel" in
				*/claude) ;;
				*) continue ;;
			esac
			dest="${PREFIX}/${rel}"
			mkdir -p -- "$(dirname -- "$dest")"
			cp -a -- "$f" "$dest" 2>/dev/null || warn "could not restore $dest"
			say "  restored $dest"
		done < <(find "$latest" -mindepth 1 \( -type f -o -type l \) 2>/dev/null)
	fi

	rm -f -- "$(p "$BIN_REL")/claude"
	rm -f -- "$(p "$SBIN_REL")/claude-egress-guard"
	# Only delete the extra paths if they still hold the gate; a restored original
	# (symlink or binary) must survive.
	[ -n "${ALT_PATH:-}" ] && is_gate "$ALT_PATH" && rm -f -- "$ALT_PATH"
	[ -n "${NATIVE_PATH:-}" ] && is_gate "$NATIVE_PATH" && rm -f -- "$NATIVE_PATH"
	rm -f -- "$(p "$CONF_REL")"
	rm -rf -- "$(p "$LIB_REL")"
	rm -f -- "$(p "$REAL_REL")/claude"
	say "gate removed; backups kept in $state"
}

# ---------------------------------------------------------------------------
# show
# ---------------------------------------------------------------------------
cmd_show() {
	local what="${1:-}" src
	case "$what" in
		guard) src=$(source_file guard-claude.sh) || die "guard-claude.sh not found" ;;
		check) src=$(source_file egress-check.sh) || die "egress-check.sh not found" ;;
		config)
			src=$(p "$CONF_REL")
			[ -r "$src" ] || die "config not found: $src"
			;;
		*) die "usage: claude-egress-guard show <guard|check|config>" ;;
	esac
	cat -- "$src"
}

# ---------------------------------------------------------------------------
# main
# ---------------------------------------------------------------------------
FAILED=0
cmd="${1:-}"
[ $# -gt 0 ] && shift
case "$cmd" in
	install) cmd_install "$@" ;;
	verify) cmd_verify "$@" ;;
	status) cmd_status "$@" ;;
	preflight) cmd_preflight "$@" ;;
	set-exit) cmd_set_exit "$@" ;;
	drill) cmd_drill "$@" ;;
	uninstall) cmd_uninstall "$@" ;;
	show) cmd_show "$@" ;;
	version | --version) say "claude-egress-guard $VERSION" ;;
	help | --help | -h | "") usage ;;
	*) err "unknown command: $cmd"; usage; exit 2 ;;
esac
