Skip to content

Latest commit

 

History

28 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

agent-cage — sandboxed coding agents on a Hetzner Cloud box

Working on this repo with an agent? Read CLAUDE.md first — it carries the invariants, the decisions already settled, and the traps already hit.

What this is

A disposable Hetzner Cloud server that runs Claude Code and Codex CLI against PHP projects, with the agents' network access enforced from outside the container they run in.

The agents run with --dangerously-skip-permissions and --dangerously-bypass-approvals-and-sandbox. Nobody approves individual commands. The network is the control surface instead: an agent that can reach api.anthropic.com and nothing else has nowhere to send your source and nothing hostile to pull in, so what it runs inside the box matters far less.

Three nested boundaries, each enforced further out than the thing it contains:

Hetzner Cloud Firewall     outside the server — root on the box cannot touch it
  └─ Ubuntu host           squid allowlist + iptables
      └─ container         no route out except squid; agent runs here

Containers get no DNS and no route to the internet. Everything goes through a squid allowlist on the host, and which squid port is reachable is the current profile — one command swaps an agent between "registries open" and "model API only". Nothing from your laptop is mounted: repos are cloned on the server, and results leave by git push.

The repo provides a php-toolchain image (PHP 8.5, Composer, Node 24) with thin Claude Code and Codex children, agentctl for day-to-day control, and a snapshot workflow that keeps the whole box cheap to destroy and rebuild.

Install — 8 steps plus prerequisites

Steps 0-2 and 8 run on your laptop, 3-7 on the server. Do them in this order: the perimeter goes up before anything starts listening, and the sudo user exists before anything needs it.

0. Prerequisites (laptop)

brew install hcloud                  # or apt
hcloud context create agent          # paste an API token from the Hetzner console

Needed however the server was made: steps 2 and 8 both drive the CLI.

1. Create the server (laptop) — skip if you made it in the web console

hcloud server create --name <server-name> --type cx23 \
  --image ubuntu-24.04 --ssh-key <your-key-name> --location <fsn1|nbg1|hel1>

The only unscripted step. Adjust image and location to taste; the type matters (CX23 = 2 vCPU / 4 GB / 40 GB).

Recreating from a snapshot? Pass --image <snapshot-id> instead, then do step 2 and stop — a new server has no firewall attached, but the user, images and credentials all came back with the image.

Creating it in the console is fine — but note that a console-created server has no Cloud Firewall attached, so step 2 is doing real work, not repeating something the console already did.

2. Lock the perimeter (laptop) — do this before step 5

bash laptop/hcloud-setup.sh <server-name>

SSH from your IP only; outbound limited to 80/443/53. This must precede bootstrap.sh, which starts squid: a proxy listening on a public interface with no firewall in front of it gets found by scanners within minutes.

Re-run this exact command whenever you change network — office, home, train. The SSH rule is pinned to the address you had when it last ran, so a new one locks you out, and it presents as ssh timing out on port 22, which reads like a broken key rather than a firewall. Re-running is safe at any time: replace-rules rewrites all four rules with your current address, and it runs before the attach step, so your access is back even if re-attaching an already-attached firewall reports an error.

Keeping this script as the only thing that writes firewall rules is deliberate. A second script that re-points only the SSH rule would be a second owner of the whole rule set, since replace-rules is all-or-nothing.

The rule covers one IPv4 /32, so connecting to the server's IPv6 address fails the same way — use the v4 address or ssh -4.

3. Create the sudo user and harden SSH (server)

A fresh Hetzner box gives you root and your key, nothing else. Everything after this step connects as honza and runs sudo, so create that user first:

scp server/secure-init.sh root@<server-ip>:/tmp/
ssh -t root@<server-ip> 'bash /tmp/secure-init.sh --user honza --copy-root-key'

--copy-root-key reuses the key Hetzner already injected into /root/.ssh/authorized_keys, so you don't have to paste anything. -t matters: the script prompts for a sudo password and for confirmation before restarting sshd.

It then disables root login and password authentication entirelyPermitRootLogin no, PasswordAuthentication no — as a 00- drop-in that wins over the PasswordAuthentication yes that cloud images ship in 50-cloud-init.conf, and comments out competing directives wherever else they appear. It verifies with sshd -T (the effective config, after includes) and refuses to restart if anything is off.

Keep that root session open until you have confirmed in a second terminal:

ssh honza@<server-ip>
sudo whoami          # root

Rollback if locked out: rm /etc/ssh/sshd_config.d/00-hardening.conf && systemctl restart ssh.

ufw stays off here. This box's INPUT chain belongs to agent-set-profile, and ufw reinserts its own chains at the top on every reload, which can cut containers off from squid. The Hetzner Cloud Firewall is the perimeter.

4. Copy this tree to the server (laptop)

rsync -a agent-cage/ honza@<server-ip>:~/agent/

The trailing slash on the source matters: it copies the contents of agent-cage/, so you get ~/agent/server/, ~/agent/image/, ~/agent/laptop/. Without it you would get ~/agent/agent-cage/server/. Check with ssh honza@<server-ip> ls ~/agent.

