diff --git a/README.md b/README.md index e402efb..fc7c433 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,85 @@ # agent-sandbox -Rootless podman sandbox for Claude Code with allowlisted egress \ No newline at end of file +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. 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`). +- 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. + +## 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. +- 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. + +## Development + +```sh +make build # local images (localhost/agent-sandbox{,-router}:dev) +make test lint pre-commit +make rpm # dist/agent-sandbox-*.rpm +make patch|minor|major +``` + +The in-repo launcher runs the local `:dev` images. The RPM's launcher pins the +images published by the release pipeline for its tag: +`artifactapi.k8s.syd1.au.unkin.net/docker-internal/agent-sandbox{,-router}`.