commit 03ab9c0cb14aae80314aeae1aa98cfdf4119dcaa Author: ducoterra Date: Tue Oct 27 15:11:29 2026 -0400 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 diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..44f36be --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.sh] +indent_style = space +indent_size = 2 +shell_variant = bash + +[*.{md,yml,yaml}] +indent_size = 2 + +[Makefile] +indent_style = tab diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..49be0d1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# secrets and local policy overrides: never commit these +env +.env +*.env +config/env +.ai-sandbox/local + +# test and build scratch +/tmp/ +*.log +/tests/.work/ + +# editors +.vscode/ +.idea/ +*.swp +.DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ff39f1d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,86 @@ +# AGENTS.md + +Working notes for anyone (human or agent) changing this repository. + +## What this project is + +One bash script, `bin/ai-sandbox`, that runs another program — usually an LLM +coding harness — inside a rootless podman container with a strict, explicit policy: +only the current directory mounted, no inherited environment, no root or +capabilities inside the container, resource caps, and egress limited to an +allowlist that the sandboxed process cannot lift. + +## Non-negotiables + +1. **Single script.** The Containerfile, the container entrypoint, the squid + config and the nftables rules are all generated from `bin/ai-sandbox` + (`containerfile()`, `entrypoint_script()`, `write_squid_conf()`, `gen_rules()` + inside the entrypoint). Keep it that way; do not split policy into files that + can drift from the script. +2. **Fail closed.** Any path where the sandbox cannot guarantee its policy must + refuse to start rather than degrade: no allowlist in `allow` mode, missing + policy file in the entrypoint (exit 78), ruleset that cannot be installed + (exit 77), `--env NAME` where NAME is unset. Escape hatches exist but must be + explicitly named (`--allow-unenforced`, `--insecure-keep-root`, `--net full`). +3. **Never leak a secret.** Values go through a `0600` env file, never argv. + Nothing that prints (`--dry-run`, `--print-policy`, the session log, error + messages) may echo a value. If you add output, add a test that a canary value + does not appear in it. +4. **No hardcoded machine paths, usernames or ports.** Everything is overridable + via `AISB_*` or a config file; defaults must work for any user on any host. +5. **Measure, don't assume, about podman.** Rootless networking varies by version + and distro. Run `tests/spike_network.sh` on a new host and update + `docs/networking.md` when reality disagrees with the design. + +## Layout + +``` +bin/ai-sandbox the application +install.sh symlink + optional alias +config/*.example documented examples (never loaded directly) +docs/networking.md the measurements behind the enforcement model +docs/threat-model.md what is and is not protected -- keep it honest +docs/recipes.md per-harness and per-language setups +tests/run.sh runner: unit (no containers) | --e2e (real containers) +tests/spike_network.sh re-measure the host +``` + +## Commands + +```bash +make test # unit tests -- fast, no containers, no network +make e2e # plus real container/egress tests (needs network + the image) +make lint # shellcheck in a container +make style # bash -n on every shell file +make check # lint + unit +./bin/ai-sandbox --dry-run --allow example.com --print-policy # introspection +``` + +Before finishing a change: `make check` green, and if you touched enforcement, +networking or the image, `make e2e` green too. + +## Conventions + +* bash ≥ 4.4, `set -Eeuo pipefail` in the main script; `set -uo pipefail` in the + test runner (assertions report, they do not abort). +* Option flow is always: defaults → config files → `AISB_*` env → CLI flags. + The built-in defaults live in `resolve_opts()`; the top of the script only sets + variables that have no config-file counterpart. Breaking this is the single + easiest way to introduce a precedence bug. +* `POL[...]` holds resolved policy, `P_*` arrays resolved lists, `OPT_*` raw flags, + `CFG_*` config-file values. Do not read raw env vars deep in the code. +* Preview paths (`--dry-run`, `--print-policy`, `--print-entrypoint`, + `--print-containerfile`) must not build images, start containers or write state. +* Tests use `t_name` + `assert_*` from `tests/lib.sh`; each file gets its own + `AISB_STATE_DIR`/`AISB_CACHE_DIR`/`AISB_CONFIG_DIR`. Never let a test touch the + real `~/.config`, `~/.local/state` or `~/.cache`, and never override `HOME` + (podman storage lives there). + +## Areas that deserve the most care + +* `entrypoint_script()` — the enforcement half. It is rendered into the image and + runs as root there. A bug here is a security bug. `tests/unit/06_entrypoint.sh` + checks the generated rule shape; `tests/e2e/11_net.sh` checks it in a container. +* `split_target()` / `classify_allows()` — the mapping from what the user typed to + what the firewall and the proxy enforce. +* `build_argv()` — every isolation flag lives here. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e30b19e --- /dev/null +++ b/Makefile @@ -0,0 +1,38 @@ +# ai-sandbox -- development tasks. Everything runs with the tools already on this +# machine plus podman; nothing needs to be installed system-wide. +SHELL := /bin/bash +BIN := bin/ai-sandbox +SHELLCHECK_IMAGE := docker.io/koalaman/shellcheck:stable + +.PHONY: help test e2e check lint style doctor build clean-demo all +help: ## show this help + @grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk -F':.*?## ' '{printf " %-12s %s\n", $$1, $$2}' + +test: ## unit tests only (no containers, no network) + ./tests/run.sh + +e2e: ## everything, including real containers and real egress tests + ./tests/run.sh --e2e + +check: lint test ## lint + unit tests (what CI would run) + +lint: ## shellcheck every shell file, in a container (nothing installed locally) + @podman run --rm -v "$(CURDIR):/src:ro,z" -w /src $(SHELLCHECK_IMAGE) \ + --severity=warning --shell=bash --exclude=SC1091 \ + bin/ai-sandbox install.sh tests/run.sh tests/lib.sh tests/spike_network.sh \ + $$(ls tests/unit/*.sh tests/e2e/*.sh) \ + && echo "shellcheck: clean" + +style: ## bash-style syntax check of every shell file (fast, no deps) + @for f in $(BIN) install.sh tests/*.sh tests/unit/*.sh tests/e2e/*.sh; do \ + [ -f "$$f" ] || continue; bash -n "$$f" || exit 1; \ + done; echo "bash -n: clean" + +doctor: ## verify this machine can run the sandbox + @$(BIN) doctor + +build: ## build the sandbox and gate images + @$(BIN) build --gate + +clean-demo: ## remove sandbox containers, gates and stale session files + @$(BIN) clean --all diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..2c367a9 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,83 @@ +# PLAN + +Execution plan for `ai-sandbox`, with the state each phase leaves behind. +Every phase ends with a gate that must pass before the next one starts. +Status as of this writing: **phases 1-7 complete**, follow-ups listed at the end. + +| # | Phase | Gate | Status | +|---|-------|------|--------| +| 0 | Measure the host before designing | `tests/spike_network.sh` results recorded in `docs/networking.md` | ✅ done — killed the sidecar-on-internal-network design, found `pasta --map-host-loopback` + in-container nftables | +| 1 | Skeleton: CLI, config, preview modes | `make test` (args, config, precedence) | ✅ done | +| 2 | Image build | build, cache, content-hash tag, contents asserted | ✅ done (`tests/e2e/10_image.sh`) | +| 3 | Enforcement in the container | generated ruleset shape + real-container capability checks | ✅ done (`tests/unit/06_entrypoint.sh`, `tests/e2e/11_net.sh`) | +| 4 | Egress gate | allowlisted host reachable, everything else refused, gate lifecycle | ✅ done (`tests/e2e/11_net.sh`) | +| 5 | Environment passing | deny-by-default, `0600` delivery, redaction, fail-closed on unset | ✅ done (`tests/unit/04_env.sh`, `tests/e2e/12_sandbox.sh`) | +| 6 | Limits, lifecycle, observability | exit codes, cleanup, caps, session log, `--keep` | ✅ done (`tests/e2e/12_sandbox.sh`) | +| 7 | Harness defaults, docs, portability sweep | alias UX, README/threat model, shellcheck clean, no personal paths | ✅ done | + +## Phase detail + +### 0 — Measure first (this is why the design looks unusual) +Ran `tests/spike_network.sh`. Three results decided the architecture: +containers on one user-defined network cannot talk to each other rootless here +(so no sidecar proxy on an internal network); `--network=host` works rootless and +has internet (so a host-loopback gate is possible); `pasta:--map-host-loopback,` +makes a container's connection to `` land on the host's loopback; and +`--cap-add=NET_ADMIN` lets root *inside* the container install nftables that +genuinely gate the pasta path. Also learned bind mounts need `:z` under SELinux. + +### 1 — Skeleton +`parse_args` with the alias-friendly rule (sandbox flags until the first +positional/unknown flag; those become arguments to the harness; `--` replaces the +command), config files at two levels, `AISB_*` overrides, `--dry-run` / +`--print-policy` / `--print-entrypoint` / `--print-containerfile`. +Precedence is built-in < config file < env < flag, and the built-in defaults live +only in `resolve_opts()` — materialising them at the top of the script silently +made the environment beat config files, which is a bug worth remembering. + +### 2 — Image +Generated Containerfile, tag = content hash of base image + versions + install +list + entrypoint + Containerfile itself, so a policy change never reuses a stale +image and variants coexist. Node tarballs are checksum-verified against +nodejs.org. `pi` is baked in so the `pi` alias works after one build. + +### 3 — Enforcement +`entrypoint_script()`: read policy → render `inet aisb` with `policy drop` → +`nft -f` → verify the default-drop rule exists → `setpriv` drop to uid 1000 with +`--inh-caps=-all --bounding-set=-all --no-new-privs`. Refuses to start (77/78) +rather than run unenforced. + +### 4 — Gate +Per-session squid container on `--network=host`, bound to `127.0.0.1:`, +config generated and bind-mounted read-only, `http_access deny all` last. +Sandbox gets proxy env + a ruleset that allows nothing else out. Torn down with +the session unless `gate up` created a named one. + +### 5-7 — Environment, lifecycle, polish +`0600` env file delivery and redaction everywhere; limits and caps asserted from +inside; `--keep` leaves the container *and* the session dir for forensics; +`clean` reaps (`--all` for running ones, `--volumes[=GLOB]` for unused home caches, +never a volume a container still references); `doctor` verifies the host including a +real bind/uid round-trip; docs; shellcheck-clean in a container. + +`/home/sandbox` is a named volume so npm and tool caches survive between runs. It +outlives the image, which is the one kind of staleness this design cannot hash away: +change the image's home layout and existing volumes keep the old files, so +`ensure_home_volume()` creates it labelled and `clean --volumes` is the documented way +to drop it. + +## Follow-ups (not yet done) + +* **`--net unix` mode**: `--network=none` plus a unix-socket proxy and an in-container + TCP→socket forwarder. Structurally stronger (no IP stack at all); needs a story + for tools that resolve names themselves. +* **Re-resolve `--no-gate` domains** on a timer, or resolve through the gate and pin + the returned answers, so long-running sessions do not go stale against CDNs. +* **Idle gate reaping** beyond `clean`: a `--gate-ttl` label check on start. +* **`--allow-from-lockfile`**: derive a first-cut allowlist from + `package.json`/`requirements.txt` registries so the first run is not a blank page. +* **Quarantine mode for untrusted repos**: `--net none` + read-only mount + + `--ephemeral-home` as a single flag, plus a warning if `.git` hooks exist. +* **`--record`**: optional pcap/CONNECT-log capture of a session for review. +* **`--mount` for agent skills/plugins**: first-class flag instead of a generic mount. +* Packaging: a `quadlet`/systemd unit for a long-lived named gate. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1608b61 --- /dev/null +++ b/README.md @@ -0,0 +1,182 @@ +# ai-sandbox + +Run an autonomous coding agent against your real project, with the escape routes +closed: only the current directory is mounted, no ambient environment variables, +no root inside, hard resource caps, and outbound network limited to an explicit +allowlist that the agent cannot lift. + +Single bash script, rootless podman, Ubuntu 24.04. No daemon, no k8s, no config +language. + +``` +$ cd ~/Projects/billing-api +$ pi --allow api.anthropic.com --env ANTHROPIC_API_KEY +[aisb] egress gate aisb-gate-7f3c1a -> 127.0.0.1:18084 (1 domains, 0 ip/cidr entries) +[aisb] egress net=allow enforce=nft proxy=[100.64.0.1:18084] direct=0 dns=0 + pi +``` + +`pi` there is an alias for `ai-sandbox`. Inside, the agent sees `/project` (your +cwd, read-write), nothing else on your disk, and the internet only where you +pointed it. + +## Install + +```bash +git clone && cd ai-sandbox +./install.sh --alias pi # symlinks to ~/.local/bin and adds the alias to ~/.bashrc +ai-sandbox doctor # checks podman, rootless, subuid, pasta, SELinux, uid mapping +ai-sandbox build # optional: build the image now instead of on first run +make test # 160+ assertions, no containers needed +``` + +Requirements: podman ≥ 5 running rootless (with a `/etc/subuid` range for your +user), and either `pasta` (default in podman 5) or `slirp4netns`. Nothing else — +shellcheck, tests and the image build all run through podman. + +## The alias, and how arguments are read + +Sandbox flags are consumed until the first positional argument or unknown flag; +those become **arguments to the harness**: + +```bash +alias pi='ai-sandbox' + +pi # runs pi inside the sandbox +pi --allow api.anthropic.com --env ANTHROPIC_API_KEY --model sonnet +# ^^^^^ sandbox ^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^ for pi +pi --shell # a bash shell in the same sandbox +pi --net none -- make test # -- replaces the command entirely +``` + +`--strict-args` turns an unrecognised flag into an error instead of a pass-through, +which is what you want in scripts. + +## Network policy + +| mode | what it means | +|---|---| +| `--net allow` *(default)* | Only the allowlist is reachable. A netfilter ruleset inside the container plus a domain-filtering proxy on the host loopback. If the ruleset cannot be installed, the sandbox **refuses to start** (exit 77). | +| `--net none` | No network at all. | +| `--net full` | Nothing restricted. Prints a warning. Never the default. | + +```bash +ai-sandbox --allow api.anthropic.com --allow registry.npmjs.org --env ANTHROPIC_API_KEY +ai-sandbox --allow 10.20.0.5:5432 # a database: pinned IP and port +ai-sandbox --allow files.pythonhosted.org --allow pypi.org +ai-sandbox --no-gate --allow example.com # no proxy container: pin resolved IPs instead +ai-sandbox --proxy corp-proxy.internal:3128 # route through a proxy you already run +``` + +Domains match that name and anything below it, and are enforced by the gate at +CONNECT time, so CDNs and rotating IPs are not a problem. IP/CIDR entries become +direct netfilter rules for non-HTTP destinations (databases, etc.). + +An allowlist is **required** in `allow` mode: an empty list is a hard error, never +a silent free-for-all. Keep it in the repo so the policy is reviewable: + +``` +# .ai-sandbox/allow (one entry per line, # comments fine) +api.anthropic.com +registry.npmjs.org +``` + +## Environment + +Nothing is inherited except `TERM LANG LC_ALL LC_CTYPE TZ`. + +```bash +--env ANTHROPIC_API_KEY # from your environment +--env FOO=bar # literal (shows up in ps; prefer the two below) +--env-file ~/.config/secrets # a 0600 NAME=VALUE file +``` + +Values are handed to podman through a `0600` file that is removed when the run +ends, so they never appear in `ps`, in the printed dry-run, or in the session log. +Asking for a variable that is not set fails the run closed. + +## Filesystem + +Your cwd is mounted at `/project` and is the working directory — that is the whole +default. Everything else is opt-in: + +```bash +--mount ~/.ssh:ro # if the agent must be able to push +--mount ../docs:/docs:ro # read-only reference material +--no-cwd # a scratch sandbox with nothing mounted +--read-only # read-only root filesystem +--ephemeral-home # do not persist /home/sandbox (default: persist in a volume) +``` + +Files the agent writes land on your disk owned by **your** uid, not root +(`--userns=keep-id`, verified by `doctor`). + +## Image + +Built once per distinct policy from `ubuntu:24.04`, tagged by content hash, reused +afterwards: git, curl, jq, build tools, python3, Node 22 (checksum-verified +against nodejs.org) and the `pi` CLI. `--install PKG` adds packages and produces a +different tag, so variants coexist. `--base-image` swaps the base. + +## Inspecting a run + +```bash +ai-sandbox --dry-run --allow example.com --env KEY # exact podman invocation, secrets redacted +ai-sandbox --print-policy # effective policy as key=value lines +ai-sandbox --print-entrypoint # the enforcement script baked into the image +ai-sandbox doctor [--deep] # host checks; --deep tests egress for real +ai-sandbox ps | clean [--all] [--volumes[=GLOB]] | inspect | gate up|status|logs|down +``` + +State lives under `~/.local/state/ai-sandbox` (session dirs, `log` of every run +with no secret values, build logs). Config in `~/.config/ai-sandbox`. + +`/home/sandbox` is a named volume (`aisb-home`) so npm and tool caches survive +between runs. It outlives the image, so after changing the image you may be looking +at a stale home: `ai-sandbox clean --volumes` reclaims unused ones. It only deletes +volumes in the `aisb-` namespace that no container references, and `--volumes=GLOB` +narrows it to one name. + +## Why it is hard to undo from inside + +The container starts as root with `CAP_NET_ADMIN`, the entrypoint installs the +egress ruleset, and then it execs your command through + +``` +setpriv --reuid=1000 --regid=1000 --clear-groups --inh-caps=-all --bounding-set=-all --no-new-privs +``` + +No root, no capabilities, `no-new-privileges`. Flushing the ruleset needs +`CAP_NET_ADMIN`; `unshare -n` needs `CAP_SYS_ADMIN`; setuid helpers cannot hand +either back. Both are asserted against a real container in +[`tests/e2e/11_net.sh`](tests/e2e/11_net.sh). Details and the measurements behind +the design: [docs/networking.md](docs/networking.md). + +**Read [docs/threat-model.md](docs/threat-model.md)** — it says plainly what this +does not protect you from (allowlisted destinations, container escapes, anything +you explicitly mounted). + +## Project layout + +``` +bin/ai-sandbox the application: one script, ~1.3k lines +install.sh symlink into ~/.local/bin + optional alias +config/ example config, allowlist and env file +docs/ networking.md, threat-model.md, recipes.md +tests/run.sh runner; tests/unit/* (no containers) tests/e2e/* (real ones) +tests/spike_network.sh re-measure podman's behaviour on a new host +Makefile make test | e2e | lint | check | doctor | build +``` + +## Development + +```bash +make test # unit: args, config, argv construction, env handling, rule generation +make e2e # + real containers: build cache, egress blocking, capability drop, lifecycle +make lint # shellcheck in a container +make style # bash -n +``` + +The Containerfile, the container entrypoint and the squid config are all generated +from `bin/ai-sandbox`, so the script remains the single source of truth +(`--print-containerfile`, `--print-entrypoint`). diff --git a/bin/ai-sandbox b/bin/ai-sandbox new file mode 100755 index 0000000..68dfdbc --- /dev/null +++ b/bin/ai-sandbox @@ -0,0 +1,1459 @@ +#!/usr/bin/env bash +# +# ai-sandbox -- rootless podman sandbox for autonomous agentic development. +# +# One script. It builds a small Ubuntu 24.04 image, then runs any command inside +# it with: only the current directory mounted, no ambient environment variables, +# no root and no capabilities inside the container, hard resource caps, and +# outbound network access limited to an explicit allowlist that the in-container +# process cannot lift. +# +# Meant to be aliased to an agent harness: +# +# alias pi='ai-sandbox' +# cd ~/Projects/foo && pi --allow api.anthropic.com --env ANTHROPIC_API_KEY +# +set -Eeuo pipefail + +VERSION="0.1.0" +PROG="${0##*/}" + +# =========================================================================== +# defaults (precedence: built-in < config file < AISB_* env < CLI flag) +# =========================================================================== +XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" +XDG_STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}" +XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" + +AISB_CONFIG_DIR="${AISB_CONFIG_DIR:-$XDG_CONFIG_HOME/ai-sandbox}" +AISB_STATE_DIR="${AISB_STATE_DIR:-$XDG_STATE_HOME/ai-sandbox}" +AISB_CACHE_DIR="${AISB_CACHE_DIR:-$XDG_CACHE_HOME/ai-sandbox}" + +AISB_IMAGE_REPO="${AISB_IMAGE_REPO:-localhost/ai-sandbox}" +AISB_GATE_IMAGE_REPO="${AISB_GATE_IMAGE_REPO:-localhost/ai-sandbox-gate}" +AISB_PI_PACKAGE="${AISB_PI_PACKAGE:-@earendil-works/pi-coding-agent}" + +# The knobs below are deliberately NOT defaulted here. A value set in the +# environment has to beat a config file, and a config file has to beat the +# built-in default -- so the built-in defaults live in resolve_opts() and the +# environment variables stay unset unless the user really set them. +# AISB_NET AISB_HARNESS AISB_PROJECT_DEST AISB_CPUS AISB_MEMORY AISB_PIDS +# AISB_TMPFS_SIZE AISB_BASE_IMAGE AISB_NODE_VERSION AISB_PI_VERSION +# AISB_SELINUX AISB_GATE_ADDR AISB_GATE_PORT AISB_HOME_CACHE AISB_EXTRA_ARGS +AISB_UID="${AISB_UID:-1000}" +AISB_GID="${AISB_GID:-1000}" +# SELinux relabel mode: ':z' (shared label) is what an SELinux host needs; hosts +# without SELinux get no option at all. Only a fallback -- AISB_SELINUX and the +# config file both win over it. +if command -v getenforce > /dev/null 2>&1; then AISB_SELINUX_FALLBACK=":z" +else AISB_SELINUX_FALLBACK=""; fi +AISB_GATE_KEEP="${AISB_GATE_KEEP:-0}" +AISB_HOME_VOLUME="${AISB_HOME_VOLUME:-aisb-home}" + +# Nothing is inherited from the host environment except these. +AISB_IMPLICIT_ENV=(TERM LANG LC_ALL LC_CTYPE TZ) + +# =========================================================================== +# helpers +# =========================================================================== +log() { printf '%s: %s\n' "$PROG" "$*" >&2; } +warn() { printf '%s: warning: %s\n' "$PROG" "$*" >&2; } +die() { printf '%s: error: %s\n' "$PROG" "$*" >&2; exit 1; } +have() { command -v "$1" >/dev/null 2>&1; } +sha12() { printf '%s' "$1" | sha256sum | cut -c1-12; } +need() { [[ -n "${2:-}" ]] || die "$1 requires a value (see --help)"; } +trim() { local s="$1"; s="${s#"${s%%[![:space:]]*}"}"; s="${s%"${s##*[![:space:]]}"}"; printf '%s' "$s"; } + +is_v4() { [[ "${1%%/*}" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; } +is_v4cidr() { [[ "$1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/([0-9]|[12][0-9]|3[0-2])$ ]]; } +is_v6() { [[ "$1" == *:* ]]; } + +resolve_v4() { getent ahostsv4 "$1" 2>/dev/null | awk '{print $1; exit}'; } + +node_arch() { case "$(uname -m)" in x86_64) echo x64 ;; aarch64) echo arm64 ;; *) uname -m ;; esac; } + +session_id() { printf '%s%04x' "$(date +%s | tail -c 6)" "$((RANDOM % 65536))"; } + +# Split HOST[:PORT] / [IPv6]:PORT / CIDR -> TARGET_HOST TARGET_PORT +split_target() { + local t="$1" + TARGET_HOST="$t"; TARGET_PORT="" + if [[ "$t" == \[* ]]; then + TARGET_HOST="${t%]*}"; TARGET_HOST="${TARGET_HOST#\[}" + [[ "$t" == *]:* ]] && TARGET_PORT="${t##*]:}" + elif [[ "$t" != */* && "$t" != *:*:* ]]; then + if [[ "$t" == *:* ]]; then TARGET_HOST="${t%%:*}"; TARGET_PORT="${t#*:}"; fi + fi + return 0 +} + +# SELinux relabel option, normalised: SELOPT ':z' | '' and SELOPT_SEP ',z' | '' +SELOPT="" SELOPT_SEP="" +set_selinux() { + SELOPT="${1:-}" + if [[ -n "${SELOPT#:}" ]]; then SELOPT_SEP=",${SELOPT#:}"; else SELOPT_SEP=""; fi +} + +# Shell-quote an argument only when it is not already safe. Unlike printf %q this +# leaves ',', ':' and '/' alone, which keeps the dry-run output readable. +shquote() { + local s="$1" + if [[ -n "$s" && "$s" =~ ^[A-Za-z0-9_@%+=:,./-]+$ ]]; then printf '%s' "$s" + else printf '%q' "$s"; fi +} + +# value on one line, no leading/trailing blank space +vline() { printf '%s' "$*" | sed 's/ */ /g; s/^ //; s/ $//'; } + +# join -- no trailing separator +join() { + local sep="$1" out="" v + shift + for v in "$@"; do + [[ -n "$out" ]] && out+="$sep" + out+="$v" + done + printf '%s' "$out" +} + +dedup() { # dedup + local -n _dd_in="$1" + local v out=() seen=" " + for v in ${_dd_in[@]+"${_dd_in[@]}"}; do + [[ "$seen" == *" $v "* ]] && continue + out+=("$v"); seen="$seen$v " + done + _dd_in=(${out[@]+"${out[@]}"}) +} + +# =========================================================================== +# usage +# =========================================================================== +usage() { + cat < | ps + $PROG clean [--all] [--volumes] | inspect | version + + Sandbox flag parsing stops at the first positional argument or unknown flag. + Those arguments become arguments *to the harness*, which is what makes an alias + behave like the harness itself: + + alias pi='$PROG' + pi --allow api.anthropic.com --env ANTHROPIC_API_KEY --model sonnet + # ^ sandbox flags ^ arguments for pi + + pi src/main.py # runs: pi src/main.py + pi -- bash -lc 'make' # -- replaces the command entirely + --strict-args turns an unknown flag into an error instead of a pass-through. + +NETWORK + --net none|allow|full Egress mode (default: allow). + none No network namespace at all. + allow Only the allowlist is reachable: an in-container netfilter ruleset + (installed by the entrypoint, unremovable by the unprivileged + command) plus an HTTP(S) gate on the host that filters by domain. + full Unrestricted, with a warning. Never the default. + --allow HOST[:PORT] Allow a domain, IPv4/IPv6 or CIDR. Repeatable. + Domains match that name and anything below it. + --allow-file FILE More allow entries, one per line (# comments allowed). + --proxy HOST:PORT Use an existing HTTP(S) proxy instead of the built-in gate. + --no-gate No gate container: enforce the allowlist as IPs only. + Domains are resolved once, on the host, at start, and + DNS is opened up. Weaker than the gate for CDNs. + +FILESYSTEM + The current directory is bind-mounted at /project and is the working directory. + Nothing else on the host is visible unless you add it. + --mount PATH[:DEST][:ro] Extra bind mount. Repeatable. Default rw, DEST=/mnt/. + --no-cwd Do not mount the current directory. + --workdir DIR Working directory in the container. Default /project. + --home-cache Persist /home/sandbox in a named volume (default). + --ephemeral-home Discard /home/sandbox on exit. + --read-only Read-only root filesystem (plus tmpfs /tmp). + +ENVIRONMENT + Inherited by default: TERM LANG LC_ALL LC_CTYPE TZ. Nothing else. + --env NAME Pass NAME from the host environment. Repeatable. + --env NAME=VALUE Set a literal value (visible in 'ps'; prefer --env/--env-file). + --env-file FILE Pass every NAME=VALUE in FILE. chmod 600 it. + Values reach the container through a 0600 file, never + through the command line, and never into the logs. + +IMAGE + --base-image REF Default docker.io/library/ubuntu:24.04 + --install PKG Extra apt package in the image. Repeatable. + Each distinct set gets its own cached image tag. + --harness NAME Default in-container command: pi | codex | claude | none. + +LIMITS + --cpus N (4) --memory SIZE (4g) --pids N (512) --tmpfs-size SIZE (1g) + +RUN + --shell Run bash instead of the harness. + --name NAME Container name (default aisb-). + --label K=V Extra label. Repeatable. + --keep Keep the container after it exits. + --interactive | --no-tty Attachment. A TTY is allocated when stdin+stdout are TTYs. + --dry-run, -n Print the exact podman invocation (secrets redacted), exit 0. + --print-policy Print the effective policy as key=value lines, exit 0. + --print-entrypoint Print the script that is baked into the image as the + container entrypoint (the enforcement half), exit 0. + --print-containerfile Print the generated Containerfile, exit 0. + +ESCAPE HATCHES (they weaken the sandbox, which is why they are named this way) + --allow-unenforced Start even if the egress ruleset cannot be installed. + Default: refuse to start (exit 77). + --insecure-keep-root Run the command as root with capabilities intact. + --strict-args Unknown flags are an error instead of a pass-through. + +SUBCOMMANDS + build Build or reuse the sandbox image (--rebuild, --gate for the gate image). + doctor Check the host: podman, rootless, subuid, helpers, SELinux, uid mapping. + gate up | down | status | logs -- manage a named long-lived egress gate. + ps List sandbox containers. + clean Remove leftover sandbox containers, gates and session files. + --all also removes running ones; --volumes[=GLOB] then reclaims + home-cache volumes that no container references (npm/tool caches, + what 'npm i -g' put there). Run it when /home/sandbox looks stale + after an image change. + inspect Effective policy for this directory. + +CONFIG + \$HOME/.config/ai-sandbox/{config,allow} and ./.ai-sandbox/{config,allow} + Every default can also be set as an environment variable: + AISB_NET, AISB_ALLOW (comma list), AISB_ENV (comma list), AISB_CPUS, AISB_MEMORY, + AISB_PIDS, AISB_HARNESS, AISB_EXTRA_ARGS, AISB_SELINUX, AISB_HOME_CACHE, ... + +EXIT STATUS + The exit code of the in-container command. 77/78: the sandbox refused to start + because it could not enforce its own policy. +EOF +} + +# =========================================================================== +# config files +# =========================================================================== +declare -A CFG=() +declare -a CFG_allow=() CFG_env=() CFG_mount=() CFG_install=() CFG_label=() + +cfg_accum_keys="allow env mount install label" + +cfg_read_kv() { # `key = value` file + local file="$1" line key val + [[ -r "$file" ]] || return 0 + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%%#*}" + line="$(trim "$line")" + [[ -n "$line" ]] || continue + [[ "$line" == *=* ]] || { warn "$file: ignoring line without '=': $line"; continue; } + key="$(trim "${line%%=*}")"; val="$(trim "${line#*=}")" + val="${val%\"}"; val="${val#\"}" + if [[ " $cfg_accum_keys " == *" $key "* ]]; then + local -n ref="CFG_$key" + ref+=("$val") + continue + fi + case "$key" in + net|harness|workdir|cpus|memory|pids|tmpfs_size|base_image|node_version|pi_version|\ + project_dest|selinux|home_cache|gate_addr|gate_port|extra_args) CFG["$key"]="$val" ;; + *) warn "$file: unknown key: $key" ;; + esac + done < "$file" +} + +cfg_read_allowlist() { # one target per line + local file="$1" line + [[ -r "$file" ]] || return 0 + while IFS= read -r line || [[ -n "$line" ]]; do + line="$(trim "${line%%#*}")" + [[ -n "$line" ]] && CFG_allow+=("$line") + done < "$file" +} + +load_config() { + cfg_read_kv "$AISB_CONFIG_DIR/config" + cfg_read_allowlist "$AISB_CONFIG_DIR/allow" + cfg_read_kv "./.ai-sandbox/config" + cfg_read_allowlist "./.ai-sandbox/allow" + local e + local IFS=',' + for e in ${AISB_ALLOW:-}; do [[ -n "$e" ]] && CFG_allow+=("$(trim "$e")"); done + for e in ${AISB_ENV:-}; do [[ -n "$e" ]] && CFG_env+=("$(trim "$e")"); done + for e in ${AISB_MOUNT:-}; do [[ -n "$e" ]] && CFG_mount+=("$(trim "$e")"); done + for e in ${AISB_INSTALL:-}; do [[ -n "$e" ]] && CFG_install+=("$(trim "$e")"); done + IFS=$' \t\n' + return 0 +} + +# =========================================================================== +# option state + parsing +# =========================================================================== +OPT_NET="" OPT_HARNESS="" OPT_WORKDIR="" OPT_CPUS="" OPT_MEMORY="" OPT_PIDS="" OPT_TMPFS="" +OPT_NAME="" OPT_PROXY="" OPT_GATE="" OPT_BASE_IMAGE="" +OPT_NO_CWD=0 OPT_SHELL=0 OPT_DRY=0 OPT_PRINT=0 OPT_NO_BUILD=0 OPT_KEEP=0 OPT_READONLY=0 +OPT_HOME_CACHE="" OPT_STRICT="${AISB_STRICT_ARGS:-0}" OPT_UNENFORCED=0 OPT_KEEP_ROOT=0 +OPT_TTY="" OPT_INTERACTIVE="" OPT_PRINT_EP=0 OPT_PRINT_CF=0 OPT_PASSTHROUGH=0 +declare -a OPT_allow=() OPT_env=() OPT_mount=() OPT_install=() OPT_label=() OPT_allow_files=() +COMMAND=() + +strict_on() { [[ "${OPT_STRICT:-0}" == 1 || "${AISB_STRICT_ARGS:-0}" == 1 ]]; } + +KNOWN_OPTS=( + net allow allow-file proxy no-gate env env-file mount no-cwd workdir home-cache ephemeral-home + harness install base-image cpus memory pids tmpfs-size name label keep read-only shell + interactive no-tty dry-run print-policy no-build allow-unenforced strict-args insecure-keep-root + print-entrypoint print-containerfile help version +) +suggest() { + local given="$1" o hit=0 hits="" + for o in "${KNOWN_OPTS[@]}"; do + if [[ "--$o" == "$given"* ]]; then hits="${hits:+$hits }--$o"; hit=1; fi + done + (( hit )) && printf 'did you mean %s? ' "$hits" + return 0 +} + +parse_args() { + while (($#)); do + case "$1" in + --) shift; COMMAND=("$@"); return ;; + -h|--help) usage; exit 0 ;; + -V|--version) printf '%s %s\n' "$PROG" "$VERSION"; exit 0 ;; + --net) need "$1" "${2:-}"; OPT_NET="$2"; shift 2 ;; + --net=*) OPT_NET="${1#*=}"; shift ;; + --harness) need "$1" "${2:-}"; OPT_HARNESS="$2"; shift 2 ;; + --harness=*) OPT_HARNESS="${1#*=}"; shift ;; + --allow) need "$1" "${2:-}"; OPT_allow+=("$2"); shift 2 ;; + --allow=*) OPT_allow+=("${1#*=}"); shift ;; + --allow-file) need "$1" "${2:-}"; OPT_allow_files+=("$2"); shift 2 ;; + --allow-file=*) OPT_allow_files+=("${1#*=}"); shift ;; + --proxy) need "$1" "${2:-}"; OPT_PROXY="$2"; shift 2 ;; + --proxy=*) OPT_PROXY="${1#*=}"; shift ;; + --no-gate) OPT_GATE=1; shift ;; + --env) need "$1" "${2:-}"; OPT_env+=("$2"); shift 2 ;; + --env=*) OPT_env+=("${1#*=}"); shift ;; + --env-file) need "$1" "${2:-}"; OPT_env+=("@${2}"); shift 2 ;; + --env-file=*) OPT_env+=("@${1#*=}"); shift ;; + --mount) need "$1" "${2:-}"; OPT_mount+=("$2"); shift 2 ;; + --mount=*) OPT_mount+=("${1#*=}"); shift ;; + --no-cwd) OPT_NO_CWD=1; shift ;; + --workdir) need "$1" "${2:-}"; OPT_WORKDIR="$2"; shift 2 ;; + --workdir=*) OPT_WORKDIR="${1#*=}"; shift ;; + --home-cache) OPT_HOME_CACHE=1; shift ;; + --ephemeral-home) OPT_HOME_CACHE=0; shift ;; + --install) need "$1" "${2:-}"; OPT_install+=("$2"); shift 2 ;; + --install=*) OPT_install+=("${1#*=}"); shift ;; + --base-image) need "$1" "${2:-}"; OPT_BASE_IMAGE="$2"; shift 2 ;; + --base-image=*) OPT_BASE_IMAGE="${1#*=}"; shift ;; + --cpus) need "$1" "${2:-}"; OPT_CPUS="$2"; shift 2 ;; + --cpus=*) OPT_CPUS="${1#*=}"; shift ;; + --memory) need "$1" "${2:-}"; OPT_MEMORY="$2"; shift 2 ;; + --memory=*) OPT_MEMORY="${1#*=}"; shift ;; + --pids) need "$1" "${2:-}"; OPT_PIDS="$2"; shift 2 ;; + --pids=*) OPT_PIDS="${1#*=}"; shift ;; + --tmpfs-size) need "$1" "${2:-}"; OPT_TMPFS="$2"; shift 2 ;; + --tmpfs-size=*) OPT_TMPFS="${1#*=}"; shift ;; + --name) need "$1" "${2:-}"; OPT_NAME="$2"; shift 2 ;; + --name=*) OPT_NAME="${1#*=}"; shift ;; + --label) need "$1" "${2:-}"; OPT_label+=("$2"); shift 2 ;; + --label=*) OPT_label+=("${1#*=}"); shift ;; + --keep) OPT_KEEP=1; shift ;; + --read-only) OPT_READONLY=1; shift ;; + --shell) OPT_SHELL=1; shift ;; + --interactive) OPT_INTERACTIVE=1; shift ;; + --no-tty) OPT_TTY=0; shift ;; + --dry-run|-n) OPT_DRY=1; shift ;; + --print-policy) OPT_PRINT=1; shift ;; + --print-entrypoint) OPT_PRINT_EP=1; shift ;; + --print-containerfile) OPT_PRINT_CF=1; shift ;; + --no-build) OPT_NO_BUILD=1; shift ;; + --allow-unenforced) OPT_UNENFORCED=1; shift ;; + --insecure-keep-root) OPT_KEEP_ROOT=1; shift ;; + --strict-args) OPT_STRICT=1; shift ;; + -*) + if strict_on; then + die "unknown option: $1 $(suggest "$1")(see --help)" + fi + # First unrecognised flag: it belongs to the harness, and so does the rest. + COMMAND=("$@"); OPT_PASSTHROUGH=1; return ;; + *) + COMMAND=("$@"); OPT_PASSTHROUGH=1; return ;; + esac + done +} + +# =========================================================================== +# resolve +# =========================================================================== +declare -A POL=() +declare -a P_allow=() P_env=() P_mount=() P_install=() P_label=() + +cfgv() { local k="$1" d="$2"; printf '%s' "${CFG[$k]:-$d}"; } + +resolve_opts() { + POL[net]="${OPT_NET:-${AISB_NET:-$(cfgv net allow)}}" + POL[harness]="${OPT_HARNESS:-${AISB_HARNESS:-$(cfgv harness pi)}}" + POL[workdir]="${OPT_WORKDIR:-${AISB_PROJECT_DEST:-$(cfgv workdir /project)}}" + POL[cpus]="${OPT_CPUS:-${AISB_CPUS:-$(cfgv cpus 4)}}" + POL[memory]="${OPT_MEMORY:-${AISB_MEMORY:-$(cfgv memory 4g)}}" + POL[pids]="${OPT_PIDS:-${AISB_PIDS:-$(cfgv pids 512)}}" + POL[tmpfs]="${OPT_TMPFS:-${AISB_TMPFS_SIZE:-$(cfgv tmpfs_size 1g)}}" + POL[base_image]="${OPT_BASE_IMAGE:-${AISB_BASE_IMAGE:-$(cfgv base_image docker.io/library/ubuntu:24.04)}}" + POL[node_version]="${AISB_NODE_VERSION:-$(cfgv node_version 22.23.4)}" + POL[pi_version]="${AISB_PI_VERSION:-$(cfgv pi_version 1.4.0)}" + POL[selinux]="${AISB_SELINUX-$(cfgv selinux "$AISB_SELINUX_FALLBACK")}" + POL[home_cache]="${OPT_HOME_CACHE:-${AISB_HOME_CACHE:-$(cfgv home_cache 1)}}" + POL[gate_addr]="${AISB_GATE_ADDR:-$(cfgv gate_addr 100.64.0.1)}" + POL[gate_port]="${AISB_GATE_PORT:-$(cfgv gate_port 18081)}" + POL[extra_args]="${AISB_EXTRA_ARGS:-$(cfgv extra_args '')}" + POL[proxy]="${OPT_PROXY}" + POL[no_gate]="${OPT_GATE:-0}" + POL[shell]="$OPT_SHELL" + POL[readonly]="$OPT_READONLY" + POL[unenforced]="$OPT_UNENFORCED" + POL[keep_root]="$OPT_KEEP_ROOT" + POL[name]="${OPT_NAME:-aisb-${SESSION:-$(session_id)}}" + POL[cwd]="$PWD" + + P_allow=(${CFG_allow[@]+"${CFG_allow[@]}"} ${OPT_allow[@]+"${OPT_allow[@]}"}) + P_env=(${CFG_env[@]+"${CFG_env[@]}"} ${OPT_env[@]+"${OPT_env[@]}"}) + P_mount=(${CFG_mount[@]+"${CFG_mount[@]}"} ${OPT_mount[@]+"${OPT_mount[@]}"}) + P_install=(${CFG_install[@]+"${CFG_install[@]}"} ${OPT_install[@]+"${OPT_install[@]}"}) + P_label=(${CFG_label[@]+"${CFG_label[@]}"} ${OPT_label[@]+"${OPT_label[@]}"}) + + local f line + for f in ${OPT_allow_files[@]+"${OPT_allow_files[@]}"}; do + [[ -r "$f" ]] || die "--allow-file not readable: $f" + while IFS= read -r line || [[ -n "$line" ]]; do + line="$(trim "${line%%#*}")" + [[ -n "$line" ]] && P_allow+=("$line") + done < "$f" + done + + dedup P_allow; dedup P_env; dedup P_mount; dedup P_install; dedup P_label + set_selinux "${POL[selinux]}" +} + +validate_opts() { + case "${POL[net]}" in + none|allow|full) ;; + *) die "--net must be none, allow or full (got '${POL[net]}')" ;; + esac + [[ "${POL[pids]}" =~ ^[0-9]+$ ]] || die "--pids must be an integer (got '${POL[pids]}')" + [[ "${POL[cpus]}" =~ ^[0-9.]+$ ]] || die "--cpus must be a number (got '${POL[cpus]}')" + [[ "${POL[memory]}" =~ ^[0-9]+([bkmgBKMG]?)$ ]] || die "--memory must look like 512m or 4g (got '${POL[memory]}')" + [[ -d "${POL[cwd]}" ]] || die "current directory does not exist" + if [[ "${POL[net]}" == full ]]; then + warn "--net full: the container has unrestricted internet access" + fi + if [[ "${POL[net]}" == allow && ${#P_allow[@]} -eq 0 && -z "${POL[proxy]}" ]]; then + die "no egress allowlist configured. Refusing to start wide open. + add one: $PROG --allow api.openai.com --env OPENAI_API_KEY + or go dark: $PROG --net none + or opt out: $PROG --net full" + fi +} + +# =========================================================================== +# session, env file, policy files +# =========================================================================== +SESSION="" SESSION_DIR="" SESSION_MOUNT="" ENV_FILE="" GATE_DIR="" +GATE_NAME="" GATE_PORT="" GATE_STARTED=0 +declare -a ENV_NAMES=() +declare -a DIRECT4=() DIRECT6=() GATE_DOMAINS=() GATE_IPS=() PROXY_IPS=() PROXY_PORTS=() + +session_init() { + SESSION="${AISB_SESSION_OVERRIDE:-$(session_id)}" + SESSION_DIR="$AISB_STATE_DIR/sessions/$SESSION" + SESSION_MOUNT="$SESSION_DIR/mnt" + GATE_DIR="$SESSION_DIR/gate" + ENV_FILE="$SESSION_DIR/env" + mkdir -p "$SESSION_MOUNT" "$GATE_DIR" + # The session dir holds the secret env file and stays 0700. The two directories + # that get bind-mounted must be world-readable: the container starts as root but + # with --cap-drop=ALL, so it has no DAC_OVERRIDE and container root is *not* your + # uid under --userns=keep-id. Nothing secret goes in here (policy and gate config + # only -- both describe the allowlist, never credentials). + chmod 700 "$SESSION_DIR" + chmod 755 "$SESSION_MOUNT" "$GATE_DIR" +} + +teardown() { + local rc=$? + trap - EXIT + [[ -n "${ENV_FILE:-}" && -f "${ENV_FILE:-}" ]] && rm -f "$ENV_FILE" + if [[ -n "${GATE_NAME:-}" && "${GATE_STARTED:-0}" == 1 && "${AISB_GATE_KEEP:-0}" != 1 ]]; then + podman rm -f "$GATE_NAME" >/dev/null 2>&1 || true + fi + # --keep leaves the session dir (env file, policy, gate config) for inspection + if [[ "${OPT_KEEP:-0}" != 1 && -n "${SESSION_DIR:-}" && -d "${SESSION_DIR:-}" ]]; then + rm -rf "$SESSION_DIR" + fi + return $rc +} + +declare -a MISSING_ENV=() + +build_env_file() { + : > "$ENV_FILE"; chmod 600 "$ENV_FILE" + local spec name value line f mode + local -a missing=() + for spec in ${P_env[@]+"${P_env[@]}"}; do + if [[ "$spec" == @* ]]; then # --env-file + f="${spec#@}" + [[ -r "$f" ]] || die "--env-file not readable: $f" + mode="$(stat -c '%a' "$f" 2>/dev/null || echo 644)" + [[ "$mode" == "600" || "$mode" == "400" ]] || warn "--env-file $f is mode $mode; chmod 600 recommended" + while IFS= read -r line || [[ -n "$line" ]]; do + line="$(trim "$line")" + [[ -n "$line" && "$line" != \#* ]] || continue + [[ "$line" == *=* ]] || { warn "ignoring env-file line without '=': $line"; continue; } + [[ "$line" != *$'\n'* ]] || die "--env-file $f: value for ${line%%=*} spans lines" + printf '%s\n' "$line" >> "$ENV_FILE" + ENV_NAMES+=("${line%%=*}") + done < "$f" + continue + fi + if [[ "$spec" == *=* ]]; then + printf '%s\n' "$spec" >> "$ENV_FILE" + ENV_NAMES+=("${spec%%=*}") + continue + fi + name="$spec" + [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || die "--env: invalid variable name: $name" + if [[ -z "${!name+x}" ]]; then missing+=("$name"); continue; fi + value="${!name}" + [[ "$value" != *$'\n'* ]] || die "--env $name: value contains a newline (not supported by podman --env-file)" + printf '%s=%s\n' "$name" "$value" >> "$ENV_FILE" + ENV_NAMES+=("$name") + done + if ((${#missing[@]})); then + # --dry-run / --print-policy report the problem instead of failing, so that a + # policy can be inspected before the credentials exist in the environment. + if (( OPT_DRY || OPT_PRINT )); then + MISSING_ENV=(${missing[@]+"${missing[@]}"}) + ENV_NAMES+=(${missing[@]+"${missing[@]}"}) + warn "--env ${missing[*]} requested but not set in the host environment" + else + die "--env ${missing[*]} requested but not set in the host environment (not passed; fail closed)" + fi + fi + chmod 600 "$ENV_FILE" +} + +# allow entries -> gate ACLs + direct netfilter entries +classify_allows() { + DIRECT4=() DIRECT6=() GATE_DOMAINS=() GATE_IPS=() + local t ports + for t in ${P_allow[@]+"${P_allow[@]}"}; do + split_target "$t" + local host="$TARGET_HOST" port="${TARGET_PORT}" + [[ -z "$port" || "$port" =~ ^[0-9]{1,5}$ ]] || die "--allow: bad port in '$t'" + ports="${port:-80,443}" + ports="${ports// /}" + if is_v4 "$host"; then + DIRECT4+=("$host $ports"); GATE_IPS+=("$host") + elif is_v4cidr "$host"; then + DIRECT4+=("$host $ports"); GATE_IPS+=("$host") + elif is_v6 "$host"; then + DIRECT6+=("$host $ports"); GATE_IPS+=("$host") + elif [[ "${POL[no_gate]}" == 1 ]]; then + local ip; ip="$(resolve_v4 "$host" || true)" + [[ -n "$ip" ]] || die "--allow $host: could not resolve it (with --no-gate, domains are resolved on the host at start)" + DIRECT4+=("$ip $ports") + else + GATE_DOMAINS+=("$host") + fi + done + dedup DIRECT4; dedup DIRECT6; dedup GATE_IPS; dedup GATE_DOMAINS +} + +resolve_proxy_endpoints() { + PROXY_IPS=(); PROXY_PORTS=() + ALLOW_DNS=0 + case "${POL[net]}" in + none|full) return 0 ;; + esac + if [[ -n "${POL[proxy]}" ]]; then + split_target "${POL[proxy]}" + local ip="$TARGET_HOST" + is_v4 "$ip" || ip="$(resolve_v4 "$ip" || true)" + [[ -n "$ip" ]] || die "--proxy: cannot resolve ${POL[proxy]}" + PROXY_IPS=("$ip"); PROXY_PORTS=("${TARGET_PORT:-3128}") + elif [[ "${POL[no_gate]}" != 1 ]]; then + PROXY_IPS=("${POL[gate_addr]}"); PROXY_PORTS=("${GATE_PORT:-${POL[gate_port]}}") + else + ALLOW_DNS=1 + warn "--no-gate: DNS is reachable (direct egress is still limited to the resolved IPs)" + fi + return 0 +} + +write_policy_files() { + { + printf 'AISB_NET=%s\n' "${POL[net]}" + printf 'AISB_UID=%s\n' "$AISB_UID" + printf 'AISB_GID=%s\n' "$AISB_GID" + printf 'AISB_UNENFORCED=%s\n' "${POL[unenforced]}" + printf 'AISB_KEEP_ROOT=%s\n' "${POL[keep_root]}" + printf 'AISB_ALLOW_ICMP=%s\n' "${AISB_ALLOW_ICMP:-1}" + printf 'AISB_ALLOW_DNS=%s\n' "$ALLOW_DNS" + printf 'AISB_PROXY_IPS=%s\n' "${PROXY_IPS[*]-}" + printf 'AISB_PROXY_PORTS=%s\n' "${PROXY_PORTS[*]-}" + } > "$SESSION_MOUNT/policy.env" + chmod 644 "$SESSION_MOUNT/policy.env" + printf '%s\n' ${DIRECT4[@]+"${DIRECT4[@]}"} > "$SESSION_MOUNT/direct4.list" + printf '%s\n' ${DIRECT6[@]+"${DIRECT6[@]}"} > "$SESSION_MOUNT/direct6.list" + chmod 644 "$SESSION_MOUNT/direct4.list" "$SESSION_MOUNT/direct6.list" +} + +# =========================================================================== +# image assets (single source of truth: this script) +# =========================================================================== +entrypoint_script() { + cat <<'AISB_ENTRY' +#!/bin/bash +# ai-sandbox container entrypoint. +# +# 1. read the policy the host wrote +# 2. install the egress ruleset (needs CAP_NET_ADMIN, which this process has) +# 3. drop every capability and switch to the unprivileged sandbox user +# +# Step 3 is what makes step 2 stick: the command cannot flush the ruleset, +# create a new network namespace, or regain privileges through a setuid helper. +set -Eeuo pipefail + +POLICY_DIR="${AISB_POLICY_DIR:-/run/aisb}" +POLICY="$POLICY_DIR/policy.env" + +log() { printf '[aisb] %s\n' "$*" >&2; } +fail() { printf '[aisb] FATAL: %s\n' "$1" >&2; exit "${2:-1}"; } + +[[ -r "$POLICY" ]] || fail "policy file $POLICY is missing -- not starting" 78 +# shellcheck source=/dev/null +source "$POLICY" + +NET="${AISB_NET:-none}" +UID_TARGET="${AISB_UID:-1000}" +GID_TARGET="${AISB_GID:-1000}" +UNENFORCED="${AISB_UNENFORCED:-0}" +KEEP_ROOT="${AISB_KEEP_ROOT:-0}" + +gen_rules() { + local dest ports fam + echo 'table inet aisb {' + echo ' chain output {' + echo ' type filter hook output priority filter; policy drop;' + echo ' oifname "lo" accept comment "aisb-lo"' + echo ' ct state established,related accept comment "aisb-est"' + if [[ "${AISB_ALLOW_ICMP:-0}" == 1 ]]; then + echo ' meta l4proto icmp icmp type echo-request counter accept comment "aisb-icmp"' + echo ' meta l4proto ipv6-icmp icmpv6 type { echo-request, echo-reply, nd-neighbor-solicit, nd-neighbor-advert, nd-router-solicit, nd-router-advert } counter accept comment "aisb-icmp6"' + fi + local f + for f in "$POLICY_DIR/direct4.list" "$POLICY_DIR/direct6.list"; do + [[ -s "$f" ]] || continue + while read -r dest ports || [[ -n "${dest:-}" ]]; do + [[ -n "${dest:-}" ]] || continue + ports="${ports:-80,443}" + fam=ip; [[ "$dest" == *:* ]] && fam=ip6 + echo " $fam daddr $dest tcp dport { ${ports// /,} } counter accept comment \"aisb-direct\"" + done < "$f" + done + local ip port + for ip in ${AISB_PROXY_IPS:-}; do + for port in ${AISB_PROXY_PORTS:-}; do + echo " ip daddr $ip tcp dport $port counter accept comment \"aisb-proxy\"" + done + done + if [[ "${AISB_ALLOW_DNS:-0}" == 1 ]]; then + echo ' meta l4proto { tcp, udp } th dport 53 counter accept comment "aisb-dns"' + fi + echo ' counter drop comment "aisb-default-drop"' + echo ' }' + echo ' chain input {' + echo ' type filter hook input priority filter; policy drop;' + echo ' iifname "lo" accept' + echo ' ct state established,related accept' + echo ' meta l4proto ipv6-icmp icmpv6 type { nd-neighbor-advert, nd-router-advert, echo-reply } accept' + echo ' }' + echo '}' +} + +ENFORCE="not-enforced" +if [[ "$NET" == allow ]]; then + command -v nft >/dev/null || fail "nftables is missing from the image; rebuild it (ai-sandbox build --rebuild)" 70 + if [[ "$UNENFORCED" == 1 ]]; then + ENFORCE="SKIPPED-BY-REQUEST" + log "WARNING: egress ruleset skipped by --allow-unenforced" + else + RULES="$(mktemp)" + gen_rules > "$RULES" + nft delete table inet aisb 2>/dev/null || true + if ! err="$(nft -f "$RULES" 2>&1)"; then + rm -f "$RULES" + fail "cannot install the egress ruleset (need CAP_NET_ADMIN): $err + Refusing to run unenforced. Debug with --allow-unenforced, or use --net none." 77 + fi + rm -f "$RULES" + nft list table inet aisb 2>/dev/null | grep -q 'aisb-default-drop' \ + || fail "egress ruleset did not take effect -- refusing to start" 77 + ENFORCE="nft" + fi +fi + +n_direct=$(cat "$POLICY_DIR"/direct4.list "$POLICY_DIR"/direct6.list 2>/dev/null | grep -cvE '^[[:space:]]*(#|$)' || true) +log "egress net=$NET enforce=$ENFORCE proxy=[${AISB_PROXY_IPS:-none}:${AISB_PROXY_PORTS:-none}] direct=$n_direct dns=${AISB_ALLOW_DNS:-0}" + +if [[ "$KEEP_ROOT" == 1 ]]; then + log "WARNING: --insecure-keep-root: running as uid $(id -u) with capabilities" + exec "$@" +fi + +command -v setpriv >/dev/null || fail "setpriv is missing from the image; rebuild it" 70 +exec setpriv --reuid="$UID_TARGET" --regid="$GID_TARGET" --clear-groups \ + --inh-caps=-all --bounding-set=-all --no-new-privs -- "$@" +AISB_ENTRY +} + +gate_entrypoint_script() { + cat <<'AISB_GATE' +#!/bin/bash +# ai-sandbox egress gate: a squid that speaks to the outside world only for +# allowlisted destinations. Config is bind-mounted read-only from the host. +set -Eeuo pipefail +CONF="${AISB_SQUID_CONF:-/etc/squid/squid.conf}" +[[ -r "$CONF" ]] || { echo "[aisb-gate] FATAL: $CONF missing" >&2; exit 78; } +squid -k parse -f "$CONF" 2>&1 | grep -v 'WARNING' >&2 || { echo "[aisb-gate] FATAL: invalid config" >&2; exit 77; } +exec squid -N -f "$CONF" +AISB_GATE +} + +containerfile() { + cat <&2; exit 1 ;; \\ + esac; \\ + tarball="node-v\${AISB_NODE_VERSION}-linux-\${nodearch}.tar.xz"; \\ + curl -fsSL -o "/tmp/\$tarball" "https://nodejs.org/dist/v\${AISB_NODE_VERSION}/\${tarball}"; \\ + if [ -n "\$AISB_NODE_SHA256" ]; then \\ + echo "\${AISB_NODE_SHA256} /tmp/\$tarball" | sha256sum -c -; \\ + fi; \\ + tar -xJf "/tmp/\$tarball" -C /usr/local --strip-components=1; \\ + rm -f "/tmp/\$tarball"; \\ + node --version; npm --version + +ARG AISB_PI_PACKAGE=${AISB_PI_PACKAGE} +ARG AISB_PI_VERSION=${POL[pi_version]} +RUN set -eux; \\ + npm config set update-notifier false; \\ + if [ -n "\$AISB_PI_VERSION" ]; then npm install -g "\${AISB_PI_PACKAGE}@\${AISB_PI_VERSION}"; fi + +# The sandbox user. Base images already ship a uid 1000 (ubuntu:24.04 has +# 'ubuntu'); that account is removed so the uid, the name in /etc/passwd and the +# home directory agree about who is running: logs print a name, and tools that +# resolve a home directory read the passwd entry rather than the HOME variable. +# (Comments in a Containerfile go through host variable expansion too, so keep +# dollar signs out of them.) +RUN set -eux; \\ + userdel -r ubuntu >/dev/null 2>&1 || true; \\ + getent group ${AISB_GID} >/dev/null 2>&1 || groupadd -g ${AISB_GID} sandbox; \\ + getent passwd ${AISB_UID} >/dev/null 2>&1 || useradd -M -u ${AISB_UID} -g ${AISB_GID} -d /home/sandbox -s /bin/bash sandbox; \\ + mkdir -p /project /run/aisb /home/sandbox; \\ + chown ${AISB_UID}:${AISB_GID} /project /home/sandbox; chmod 0755 /home/sandbox; \\ + printf '%s\\n' 'PS1="sandbox:\\w\\$ "' > /home/sandbox/.bashrc; \\ + printf '%s\\n' 'export PATH="/home/sandbox/.local/bin:\$PATH"' >> /home/sandbox/.bashrc; \\ + printf '%s\\n' 'if [ -n "\$BASH_VERSION" ] && [ -f "\$HOME/.bashrc" ]; then . "\$HOME/.bashrc"; fi' > /home/sandbox/.profile; \\ + chown -R ${AISB_UID}:${AISB_GID} /home/sandbox + +RUN git config --system --add safe.directory '*' || true + +COPY aisb-entrypoint /usr/local/lib/aisb/entrypoint.sh +RUN chmod 0755 /usr/local/lib/aisb/entrypoint.sh + +ENV HOME=/home/sandbox \\ + USER=sandbox \\ + LOGNAME=sandbox \\ + npm_config_update_notifier=false \\ + GIT_TERMINAL_PROMPT=0 \\ + PIP_DISABLE_PIP_VERSION_CHECK=1 \\ + AISB=1 + +WORKDIR ${POL[workdir]} +ENTRYPOINT ["/usr/local/lib/aisb/entrypoint.sh"] +CMD ["bash", "-li"] +AISB_CF +} + +gate_containerfile() { + cat </dev/null \ + | awk -v t="$t" '$2 == t {print $1}' + return 0 +} + +ensure_image() { # $1=sandbox|gate + local kind="$1" ref ctx + if [[ "$kind" == sandbox ]]; then + ref="$(image_ref)" + [[ "${OPT_IMAGE_EXISTS_OK:-0}" == 1 ]] && podman image exists "$ref" && { printf '%s\n' "$ref"; return 0; } + if podman image exists "$ref"; then printf '%s\n' "$ref"; return 0; fi + [[ "${OPT_NO_BUILD:-0}" == 1 ]] && die "image $ref is not built (and --no-build was given). Run: $PROG build" + ctx="$AISB_CACHE_DIR/build/sandbox-$(image_hash)" + rm -rf "$ctx"; mkdir -p "$ctx" + containerfile > "$ctx/Containerfile" + entrypoint_script > "$ctx/aisb-entrypoint" + chmod 755 "$ctx/aisb-entrypoint" + local sha; sha="$(node_sha256)" + build_in "$ctx" "$ref" \ + --build-arg "AISB_NODE_SHA256=${sha}" \ + --build-arg "AISB_EXTRA_PACKAGES=$(printf '%s ' ${P_install[@]+"${P_install[@]}"})" + else + ref="$(gate_image_ref)" + if podman image exists "$ref"; then printf '%s\n' "$ref"; return 0; fi + [[ "${OPT_NO_BUILD:-0}" == 1 ]] && die "gate image is not built (and --no-build was given). Run: $PROG build --gate" + ctx="$AISB_CACHE_DIR/build/gate-$(sha12 "$ref")" + rm -rf "$ctx"; mkdir -p "$ctx" + gate_containerfile > "$ctx/Containerfile" + gate_entrypoint_script > "$ctx/aisb-gate-entrypoint" + chmod 755 "$ctx/aisb-gate-entrypoint" + build_in "$ctx" "$ref" + fi + printf '%s\n' "$ref" +} + +build_in() { + local ctx="$1" ref="$2"; shift 2 + local logf + mkdir -p "$AISB_STATE_DIR" + logf="$AISB_STATE_DIR/build-$(basename "$ctx").log" + if podman build -t "$ref" "$@" "$ctx" > "$logf" 2>&1; then + log "built $ref" + rm -rf "$ctx" + else + tail -25 "$logf" >&2 + die "image build failed for $ref (full log: $logf)" + fi +} + +cmd_build() { + local force=0 want_gate=0 + while (($#)); do + case "$1" in + --rebuild|-f) force=1; shift ;; + --gate) want_gate=1; shift ;; + *) die "build: unknown argument $1" ;; + esac + done + mkdir -p "$AISB_CACHE_DIR" "$AISB_STATE_DIR" + if (( force )); then + podman rmi -f "$(image_ref)" >/dev/null 2>&1 || true + [[ $want_gate == 1 ]] && podman rmi -f "$(gate_image_ref)" >/dev/null 2>&1 || true + fi + local ref gref + ref="$(ensure_image sandbox)"; log "sandbox image: $ref" + if (( want_gate )); then gref="$(ensure_image gate)"; log "gate image: $gref"; fi +} + +# =========================================================================== +# egress gate +# =========================================================================== +port_free() { ! ss -H -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]${1}\$"; } + +pick_port() { + local p="${POL[gate_port]}" i + for ((i = 0; i < 64; i++)); do + port_free "$p" && { printf '%s\n' "$p"; return 0; } + p=$((p + 1)) + done + die "no free TCP port at or above ${POL[gate_port]}" +} + +write_squid_conf() { # $1=port $2=path + local port="$1" dest="$2" + local domains="${GATE_DOMAINS[*]-}" ips="${GATE_IPS[*]-}" + { + echo "# generated by $PROG $VERSION -- do not edit" + echo "http_port 127.0.0.1:$port" + echo "visible_hostname aisb-gate" + echo "cache_mem 8 MB" + echo "access_log stdio:/dev/stdout" + echo "pid_filename /tmp/aisb-squid.pid" # the gate runs unprivileged: no /run write + echo "cache_log /dev/stderr" + echo "debug_options NONE" + # squid 6: 'manager' is a built-in ACL; obsolete directives are hard errors + echo "acl SSL_ports port 443 8443" + echo "acl Safe_ports port 80 443 8443" + echo "acl CONNECT method CONNECT" + [[ -n "$domains" ]] && echo "acl aisb_domain dstdomain $domains" + [[ -n "$ips" ]] && echo "acl aisb_ip dst $ips" + echo "http_access deny manager" + echo "http_access deny !Safe_ports" + echo "http_access deny CONNECT !SSL_ports" + [[ -n "$domains" ]] && echo "http_access allow aisb_domain" + [[ -n "$ips" ]] && echo "http_access allow aisb_ip" + echo "http_access deny all" + } > "$dest" + chmod 644 "$dest" # bind-mounted into the gate container, which has no caps +} + +gate_up() { # $1=name $2=port + local name="$1" port="$2" ref conf + ref="$(ensure_image gate)" + conf="$GATE_DIR/squid.conf" + write_squid_conf "$port" "$conf" + podman rm -f "$name" >/dev/null 2>&1 || true + podman run -d --name "$name" \ + --network=host \ + --user proxy \ + --stop-signal SIGTERM --stop-timeout 3 \ + --label ai-sandbox=1 --label aisb.role=gate \ + --label "aisb.session=$SESSION" \ + --label "aisb.allow=$(sha12 "${domains[*]-}|${ips[*]-}")" \ + --security-opt no-new-privileges \ + --cap-drop=ALL \ + --memory 512m --pids-limit 128 \ + -v "${conf}:/etc/squid/squid.conf:ro${SELOPT_SEP}" \ + "$ref" > /dev/null + local i up=0 + for ((i = 0; i < 80; i++)); do + if ! podman ps --format '{{.Names}}' | grep -qx "$name"; then + die "egress gate $name exited during startup: +$(podman logs "$name" 2>&1 | tail -8)" + fi + # any HTTP answer from the proxy -- including a 403 for the probe host -- means + # it is up and enforcing. + if curl -sS -m 2 -x "http://127.0.0.1:$port" -o /dev/null "http://aisb-probe.invalid/"; then + up=1; break + fi + sleep 0.25 + done + (( up )) || die "egress gate on 127.0.0.1:$port never answered +$(podman logs "$name" 2>&1 | tail -8)" + GATE_STARTED=1 + log "egress gate $name -> 127.0.0.1:$port (${#GATE_DOMAINS[@]} domains, ${#GATE_IPS[@]} ip/cidr entries)" +} + +cmd_gate() { + local action="${1:-status}"; shift || true + load_config + session_init + OPT_allow=() # config allow entries arrive through CFG_allow + while (($#)); do + case "$1" in + --allow) OPT_allow+=("${2:-}"); shift 2 ;; + --allow=*) OPT_allow+=("${1#*=}"); shift ;; + *) die "gate: unexpected argument: $1" ;; + esac + done + resolve_opts + classify_allows + local name="${AISB_GATE_NAME:-aisb-gate}" + case "$action" in + up) + [[ ${#P_allow[@]} -gt 0 ]] || die "the gate needs an allowlist: --allow HOST[:PORT]" + GATE_PORT="$(pick_port)" + gate_up "$name" "$GATE_PORT" + printf 'name=%s\nport=%s\nproxy=export HTTPS_PROXY=http://%s:%s\n' \ + "$name" "$GATE_PORT" "${POL[gate_addr]}" "$GATE_PORT" + ;; + down) podman rm -f "$name" >/dev/null 2>&1 && log "gate down: $name" || log "gate $name was not running" ;; + status) + if podman ps --format '{{.Names}}' | grep -qx "$name"; then + podman ps --filter "name=^${name}\$" --format '{{.Names}} {{.Status}}' + curl -sS -m 3 -x "http://127.0.0.1:${AISB_GATE_PORT}" -o /dev/null http://aisb-probe.invalid/ 2>&1 | sed 's/^/probe: /' || true + else + echo "gate $name: not running" + fi + ;; + logs) podman logs --tail "${AISB_TAIL:-100}" "$name" ;; + *) die "gate: unknown action '$action' (up | down | status | logs)" ;; + esac +} + +# =========================================================================== +# podman argv +# =========================================================================== +declare -a ARGV=() + +harness_argv() { # the harness command as words (empty for none/bash) + case "${POL[harness]}" in + pi) printf 'pi\n' ;; + codex) printf 'codex\n' ;; + claude) printf 'claude\n' ;; + none|bash) printf '\n' ;; + *) printf '%s\n' "${POL[harness]}" ;; + esac +} + +default_command() { + local -a hb=() + if [[ "${POL[shell]}" == 1 ]]; then COMMAND=(bash -li); return; fi + if [[ "${POL[harness]}" != none && "${POL[harness]}" != bash ]]; then + read -r -a hb <<< "$(harness_argv)" + fi + if [[ ${#COMMAND[@]} -eq 0 ]]; then + # nothing given at all: just start the harness (or a shell) + if ((${#hb[@]})); then COMMAND=("${hb[@]}"); else COMMAND=(bash -li); fi + elif [[ "${OPT_PASSTHROUGH:-0}" == 1 && ${#hb[@]} -gt 0 ]]; then + # arguments meant for the harness, e.g. `pi --model sonnet` + COMMAND=("${hb[@]}" "${COMMAND[@]}") + fi + # otherwise -- was used: the command replaces the harness +} + +ensure_home_volume() { # $1=name + local name="$1" + [[ -n "$name" ]] || return 0 + podman volume inspect "$name" > /dev/null 2>&1 && return 0 + podman volume create --label ai-sandbox=1 "$name" > /dev/null 2>&1 \ + || die "could not create the home cache volume: $name" +} + +build_argv() { + local net_arg + case "${POL[net]}" in + none) net_arg="none" ;; + full) net_arg="pasta" ;; + allow) net_arg="pasta:--map-host-loopback,${POL[gate_addr]}" ;; + esac + + ARGV=(podman run --name "${POL[name]}") + ARGV+=(--label ai-sandbox=1 --label "aisb.session=${SESSION}" + --label "aisb.net=${POL[net]}" --label "aisb.harness=${POL[harness]}") + local l + for l in ${P_label[@]+"${P_label[@]}"}; do ARGV+=(--label "$l"); done + [[ "${OPT_KEEP:-0}" == 1 ]] || ARGV+=(--rm) + ARGV+=(--hostname aisb) + ARGV+=(--userns "keep-id:uid=${AISB_UID},gid=${AISB_GID}") + ARGV+=(-u 0:0) # entrypoint drops to $AISB_UID before the command runs + ARGV+=(--workdir "${POL[workdir]}") + # Only the entrypoint ever holds these; setpriv clears the bounding set before + # the harness runs, so the command itself starts with zero capabilities. + ARGV+=(--cap-drop=ALL --cap-add=setuid --cap-add=setgid --cap-add=setpcap) + [[ "${POL[net]}" == allow ]] && ARGV+=(--cap-add=NET_ADMIN) + ARGV+=(--security-opt no-new-privileges) + ARGV+=(--pids-limit "${POL[pids]}" --memory "${POL[memory]}" --cpus "${POL[cpus]}") + ARGV+=(--tmpfs "/tmp:rw,nosuid,nodev,size=${POL[tmpfs]}") + ARGV+=(--network "$net_arg") + ARGV+=(--stop-signal SIGTERM --pull missing) + + if [[ -n "${OPT_TTY:-}" ]]; then + [[ "${OPT_TTY}" == 1 ]] && ARGV+=(-t) + elif [[ -t 0 && -t 1 ]]; then + ARGV+=(-t) + fi + if [[ -n "${OPT_INTERACTIVE:-}" ]]; then + [[ "${OPT_INTERACTIVE}" == 1 ]] && ARGV+=(-i) + elif [[ -t 0 ]]; then + ARGV+=(-i) + fi + + local v + for v in "${AISB_IMPLICIT_ENV[@]}"; do [[ -n "${!v+x}" ]] && ARGV+=(--env "$v"); done + ARGV+=(--env "HOME=/home/sandbox" --env "AISB=1" --env "AISB_SESSION=${SESSION}") + if ((${#PROXY_IPS[@]})); then + local px="http://${PROXY_IPS[0]}:${PROXY_PORTS[0]}" + ARGV+=(--env "HTTP_PROXY=$px" --env "http_proxy=$px" + --env "HTTPS_PROXY=$px" --env "https_proxy=$px" + --env "ALL_PROXY=$px" --env "all_proxy=$px" + --env "NO_PROXY=localhost,127.0.0.1,::1" + --env "no_proxy=localhost,127.0.0.1,::1") + fi + [[ -s "$ENV_FILE" ]] && ARGV+=(--env-file "$ENV_FILE") + + [[ "${OPT_NO_CWD:-0}" == 1 ]] || ARGV+=(-v "${POL[cwd]}:${POL[workdir]}${SELOPT}") + ARGV+=(-v "${SESSION_MOUNT}:/run/aisb:ro${SELOPT_SEP}") + if [[ "${POL[home_cache]}" == 1 ]]; then + # created up front with our label (podman has no --volume-label on --mount), so + # 'clean --volumes' can recognise it; the aisb- name prefix covers older volumes. + # --dry-run must stay side-effect free, so it is skipped there. + [[ "${OPT_DRY}" == 1 ]] || ensure_home_volume "${AISB_HOME_VOLUME}" + ARGV+=(--mount "type=volume,source=${AISB_HOME_VOLUME},target=/home/sandbox") + else + ARGV+=(--tmpfs "/home/sandbox:rw,nosuid,nodev,size=${POL[tmpfs]}") + fi + [[ "${POL[readonly]}" == 1 ]] && ARGV+=(--read-only) + + local m src dst opt rest + for m in ${P_mount[@]+"${P_mount[@]}"}; do + src="${m%%:*}"; rest="${m#*:}"; dst=""; opt="" + if [[ "$rest" == *:* ]]; then dst="${rest%%:*}"; opt="${rest##*:}"; + elif [[ -n "$rest" && "$rest" != "$m" ]]; then dst="$rest"; fi + [[ -e "$src" ]] || die "--mount: path does not exist: $src" + [[ -n "$dst" ]] || dst="/mnt/$(basename -- "$src")" + [[ -z "$opt" || "$opt" == ro || "$opt" == rw ]] || die "--mount: option must be ro or rw (got '$opt')" + if [[ -n "$opt" ]]; then ARGV+=(-v "${src}:${dst}:${opt}${SELOPT_SEP}") + else ARGV+=(-v "${src}:${dst}${SELOPT}"); fi + done + + default_command # handles: nothing given, pass-through args, and -- replacement + + if [[ -n "${POL[extra_args]}" ]]; then + local -a xa; read -r -a xa <<< "${POL[extra_args]}" + ARGV+=("${xa[@]}") + fi + ARGV+=("${POL[image_ref]}" "${COMMAND[@]}") +} + +print_argv() { + local a + printf '# %s %s -- effective podman invocation (env values redacted)\n' "$PROG" "$VERSION" + printf '# egress: net=%s proxy=%s domains=[%s] direct=[%s]\n' \ + "${POL[net]}" "${PROXY_IPS[0]-none}:${PROXY_PORTS[0]-none}" \ + "$(printf '%s ' ${GATE_DOMAINS[@]+"${GATE_DOMAINS[@]}"})" \ + "$(printf '%s ' ${DIRECT4[@]+"${DIRECT4[@]}"})" + printf '# env (%d, passed via 0600 file): %s\n' "${#ENV_NAMES[@]}" \ + "$(printf '%s ' ${ENV_NAMES[@]+"${ENV_NAMES[@]}"})" + for a in "${ARGV[@]}"; do + if [[ "$a" == "$ENV_FILE" ]]; then printf ' ' + else printf '%s ' "$(shquote "$a")"; fi + done + printf '\n' +} + +print_policy() { + printf 'image=%s\n' "${POL[image_ref]}" + printf 'gate_image=%s\n' "$(gate_image_ref)" + printf 'container=%s\n' "${POL[name]}" + printf 'net=%s\n' "${POL[net]}" + printf 'proxy=%s\n' "${PROXY_IPS[*]-none}:${PROXY_PORTS[*]-none}" + printf 'harness=%s\n' "${POL[harness]}" + printf 'command=%s\n' "$(printf '%s ' ${COMMAND[@]+"${COMMAND[@]}"})" + printf 'workdir=%s\n' "${POL[workdir]}" + printf 'cwd=%s\n' "${POL[cwd]}" + printf 'cwd_mounted=%s\n' "$([[ ${OPT_NO_CWD:-0} == 1 ]] && echo no || echo yes)" + printf 'limits=cpus:%s,memory:%s,pids:%s\n' "${POL[cpus]}" "${POL[memory]}" "${POL[pids]}" + printf 'home_cache=%s\n' "${POL[home_cache]}" + printf 'selinux=%s\n' "${POL[selinux]:-off}" + printf 'allow=%s\n' "$(join ' ' ${P_allow[@]+"${P_allow[@]}"})" + printf 'gate_domains=%s\n' "$(join ' ' ${GATE_DOMAINS[@]+"${GATE_DOMAINS[@]}"})" + printf 'direct=%s\n' "$(join ';' ${DIRECT4[@]+"${DIRECT4[@]}"})" + printf 'direct6=%s\n' "$(join ';' ${DIRECT6[@]+"${DIRECT6[@]}"})" + printf 'env_names=%s\n' "$(join ' ' ${ENV_NAMES[@]+"${ENV_NAMES[@]}"})" + printf 'env_missing=%s\n' "$(join ' ' ${MISSING_ENV[@]+"${MISSING_ENV[@]}"})" + printf 'mounts=%s\n' "$(join ';' ${P_mount[@]+"${P_mount[@]}"})" +} + +# =========================================================================== +# run +# =========================================================================== +cmd_run() { + mkdir -p "$AISB_STATE_DIR" "$AISB_CACHE_DIR" + load_config + parse_args "$@" + session_init + trap teardown EXIT + resolve_opts + (( OPT_PRINT_EP )) && { entrypoint_script; exit 0; } + (( OPT_PRINT_CF )) && { containerfile; exit 0; } + validate_opts + set_selinux "${POL[selinux]}" + POL[image_ref]="$(image_ref)" + + classify_allows + + # Decide the egress path before anything is started: gate, external proxy, or + # a bare IP-pinned allowlist (--no-gate). + local want_gate=0 + if [[ "${POL[net]}" == allow && "${POL[no_gate]}" != 1 && -z "${POL[proxy]}" ]]; then + want_gate=1 + GATE_NAME="aisb-gate-$SESSION" + if (( OPT_PRINT || OPT_DRY )); then + GATE_PORT="${POL[gate_port]}" # preview: never pick, never start + else + GATE_PORT="$(pick_port)" + fi + fi + resolve_proxy_endpoints + + build_env_file + write_policy_files + build_argv + + if (( OPT_PRINT )); then print_policy; exit 0; fi + if (( OPT_DRY )); then print_argv; exit 0; fi + + ensure_image sandbox > /dev/null + # The gate only comes up once we know the sandbox image exists: a gate with no + # sandbox behind it is an open door for nothing. + if (( want_gate )); then gate_up "$GATE_NAME" "$GATE_PORT"; fi + + local rc=0 + "${ARGV[@]}" || rc=$? + log_session "$rc" + exit "$rc" +} +log_session() { + printf '%s session=%s net=%s allow=%d env=%d rc=%s cmd=%s\n' \ + "$(date -Is)" "$SESSION" "${POL[net]}" "${#P_allow[@]}" "${#ENV_NAMES[@]}" "$1" \ + "${COMMAND[*]-}" >> "$AISB_STATE_DIR/log" +} + +# =========================================================================== +# doctor +# =========================================================================== +declare -i DOC_FAIL=0 DOC_WARN=0 +chk() { + local lvl="$1"; shift + case "$lvl" in + FAIL) DOC_FAIL+=1; printf ' [FAIL] %s\n' "$*" ;; + WARN) DOC_WARN+=1; printf ' [WARN] %s\n' "$*" ;; + PASS) printf ' [ ok ] %s\n' "$*" ;; + *) printf ' [info] %s\n' "$*" ;; + esac +} + +cmd_doctor() { + mkdir -p "$AISB_STATE_DIR" "$AISB_CACHE_DIR" # so the smoke test can write its log + local deep=0 + [[ "${1:-}" == --deep || "${1:-}" == -d ]] && deep=1 + printf '%s %s -- doctor (uid %s, %s)\n' "$PROG" "$VERSION" "$(id -u)" "$(uname -srm)" + + if ! have podman; then chk FAIL "podman is not installed"; exit 1; fi + chk INFO "podman $(podman --version | awk '{print $3}')" + if [[ "$(id -u)" == 0 ]]; then + chk FAIL "running as root: ai-sandbox is built for rootless podman" + else + chk PASS "rootless (uid $(id -u))" + fi + if grep -q "^$(id -un):" /etc/subuid 2>/dev/null && grep -q "^$(id -un):" /etc/subgid 2>/dev/null; then + chk PASS "subuid/subgid ranges present" + else + chk FAIL "no /etc/subuid range for $(id -un); rootless podman cannot work + fix: sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $(id -un)" + fi + if have pasta; then + chk PASS "network helper: $(pasta --version 2>/dev/null | head -1)" + elif have slirp4netns; then + chk PASS "network helper: slirp4netns" + else + chk WARN "neither pasta nor slirp4netns found; --net allow and --net full need one" + fi + if have getenforce; then + case "$(getenforce 2>/dev/null)" in + Enforcing) chk PASS "SELinux enforcing; bind mounts labelled '${POL[selinux]:-none}'" ;; + Permissive) chk WARN "SELinux is permissive" ;; + *) chk INFO "SELinux: $(getenforce 2>/dev/null)" ;; + esac + fi + if ! podman info > /dev/null 2>&1; then + chk FAIL "'podman info' failed -- the container runtime is not usable"; exit 1 + fi + + local ref; ref="$(image_ref)" + if podman image exists "$ref"; then chk PASS "sandbox image present: $ref" + else chk WARN "sandbox image not built: $ref (run: $PROG build)"; fi + if podman image exists "$(gate_image_ref)"; then chk PASS "gate image present: $(gate_image_ref)" + else chk WARN "gate image not built: $(gate_image_ref) (built on first use)"; fi + + chk INFO "policy: net=${POL[net]} harness=${POL[harness]} limits=cpus:${POL[cpus]},memory:${POL[memory]},pids:${POL[pids]}" + chk INFO "dirs: config=$AISB_CONFIG_DIR state=$AISB_STATE_DIR cache=$AISB_CACHE_DIR" + if ((${#P_allow[@]})); then + chk INFO "allowlist from config/env: ${P_allow[*]}" + else + chk INFO "no allowlist configured yet (--allow, .ai-sandbox/allow, $AISB_CONFIG_DIR/allow)" + fi + + if [[ -d "$PWD/.git" ]]; then + if [[ -n "$(git -C "$PWD" status --porcelain 2>/dev/null)" ]]; then + chk WARN "the working tree has uncommitted changes: the sandbox can rewrite them (it has rw access)" + else + chk PASS "working tree is clean" + fi + fi + + if podman image exists "$ref"; then + local d pol rc=0 + d="$(mktemp -d)" + # a minimal, net=none policy so the entrypoint has something to check + pol="$AISB_STATE_DIR/doctor-policy"; mkdir -p "$pol" + printf 'AISB_NET=none\nAISB_UID=%s\nAISB_GID=%s\nAISB_ALLOW_ICMP=0\nAISB_ALLOW_DNS=0\nAISB_PROXY_IPS=\nAISB_PROXY_PORTS=\n' \ + "$AISB_UID" "$AISB_GID" > "$pol/policy.env" + : > "$pol/direct4.list"; : > "$pol/direct6.list" + chmod 755 "$pol"; chmod 644 "$pol"/* + podman run --rm --network none --cap-drop=ALL --cap-add=setuid --cap-add=setgid --cap-add=setpcap \ + --userns "keep-id:uid=${AISB_UID},gid=${AISB_GID}" \ + -v "${d}:/project${SELOPT}" -v "${pol}:/run/aisb:ro${SELOPT_SEP}" -w /project "$ref" \ + bash -lc 'touch /project/.aisb-doctor' > "$AISB_STATE_DIR/doctor.out" 2>&1 || rc=$? + if (( rc != 0 )); then + chk FAIL "container smoke test failed (see $AISB_STATE_DIR/doctor.out)" + elif [[ "$(stat -c %u "$d/.aisb-doctor" 2>/dev/null)" == "$(id -u)" ]]; then + chk PASS "bind mount + uid mapping: files land on the host owned by uid $(id -u)" + else + chk FAIL "file written in the container is owned by uid $(stat -c %u "$d/.aisb-doctor" 2>/dev/null), expected $(id -u)" + fi + rm -rf "$d" + fi + + if (( deep )) && (($DOC_FAIL == 0)); then + chk INFO "deep check: does the sandbox actually block non-allowlisted egress?" + local out rc=0 + out="$("$PROG" --net allow --no-gate --allow example.com --print-policy 2>&1)" || rc=$? + chk INFO "policy under test: $(printf '%s' "$out" | grep '^direct=' || true)" + out="$("$PROG" --net allow --no-gate --allow example.com -- \ + bash -c 'curl -sS -m8 -o /dev/null -w "%{http_code}" http://example.com; echo; \ + curl -sS -m8 -o /dev/null http://example.org && echo NOT_BLOCKED || echo BLOCKED' 2>&1)" || true + printf '%s\n' "$out" | sed 's/^/ /' + if printf '%s' "$out" | grep -q 'NOT_BLOCKED'; then + chk FAIL "egress to a non-allowlisted host succeeded" + elif printf '%s' "$out" | grep -q 'BLOCKED'; then + chk PASS "egress outside the allowlist is blocked" + fi + fi + + if (( DOC_FAIL )); then + printf '\ndoctor: %d problem(s), %d warning(s)\n' "$DOC_FAIL" "$DOC_WARN"; exit 1 + fi + printf '\ndoctor: no problems found (%d warning(s))\n' "$DOC_WARN" +} + +# =========================================================================== +# ps / clean / inspect +# =========================================================================== +cmd_ps() { + podman ps -a --filter label=ai-sandbox=1 --format '{{.Names}}\t{{.Status}}\t{{.Image}}' +} + +cmd_clean() { + local all=0 volumes=0 volpat="" ttl="${AISB_TTL_MIN:-360}" n v + while (($#)); do + case "$1" in + --all|-a) all=1 ;; + --volumes|-V) volumes=1 ;; + --volumes=*) volumes=1; volpat="${1#*=}" ;; + *) die "clean: unknown option: $1 (try --all or --volumes)" ;; + esac + shift + done + while IFS= read -r n; do + [[ -n "$n" ]] || continue + if podman ps --format '{{.Names}}' | grep -qx "$n" && (( all == 0 )); then + continue # never kill a live container unless --all + fi + if [[ -d "$AISB_STATE_DIR/sessions/${n#aisb-gate-}" ]] && (( all == 0 )); then + continue # a session dir still exists: it may be in use + fi + podman rm -f "$n" > /dev/null 2>&1 && log "removed container $n" + done < <(podman ps -a --filter label=ai-sandbox=1 --format '{{.Names}}') + if [[ -d "$AISB_STATE_DIR/sessions" ]]; then + find "$AISB_STATE_DIR/sessions" -mindepth 1 -maxdepth 1 -type d -mmin +"$ttl" -exec rm -rf {} + 2>/dev/null || true + log "pruned session dirs older than $ttl min" + fi + if (( volumes )); then + # Two guards before anything is deleted: the name has to be in our namespace (the + # aisb- prefix, or labelled by us) and no container may reference it, so a sandbox + # that is still running never loses its home. Note this runs *after* the sweep + # above, which removes stopped leftovers: a --keep'ed run is a leftover, and with + # --volumes its cache goes with it. A pattern narrows it further: + # ai-sandbox clean --volumes=aisb-home + while IFS= read -r v; do + [[ -n "$v" ]] || continue + # shellcheck disable=SC2053 # glob matching is the point here + [[ -z "$volpat" || "$v" == $volpat ]] || continue + podman volume rm "$v" > /dev/null 2>&1 && log "removed volume $v" \ + || log "volume $v is still in use, left alone" + done < <({ + podman volume ls --filter dangling=true --format '{{.Name}}' + podman volume ls --filter label=ai-sandbox=1 --filter dangling=true --format '{{.Name}}' + } | grep -E '^aisb' | sort -u) + fi + log "clean done ($(podman ps -a --filter label=ai-sandbox=1 -q | wc -l) sandbox containers left)" +} + +cmd_inspect() { + mkdir -p "$AISB_STATE_DIR" + load_config + parse_args "$@" + session_init + resolve_opts + set_selinux "${POL[selinux]}" + POL[image_ref]="$(image_ref)" + classify_allows + GATE_PORT="${POL[gate_port]}" + resolve_proxy_endpoints + build_env_file + print_policy +} + +# =========================================================================== +# main +# =========================================================================== +main() { + case "${1:-run}" in + build) shift; load_config; resolve_opts_min; cmd_build "$@" ;; + doctor) shift; load_config; resolve_opts_min; cmd_doctor "$@" ;; + gate) shift; cmd_gate "$@" ;; + ps) shift; cmd_ps ;; + clean) shift; cmd_clean "$@" ;; + inspect) shift; cmd_inspect "$@" ;; + version|-V) printf '%s %s\n' "$PROG" "$VERSION" ;; + help|-h|--help) usage ;; + *) cmd_run "$@" ;; + esac +} + +resolve_opts_min() { SESSION="${AISB_SESSION_OVERRIDE:-$(session_id)}"; resolve_opts; } + +main "$@" diff --git a/config/allow.example b/config/allow.example new file mode 100644 index 0000000..b5bdefb --- /dev/null +++ b/config/allow.example @@ -0,0 +1,22 @@ +# Egress allowlist -- copy to ~/.config/ai-sandbox/allow (user-wide) or to +# ./.ai-sandbox/allow in a repository (checked in, so the policy is reviewable). +# +# One entry per line. '#' starts a comment. Blank lines are ignored. +# In --net allow mode at least one entry is required; an empty list is an error. +# +# domain matched by the gate, including subdomains, at CONNECT time +# domain:port same, and a direct netfilter rule for that port +# 10.0.0.5:5432 pinned host and port (databases, internal services) +# 10.20.0.0/24 pinned subnet (default ports 80,443 unless :port given) +# [fd00::5]:8443 pinned ipv6 + +api.anthropic.com +registry.npmjs.org + +# typical additions, by kind: +# api.openai.com +# github.com # git push, if you also mount ~/.ssh +# pypi.org +# files.pythonhosted.org +# index.crates.io +# proxy.golang.org diff --git a/config/config.example b/config/config.example new file mode 100644 index 0000000..8977a66 --- /dev/null +++ b/config/config.example @@ -0,0 +1,67 @@ +# config for ai-sandbox -- copy to ~/.config/ai-sandbox/config and edit +# +# Format: key = value. '#' starts a comment. +# Precedence: built-in default < this file (user) < ./.ai-sandbox/config (repo) +# < AISB_* environment variable < command line flag. +# +# Keys listed under "repeatable" accumulate; every other key replaces. + +# --- network --------------------------------------------------------------- +# none | allow | full. allow is the safe default and requires an allowlist. +net = allow + +# Address that stands for the host's loopback inside the container +# (--network=pasta:--map-host-loopback,). Change only if it collides +# with a real network you need to reach. +# gate_addr = 100.64.0.1 + +# Preferred first port for the per-session egress gate; the next free port is +# used if it is busy. +# gate_port = 18081 + +# --- harness --------------------------------------------------------------- +# Default in-container command: pi | codex | claude | none | "" +# harness = pi + +# --- mounts and layout ----------------------------------------------------- +# workdir = /project + +# --- limits ---------------------------------------------------------------- +# cpus = 4 +# memory = 4g +# pids = 512 +# tmpfs_size = 1g + +# --- image ----------------------------------------------------------------- +# base_image = docker.io/library/ubuntu:24.04 +# node_version = 22.23.4 +# pi_version = 1.4.0 + +# --- behaviour ------------------------------------------------------------- +# SELinux bind-mount option: ':z' (shared), ':Z' (private) or empty to disable. +# Defaults to ':z' when getenforce exists. +# selinux = :z + +# Persist /home/sandbox in a named volume so harness logins and tool caches +# survive between runs. 0 = use a throwaway tmpfs instead. +# home_cache = 1 + +# Appended verbatim to the podman invocation (power users; word-split). +# extra_args = --annotation io.k8s.description="agent sandbox" + +# --- repeatable keys ------------------------------------------------------- +# These may appear more than once, and are merged with --allow / --env / etc. +# +# allow = api.anthropic.com +# allow = registry.npmjs.org:443 +# allow = 10.20.0.5:5432 +# +# env = ANTHROPIC_API_KEY +# env = CI_JOB_TOKEN +# +# mount = ~/.ssh:ro +# mount = ../docs:/docs:ro +# +# install = postgresql-client +# +# label = team=platform diff --git a/config/env.example b/config/env.example new file mode 100644 index 0000000..57b9522 --- /dev/null +++ b/config/env.example @@ -0,0 +1,11 @@ +# Copy to a file only you can read, then: ai-sandbox --env-file ~/.config/ai-sandbox/env +# +# chmod 600 ~/.config/ai-sandbox/env +# +# One NAME=VALUE per line. '#' comments and blank lines are ignored. +# Nothing here is passed to the container unless you ask for it with --env or +# --env-file, so listing a variable is not the same as granting it. +# +# ANTHROPIC_API_KEY=sk-ant-... +# OPENAI_API_KEY=sk-... +# ANTHROPIC_MODEL=sonnet diff --git a/docs/networking.md b/docs/networking.md new file mode 100644 index 0000000..1b38b0d --- /dev/null +++ b/docs/networking.md @@ -0,0 +1,107 @@ +# Networking: how `--net allow` actually holds + +Everything in this file was measured on the reference host (Fedora 44, podman +5.8.9, rootless, `pasta` as the rootless network helper, SELinux enforcing) with +[`tests/spike_network.sh`](../tests/spike_network.sh). Re-run that script on a new +machine before trusting the model; podman's rootless networking is the part of the +stack most likely to differ. + +## The two mechanisms + +`--net allow` stacks two independent controls: + +1. **A netfilter ruleset inside the container's own network namespace.** + The container starts with `--cap-add=NET_ADMIN` and the image's entrypoint runs + as root *only long enough* to install an `inet aisb` table with + `policy drop` on the output hook, then it execs the real command through + + ``` + setpriv --reuid=1000 --regid=1000 --clear-groups --inh-caps=-all --bounding-set=-all --no-new-privs + ``` + + After that exec the command has no capabilities and no root. Removing a rule + needs `CAP_NET_ADMIN`, opening a new network namespace needs `CAP_SYS_ADMIN`, + and a setuid helper can't hand either back because `no-new-privileges` is set on + the container. The ruleset is therefore a one-way door. + +2. **An egress gate on the host loopback.** With `pasta`, a container can be given + a magic address that *means* the host's loopback: + + ``` + --network=pasta:--map-host-loopback,100.64.0.1 + ``` + + Connecting to `100.64.0.1:PORT` inside the container lands on the host's + `127.0.0.1:PORT`. The gate is a squid container started with `--network=host` + (so it has internet and the host's resolver) bound to `127.0.0.1:`, + with `http_access deny all` after the allowlist. The sandbox gets + `HTTP(S)_PROXY` pointing at `100.64.0.1:`, and the netfilter ruleset + allows nothing else out. + +The gate itself runs as the image's unprivileged `proxy` user (`--user proxy`, +`--cap-drop=ALL`, `pid_filename /tmp/...`): squid normally starts as root and drops +privileges itself, which needs SETUID/SETGID we would rather not grant. Binding a +high loopback port does not need them. squid 6 treats obsolete directives as hard +errors, so the generated config avoids `dns_v4_first` and `max_mem_obj_size` and does +not redeclare the built-in `manager` ACL. + +Together: the harness can only talk to the gate, and the gate only relays to +domains you allowlisted, resolved by the gate itself. CDNs, SNI and CONNECT-time +filtering all work because squid terminates the CONNECT request and resolves the +name itself. + +## Measurements + +| Probe | Result | Consequence | +|---|---|---| +| `--network=` container, egress | blocked (no default route) | a blackout network is available | +| `--network=` container, DNS | blocked | internal mode is a real blackout | +| container → container on the same user-defined network | **does not work** (`Failed to connect ... Could not connect to server`) | rules out the "sandbox + sidecar proxy on a shared internal network" design | +| `--network=host` in a rootless container | works, has internet | makes the host-loopback gate possible | +| `--network=pasta` (default), egress | works | pasta is a plain internet tap, no filtering | +| container → host `127.0.0.1` via `--map-host-loopback,100.64.0.1` | **works** | the gate address | +| `--map-host-loopback,127.0.0.1` | rejected by passt (`Invalid address to remap to host`) | the magic address must not itself be loopback | +| `--network=pasta:--map-gw` | works (the gateway address already maps to the host by default) | `--map-host-loopback` chosen for explicitness | +| squid as root with `--cap-drop=ALL` | `setgid: (1) Operation not permitted`, gate dies | run the gate as `proxy` from the start | +| squid 6 with `dns_v4_first` / `max_mem_obj_size` | `ERROR: Directive ... is obsolete`, config refused | generate only current directives | +| sandbox in allow mode, `curl` with the proxy env removed | connection times out | netfilter, not just `HTTP_PROXY`, is doing the gating | +| sandbox in allow mode, raw TCP socket to a literal IP, DNS to 8.8.8.8 | both blocked | nothing bypasses the ruleset by ignoring proxy variables | +| root in a rootless container, `nft add table` with podman's default caps | **fails** (`Operation not permitted`) | NET_ADMIN is not in podman's default cap set, so it must be added deliberately | +| root + `--cap-add=NET_ADMIN`, drop `tcp dport 80` inside the container | egress to port 80 **times out** | in-container netfilter really does gate the pasta path | +| `--cap-drop=ALL`, `nft add table` | fails | a non-privileged process cannot install or remove rules | +| `--pids-limit 64 --memory 256m --cpus 0.5` | `pids.max=64`, `memory.max=268435456` visible inside | limits are real (note: `/proc/cpuinfo` still lists all cores; use `nproc`/cgroup limits, not cpuinfo) | +| `-v $dir:/project` with SELinux enforcing, `--userns=keep-id:uid=1000,gid=1000` | write **denied** | bind mounts need `:z` on SELinux hosts | +| same, with `:z` | write succeeds, file owned by the host uid (1000:1000) | project files keep sane ownership | + +## Why not the other designs + +* **Internal network + sidecar proxy container.** The obvious design, and it is + what most write-ups suggest. On this host, two containers on the same + user-defined network cannot reach each other at all, so the sandbox could never + reach its own proxy. Rejected on measurement, not taste. +* **`--network=none` plus a unix-socket proxy.** Structurally the strongest (no IP + stack at all) and worth revisiting, but only `curl` and a handful of tools speak + `--unix-socket`; everything else (git, npm, pip, node's fetch) would need a + TCP-to-socket forwarder inside the container, and DNS would need its own path. + The current design gets the same practical containment with the two controls above. +* **DNS filtering only.** A resolver allowlist is trivially bypassed by connecting + to a literal IP, and it breaks the SNI-based allowlist that real agent traffic + needs. Used only as the fallback in `--no-gate` mode, where it is paired with + IP-pinned netfilter rules. +* **`--network=slirp4netns`.** Not installed here; `pasta` is the default helper in + podman 5 and is what the code targets (it falls back to whatever helper podman + picks, since the code only ever asks for the `pasta` driver). + +## Failure modes we chose + +* If the ruleset cannot be installed (no NET_ADMIN, no `nft`, weird kernel) the + container **refuses to start** with exit 77 rather than running unfiltered. + `--allow-unenforced` exists for debugging and says so in the flag name. +* If the policy file is missing the entrypoint exits 78 before running anything. +* The gate container is created per session, bound to loopback only, and removed + when the sandboxed command exits. A gate that outlives its session would be an + open door with nobody watching it. `gate up` is the explicit, named exception. +* In gate mode DNS is **closed** inside the container. That is deliberate: it stops + a sandboxed process from resolving names and then talking to them behind the + proxy's back. Tools that insist on resolving names themselves need + `--allow ` entries instead. diff --git a/docs/recipes.md b/docs/recipes.md new file mode 100644 index 0000000..f0e056f --- /dev/null +++ b/docs/recipes.md @@ -0,0 +1,146 @@ +# Recipes + +Concrete invocations. `pi` below is whatever you aliased to `ai-sandbox`. + +## A first session + +```bash +cd ~/Projects/myapp +ai-sandbox doctor # is this machine ready? +pi --allow api.anthropic.com --env ANTHROPIC_API_KEY +``` + +Same thing, but remembered by the repo (so teammates review the policy, not just +your shell history): + +```bash +mkdir -p .ai-sandbox +printf 'api.anthropic.com\n' > .ai-sandbox/allow +pi --env ANTHROPIC_API_KEY +``` + +## Per harness + +```bash +# pi (baked into the image) +pi --allow api.anthropic.com --env ANTHROPIC_API_KEY + +# pi with a proxy/harness you pass explicitly +pi --harness 'pi --model sonnet' --allow api.anthropic.com --env ANTHROPIC_API_KEY + +# OpenAI Codex CLI (install it into the image with --install or use a custom base) +pi --allow chatgpt.com --allow api.openai.com --env OPENAI_API_KEY + +# Claude Code +pi --allow api.anthropic.com --env ANTHROPIC_API_KEY --env CLAUDE_CODE_OAUTH_TOKEN + +# just a shell in the sandbox +pi --shell + +# any other build/test command, no network needed +pi --net none -- make test +``` + +## Per ecosystem + +Only the endpoints the tool actually needs. Everything else stays blocked, so a +typosquatted package from an unexpected host fails loudly instead of quietly +working. + +```bash +# npm / node +pi --allow registry.npmjs.org --allow nodejs.org --allow github.com + +# python: pypi needs both of these (the CDN that serves the files is separate) +pi --allow pypi.org --allow files.pythonhosted.org + +# rust +pi --allow crates.io --allow static.crates.io --allow index.crates.io + +# go +pi --allow proxy.golang.org --allow sum.golang.org + +# docker/podman images from within a build +pi --allow registry-1.docker.io --allow auth.docker.io --allow production.cloudflare.docker.com + +# a project that also needs its dev database (non-HTTP: pinned IP + port) +pi --allow api.anthropic.com --allow 10.20.0.5:5432 --env ANTHROPIC_API_KEY +``` + +## Git + +```bash +# fetch/push over https with a token in the environment +pi --allow github.com --env GH_TOKEN -- git push origin main + +# ssh: the key has to be mounted deliberately (an explicit, reviewable grant) +pi --allow github.com --mount ~/.ssh:ro -- git push origin main + +# read-only view of a sibling repo for context +pi --allow api.anthropic.com --env ANTHROPIC_API_KEY --mount ../docs:/docs:ro +``` + +## Tightening a run + +```bash +# nothing persistent, nothing readable, nothing writable outside the repo +pi --net none --ephemeral-home --read-only -- make build + +# an entirely blank sandbox for untrusted code +pi --net none --no-cwd --ephemeral-home --read-only -- bash + +# CI-ish: refuse to start if the policy or environment is not exactly right +pi --strict-args --net allow --allow-file .ai-sandbox/allow --env-file .ci/env -- npm ci +``` + +## Long-lived gate shared by several sandboxes + +```bash +ai-sandbox gate up --allow api.anthropic.com --allow registry.npmjs.org +# name=aisb-gate +# port=18081 +# proxy_env=export HTTPS_PROXY=http://100.64.0.1:18081 + +ai-sandbox --proxy 100.64.0.1:18081 --allow api.anthropic.com --shell +ai-sandbox gate logs # what did it allow/deny? +ai-sandbox gate down +``` + +## Debugging + +```bash +ai-sandbox --dry-run --allow example.com --env KEY # the exact podman invocation +ai-sandbox --print-policy # effective policy, no secrets +ai-sandbox --print-entrypoint | less # the enforcement script +ai-sandbox --keep --net none -- false # leave the container for forensics +ai-sandbox ps +AISB_GATE_KEEP=1 pi --allow example.com --shell # keep the gate after exit +ai-sandbox clean --all + +# "the ruleset would not install" (exit 77): check the host +podman info | grep -i rootless +grep "$(id -un)" /etc/subuid +``` + +## Making it the default for a project + +`.ai-sandbox/config`: + +```ini +net = allow +harness = pi +cpus = 8 +memory = 8g +mount = ../specs:/specs:ro +env = ANTHROPIC_API_KEY +``` + +`.ai-sandbox/allow`: + +``` +api.anthropic.com +registry.npmjs.org +``` + +Then `pi` alone in that directory is the whole invocation — and because both files +are in the repository, the sandbox policy gets reviewed with the code. diff --git a/docs/threat-model.md b/docs/threat-model.md new file mode 100644 index 0000000..b2d546d --- /dev/null +++ b/docs/threat-model.md @@ -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.* diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..8101582 --- /dev/null +++ b/install.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# install.sh -- put ai-sandbox on PATH and wire up an alias. +# +# ./install.sh install to ~/.local/bin, print the alias line +# ./install.sh --alias pi also append the alias to ~/.bashrc +# ./install.sh --prefix DIR install somewhere else +# ./install.sh --uninstall remove what this script installed +set -Eeuo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PREFIX="${HOME}/.local/bin" +ALIAS_NAME="" +UNINSTALL=0 + +while (($#)); do + case "$1" in + --prefix) PREFIX="${2:?--prefix needs a directory}"; shift 2 ;; + --prefix=*) PREFIX="${1#*=}"; shift ;; + --alias) ALIAS_NAME="${2:?--alias needs a name, e.g. --alias pi}"; shift 2 ;; + --alias=*) ALIAS_NAME="${1#*=}"; shift ;; + --uninstall) UNINSTALL=1; shift ;; + -h|--help) sed -n '2,10p' "$0"; exit 0 ;; + *) echo "unknown option: $1" >&2; exit 2 ;; + esac +done + +target="$PREFIX/ai-sandbox" +rcfile="${HOME}/.bashrc" + +if (( UNINSTALL )); then + [[ -L "$target" || -f "$target" ]] && { rm -f "$target"; echo "removed $target"; } || echo "nothing at $target" + if [[ -n "$ALIAS_NAME" ]]; then + tmp="$(mktemp)" + grep -v "^alias ${ALIAS_NAME}=" "$rcfile" > "$tmp" 2>/dev/null || true + mv "$tmp" "$rcfile"; echo "removed the ${ALIAS_NAME} alias from $rcfile" + fi + exit 0 +fi + +[[ -x "$HERE/bin/ai-sandbox" ]] || { echo "bin/ai-sandbox is missing or not executable" >&2; exit 1; } +command -v podman > /dev/null || echo "warning: podman is not installed; ai-sandbox needs it" + +mkdir -p "$PREFIX" +ln -sfn "$HERE/bin/ai-sandbox" "$target" +echo "installed $target -> $HERE/bin/ai-sandbox" + +if [[ -n "$ALIAS_NAME" ]]; then + line="alias ${ALIAS_NAME}='${target}'" + if grep -qxF "$line" "$rcfile" 2>/dev/null; then + echo "alias already present in $rcfile" + else + printf '\n# added by ai-sandbox install.sh\n%s\n' "$line" >> "$rcfile" + echo "added to $rcfile: $line" + fi +else + echo + echo "To use it as a harness wrapper, add this to $rcfile:" + echo " alias pi='$target'" + echo "then: source $rcfile && cd your/project && pi --allow api.anthropic.com --env ANTHROPIC_API_KEY" +fi + +echo +echo "next: $target doctor # sanity-check this machine" +echo " $target build # build the sandbox image now (otherwise built on first run)" diff --git a/tests/e2e/10_image.sh b/tests/e2e/10_image.sh new file mode 100644 index 0000000..f255551 --- /dev/null +++ b/tests/e2e/10_image.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# tests/e2e/10_image.sh -- the image itself: build, cache, contents, fail-closed entrypoint. +source "$TESTS/lib.sh" + +PROJ="$WORK/proj"; mkdir -p "$PROJ"; cd "$PROJ" || exit 1 + +t_name "build" +sb build +assert_rc 0 "ai-sandbox build succeeds" +IMG="$("$AISB" --print-policy --net none 2>/dev/null | grep '^image=' | cut -d= -f2)" +[[ -n "$IMG" ]] && _ok "image ref resolved: $IMG" || _bad "image ref resolved" +podman image exists "$IMG" && _ok "image exists in podman storage" || _bad "image exists" + +t_name "build is cached and tagged by content" +START=$SECONDS +sb build +assert_rc 0 "rebuild succeeds" +(( SECONDS - START < 15 )) && _ok "second build reused the cache ($((SECONDS - START))s)" \ + || _bad "second build reused the cache ($((SECONDS - START))s)" +IMG2="$("$AISB" --print-policy --net none 2>/dev/null | grep '^image=' | cut -d= -f2)" +assert_eq "$IMG" "$IMG2" "same inputs, same tag" +sb --print-policy --net none --install hexd > /dev/null +IMG3="$("$AISB" --print-policy --net none --install hexd | grep '^image=' | cut -d= -f2)" +[[ "$IMG3" != "$IMG" ]] && _ok "extra packages produce a different tag" || _bad "extra packages change the tag" + +t_name "the entrypoint fails closed without a policy" +podman run --rm --network none --cap-drop=ALL "$IMG" true > "$WORK/fc.out" 2>&1 +assert_eq 78 "$?" "no /run/aisb policy -> exit 78, nothing ran" +grep -q 'policy file' "$WORK/fc.out" && _ok "it says why" || _bad "it says why: $(cat "$WORK/fc.out")" + +t_name "image contents" +POL="$WORK/pol"; mkdir -p "$POL" +printf 'AISB_NET=none\nAISB_UID=1000\nAISB_GID=1000\nAISB_ALLOW_ICMP=1\nAISB_ALLOW_DNS=0\n' > "$POL/policy.env" +: > "$POL/direct4.list"; : > "$POL/direct6.list" +# podman takes mount options as a comma list: ":ro,:z" not ":ro:z" +SEL="${AISB_SELINUX:-:z}"; SELC="${SEL#:}" +run_in() { + podman run --rm --network none --cap-drop=ALL --cap-add=setuid --cap-add=setgid --cap-add=setpcap \ + --userns "keep-id:uid=1000,gid=1000" -u 0:0 \ + --security-opt no-new-privileges \ + -v "$POL:/run/aisb:ro${SELC:+,$SELC}" -v "$PROJ:/project$SEL" -w /project \ + "$IMG" bash -lc "$1" 2>&1 +} +OUT="$(run_in 'id -u')"; assert_eq "1000" "$(printf '%s' "$OUT" | tail -1)" "drops to uid 1000" +OUT="$(run_in 'grep CapEff /proc/self/status | cut -f2')" +assert_eq "0000000000000000" "$(printf '%s' "$OUT" | tail -1)" "no capabilities left for the command" +OUT="$(run_in 'node --version')" +printf '%s' "$OUT" | grep -qE '^v2[2-9]' && _ok "node $(printf '%s' "$OUT" | tail -1)" || _bad "node 22+: $OUT" +OUT="$(run_in 'command -v pi')" +printf '%s' "$OUT" | grep -q '/pi' && _ok "the pi harness is installed" || _bad "pi installed: $OUT" +OUT="$(run_in 'nft --version && setpriv --version | head -1 && git --version')" +assert_contains "nftables" "nftables present" +assert_contains "git version" "git present" +OUT="$(run_in 'pwd')" +assert_eq "/project" "$(printf '%s' "$OUT" | tail -1)" "working directory is /project" + +t_name "the container cannot see the host filesystem" +SECRET="$WORK/parent-secret.txt"; echo "do not read" > "$SECRET" +OUT="$(run_in "cat '$SECRET' 2>&1 || echo NOT_VISIBLE")" +assert_contains "NOT_VISIBLE" "files outside the mount are unreachable" +OUT="$(run_in 'ls / | tr "\n" " "')" +assert_absent "$(id -un)" "no host home directories in /" + +t_name "resource caps are visible from inside" +OUT="$(podman run --rm --network none --cap-drop=ALL --cap-add=setuid --cap-add=setgid --cap-add=setpcap --pids-limit 64 --memory 128m \ + --userns "keep-id:uid=1000,gid=1000" -u 0:0 \ + -v "$POL:/run/aisb:ro${SELC:+,$SELC}" "$IMG" \ + bash -lc 'cat /sys/fs/cgroup/pids.max; cat /sys/fs/cgroup/memory.max' 2>&1)" +assert_contains "64" "pids.max = 64" +assert_contains "134217728" "memory.max = 128m" diff --git a/tests/e2e/11_net.sh b/tests/e2e/11_net.sh new file mode 100644 index 0000000..0085e82 --- /dev/null +++ b/tests/e2e/11_net.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# tests/e2e/11_net.sh -- the promise of the project: only the allowlist is reachable. +# +# Needs outbound internet on the host; skips (rc 0) when there is none. +source "$TESTS/lib.sh" + +PROJ="$WORK/proj"; mkdir -p "$PROJ"; cp "$TESTS/fixtures/egress_probe.sh" "$PROJ/" +cd "$PROJ" || exit 1 + +if ! curl -m8 -sS -o /dev/null http://example.com 2>/dev/null; then + echo " (skipped: no outbound internet on this host, so egress cannot be tested)" + summary; exit 0 +fi + +GATE="--net allow --allow example.com" + +# a service on the host's loopback that the sandbox must not be able to reach +HOSTPORT="$(python3 -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1]);s.close()')" +python3 -m http.server "$HOSTPORT" --bind 127.0.0.1 --directory "$WORK" >/dev/null 2>&1 & +SRV=$! +trap 'kill '"$SRV"' 2>/dev/null' EXIT +sleep 1 + +t_name "allow mode: enforcement in one run" +sb $GATE --env AISB_PROBE_HOSTSVC="$HOSTPORT" -- bash /project/egress_probe.sh +assert_contains "ok_allowed_http=200" "allowlisted http works (through the gate)" +assert_contains "ok_allowed_https=200" "allowlisted https works (CONNECT)" +assert_contains "ok_gate_denied_domain=403" "a non-allowlisted domain is refused by the gate" +assert_contains "ok_gate_denied_https=" "https CONNECT to a non-allowlisted host is refused" +assert_contains "ok_direct_egress_blocked=domain" "ignoring the proxy env does not open a direct route" +assert_contains "ok_direct_egress_blocked=rawip" "a literal IP is not routable either" +assert_contains "ok_raw_socket_blocked" "a raw socket cannot connect out" +assert_contains "ok_host_service_blocked" "only the gate's port is reachable on the mapped loopback address" +assert_contains "ok_dns_blocked" "DNS is closed: names cannot be resolved behind the proxy's back" + +t_name "the ruleset cannot be lifted from inside" +assert_contains "uid=1000" "the harness runs unprivileged" +assert_contains "capeff=0000000000000000" "no capabilities remain for it" +assert_contains "ok_rules_hidden" "it cannot read the ruleset" +assert_contains "ok_rules_locked" "it cannot delete the ruleset" +assert_contains "ok_netns_locked" "it cannot open its own network namespace" +assert_contains "ok_still_enforced=403" "and enforcement still holds afterwards" + +t_name "an IP entry is allowed through the gate as well" +sb --net allow --allow example.com --allow 1.1.1.1:80 -- bash -lc ' + echo "ip_entry_code=$(curl -m10 -sS -o /dev/null -w %{http_code} http://1.1.1.1 2>&1)" + echo "domain_code=$(curl -m10 -sS -o /dev/null -w %{http_code} http://example.org 2>&1)"' +assert_absent "ip_entry_code=403" "an allowlisted IP is not refused by the gate" +assert_contains "domain_code=403" "while an unlisted domain still is" + +t_name "--net none is dark" +sb --net none -- bash -lc ' + curl -m5 -sS -o /dev/null http://example.com 2>/dev/null && echo BAD_open || echo ok_dark + ip -4 -o addr show 2>/dev/null | grep -c global | sed "s/^/addrs=/" + echo "capeff=$(grep CapEff /proc/self/status | cut -f2)"' +assert_contains "ok_dark" "no egress at all" +assert_contains "addrs=0" "no global addresses in the namespace" +assert_contains "capeff=0000000000000000" "no capabilities either" + +t_name "--no-gate: IPs resolved up front, everything else dropped" +sb --net allow --no-gate --allow example.com -- bash -lc ' + echo "allowed=$(curl -m15 -sS -o /dev/null -w %{http_code} http://example.com 2>&1)" + curl -m8 -sS -o /dev/null http://example.org 2>/dev/null && echo BAD_not_blocked || echo ok_blocked + echo "dns=$(: /dev/null && echo open || echo closed)"' +assert_contains "allowed=200" "the resolved address of an allowlisted name works" +assert_contains "ok_blocked" "anything else is dropped by netfilter" + +t_name "the gate does not outlive the session" +BEFORE_GATES="$(podman ps -a --filter label=aisb.role=gate -q | wc -l | tr -d ' ')" +sb $GATE -- true +AFTER_GATES="$(podman ps -a --filter label=aisb.role=gate -q | wc -l | tr -d ' ')" +assert_eq "$BEFORE_GATES" "$AFTER_GATES" "no gate container is left behind by a run" +LEFT="$(ls -A "$AISB_STATE_DIR/sessions" 2>/dev/null | wc -l | tr -d ' ')" +assert_eq "0" "$LEFT" "no session directory is left behind" + +t_name "a named gate outlives a session on purpose" +AISB_GATE_NAME=aisb-test-gate "$AISB" gate up --allow example.com > "$WORK/gate.out" 2>&1 +OUT="$(cat "$WORK/gate.out")" +assert_rc 0 "gate up succeeds" +assert_contains "port=" "and reports the port" +PORT="$(printf '%s' "$OUT" | grep '^port=' | cut -d= -f2)" +if [[ -n "$PORT" ]]; then + code="$(curl -sS -m6 -x "http://127.0.0.1:$PORT" -o /dev/null -w '%{http_code}' http://example.com/ 2>/dev/null || echo 000)" + assert_eq "200" "$code" "the named gate proxies an allowlisted host" + code="$(curl -sS -m6 -x "http://127.0.0.1:$PORT" -o /dev/null -w '%{http_code}' http://example.org/ 2>/dev/null || echo 000)" + assert_eq "403" "$code" "and refuses a non-allowlisted host" +fi +AISB_GATE_NAME=aisb-test-gate "$AISB" gate down > /dev/null 2>&1 +assert_eq "0" "$(podman ps -a --filter name=aisb-test-gate -q | wc -l | tr -d ' ')" "gate down removes it" + +t_name "a sandbox can use a named gate instead of starting its own" +AISB_GATE_NAME=aisb-test-gate2 "$AISB" gate up --allow example.com > "$WORK/gate2.out" 2>&1 +PORT2="$(grep '^port=' "$WORK/gate2.out" | cut -d= -f2)" +sb --net allow --proxy "100.64.0.1:${PORT2}" --allow example.com -- bash -lc ' + echo "via_proxy=$(curl -m15 -sS -o /dev/null -w %{http_code} http://example.com 2>&1)"' +assert_contains "via_proxy=200" "--proxy routes through the named gate" +AISB_GATE_NAME=aisb-test-gate2 "$AISB" gate down > /dev/null 2>&1 + +t_name "a bad gate config fails closed rather than running wide open" +# point the sandbox at a port where nothing answers: the run must not silently +# proceed with a broken egress path. +sb --net allow --proxy 100.64.0.1:1 -- bash -lc ' + code=$(curl -m8 -sS -o /dev/null -w %{http_code} http://example.com 2>/dev/null); echo "code=$code" + exit 0' +assert_contains "code=000" "with a dead proxy, egress simply fails (no bypass)" + +kill "$SRV" 2>/dev/null diff --git a/tests/e2e/12_sandbox.sh b/tests/e2e/12_sandbox.sh new file mode 100644 index 0000000..68a672c --- /dev/null +++ b/tests/e2e/12_sandbox.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# tests/e2e/12_sandbox.sh -- lifecycle, filesystem and environment behaviour of a real run. +source "$TESTS/lib.sh" + +WORK_DIR="$WORK/project"; mkdir -p "$WORK_DIR"; cd "$WORK_DIR" || exit 1 +echo "hello from the host" > "$WORK_DIR/host-file.txt" +export AISB_CANARY_IN=canary-value-42 +unset AISB_CANARY_OUT +DARK="--net none" + +t_name "the command runs and its exit code comes back" +sb $DARK -- bash -c 'echo ran; exit 0' +assert_rc 0 "exit 0 propagates"; assert_contains "ran" "stdout reaches the host" +sb $DARK -- bash -c 'exit 3' +assert_rc 3 "exit 3 propagates" +sb $DARK -- bash -c 'exit 127' +assert_rc 127 "exit 127 propagates" + +t_name "default command follows the harness" +sb $DARK --harness none -- true >/dev/null; assert_rc 0 "harness none is accepted" +sb $DARK --harness pi -- bash -lc "command -v pi" +assert_rc 0 "the default pi binary exists in the image" + +t_name "the project directory is mounted, writable, and owned by the host user" +sb $DARK -- bash -c 'pwd; touch /project/from-container.txt; echo wrote' +assert_contains "/project" "working directory is /project" +[[ -f "$WORK_DIR/from-container.txt" ]] && _ok "writes land on the host" || _bad "writes land on the host" +if [[ "$(stat -c %u "$WORK_DIR/from-container.txt")" == "$(id -u)" ]]; then + _ok "the file is owned by the host uid, not by root" +else + _bad "the file is owned by the host uid" "owner=$(stat -c %u "$WORK_DIR/from-container.txt") expected=$(id -u)" +fi +sb $DARK -- bash -c 'cat /project/host-file.txt' +assert_contains "hello from the host" "existing project files are readable" + +t_name "nothing else on the host is visible" +echo secret > "$WORK/not-shared.txt" +sb $DARK -- bash -c "cat '$WORK/not-shared.txt' 2>&1 || echo NOT_VISIBLE" +assert_contains "NOT_VISIBLE" "the parent directory is not mounted" +sb $DARK -- bash -c 'ls /root 2>&1 | head -1; test -e /etc/shadow && echo has_shadow' +assert_absent "$(hostname)" "the host filesystem layout is gone" + +t_name "environment: deny by default, allow on request" +sb $DARK -- bash -c 'echo "canary=[${AISB_CANARY_IN:-unset}]"; echo "home=$HOME"; echo "aisb=$AISB"' +assert_contains "canary=[unset]" "a host variable does not leak in" +assert_contains "home=/home/sandbox" "HOME is the container's own" +assert_contains "aisb=1" "the sandbox marks itself" +sb $DARK --env AISB_CANARY_IN -- bash -c 'echo "canary=[$AISB_CANARY_IN]"' +assert_contains "canary=[canary-value-42]" "--env passes exactly what was asked for" +sb $DARK --env AISB_CANARY_OUT -- bash -c 'true' +assert_rc 1 "an unset --env variable fails the run closed" + +t_name "the value never appears in a command line" +( sb $DARK --env AISB_CANARY_IN -- bash -c 'sleep 2' >/dev/null 2>&1 ) & +RUNPID=$! +sleep 1.5 +# Every process the value could legitimately have been handed to: the container +# runtime. argv[0] is matched exactly so the test's own shell (whose command line +# contains this very string) can never be the thing that matches. +# (the bracket keeps this grep from matching its own command line) +LEAKY="$(ps -ewwo comm=,args= | awk '{a=$1; sub(".*/","",a)} a ~ /^(podman|crun|conmon|catatonit)$/' \ + | grep -c 'canar[y]-value-42' || true)" +assert_eq "0" "$LEAKY" "no podman/crun/conmon argv carries the secret" +wait $RUNPID 2>/dev/null + +t_name "the env file is 0600 while running and gone afterwards" +( sb $DARK --env AISB_CANARY_IN -- bash -c 'sleep 2' >/dev/null 2>&1 ) & +RUNPID2=$! +sleep 1.5 +ENVF="$(find "$AISB_STATE_DIR/sessions" -name env -print -quit 2>/dev/null)" +if [[ -n "$ENVF" ]]; then + assert_file_mode "$ENVF" 600 "the env file is owner-read/write only" + grep -q 'AISB_CANARY_IN=canary-value-42' "$ENVF" && _ok "and it holds the value we asked for" || _bad "env file content" +else + _bad "the env file is owner-read/write only" "no env file found while the container was running" +fi +wait $RUNPID2 2>/dev/null +sb $DARK --env AISB_CANARY_IN -- true +LEFT="$(find "$AISB_STATE_DIR/sessions" -name env 2>/dev/null | wc -l | tr -d ' ')" +assert_eq "0" "$LEFT" "the env file is deleted when the run ends, even with --keep" +sb $DARK --keep --env AISB_CANARY_IN -- true +LEFT2="$(find "$AISB_STATE_DIR/sessions" -name env 2>/dev/null | wc -l | tr -d ' ')" +assert_eq "0" "$LEFT2" "--keep keeps the container, not the secret" + +t_name "extra mounts" +mkdir -p "$WORK/ro-src" "$WORK/rw-src"; echo data > "$WORK/ro-src/file" +sb $DARK --mount "$WORK/ro-src:/ro:ro" --mount "$WORK/rw-src:/rw" -- bash -c ' + cat /ro/file + touch /rw/created && echo rw_ok + touch /ro/writable 2>/dev/null && echo "BAD: ro mount is writable" || echo ro_locked=ok +' +assert_contains "data" "read-only mount is readable" +assert_contains "rw_ok" "read-write mount is writable" +assert_contains "ro_locked=ok" "ro really means ro" + +t_name "the home directory" +sb $DARK --ephemeral-home -- bash -c 'echo hi > "$HOME/scratch"; ls -A "$HOME" | tr "\n" " "' +assert_contains "scratch" "home is writable with an ephemeral mount" +sb $DARK --home-cache -- bash -c 'echo cached > "$HOME/cached-marker"' +sb $DARK --home-cache -- bash -c 'cat "$HOME/cached-marker"' +assert_contains "cached" "the home cache volume persists across runs" +sb $DARK --ephemeral-home -- bash -c 'test -f "$HOME/cached-marker" && echo LEAK || echo isolated' +assert_contains "isolated" "an ephemeral home does not see it" + +t_name "read-only root filesystem" +sb $DARK --read-only -- bash -c 'touch /usr/bin/nope 2>/dev/null && echo "BAD: wrote to rootfs" || echo rootfs_locked=ok; touch /project/rw-still-works && echo project_still_writable' +assert_contains "rootfs_locked=ok" "--read-only blocks writes outside mounts" +assert_contains "project_still_writable" "the project mount stays writable" + +t_name "lifecycle" +BEFORE="$(podman ps -a --filter label=ai-sandbox=1 -q | wc -l | tr -d ' ')" +sb $DARK -- true +AFTER="$(podman ps -a --filter label=ai-sandbox=1 -q | wc -l | tr -d ' ')" +assert_eq "$BEFORE" "$AFTER" "the container is removed when the command exits" +sb $DARK --keep --name aisb-keeptest -- true +podman ps -a --format '{{.Names}}' | grep -qx aisb-keeptest && _ok "--keep leaves the container" || _bad "--keep leaves the container" +SESSIONS_KEPT="$(ls -d "$AISB_STATE_DIR"/sessions/* 2>/dev/null | wc -l | tr -d ' ')" +[[ "$SESSIONS_KEPT" -ge 1 ]] && _ok "--keep leaves the session dir for inspection" || _bad "--keep leaves the session dir" +sb clean --all > /dev/null +GONE="$(podman ps -a --filter name=aisb-keeptest -q | wc -l | tr -d ' ')" +assert_eq "0" "$GONE" "clean removes leftovers" + +t_name "clean --volumes reclaims the home cache, carefully" +podman volume create not-aisb-decoy > /dev/null 2>&1 +podman volume create aisb-other-dangling > /dev/null 2>&1 +AISB_HOME_VOLUME=aisb-test-home sb $DARK -- true +if podman volume exists aisb-test-home > /dev/null 2>&1; then _ok "the run created its home cache volume"; else _bad "the run created its home cache volume"; fi +sb clean --volumes=aisb-test-home > /dev/null +if podman volume exists aisb-test-home > /dev/null 2>&1; then _bad "clean --volumes=NAME reclaimed the unused volume"; else _ok "clean --volumes=NAME reclaimed the unused volume"; fi +if podman volume exists aisb-other-dangling > /dev/null 2>&1; then _ok "an unmatched volume was left alone"; else _bad "an unmatched volume was left alone"; fi + +# a live sandbox's home is never reclaimed while it runs +AISB_HOME_VOLUME=aisb-test-home2 sb $DARK --name aisb-vollive -- bash -c 'sleep 25' > /dev/null 2>&1 & +for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do + podman ps --filter name=aisb-vollive -q | grep -q . && break + sleep 0.5 +done +AISB_HOME_VOLUME=aisb-test-home2 sb clean --volumes=aisb-test-home2 > /dev/null +if podman volume exists aisb-test-home2 > /dev/null 2>&1; then _ok "a running sandbox keeps its home volume"; else _bad "a running sandbox keeps its home volume"; fi +podman rm -f aisb-vollive > /dev/null 2>&1 +AISB_HOME_VOLUME=aisb-test-home2 sb clean --volumes=aisb-test-home2 > /dev/null +if podman volume exists aisb-test-home2 > /dev/null 2>&1; then _bad "it is reclaimed once nothing references it"; else _ok "it is reclaimed once nothing references it"; fi +podman volume rm not-aisb-decoy aisb-other-dangling > /dev/null 2>&1 + +t_name "the session log records what ran, without secrets" +LOG="$(cat "$AISB_STATE_DIR/log" 2>/dev/null || true)" +printf '%s' "$LOG" | grep -q 'net=none' && _ok "the log records the run" || _bad "the log records the run" +printf '%s' "$LOG" | grep -q 'canary-value-42' && _bad "secrets must never reach the log" || _ok "no secrets in the log" + +t_name "doctor" +sb doctor +assert_rc 0 "doctor reports no problems" +assert_contains "bind mount + uid mapping" "doctor verifies uid mapping" +assert_contains "rootless" "doctor verifies rootless podman" diff --git a/tests/fixtures/egress_probe.sh b/tests/fixtures/egress_probe.sh new file mode 100644 index 0000000..70ed706 --- /dev/null +++ b/tests/fixtures/egress_probe.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# tests/fixtures/egress_probe.sh -- runs *inside* the sandbox and reports, one +# marker per line, what is and is not reachable. Kept as a file so the outer test +# does not have to nest five layers of quoting. +# +# Markers start with ok_/BAD_ so the caller can grep for either. +set -uo pipefail + +ALLOW="${AISB_PROBE_ALLOW:-example.com}" +OTHER="${AISB_PROBE_OTHER:-example.org}" +RAWIP="${AISB_PROBE_RAWIP:-1.1.1.1}" +GATE="${AISB_PROBE_GATE:-100.64.0.1}" +HOSTSVC="${AISB_PROBE_HOSTSVC:-18099}" + +# 1. allowlisted destination, through the proxy +code="$(curl -m15 -sS -o /dev/null -w '%{http_code}' "http://$ALLOW" 2>/dev/null)" +[[ "$code" == 200 ]] && echo "ok_allowed_http=$code" || echo "BAD_allowed_http=$code" + +# 2. allowlisted destination over https (CONNECT) +code="$(curl -m15 -sS -o /dev/null -w '%{http_code}' "https://$ALLOW" 2>/dev/null)" +[[ "$code" == 200 ]] && echo "ok_allowed_https=$code" || echo "BAD_allowed_https=$code" + +# 3. non-allowlisted domain: the gate must answer 403 +code="$(curl -m10 -sS -o /dev/null -w '%{http_code}' "http://$OTHER" 2>/dev/null)" +[[ "$code" == 403 ]] && echo "ok_gate_denied_domain=$code" || echo "BAD_gate_denied_domain=$code" +# a denied CONNECT is refused by the proxy: curl reports 000/502, never 200 +code="$(curl -m10 -sS -o /dev/null -w '%{http_code}' "https://$OTHER" 2>/dev/null)" +[[ "$code" != 200 ]] && echo "ok_gate_denied_https=$code" || echo "BAD_gate_denied_https=$code" + +# 4. same, but pretending the caller ignores the proxy env: the netfilter ruleset +# is what stops it. Anything other than a connection failure is a leak. +nop () { env -u http_proxy -u https_proxy -u all_proxy -u HTTP_PROXY -u HTTPS_PROXY -u ALL_PROXY "$@"; } +if nop curl -m8 -sS -o /dev/null "http://$OTHER" 2>/dev/null; then + echo "BAD_direct_egress=domain" +else + echo "ok_direct_egress_blocked=domain" +fi +if nop curl -m8 -sS -o /dev/null "http://$RAWIP" 2>/dev/null; then + echo "BAD_direct_egress=rawip" +else + echo "ok_direct_egress_blocked=rawip" +fi + +# 5. a raw socket (no proxy semantics at all) must not connect either +python3 - "$RAWIP" "$GATE" <<'PY' +import socket, sys +rawip, gate = sys.argv[1], sys.argv[2] +for label, host, port in (("rawip", rawip, 80), ("cdnish", "93.184.216.34", 80), ("dns", "8.8.8.8", 53)): + s = socket.socket(); s.settimeout(6) + try: + s.connect((host, port)); print(f"BAD_raw_socket={label}") + except Exception as e: + print(f"ok_raw_socket_blocked={label}:{type(e).__name__}") + finally: + s.close() +PY + +# 6. an unrelated service on the host's loopback must be unreachable through the +# mapped address: only the gate's own port is routable there. Tested without +# the proxy, because a proxied request dies at the squid and would exit 0 on a +# 403 -- which proves the proxy works, not that the route is closed. +if nop curl -m6 -sS -o /dev/null "http://$GATE:$HOSTSVC/" 2>/dev/null; then + echo "BAD_host_service_reachable=$HOSTSVC" +else + echo "ok_host_service_blocked=$HOSTSVC" +fi + +# 7. DNS: gate mode keeps it shut, so nothing can resolve names behind the proxy +if getent hosts "$OTHER" > /dev/null 2>&1; then + echo "BAD_dns_resolved=$OTHER" +else + echo "ok_dns_blocked=$OTHER" +fi + +# 8. who are we, and can we touch the ruleset? +echo "uid=$(id -u)" +echo "capeff=$(grep CapEff /proc/self/status | cut -f2)" +if nft list table inet aisb > /dev/null 2>&1; then echo "BAD_rules_readable"; else echo "ok_rules_hidden"; fi +if nft delete table inet aisb > /dev/null 2>&1; then echo "BAD_rules_deleted"; else echo "ok_rules_locked"; fi +if unshare -n true > /dev/null 2>&1; then echo "BAD_new_netns"; else echo "ok_netns_locked"; fi + +# 9. and after all that, enforcement is still in place +code="$(curl -m10 -sS -o /dev/null -w '%{http_code}' "http://$OTHER" 2>/dev/null)" +[[ "$code" == 403 ]] && echo "ok_still_enforced=$code" || echo "BAD_still_enforced=$code" diff --git a/tests/lib.sh b/tests/lib.sh new file mode 100644 index 0000000..89cbc84 --- /dev/null +++ b/tests/lib.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# tests/lib.sh -- tiny assertion helpers for the ai-sandbox test suite. +# +# Every test file gets: AISB (path to the script), WORK (scratch dir), and uses +# sb() to run the sandbox with fully redirected XDG dirs so nothing touches the +# real machine. + +PASS=0 +FAIL=0 +FAILED_NAMES=() +_current="" + +t_name() { _current="$1"; printf '\n\033[1m# %s\033[0m\n' "$1"; } + +_ok() { PASS=$((PASS + 1)); printf ' \033[32mok\033[0m %s\n' "$1"; } +_bad() { FAIL=$((FAIL + 1)); FAILED_NAMES+=("$_current :: $1"); printf ' \033[31mFAIL\033[0m %s\n' "$1"; shift || true; + [[ -n "${1:-}" ]] && printf ' %s\n' "$1"; return 0; } + +# sb [args...] -> sets RC, OUT (stdout+stderr) +sb() { + OUT="$("$AISB" "$@" 2>&1)" && RC=0 || RC=$? + return 0 +} + +# sb_env VAR=val ... -- args (env prefix, without leaking into the test shell) +sb_env() { + local -a envs=() + while (($#)) && [[ "$1" != -* && "$1" == *=* ]]; do envs+=("$1"); shift; done + OUT="$(env "${envs[@]}" "$AISB" "$@" 2>&1)" && RC=0 || RC=$? + return 0 +} + +assert_rc() { # assert_rc