5. Bootstrap (server)

ssh -t honza@<server-ip> 'cd ~/agent/server && sudo bash bootstrap.sh'

-t allocates a TTY so sudo can prompt for your password. Afterwards log in again (a fresh session) so the docker group membership applies.

Installs Docker, squid, 4 GB swap, the profile switcher, and the directory layout. Idempotent — this is also the repair path, so re-run it rather than patching files on the box.

6. Git identity and credentials (server)

cd ~/agent/server && bash setup-git.sh

Prompts for name, email, forge username and a fine-grained PAT, writes ~/agent/coder/.git-credentials at 0600, and optionally clones your repo into ~/agent/project_workspace/<name>.

That clone target is one project; see Projects under Daily use for working with more than one.

Scope the PAT to every repository you intend to work on, Contents: read and write, nothing else. The agent can read this file — it has to, in order to push. Tight scoping is the control, not secrecy.

Optional: pushing from the host as honza

That PAT is the container's credential. To push from the host as well, know that port 22 is closed: the Cloud Firewall permits outbound 80/443/53 only, so a git@github.com: remote times out. It presents exactly like a bad key, so check the port before you debug the key:

timeout 8 bash -c '</dev/tcp/github.com/22'      && echo "22: open" || echo "22: BLOCKED"
timeout 8 bash -c '</dev/tcp/ssh.github.com/443' && echo "443: open"

22: BLOCKED with 443: open is the correct, expected state. Route SSH over GitHub's 443 endpoint:

ssh-keygen -t ed25519 -C "you@example.com"   # skip if you already have a key
cat ~/.ssh/id_ed25519.pub                    # add at github.com/settings/keys

Then ~/.ssh/config:

Host github.com
    HostName ssh.github.com
    User git
    Port 443
chmod 600 ~/.ssh/config
ssh -T git@github.com      # Hi <user>! You've successfully authenticated...

Remote URLs stay git@github.com:... — the config rewrites host and port underneath. On first connect, check the offered fingerprint for [ssh.github.com]:443 against GitHub's published host keys rather than accepting it blind.

Host-only. Containers keep HTTPS + PAT: squid tunnels CONNECT to 443, but git-over-SSH through an HTTP proxy needs a ProxyCommand, which is more moving parts than a repo-scoped token is worth. And ~/.ssh/config is host state — it survives a snapshot restore, not a rebuild from an older image.

7. Build images and log in (server)

agentctl net build      # doctor needs the registries reachable
agentctl doctor         # proves the proxy path, ~30s — run before any long build
agentctl net work
agentctl build          # ~12 min once for php-toolchain, then ~1 min each
                        # opens `build` itself and restores your profile after

agentctl claude         # log in, then /exit
agentctl codex          # log in, then exit

Logging in needs no project — these land in default_project, and the credentials are stored under ~/agent/coder/, so they apply to every project.

Then confirm the network model is actually in force:

agentctl status                                   # profile : work
sudo iptables -S INPUT | grep -E '313[01]|312[89]' # one ACCEPT, one REJECT, one DROP

Three rules exactly: ACCEPT the profile's port from 172.16.0.0/12, REJECT all four from 172.16.0.0/12, DROP all four from everywhere else. Stray single-port ACCEPT lines mean an old profile was never closed.

8. Snapshot the working state (laptop)

bash laptop/snapshot.sh save <server-name>

That snapshot is your rollback point: images built, both CLIs authenticated.

Daily use

bash laptop/hcloud-setup.sh <server-name>   # from the laptop, after any network change
ssh honza@<server-ip>
tmux new -s work
agentctl status                       # profile, memory, containers
agentctl logs                         # watch what it reaches for

The first line is only needed when your public IP has moved since you last ran it, but running it every time costs a second and saves diagnosing a timeout as a key problem. See step 2.

Projects

Every directory under ~/agent/project_workspace/ is one project, and a session mounts exactly one of them:

ls ~/agent/project_workspace/         # what you have
agentctl claude myshop                # work on myshop
agentctl codex myshop                 # or the other agent, same project
agentctl claude                       # no name: default_project

The argument is the directory name, nothing more. The parent directory is never mounted, so an agent working on myshop cannot see, read or write any other project — that is the reason a default_project exists rather than the bare command widening the mount.

Add a project by cloning into that directory. The clone runs on the host, so it needs the PAT explicitly and does not care which profile you are on:

git -c credential.helper="store --file=$HOME/agent/coder/.git-credentials" \
    clone <url> ~/agent/project_workspace/myshop

Switching projects is just a different argument next time you start a session. Each project keeps its own agent history and memory, because the in-container path differs.

A container reads its proxy port once, at startup. Everything below follows from that: you choose the profile before starting a session, and changing it afterwards does not reach that session — it closes the port the session is already using, taking its network away rather than widening it.

So pick the session shape by whether the agent installs its own dependencies:

# It does — attended work. Registries reachable for the whole session.
agentctl net build && agentctl claude myshop
agentctl net work                     # or locked, when you step away

# It doesn't — session stays on `work`, deps run from the host.
agentctl claude myshop                    # second window:
agentctl deps myshop -- composer install  # separate container, same project

