diff --git a/examples/dedicated-server/README.md b/examples/dedicated-server/README.md index d28e229..324fec2 100644 --- a/examples/dedicated-server/README.md +++ b/examples/dedicated-server/README.md @@ -15,22 +15,24 @@ Substitute `recode-agent` / uid `1001` / `recode-notifications` for your own val | `systemd/recode-agent-nft.service` | `/etc/systemd/system/` | Loads the table at boot; deletes it on stop | | `bin/egress-alarm.sh` | `/usr/local/sbin/` | Follows the kernel log, pushes blocked egress to ntfy | | `systemd/egress-alarm.service` | `/etc/systemd/system/` | Supervises the follower (runs as root — see below) | -| `bin/divergence-check.sh` | `/usr/local/sbin/` | Alerts when the agent's checkout diverges from `origin/main` | -| `systemd/divergence-check.{service,timer}` | `/etc/systemd/system/` | Hourly tripwire | -| `audit/recode-agent.rules` | `/etc/audit/rules.d/` | auditd watches on scripts, `.env`, settings, unit files | +| `agents.conf` | `/etc/recode-agents.conf` | **The registry.** One `user:repo` line per agent user; everything else reads it | +| `bin/posture-check.sh` | `/usr/local/sbin/` | Hourly: git divergence **and** egress-fence coverage, for every agent in the registry | +| `systemd/posture-check.{service,timer}` | `/etc/systemd/system/` | Hourly tripwire | +| `bin/gen-audit-rules.sh` | run as needed | Regenerates the auditd watch list from the registry | | `systemd/pr-loop-hardening.conf` | `~/.config/systemd/user/pr-loop-.service.d/` | User-unit-safe hardening drop-in | Install: ```bash sudo mkdir -p /etc/nftables.d +sudo install -m 644 agents.conf /etc/recode-agents.conf sudo install -m 644 nftables/recode-agent.nft /etc/nftables.d/ sudo install -m 755 bin/*.sh /usr/local/sbin/ sudo install -m 644 systemd/*.service systemd/*.timer /etc/systemd/system/ -sudo install -m 640 audit/recode-agent.rules /etc/audit/rules.d/ +sudo bash bin/gen-audit-rules.sh | sudo tee /etc/audit/rules.d/recode-agent.rules >/dev/null sudo nft -c -f /etc/nftables.d/recode-agent.nft # syntax check BEFORE enabling sudo systemctl daemon-reload -sudo systemctl enable --now recode-agent-nft.service egress-alarm.service divergence-check.timer +sudo systemctl enable --now recode-agent-nft.service egress-alarm.service posture-check.timer sudo augenrules --load ``` @@ -42,6 +44,26 @@ cp pr-loop-hardening.conf ~/.config/systemd/user/pr-loop-.service.d/harden systemctl --user daemon-reload && systemctl --user restart pr-loop-.service ``` +## Adding a second (or third) agent user + +Everything except the nftables set is driven by `/etc/recode-agents.conf`: + +```bash +echo 'redeploy-agent:/home/redeploy-agent/reDeploy' | sudo tee -a /etc/recode-agents.conf +sudo bash gen-audit-rules.sh | sudo tee /etc/audit/rules.d/recode-agent.rules >/dev/null +sudo augenrules --load +``` + +**Then add the uid to the nftables set by hand** — edit `elements = { ... }` in +`/etc/nftables.d/recode-agent.nft`, then `sudo nft -c -f` it and restart the unit. + +That last step is deliberately manual. A generator that populated the set from the registry +would, on any failure, leave the set **empty** — and an empty set means *no agent is fenced*, +silently, because the drop rule lives in a chain only listed uids ever jump into. Fail-open is +the wrong failure for this component. Instead `posture-check.sh` cross-checks the registry +against the live set every hour and pages you if they disagree, so forgetting is noisy rather +than invisible. + ## Decisions behind these files Each of these cost real debugging time; the rationale matters more than the syntax. diff --git a/examples/dedicated-server/agents.conf b/examples/dedicated-server/agents.conf new file mode 100644 index 0000000..0ed7f98 --- /dev/null +++ b/examples/dedicated-server/agents.conf @@ -0,0 +1,15 @@ +# Agent registry for the dedicated-server layers. One line per agent user: +# +# : +# +# Consumed by: +# - bin/posture-check.sh (git divergence + egress-fence coverage, hourly) +# - bin/gen-audit-rules.sh (regenerates the auditd watch list) +# +# The nftables set in nftables/recode-agent.nft is NOT generated from this file +# on purpose -- a generator that fails leaves the fence empty, i.e. fail-OPEN. +# Edit the set's `elements` by hand, and let posture-check.sh tell you when this +# file and the live set disagree. +recode-agent:/home/recode-agent/reCode +# redeploy-agent:/home/redeploy-agent/reDeploy +# redefi-agent:/home/redefi-agent/reDeFi diff --git a/examples/dedicated-server/audit/recode-agent.rules b/examples/dedicated-server/audit/recode-agent.rules deleted file mode 100644 index 290388d..0000000 --- a/examples/dedicated-server/audit/recode-agent.rules +++ /dev/null @@ -1,9 +0,0 @@ -# auditd watches on the loop's daemon-executed paths and credentials. -# Any write here outside an expected driver window is worth investigating. --w /home/recode-agent/reCode/.claude/scripts/ -p wa -k recode_scripts --w /home/recode-agent/reCode/self/ -p wa -k recode_self --w /home/recode-agent/reCode/.env -p wa -k recode_env --w /home/recode-agent/reCode/.claude/settings.local.json -p wa -k recode_settings --w /home/recode-agent/.config/systemd/user/ -p wa -k recode_units --w /etc/claude-code/managed-settings.json -p wa -k recode_managed --w /etc/nftables.d/recode-agent.nft -p wa -k recode_nft diff --git a/examples/dedicated-server/bin/divergence-check.sh b/examples/dedicated-server/bin/divergence-check.sh deleted file mode 100755 index 7ab46d9..0000000 --- a/examples/dedicated-server/bin/divergence-check.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bash -# Root-owned tripwire: alert when the agent's daemon-executed paths diverge -# from origin/main. Enforced from OUTSIDE the agent's trust zone. -set -euo pipefail -R=/home/recode-agent/reCode -TOPIC="${NTFY_TOPIC:-recode-notifications}" -PATHS=".claude/scripts self" - -as_agent() { sudo -u recode-agent git -C "$R" "$@"; } - -as_agent fetch -q origin main 2>/dev/null || true -dirty=$(as_agent status --porcelain -- $PATHS 2>/dev/null || true) -drift=$(as_agent diff --stat origin/main -- $PATHS 2>/dev/null || true) - -if [ -n "$dirty" ] || [ -n "$drift" ]; then - body=$(printf 'uncommitted:\n%s\n\nvs origin/main:\n%s\n' "$dirty" "$drift") - curl -fsS -m 10 \ - -H "Title: BusyBee: agent checkout diverges from origin/main" \ - -H "Priority: high" \ - -d "$body" "https://ntfy.sh/$TOPIC" >/dev/null || true - echo "$body" -fi diff --git a/examples/dedicated-server/bin/gen-audit-rules.sh b/examples/dedicated-server/bin/gen-audit-rules.sh new file mode 100755 index 0000000..9a780ac --- /dev/null +++ b/examples/dedicated-server/bin/gen-audit-rules.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Regenerate the auditd watch list from agents.conf. +# sudo bin/gen-audit-rules.sh | sudo tee /etc/audit/rules.d/recode-agent.rules +# sudo augenrules --load && sudo auditctl -l | wc -l +set -u +CONF="${AGENTS_CONF:-/etc/recode-agents.conf}" + +echo "# Generated by gen-agent-audit-rules from $CONF -- do not hand-edit." +echo "# Any write to these paths outside an expected driver window is worth investigating." +echo "-w /etc/claude-code/managed-settings.json -p wa -k recode_managed" +echo "-w /etc/nftables.d/recode-agent.nft -p wa -k recode_nft" +echo "-w $CONF -p wa -k recode_agents_conf" + +while IFS=: read -r user repo; do + case "${user:-}" in ''|\#*) continue ;; esac + key=$(printf '%s' "$user" | tr -c 'a-zA-Z0-9' '_') + echo + echo "# --- $user ($repo) ---" + echo "-w $repo/.claude/scripts/ -p wa -k ${key}_scripts" + echo "-w $repo/self/ -p wa -k ${key}_self" + echo "-w $repo/.env -p wa -k ${key}_env" + echo "-w $repo/.claude/settings.local.json -p wa -k ${key}_settings" + echo "-w /home/$user/.config/systemd/user/ -p wa -k ${key}_units" +done < "$CONF" diff --git a/examples/dedicated-server/bin/posture-check.sh b/examples/dedicated-server/bin/posture-check.sh new file mode 100755 index 0000000..f3b1938 --- /dev/null +++ b/examples/dedicated-server/bin/posture-check.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Root-owned tripwire, run hourly from outside every agent's trust zone. +# +# Two checks, deliberately in one script so they share one timer and one alert +# channel: +# 1. GIT DIVERGENCE -- each agent's daemon-executed paths vs origin/main. +# 2. FENCE COVERAGE -- every agent user in agents.conf is present in the +# nftables agent_uids set. A uid missing from that set is unfenced AND +# unalarmed (the drop rule sits in a chain only listed uids jump into), so +# an unfenced agent is indistinguishable from a quiet one. This check is +# the only thing that makes that failure visible. +# +# Not `set -e`: every lookup here can legitimately fail, and a follower that +# dies on the first failure reports nothing while looking healthy. +set -u + +CONF="${AGENTS_CONF:-/etc/recode-agents.conf}" +TOPIC="${NTFY_TOPIC:-recode-notifications}" +PATHS=".claude/scripts self" + +alert() { + curl -fsS -m 10 -H "Title: $1" -H "Priority: high" -d "$2" \ + "https://ntfy.sh/$TOPIC" >/dev/null 2>&1 + printf '%s\n%s\n' "$1" "$2" +} + +[ -r "$CONF" ] || { alert "BusyBee: posture-check misconfigured" \ + "cannot read $CONF -- no agents checked"; exit 1; } + +# ---- 1. git divergence, per agent ----------------------------------------- +while IFS=: read -r user repo; do + case "${user:-}" in ''|\#*) continue ;; esac + [ -d "$repo" ] || { alert "BusyBee: $user repo missing" "no such path: $repo"; continue; } + + as_agent() { sudo -u "$user" git -C "$repo" "$@"; } + as_agent fetch -q origin main 2>/dev/null + dirty=$(as_agent status --porcelain -- $PATHS 2>/dev/null) + drift=$(as_agent diff --stat origin/main -- $PATHS 2>/dev/null) + + if [ -n "$dirty" ] || [ -n "$drift" ]; then + alert "BusyBee: $user checkout diverges from origin/main" \ + "$(printf 'uncommitted:\n%s\n\nvs origin/main:\n%s\n' "$dirty" "$drift")" + fi +done < "$CONF" + +# ---- 2. egress-fence coverage --------------------------------------------- +live_uids=$(nft -j list set inet recode_agent agent_uids 2>/dev/null \ + | grep -oE '"val":[0-9]+' | cut -d: -f2 | sort -u) +if [ -z "$live_uids" ]; then + alert "BusyBee: egress fence NOT LOADED" \ + "nftables table inet recode_agent has no agent_uids set -- NO agent is fenced" + exit 1 +fi + +missing="" +while IFS=: read -r user repo; do + case "${user:-}" in ''|\#*) continue ;; esac + uid=$(id -u "$user" 2>/dev/null) || { missing="$missing $user(no-such-user)"; continue; } + printf '%s\n' "$live_uids" | grep -qx "$uid" || missing="$missing $user(uid $uid)" +done < "$CONF" + +[ -n "$missing" ] && alert "BusyBee: agent(s) NOT covered by the egress fence" \ + "missing from nftables agent_uids:$missing + +Add the uid to the set in /etc/nftables.d/recode-agent.nft, then: + sudo nft -c -f /etc/nftables.d/recode-agent.nft + sudo systemctl restart recode-agent-nft.service" + +exit 0 diff --git a/examples/dedicated-server/nftables/recode-agent.nft b/examples/dedicated-server/nftables/recode-agent.nft index 36f3d0f..5716904 100644 --- a/examples/dedicated-server/nftables/recode-agent.nft +++ b/examples/dedicated-server/nftables/recode-agent.nft @@ -1,28 +1,40 @@ #!/usr/sbin/nft -f -# Protocol/port egress fence for the loop's agent user (HARDENING.md step 5). +# Protocol/port egress fence for the loop's agent users (HARDENING.md step 5). # Independent of ufw: adds only its own table, never flushes the ruleset. # +# ── ADDING AN AGENT USER ──────────────────────────────────────────────────── +# Add its uid to `agent_uids` below, then: +# sudo nft -c -f /etc/nftables.d/recode-agent.nft # syntax check FIRST +# sudo systemctl restart recode-agent-nft.service +# sudo nft list set inet recode_agent agent_uids # confirm it is there +# A uid missing from this set is NOT fenced and raises NO alarm -- the drop rule +# lives in a chain only listed uids ever jump into, so an unfenced agent looks +# exactly like a quiet one. posture-check.sh cross-checks this set against +# agents.conf hourly precisely because that failure is otherwise invisible. +# # JUMP on a positive skuid match -- `skuid != N accept` never matches # kernel-generated packets (ICMPv6 MLD/ND, DHCP renewal), which would then fall # through to the drop and silently filter the host's own network stack. # # Scope, stated honestly: this restricts PROTOCOL and PORT, not destination. -# Per-destination filtering by IP set was tried and abandoned -- GitHub, -# Datadog, downloads.claude.ai and the remote-control endpoint all rotate -# addresses across fleets no DNS snapshot can track, so it dropped real work -# between refreshes. And since GitHub must be reachable for the loop to -# function, destination filtering could never have closed the exfiltration -# path anyway (HARDENING.md lists GitHub as a sanctioned exfil channel among -# the irreducible risks). What remains is worth having: no outbound SSH, no -# arbitrary ports, no non-HTTPS protocols -- and an alarm that only fires on -# genuinely anomalous traffic, so it stays worth reading. -# -# For true per-domain control, the correct build is an L7 proxy with a domain -# allowlist, with direct 443 blocked and the agent forced through it. +# Per-destination IP-set filtering was tried and abandoned -- GitHub, Datadog, +# downloads.claude.ai and the remote-control endpoint all rotate addresses +# faster than a DNS refresh can track, and since GitHub must be reachable for +# the loop to work, destination filtering could never close the exfiltration +# path anyway. What remains is worth having: no outbound SSH, no arbitrary +# ports, no non-HTTPS protocols, and an alarm that only fires on genuinely +# anomalous traffic. For true per-domain control, build an L7 proxy with a +# domain allowlist and block direct 443. table inet recode_agent { + # Every loop agent user's uid. `id -u ` to find one. + set agent_uids { + type uid + elements = { 1001 } + } + chain output { type filter hook output priority 0; policy accept; - meta skuid 1001 jump agent_out + meta skuid @agent_uids jump agent_out } chain agent_out { @@ -37,7 +49,7 @@ table inet recode_agent { tcp dport 53 accept tcp dport 443 accept - # Anything else from this UID is anomalous: block it and page. + # Anything else from these UIDs is anomalous: block it and page. counter log prefix "recode-egress-drop " level warn drop } } diff --git a/examples/dedicated-server/systemd/divergence-check.service b/examples/dedicated-server/systemd/divergence-check.service deleted file mode 100644 index cfedd88..0000000 --- a/examples/dedicated-server/systemd/divergence-check.service +++ /dev/null @@ -1,6 +0,0 @@ -[Unit] -Description=Check the agent checkout against origin/main - -[Service] -Type=oneshot -ExecStart=/usr/local/sbin/divergence-check.sh diff --git a/examples/dedicated-server/systemd/posture-check.service b/examples/dedicated-server/systemd/posture-check.service new file mode 100644 index 0000000..501557a --- /dev/null +++ b/examples/dedicated-server/systemd/posture-check.service @@ -0,0 +1,6 @@ +[Unit] +Description=Agent posture check (git divergence + egress-fence coverage) + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/posture-check.sh diff --git a/examples/dedicated-server/systemd/divergence-check.timer b/examples/dedicated-server/systemd/posture-check.timer similarity index 63% rename from examples/dedicated-server/systemd/divergence-check.timer rename to examples/dedicated-server/systemd/posture-check.timer index 1b8a7cd..69103d2 100644 --- a/examples/dedicated-server/systemd/divergence-check.timer +++ b/examples/dedicated-server/systemd/posture-check.timer @@ -1,5 +1,5 @@ [Unit] -Description=Hourly divergence check of the agent checkout +Description=Hourly agent posture check [Timer] OnBootSec=10min