add ai-sandbox: rootless podman sandbox for agentic development
A single bash script that runs a coding harness (pi by default) inside a rootless Ubuntu 24.04 container with only the current directory mounted at /project, an explicit egress allowlist and an explicit environment: nothing is passed in that was not named on the command line or in a config file. Network enforcement here is empirical rather than assumed (tests/spike_network.sh, docs/networking.md). The original design -- a sidecar proxy on an internal network -- does not work rootless on this host, so allow mode runs pasta with --map-host-loopback, installs an inet aisb ruleset inside the container that drops every egress path except the gate's, and relays through a squid gate bound to the host's loopback that resolves names itself and refuses anything unlisted. The entrypoint checks that the default-drop rule really exists before setpriv drops to uid 1000 with an empty capability set, and exits 77/78 rather than run unenforced. The Containerfile, the entrypoint, the squid config generator and the ruleset renderer all live in bin/ai-sandbox, and the image is tagged by content hash, so a policy change can never silently reuse a stale image. Tests are pure bash: 161 assertions start no container and touch no network, 256 run real ones, including proof that a raw socket, a stripped proxy env and DNS all stay blocked while an allowlisted host works over both http and CONNECT. LLM-Generated: true
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
# Threat model
|
||||
|
||||
Read this before relying on `ai-sandbox`. It is a **seatbelt, not a jail**: it
|
||||
raises the cost of an agent's mistakes and of a moderately compromised prompt
|
||||
context, on a machine you still log into as yourself.
|
||||
|
||||
## Who we assume is hostile
|
||||
|
||||
The modelled adversary is **the agent you are running**: an LLM harness with your
|
||||
write access to one directory, capable of running arbitrary shell commands, and
|
||||
vulnerable to prompt injection from the files it reads, the web pages it fetches,
|
||||
and the package changelogs it installs. We assume it will try to escape when a
|
||||
prompt injection tells it to.
|
||||
|
||||
We do **not** model a kernel-level adversary, a hostile container image from a
|
||||
registry you don't control, or someone with access to your host user account.
|
||||
|
||||
## What stops working (defence in depth, most to least load-bearing)
|
||||
|
||||
| Attack | Stopped by | Evidence |
|
||||
|---|---|---|
|
||||
| Editing / deleting anything outside the project directory | only `$PWD` is mounted; the rest of the host is not in the mount namespace | `tests/e2e/12_sandbox.sh` |
|
||||
| Reading `~/.ssh`, `~/.aws`, `~/.config/gcloud`, browser profiles | same as above; those paths do not exist in the container | `tests/e2e/12_sandbox.sh` |
|
||||
| Exfiltrating your environment (API keys for *other* services) | nothing is passed through unless named with `--env` | `tests/unit/04_env.sh`, `tests/e2e/12_sandbox.sh` |
|
||||
| Passing secrets through the process list to other users | values travel in a `0600` file, never in argv | `tests/e2e/12_sandbox.sh` |
|
||||
| Egress to an arbitrary host (curl, python, a raw socket, a bespoke exfil tool) | netfilter `policy drop` inside the container's namespace | `tests/e2e/11_net.sh` |
|
||||
| Egress through the proxy to a non-allowlisted domain, including HTTPS | squid's `http_access deny all` after the allowlist | `tests/e2e/11_net.sh` |
|
||||
| Deleting or weakening the egress ruleset | the command runs with no capabilities; `CAP_NET_ADMIN` was dropped across `exec` | `tests/e2e/11_net.sh` |
|
||||
| `unshare -n` to get a fresh namespace with its own routes | requires `CAP_SYS_ADMIN`, which the command does not have | `tests/e2e/11_net.sh` |
|
||||
| Privilege escalation via a setuid binary in the image | `--security-opt no-new-privileges` + `--cap-drop=ALL` | container config, asserted in `tests/e2e/10_image.sh` |
|
||||
| Reaching other host services on loopback | only the gate's port is routable through the mapped loopback address | `tests/e2e/11_net.sh` |
|
||||
| Fork bombs, memory bombs, runaway builds | `--pids-limit`, `--memory`, `--cpus`, `--tmpfs` sizes | `tests/e2e/10_image.sh` |
|
||||
| Writing to the host's package manager, `/etc`, global npm/pip | read-only-ish image, `HOME` inside the container, optional `--read-only` | `tests/e2e/12_sandbox.sh` |
|
||||
| Credential persistence you did not ask for | `--ephemeral-home`; the default home volume is named and inspectable | `tests/e2e/12_sandbox.sh` |
|
||||
| Silent wide-open starts | an empty allowlist in `allow` mode is a hard error | `tests/unit/01_args.sh` |
|
||||
|
||||
## What does **not** stop it
|
||||
|
||||
Be honest about these, and design your work accordingly.
|
||||
|
||||
1. **Anything you allowlist is reachable, in both directions.** Allow
|
||||
`api.github.com` and a prompt-injected agent can push your code to a repository
|
||||
it controls, using the token you deliberately handed it. The allowlist limits
|
||||
*where*, not *what*. Prefer least-privilege, read-only, short-lived tokens.
|
||||
2. **The project directory is genuinely writable.** A bad `rm -rf`, a force push, a
|
||||
corrupted lockfile all still happen — just confined to that directory. Keep the
|
||||
tree committed (`ai-sandbox doctor` warns you when it is dirty).
|
||||
3. **Kernel and runtime escapes.** A container is not a VM. A container escape
|
||||
vulnerability in runc/crun or the kernel gets an attacker to your user account.
|
||||
For genuinely untrusted code, use a VM or a disposable machine.
|
||||
4. **`--net full` and the escape hatches do what they say.** `--allow-unenforced`,
|
||||
`--insecure-keep-root`, `--mount`, `AISB_EXTRA_ARGS`, `--net full` all weaken the
|
||||
posture. They are named to be uncomfortable on purpose; nothing uses them
|
||||
implicitly.
|
||||
5. **Side channels.** Timing, resource consumption, and "the agent typed X into a
|
||||
form on a site you allowed" are out of scope.
|
||||
6. **A cached home volume can hold credentials.** If a harness logs in inside the
|
||||
sandbox, the token lives in the `aisb-home` volume, and any later sandbox run
|
||||
using that volume can read it. `--ephemeral-home` per run, or a dedicated
|
||||
`AISB_HOME_VOLUME` per trust level.
|
||||
7. **DNS is closed in gate mode, open in `--no-gate` mode.** With `--no-gate`, name
|
||||
lookups leave the container; that is a modest data channel and a fingerprint of
|
||||
what you are doing. Egress to non-allowlisted IPs stays blocked either way.
|
||||
8. **The gate sees everything that flows through it** — including any credentials
|
||||
in proxied requests. That is the point of an egress proxy; the squid config logs
|
||||
to its own container log, and `gate logs` shows them. Do not put secrets in URLs.
|
||||
9. **Supply chain inside the allowlist.** `registry.npmjs.org` allowlisted means any
|
||||
package, including a typosquatted one, can be fetched. Allowlisting a domain is
|
||||
not a security review of what you install from it.
|
||||
10. **Rootless podman still means shared kernel and shared UID namespace.** Files
|
||||
the container writes are owned by *you*. There is no uid boundary between
|
||||
"agent's file" and "your file" beyond ownership metadata and your own discipline.
|
||||
|
||||
## Residual risk rating
|
||||
|
||||
*Accidental destruction and ambient credential leakage: largely eliminated.*
|
||||
*Deliberate data exfiltration over an allowlisted path: mitigated, not prevented.*
|
||||
*Container escape to the host: out of scope, accept it or use a VM.*
|
||||
Reference in New Issue
Block a user