agentctl deps flips to build, runs one short-lived container against the same project mount, and restores your profile on the way out — including when the command fails. vendor/ lands where the session sees it. Name the same project the session is on: with the name omitted both default to default_project, which is right only if that is where the session is.

It restores the profile you were on, not work. Start from build and it prints profile: build on both sides; that is the restore working, not failing to close.

The first shape is the convenient one and costs you the supply chain: the agent can pull anything from packagist and npm until the session ends. Fine while you're watching it, wrong for anything unattended — locked is what earns the right to skip per-command approval.

Detach with Ctrl-b d; the session survives disconnection. Reattach with tmux a -t work.

Network profiles

profile model API git hosting registries docs + temp use for
build agentctl build, deps, attended sessions that install their own deps
work default
locked unattended runs, untrusted repos
open everything — no allowlist exploratory work, sitting with it
offline executing code you don't trust

open is the lazy way through a task whose domains you cannot predict: nothing to enumerate, nothing to grant. It keeps every control except the allowlist — still no direct route out, still no DNS in the container, still CONNECT to 443 only — but the allowlist was what made running without command approval a reasonable trade, so stay with the session. Afterwards:

agentctl domains          # every host reached, most-used first

Move the ones you'll need again into docs.txt or temp.txt, then agentctl net work. That turns an open session into a real allowlist instead of a habit.

agentctl net locked is what earns the right to run agents without approving each command: reachable destinations are api.anthropic.com and nothing else, so there is nowhere to exfiltrate to and nothing hostile to pull in. That is also why the docs and temporary lists stop at work — every allowed hostname is somewhere data could go.

Letting the agent reach something new

Reference material the project always needs goes in server/domains/docs.txt, committed like any other change, then rsync and bootstrap.sh.

A domain needed for one task — a vendor console the agent has to click through once — goes in /etc/squid/domains/temp.txt on the box, which is deliberately not in the repo:

sudo tee /etc/squid/domains/temp.txt <<'EOF'
partners.shopify.com
EOF
sudo squid -k reconfigure                         # grant

printf 'disabled.invalid\n' | sudo tee /etc/squid/domains/temp.txt
sudo squid -k reconfigure                         # revoke

To revoke on a timer instead of remembering:

sudo systemd-run --on-active=2h --unit=agent-temp-revoke \
  /bin/sh -c 'printf "disabled.invalid\n" > /etc/squid/domains/temp.txt; squid -k reconfigure'

agentctl status shows the active grant. Don't guess the domain list — grant what you expect, run the task, and read sudo tail -30 /var/log/squid/access.log for TCP_DENIED lines naming what was actually refused.

Squid sees hostnames, not paths, so allowing a console allows everything on it. Grant windows should be attended; the timer stops you forgetting, not the agent.

Enforcement has three independent layers, none inside the container:

  • DOCKER-USER -j DROP — containers can never route out directly, in any profile. The only path is squid, reached via the docker bridge gateway. This drops port 53 too, so containers have no DNS: names are resolved by squid on the far side of the proxy. That closes DNS tunnelling as an exfil channel, and it means anything in a container that ignores HTTP_PROXY simply fails.
  • Which squid port is open in INPUT from the docker subnet is the profile. Switching profiles closes the previous port, so repointing HTTP_PROXY at another one reaches nothing.
  • Everything outside 172.16.0.0/12 is refused both in INPUT and in squid.conf. The profile ACLs are keyed on port with no source restriction, so without this, anyone who could reach an open port could use the proxy for whatever that port allows.

Unsetting HTTP_PROXY inside the container therefore achieves nothing.

Rollback

Two levels:

  • Work in progress: git checkout . / git clean -fd in the workspace.
  • The whole box: snapshot.sh restore <server> <id> rebuilds it from your image. Everything since is gone — which is the point.

Take a fresh snapshot after each successful agentctl build.

A snapshot captures the active profile. agent-set-profile writes /etc/agent/profile and saves the iptables rules via netfilter-persistent, and agent-profile.service re-applies them at boot. A box imaged while on build therefore comes back up on build — registries open — every time you restore it, and every server you create from that image starts the same way. Set the profile you want to inherit before saving:

agentctl net work && agentctl status      # profile : work

Cost

Delete the server when you're not using it and recreate from the snapshot: CX23 bills at €0.011/h, snapshots at €0.0143/GB/month on used space only. Forty hours a month plus a ~12 GB snapshot is around €0.60.

Note that a powered-off server still bills. Delete, don't stop.

Rules that keep this honest

  • Repos are cloned on the server. Nothing is bind-mounted from your laptop.
  • Results leave via git push only.
  • The PAT is fine-grained and repo-scoped. Never your main SSH key.
  • Keep this box separate from your LAMP droplet. A production host with database credentials is exactly where an agent shell should not exist.
  • After a suspicious session: snapshot.sh restore, then rotate the PAT.

About

Run Claude Code and Codex unattended on a disposable Hetzner box. No per-command approval — egress goes through a squid allowlist enforced outside the container.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages