Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 27 additions & 5 deletions examples/dedicated-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | `~<agent>/.config/systemd/user/pr-loop-<slug>.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
```

Expand All @@ -42,6 +44,26 @@ cp pr-loop-hardening.conf ~/.config/systemd/user/pr-loop-<slug>.service.d/harden
systemctl --user daemon-reload && systemctl --user restart pr-loop-<slug>.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.
Expand Down
15 changes: 15 additions & 0 deletions examples/dedicated-server/agents.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Agent registry for the dedicated-server layers. One line per agent user:
#
# <unix-user>:<absolute path to that user's repo clone>
#
# 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
9 changes: 0 additions & 9 deletions examples/dedicated-server/audit/recode-agent.rules

This file was deleted.

22 changes: 0 additions & 22 deletions examples/dedicated-server/bin/divergence-check.sh

This file was deleted.

24 changes: 24 additions & 0 deletions examples/dedicated-server/bin/gen-audit-rules.sh
Original file line number Diff line number Diff line change
@@ -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"
69 changes: 69 additions & 0 deletions examples/dedicated-server/bin/posture-check.sh
Original file line number Diff line number Diff line change
@@ -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
42 changes: 27 additions & 15 deletions examples/dedicated-server/nftables/recode-agent.nft
Original file line number Diff line number Diff line change
@@ -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 <agent-user>` 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 {
Expand All @@ -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
}
}
6 changes: 0 additions & 6 deletions examples/dedicated-server/systemd/divergence-check.service

This file was deleted.

6 changes: 6 additions & 0 deletions examples/dedicated-server/systemd/posture-check.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[Unit]
Description=Agent posture check (git divergence + egress-fence coverage)

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/posture-check.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[Unit]
Description=Hourly divergence check of the agent checkout
Description=Hourly agent posture check

[Timer]
OnBootSec=10min
Expand Down
Loading