2026-10-24 17:38:05 +11:00

agent-sandbox

Runs Claude Code in a rootless podman sandbox. The only thing visible from the host is the directory you launch it from, and the only way out of the sandbox is a per-session router that serves the hostnames on your allowlist.

Usage

cd ~/src/some-repo
agent-sandbox [claude args...]       # e.g. agent-sandbox --continue
agent-sandbox --exec bash            # a shell in the sandbox instead of claude
  • $PWD is mounted read-write at the same path (its resolved target is what gets mounted). Files you create there are owned by your host user. Nothing else from the host is mounted, and SELinux labels on your files are never changed (label=disable).
  • It only launches from a directory strictly under an allowed path (see below), never from $HOME, a directory containing its config, or one that is or contains a $PATH entry (such as ~/bin or ~/go).
  • Claude's home is the agent-sandbox-home podman volume (login, config and nested container images live here). All sessions share it.
  • podman works inside the sandbox (nested rootless). The host podman socket is never exposed.
  • You can run several sessions at once, from different directories.
  • Environment overrides: AGENT_SANDBOX_IMAGE, AGENT_SANDBOX_ROUTER_IMAGE, AGENT_SANDBOX_ALLOWLIST, AGENT_SANDBOX_HOME_VOLUME.

Allowlist

~/.config/agent-sandbox/allowed-hosts is copied from the packaged default on first run. It is read at the start of each session.

api.anthropic.com      exactly this host (ports 80 and 443)
.example.com           example.com and every subdomain (ports 80 and 443)
db.example.com:5432    this exact host on TCP 5432 too (ssh, postgres, ...)

You don't need a proxy setting. Allowlisted names resolve to loopback addresses inside the sandbox, and all other names return NXDOMAIN.

Allowed paths

~/.config/agent-sandbox/allowed-paths lists the roots you may launch from, one absolute or ~/ path per line (# comments allowed), compared after resolving symlinks. Without the file the only root is $HOME, minus its dot-directories. Listed roots are trusted even under a dot-directory, but these abort the launch: $HOME or its parents; a top-level dot-directory such as ~/.local; anything under /run, /tmp, /var/tmp, /proc, /sys or /dev; anything that is, holds or sits in ~/.ssh, ~/.gnupg, ~/.config, $XDG_RUNTIME_DIR or podman's storage (~/.local/share/containers); anything that is or contains a $PATH entry.

~/.local/src
/srv/work

How it works

Each session gets its own --internal podman network with no route out, and a pod on that network that holds the sandbox's netns, DNS and hosts file.

  • router (on the internal net and the shared egress net): dnsmasq answers for allowlisted names only and never forwards a query. nginx routes 443 by SNI and 80 by Host header to allowlisted names only. Each host:port entry has its own router port, which forwards to that host and port only.
  • forwarder (in the pod, read-only, no capabilities except NET_BIND_SERVICE): socat listeners on the loopback IPs that forward to the router.
  • claude (in the pod, non-root keep-id user, no setuid binaries, no NET_ADMIN, NET_RAW or SYS_ADMIN): has no capability over the netns. It can't change addresses, routes, nftables, /etc/hosts or /etc/resolv.conf, and can't see or signal the forwarders.
  • Nested containers share claude's netns by default. They get the same DNS and forwarders, and they can't change the netns either. A nested container on its own network (--network pasta|bridge) has no egress.

Threat model

  • Confined: direct IP egress, other hostnames, DNS exfiltration (dnsmasq never forwards), SNI or Host spoofing (the destination is always the name the client sent, and it must be on the allowlist), the host filesystem beyond $PWD, and the host podman socket.
  • Not confined: anything you allowlist is still an exfiltration channel. git.unkin.net and api.anthropic.com can carry your data out. $PWD is writable, so anything there, including .git/hooks, runs later on the host if you trust it.
  • Files written in the launch dir can still run on the host later (git hooks, Makefiles, .envrc), so review changes before running them.
  • Router-side DNS resolves allowlisted names on the host's resolvers, so internal names like vault.service.consul work.
  • The home volume persists across sessions and is shared by all of them.
  • Never hard-link the allowlist into a directory you launch from; hardlinks can't be detected by path.

Development

agent-sandbox is a Go CLI built on the podman API bindings. Each session starts its own podman system service on a private socket under $XDG_RUNTIME_DIR (mode 0700, never mounted into a container) and stops it on exit.

make build        # dist/agent-sandbox + local images (localhost/agent-sandbox{,-router}:dev)
make test vet lint pre-commit
make rpm          # dist/agent-sandbox-*.rpm
make patch|minor|major

A dev build runs the local :dev images. Release builds pin the images published by the release pipeline for their tag: artifactapi.k8s.syd1.au.unkin.net/docker-internal/agent-sandbox{,-router}.

S
Description
Rootless podman sandbox for Claude Code with allowlisted egress
Readme 286 KiB
v0.1.0 Latest
2026-10-24 20:40:16 +11:00
Languages
Go 91.4%
Makefile 3.4%
Dockerfile 3.1%
Shell 2.1%