Files
unkin-agent f60caa7e98
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/lint Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Refuse launch dirs holding $PATH entries and mount the checked path
2026-10-24 17:43:06 +11:00

116 lines
5.1 KiB
Markdown

# 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
```sh
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.
```sh
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}`.