116 lines
5.1 KiB
Markdown
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}`.
|