diff --git a/.agents/skills/doc-sandbox/SKILL.md b/.agents/skills/doc-sandbox/SKILL.md index a1ccb43..f29651e 100644 --- a/.agents/skills/doc-sandbox/SKILL.md +++ b/.agents/skills/doc-sandbox/SKILL.md @@ -83,14 +83,14 @@ Not for an explanation, unless the user asked about the machine and agrees: | topic | fact | |---|---| -| precedence | built-in default < `~/.config/ai-sandbox/{config,allow}` < `./.ai-sandbox/{config,allow}` < `AISB_*` < CLI. `allow/env/mount/install/label` accumulate; other keys replace | +| precedence | built-in default < `~/.config/ai-sandbox/{config,allow}` < `./.ai-sandbox/{config,allow}` < `AISB_*` < CLI. `allow/env/mount/install/label/publish` accumulate; other keys replace | | arg parsing | stops at the first positional or unknown flag; the rest goes **to the harness**. `--` replaces the command. `--strict-args` makes an unknown flag an error | | default net | `allow`, and an empty allowlist in `allow` mode is a hard error, never a silent free-for-all | | enforcement | two stacked controls: an in-container `inet aisb` nft table with `policy drop` on output, installed by the entrypoint as root before it `setpriv`s to uid 1000 with no capabilities, plus a squid gate on the host loopback that filters by domain at CONNECT | | refusals | exit **77** ruleset could not be installed; **78** policy file missing in the container. Both are the sandbox working; `--allow-unenforced` is the named escape hatch | | gate ports | squid relays only **80, 443, 8443**; `--allow domain:8080` is not what it looks like. IP/CIDR entries are direct netfilter rules instead (default ports 80,443) | | host services | `100.64.0.1` (config: `gate_addr`) inside the container *is* the host's loopback; only the gate's port is reachable through it unless you `--allow 100.64.0.1:` | -| inbound | there is no publish flag, and in `--net allow` the generated ruleset's input chain is `policy drop` too, so a `-p` pushed through `extra_args` is dropped inside. That last half is a reading of `gen_rules()`, not a measurement — probe it (right-sandbox's `references/port-and-loopback.md`) before promising a preview URL | +| inbound | `--publish [ADDR:]HOST:CONTAINER` (config `publish`, `AISB_PUBLISH`): forwards a host port into the container. Bound to **127.0.0.1** unless the spec names an address (podman's bare `-p` binds every interface, measured), one port per entry, an address needs three parts, `--net none` refuses instead of starting with nothing listening, a busy host port is named before the build. The server must listen on `0.0.0.0` **inside**: a loopback-only server answers inside and refuses the published port (measured). Ingress only — egress enforcement is untouched in the same run | | filesystem | cwd mounted rw **at its host path** (falls back to `/project` with a message when the host path would bury `/tmp`, `/usr`, `/etc`, `/var`, `/opt`, `/run`, the container home, or `/`); nothing else is mounted except the `.agents` share and the read-only policy mount at `/run/aisb` | | the container user | uid/gid 1000, the image's own `sandbox` account, `$HOME` = its home (`--userns=keep-id`, so files the agent writes are owned by *you* on the host). Skills land at `~/.agents` on both sides and they are different directories | | skills | host `~/.agents` → the container's `~/.agents` (`agents_dest`) **read-only**, no flag makes it writable; `--no-agents` to withhold it, `--agents-dir` for another directory. Its contents are readable by whatever runs in there | @@ -105,9 +105,11 @@ Not for an explanation, unless the user asked about the machine and agrees: Say it plainly, then give the closest real thing: -* "publish a port" → no; run the server on the host (the mount is your disk), or one named - `--net full` preview run with `AISB_EXTRA_ARGS=-p …` and an explicit warning that that run has no - egress policy. +* "publish a port on my network so my phone can see it" → not as a committed setting. + `--publish 0.0.0.0:18080:8000` exists and warns on every run, because that is the sandbox becoming + a server on the LAN; `publish = 8000` (loopback) is the setting a repo file should carry. The old + answer — `AISB_EXTRA_ARGS=-p …` with `--net full` — buys nothing that `--publish` does not, and it + costs the egress policy. * "make the sandbox able to write my skills" → no, by design: a skill is an instruction you follow later on the host. Author it in the project and move it yourself. * "let the pipeline push from inside the sandbox" → no: an agent that can push can ship what you are diff --git a/.agents/skills/doc-sandbox/references/source-map.md b/.agents/skills/doc-sandbox/references/source-map.md index 9e9846f..b325975 100644 --- a/.agents/skills/doc-sandbox/references/source-map.md +++ b/.agents/skills/doc-sandbox/references/source-map.md @@ -7,8 +7,8 @@ not** — grep for these in the installed script (`readlink -f "$(command -v ai- | question | read | |---|---| -| "what does `--x` do / does it exist" | `usage()` (grouped by NETWORK / FILESYSTEM / ENVIRONMENT / IMAGE / PI CONFIG / LIMITS / RUN / ESCAPE HATCHES / SUBCOMMANDS), `parse_args()`, and `KNOWN_OPTS` + `suggest()` for the accepted set | -| "which config keys are legal" | `cfg_read_kv()` — the `case` lists them, the accumulate list is `cfg_accum_keys` (`allow env mount install label`). Anything else warns `unknown key` and is ignored | +| "what does `--x` do / does it exist" | `usage()` (grouped by NETWORK / PORTS (INGRESS) / FILESYSTEM / ENVIRONMENT / IMAGE / PI CONFIG / LIMITS / RUN / ESCAPE HATCHES / SUBCOMMANDS), `parse_args()`, and `KNOWN_OPTS` + `suggest()` for the accepted set | +| "which config keys are legal" | `cfg_read_kv()` — the `case` lists them, the accumulate list is `cfg_accum_keys` (`allow env mount install label publish`). Anything else warns `unknown key` and is ignored | | "what wins, my flag or the file" | `resolve_opts()` — every line is `${OPT_x:-${AISB_X:-$(cfgv x default)}}`: CLI, then env, then config, then default. `load_config()` shows the file order | | "`AISB_*` variables" | the comment block above `usage()` and `--help`'s ENVIRONMENT section | | "why did arg parsing eat my harness flag" | `parse_args()` stops at the first positional/unknown; `--` sets `COMMAND`; `--strict-args`; `default_command()`/`harness_argv()` decide what actually runs | @@ -25,6 +25,8 @@ not** — grep for these in the installed script (`readlink -f "$(command -v ai- | gate lifecycle / ports / reuse | `pick_port()`, `gate_up()`, `teardown()`, `cmd_gate()`; `AISB_GATE_KEEP=1`; `gate_port`/`gate_addr` | `ai-sandbox gate status` | | `--no-gate` vs `--proxy` vs the gate | `resolve_proxy_endpoints()` — `--proxy` uses your proxy (default port 3128); `--no-gate` resolves domains once on the host, opens DNS and warns; default is the built-in gate | `--dry-run` (the `HTTP(S)_PROXY` args) | | host-loopback / `100.64.0.1` | `build_argv()`'s `--network=pasta:--map-host-loopback,${gate_addr}` and the `NO_PROXY` defaults | `docs/networking.md`'s measurement table | +| "can I see the server the sandbox is running" / what `publish` means | `resolve_publish()` (the grammar, the loopback default `publish_addr`, the refusals), `build_argv()`'s `--publish` + `aisb.publish` label, `write_policy_files()`'s `AISB_PUBLISH_PORTS`, and `publish_rules()` in the entrypoint. `pick_port()` skips published host ports | `--dry-run`'s `# ingress:` line; `docs/networking.md` "Publishing a port"; `tests/e2e/13_publish.sh` (host reaches it, egress still refused, loopback-only server inside does not answer, busy port refuses the run) | +| "does publishing weaken the sandbox?" | It is ingress only: the output chain, the gate and the allowlist are untouched, and the mapping is decided on the host before the container starts — the command has no `CAP_NET_ADMIN` and cannot add or widen a port | `tests/unit/06_entrypoint.sh` (the rule lands in `input`, never `output`), `tests/e2e/13_publish.sh`, threat model #14 | | "is my machine able to do this" | `cmd_doctor()`, `tests/spike_network.sh` | `ai-sandbox doctor --deep` | ## Filesystem, mounts, the working directory @@ -89,7 +91,10 @@ not** — grep for these in the installed script (`readlink -f "$(command -v ai- `gate up` is the explicit long-lived exception. * **ruleset** — the `inet aisb` nft table installed inside the container's namespace: output `policy drop` with room for loopback, established/related, ICMP, the direct IP entries, the gate's - address:port, and DNS only in `--no-gate` mode. Input `policy drop` too, so nothing is published in. + address:port, and DNS only in `--no-gate` mode. Input is `policy drop` as well, accepting loopback, + established/related, a few icmpv6 types and — since `publish` exists — the tcp ports the host + forwards in (`aisb-publish`). Measured on the reference host pasta delivers those over loopback, so + that rule carries 0 packets there and does the work on a host that delivers on the interface. * **`POL[...]` / `P_*` / `OPT_*` / `CFG_*`** — resolved policy / resolved lists / raw CLI flags / config-file values. Reading raw env vars elsewhere is the bug pattern; a helper called as `X="$(helper)"` runs in a subshell, so any `POL[..]` it sets is discarded. @@ -112,6 +117,9 @@ not** — grep for these in the installed script (`readlink -f "$(command -v ai- | How do I let it push? | You don't, from inside. `--allow github.com` + `--env GH_TOKEN` or `--mount ~/.ssh:ro` does it, and the threat model explains the price. For pipelines, run `ai-sandbox-autophase`, which pushes on the host. | | How do I see what the gate allowed? | `ai-sandbox gate logs` (and `gate status`, `gate down`). Don't put secrets in URLs: the gate sees what flows through it. | | A stale container home after a rebuild? | It is the `aisb-home` volume. `ai-sandbox clean --volumes[=GLOB]`, or `--ephemeral-home` per run. | +| How do I see the dev server the sandbox runs? | `--publish 8000` (config `publish = 8000`): host `127.0.0.1:8000` → container `8000`, warned if you name a non-loopback address. The server inside must bind `0.0.0.0` — a `127.0.0.1`-only server answers inside and refuses the published port (measured). `--net none` refuses to publish, because podman drops the mapping there and starts anyway. | +| Is a published port a security hole? | It is ingress you asked for: whatever that server serves is reachable from the address it is bound to, for as long as the run lasts. Loopback by default (podman's bare `-p` binds every interface), no ranges, the agent cannot open or widen one, egress enforcement is untouched. Threat model #14. | +| How does the sandbox reach a database on my machine? | That is egress, not `publish`: `allow = 100.64.0.1:` (and a `NO_PROXY` entry when it is HTTP on an unusual port). `publish` points the other way. | | How do I refresh the base? | `ai-sandbox update --check` then `ai-sandbox update` (or `build --refresh-base`). TTL re-resolves a digest, it never downloads on a preview. | | Do `--dry-run`/`--print-*` touch the network? | No — that determinism is deliberate. `inspect` is the one that asks the machine now. | | Is the container root? | It starts root with `CAP_NET_ADMIN` just long enough to install the ruleset, then `setpriv` to uid 1000 with no capabilities and `no-new-privileges`. `--insecure-keep-root` is the opposite. | diff --git a/.agents/skills/right-sandbox/SKILL.md b/.agents/skills/right-sandbox/SKILL.md index 6157107..e1ee503 100644 --- a/.agents/skills/right-sandbox/SKILL.md +++ b/.agents/skills/right-sandbox/SKILL.md @@ -1,6 +1,6 @@ --- name: right-sandbox -description: Analyzes a project directory and writes that project's ai-sandbox policy — .ai-sandbox/allow plus .ai-sandbox/config (egress allowlist, environment variable names, bind mounts, working directory, resource limits, harness, extra image packages, access to services on the host) — so that a plain `ai-sandbox` run in the directory is the correct, reviewable invocation. Use when the user asks to set up, configure, write, fix, review or tighten ai-sandbox or the sandbox for a project, wants a .ai-sandbox config committed with the code, or asks why a sandboxed agent cannot reach a package registry, a database, a localhost port or its model gateway. Writes policy files only — never builds, never starts a container, never puts a credential value in a file. +description: Analyzes a project directory and writes that project's ai-sandbox policy — .ai-sandbox/allow plus .ai-sandbox/config (egress allowlist, environment variable names, bind mounts, published ports so a local web server can be run and previewed from the host, working directory, resource limits, harness, extra image packages, access to services on the host) — so that a plain `ai-sandbox` run in the directory is the correct, reviewable invocation. Use when the user asks to set up, configure, write, fix, review or tighten ai-sandbox or the sandbox for a project, wants a .ai-sandbox config committed with the code, wants to run a dev server or expose a port from inside the sandbox, or asks why a sandboxed agent cannot reach a package registry, a database, a localhost port or its model gateway. Writes policy files only — never builds, never starts a container, never puts a credential value in a file. --- # right-sandbox @@ -73,6 +73,7 @@ made deliberately. | package installs | lockfiles (`pnpm-lock.yaml`, `uv.lock`, `Cargo.lock`, `go.sum`, `Gemfile.lock`, `pom.xml`…) | `allow` entries for the registries that ecosystem really contacts — see the table in `references/settings-map.md` | | model gateway | `~/.pi/agent/models.json` `baseUrl` host, or the base URL the user's harness is configured with | `allow = `; without it a bridged pi starts and cannot answer | | a database / queue / cache | `docker-compose*.yml` published ports, `.env` URLs, `DATABASE_URL` | `allow = 100.64.0.1:` + an env var that points the app at `100.64.0.1` — see `references/port-and-loopback.md` | +| a server the project runs, to be seen from the host (dev server, storybook, API docs, a preview the agent should be able to start *and* drive) | `scripts.dev` / `start` in `package.json`, `vite.config.*`, `manage.py runserver`, `uvicorn …`, a `Dockerfile` `EXPOSE`, the port in the project's own README | `publish = ` (loopback by default) — ingress, not an `allow` line. `references/port-and-loopback.md` has the bind-`0.0.0.0` requirement, which is what makes the difference between configured and working | | a non-HTTP backend | pinned host:port (a LAN inference box, an internal API) | `allow = :` (IP/CIDR become netfilter rules; domains go through the gate) | | credential the harness needs | `.env.example`, CI `secrets.*`, `--env NAME` in the user's launcher | `env = NAME` (name only), and list what the user must export | | reference material / sibling repo | imports or docs that read `../something` | `mount = ../something:/something:ro` | @@ -128,6 +129,10 @@ env = @./.ai-sandbox/env # apt packages the test suite needs that the image lacks install = postgresql-client + +# ingress: the dev server this project runs, previewable on the host at +# http://127.0.0.1:8000 (loopback only; the server binds 0.0.0.0 inside) +publish = 8000 ``` `env = @path` is the config-file form of `--env-file` (`--env-file F` becomes the entry `@F`), so @@ -150,6 +155,9 @@ to put in it. opened**; weaker for CDNs and rotating IPs. `--proxy host:port` uses a proxy you already run (default port 3128). Neither belongs in a committed config without a comment justifying it. * An empty allowlist in `allow` mode is a hard error. Never "fix" that by widening net. +* `publish` is the one **ingress** key and it is not an allow entry: `allow = 100.64.0.1:5432` lets + the sandbox reach your machine, `publish = 5432:5432` lets your machine reach the sandbox. Writing + the wrong one of these is how a service ends up open on the wrong side. ## 5. Verify (no builds, no containers, no network) @@ -176,7 +184,10 @@ Check, in this order, and fix the files instead of explaining the output: 4. `mounts=` and `agents=` are what you wrote and nothing more. 5. the dry run shows `--network pasta:--map-host-loopback,100.64.0.1`, `--cap-drop=ALL`, `--security-opt no-new-privileges`, `--userns keep-id:…` and no secret values. -6. `--print-policy | grep -i` for each credential name from §1: the *name* may appear, a *value* +6. if you wrote `publish`: the `# ingress: publish=[…]` line shows `127.0.0.1` on every entry unless + the user asked for an address, and the project's dev command binds `0.0.0.0` — a published port + pointing at a `127.0.0.1`-only server is configured, silent and useless. +7. `--print-policy | grep -i` for each credential name from §1: the *name* may appear, a *value* must not. Then, with the user watching (this builds the image the first time and starts a container): @@ -184,12 +195,14 @@ Then, with the user watching (this builds the image the first time and starts a ```bash ai-sandbox --shell # egress, mounts, env present ai-sandbox --net none -- make test # the offline half of the toolchain -ai-sandbox doctor # host readiness +ai-sandbox doctor # host readiness, incl. the ports it would publish ``` Probe the two or three endpoints the project actually needs from inside (`curl -sS -m 5 -o /dev/null -w '%{http_code}\n' https://registry.npmjs.org/`) rather than assuming an allowlist entry -means a working toolchain. +means a working toolchain. If you wrote `publish`, probe the other direction too, from the host, +while a sandbox is still running (`references/port-and-loopback.md` §2 has the two-shell probe): a +published port nobody answered during the review is not a verified setting. ## 6. Report @@ -198,19 +211,26 @@ Finish with exactly these five points, so the user can act without reading the f 1. **Files written** — paths, plus the `.gitignore` change if any. 2. **`cd && ai-sandbox`** is now the invocation (and the alias note if they use one). 3. **What they must export** first (`env =` names that are unset), and where values may go. -4. **What is deliberately not allowed** — the git remote first, then anything you declined to open - because the honest answer was "not without weakening the sandbox" (inbound port publishing is - usually on this list). +4. **What is deliberately not allowed, and what is open** — the git remote first, then anything you + declined to open because the honest answer was "not without weakening the sandbox". If you wrote + `publish`, name each port, its host address and what listens there: an open port belongs in the + report even though it was requested. 5. **What you verified and how** — the two preview commands and any live probe, with results. ## Ports, localhost, "the app must be reachable" -Read `references/port-and-loopback.md` before promising anything about ports. Short version: a -sandboxed process reaches *services on your machine* through the mapped loopback address -`100.64.0.1`, which needs an `allow` entry **and** a client that does not send that request to the -egress proxy; and a port the sandbox listens on is **not** reachable from the host in `--net allow` -mode, because the generated ruleset's input chain is `policy drop`. Verify on the machine rather -than repeating that from memory — the file has the probes. +Read `references/port-and-loopback.md` before promising anything about ports. Two directions, two +settings, and the wrong one is how a service ends up open on the wrong side: + +* **the sandbox → a service on your machine**: `allow = 100.64.0.1:` (the mapped loopback + address), plus a client that does not send that request to the egress proxy. +* **your machine → the sandbox**: `publish = `, bound to `127.0.0.1` unless the spec names an + address. It is ingress: egress enforcement is untouched by it, and the agent inside cannot open or + widen a port of its own. The server has to bind `0.0.0.0` *inside* — that is the difference between + a configured preview and a working one, and it is measured, not folklore. + +Verify on the machine rather than repeating any of this from memory: `--dry-run` shows +`# ingress: publish=[…]`, and `tests/e2e/13_publish.sh` is what the claims rest on. ## References diff --git a/.agents/skills/right-sandbox/references/port-and-loopback.md b/.agents/skills/right-sandbox/references/port-and-loopback.md index 8a6a852..f452ae9 100644 --- a/.agents/skills/right-sandbox/references/port-and-loopback.md +++ b/.agents/skills/right-sandbox/references/port-and-loopback.md @@ -2,10 +2,14 @@ Two separate problems wear the same name. Get the direction right before touching a config. -| | what it is | possible in `--net allow`? | -|---|---|---| -| **outbound** | the sandboxed process reaches a service running on your machine (dev postgres, redis, a mock API, a local model server) | **yes** — one allow entry, sometimes plus a proxy bypass | -| **inbound** | your browser on the host reaches a server the sandbox started | **no** — the generated ruleset drops it; three workarounds below | +| | what it is | how | direction | +|---|---|---|---| +| **outbound** | the sandboxed process reaches a service running on your machine (dev postgres, redis, a mock API, a local model server) | `allow = 100.64.0.1:` — an ordinary allow entry | egress | +| **inbound** | your browser on the host reaches a server the sandbox started | `publish = ` / `--publish` | ingress | + +They are not the same setting and one does not imply the other. `allow = 100.64.0.1:5432` does not +open a port; `publish = 5432:5432` does not allow egress. Getting these backwards is how a database +ends up reachable from the wrong side. Container-to-container is a third case, already measured on the reference host: two containers on a shared user-defined network **cannot reach each other**, so do not design a sidecar proxy, a @@ -66,54 +70,141 @@ env = DATABASE_URL=postgresql://dev@100.64.0.1:5432/app_dev If `100.64.0.1` collides with a real network the project must reach, change `gate_addr` (and remember the gate itself is reached at that address too). -## 2. Inbound: "I want to see the app the agent is building" +## 2. Inbound: publishing a port the sandbox listens on -There is **no publish flag** (`--publish`/`-p` are not in `parse_args()`). You can push one through -the escape hatch — `extra_args = -p 8000:8000` or `AISB_EXTRA_ARGS="-p 8000:8000"` — and then read -the ruleset the entrypoint installs: the `input` chain is `policy drop` with room only for `lo`, -`established,related` and a few icmpv6 types, so a fresh inbound connection is dropped inside the -container. Confirm on the machine instead of trusting that reading: +`--publish` (config key `publish`, repeatable) forwards a host port into the container, so a client +on the host — a browser, curl, an API tester — reaches a server the sandboxed process runs. It is +the only ingress setting in the tool, and the ruleset names the ports it opened +(`nft list table inet aisb` → the `aisb-publish` rule). -```bash -AISB_EXTRA_ARGS="-p 8000:8000" ai-sandbox --shell - # inside: python3 -m http.server 8000 & - # host: curl -sS -m 3 -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/ +Shapes, and what they bind. **The default address is `127.0.0.1`**, because a bare podman `-p` binds +every interface (measured) and a port in a committed config must not quietly be a port on the LAN: + +| spec | host side | container side | +|---|---|---| +| `8000` | `127.0.0.1:8000` | `8000` | +| `18080:8000` | `127.0.0.1:18080` | `8000` | +| `0.0.0.0:18080:8000` | every interface — **warns on every run** | `8000` | +| `[::1]:18080:8000` | ipv6 loopback | `8000` | + +Rules the parser enforces, and the reasons to repeat them to the user: + +* **One port per entry.** No ranges, no `/udp`: this list is reviewed line by line, and a range is a + line nobody reads. +* **An address means three parts.** `127.0.0.1:8000` is refused rather than guessed at. +* **`--net none` refuses to publish.** Measured: podman discards port mappings there with one line on + its own stderr and starts the container anyway, so "it ran" would mean "nothing is listening". +* **A busy host port is named before the container starts**, not after a build. Suggest + `publish = :` rather than telling the user to go find `ss` output. +* **No `-p` flag.** `-p` passes through to the harness (`claude -p` prints), which is deliberate. +* **No `*`.** podman's shorthand for "every interface" is refused; write `0.0.0.0`. Typing the address + is the warning. + +### The one thing that makes a published port look broken + +**The server has to bind `0.0.0.0` inside the container.** Measured: a server bound to +`127.0.0.1` inside answers `200` from inside and refuses the published port from the host, because +pasta delivers the connection to the container's own address, not to its loopback. So the config is +not enough — the project's dev command has to cooperate: + +| stack | what the preview command needs | +|---|---| +| vite | `npm run dev -- --host 0.0.0.0` | +| Next / webpack dev servers | `--hostname 0.0.0.0` | +| Django | `runserver 0.0.0.0:8000` | +| Flask | `flask --app … run --host 0.0.0.0 -p 8000` | +| uvicorn / FastAPI | `uvicorn --host 0.0.0.0 --port 8000 …` | +| a bare static server | `python3 -m http.server 8000 --bind 0.0.0.0` | + +A dev-server config file that pins `host: 127.0.0.1` (`vite.config.*`, `webpack-dev-server`, +`gunicorn -b 127.0.0.1:8000`) needs that changed, or the preview command overrides it on the command +line. Say which, or the user will conclude the sandbox is broken. + +### What publishing does not do + +* **It does not weaken egress.** The same run answers the host and still refuses every domain + outside the allowlist; the entrypoint still drops all capabilities before the command runs. + `tests/e2e/13_publish.sh` asserts both halves in one container, so this is not a claim to re-derive. +* **It does not let the agent open more ports.** The mapping is decided on the host before the + container starts; inside, the command has no `CAP_NET_ADMIN` and cannot add or widen one. +* **It does not survive the run.** The listener is pasta's, removed with the container. If a port is + still bound afterwards, something was started with `--keep` — `ai-sandbox ps`, `ai-sandbox clean`. + +### What to write, and what not to + +Loopback publishing is a legitimate project setting: it says "this project runs a server on this +port, and previewing it is part of working on it". + +```ini +# .ai-sandbox/config +publish = 8000 # the app: http://127.0.0.1:8000 +publish = 18080:3000 # the storybook, on a host port that is free ``` -Expected on a stock host in `--net allow`: no answer. If yours answers, that is a fact worth adding -to `docs/networking.md` — measure and report it, do not build the workflow on it. +Comment each one with what runs there — a port without a name is a port a later reader cannot +remove. Do **not** commit: -What to tell the user instead, in order of preference: +* a **non-loopback** address (`0.0.0.0:…`, `192.168.x.x:…`). It warns on every run because it means + what it says: anything that can route to the machine can reach whatever the sandbox serves. If the + user genuinely needs a phone on their WiFi to reach the preview, that is a per-run flag, said out + loud, not a line in a file that outlives the run. +* `publish_addr = 0.0.0.0` in a repo config. It changes the default for every bare `publish` line in + that directory, which is the same mistake wearing a different hat. It belongs in + `~/.config/ai-sandbox/config` if anywhere. +* a port for a service the project does not actually run, "in case". + +### Verify it, in this order + +```bash +ai-sandbox --dry-run # '# ingress: publish=[127.0.0.1:8000:8000]' and + # '--publish 127.0.0.1:8000:8000' in the invocation +ai-sandbox --print-policy | grep '^publish=' # canonical addr:host:container, loopback unless you + # wrote an address +``` + +Then the only check that answers the real question, with the user watching (a real container; the +port exists only while it runs): + +```bash +ai-sandbox --publish 8000 --shell + # inside: python3 -m http.server 8000 --bind 0.0.0.0 >/tmp/s.log 2>&1 & + # inside: curl -sS -o /dev/null -m2 -w 'inside=%{http_code}\n' http://127.0.0.1:8000/ + # host: curl -sS -o /dev/null -m3 -w 'host=%{http_code}\n' http://127.0.0.1:8000/ +``` + +`host=200` is the result. `host=000` with `inside=200` is the bind address, not the sandbox — go back +to the table above. A refusal naming the port means something else holds it. `--print-entrypoint | +grep -A6 'chain input'` shows the accept rule shape; on the reference host pasta forwards over +loopback, so that rule's packet counter stays at 0 while the port serves — the rule is the audit +trail and the portability guarantee, not what carries the traffic there. + +### When the honest answer is still "don't publish" 1. **Run the server on the host.** The project mount *is* their disk: the agent edits, the host's - `npm run dev` / `runserver` picks it up, the browser hits a normal host port. Nothing is - published, nothing is opened, egress stays enforced. This is the answer for 90% of "preview the - app" requests. -2. **One named preview run**, when the server itself must be the sandboxed process (the agent needs - to drive it, seed it, watch its logs): - ```bash - AISB_EXTRA_ARGS="-p 8000:8000" ai-sandbox --net full -- npm run dev - ``` - Say out loud what this is: `--net full` means that run has **no egress policy**. It is a - preview of a trusted project, not a project setting — which is why it never belongs in - `.ai-sandbox/config`. -3. **Split the loop**: the agent builds and unit-tests in the sandbox, writes a screenshotable - static export or a `make preview` target, and the human runs that target. This keeps the review - step where it belongs. + `npm run dev` / `runserver` picks it up, the browser hits a normal host port. Nothing is published + and nothing is opened. Still the right answer when the agent does not need to drive the server — + and that is most "preview the app" requests. +2. **Publish it**, when the server itself has to be the sandboxed process: the agent seeds a + database it starts, runs a migration then serves, needs to watch the server's own logs, or the + test *is* hitting the endpoint. Loopback, named in `.ai-sandbox/config`, with the bind address + fixed in the same change. +3. **Split the loop**: the agent builds and unit-tests in the sandbox, writes a static export or a + `make preview` target, and the human runs that. Keeps the review step where it belongs. -If the user genuinely needs publish-with-enforcement, the honest answer is that it is not -implemented: a `--publish PORT` would need an `input` accept rule per port in `gen_rules()`, the -port list carried in the session policy, and a threat-model line about who may connect. Offer to -write it as a change to `bin/ai-sandbox` — as its own phase, with a `tests/e2e` probe — rather than -hacking `extra_args` into a committed config and calling it secure. +If the ask is "publish it on the LAN so I can check it on my phone", that is a per-run +`--publish 0.0.0.0:18080:8000` with the warning read out, never the committed config — and if the +user wants it permanently, say plainly that the sandbox is then a server on their network. ## 3. Notes that keep coming up * The gate binds the **host's loopback** only, on a free port at or above `gate_port` (18081); - `ai-sandbox gate status` and `gate logs` show a long-lived one. + `ai-sandbox gate status` and `gate logs` show a long-lived one. `pick_port()` skips ports this run + publishes, so a `publish = 18081` does not lose the run. * `NO_PROXY` covering `localhost` is correct and must stay — tooling that refuses to proxy the container's own loopback (dev databases, auth servers, vite HMR) breaks otherwise. * `--no-gate` opens DNS inside the container; the mapped loopback address still needs its `allow` line. * `--net none` has no interface at all: neither direction works, which makes it the right mode for - `make test` on a toolchain that is already installed. + `make test` on a toolchain that is already installed — and the reason it refuses `--publish`. +* `ai-sandbox doctor` reports the resolved `publish` lines and warns about a non-loopback bind or a + host port already in use. It never binds anything itself. diff --git a/.agents/skills/right-sandbox/references/settings-map.md b/.agents/skills/right-sandbox/references/settings-map.md index 0fd7f3d..67abaac 100644 --- a/.agents/skills/right-sandbox/references/settings-map.md +++ b/.agents/skills/right-sandbox/references/settings-map.md @@ -11,9 +11,10 @@ built-in default < ~/.config/ai-sandbox/{config,allow} < ./.ai-sandbox/{conf < AISB_* environment < command-line flag ``` -`allow`, `env`, `mount`, `install`, `label` **accumulate** from every source (deduped); every other -key replaces. So a repo file cannot revoke a user-wide `allow` line — say so if the user expects a -repo config to tighten their own machine. +`allow`, `env`, `mount`, `install`, `label`, `publish` **accumulate** from every source (deduped); +every other key replaces. So a repo file cannot revoke a user-wide `allow` line — say so if the user +expects a repo config to tighten their own machine. It also cannot *un*publish a port the user's +own config opens. ## The container, for orientation @@ -43,6 +44,7 @@ directories — the host one is bind-mounted read-only onto the container one at | `home_cache` | `1` | `0` = throwaway tmpfs home (also `--ephemeral-home`) | | `gate_addr` | `100.64.0.1` | the address that *means* the host's loopback inside the container | | `gate_port` | `18081` | preferred first port; next free is used | +| `publish_addr` | `127.0.0.1` | the host address a bare `publish = 8000` binds. Machine-wide setting: put it in the user config, never in a repo file, and do not widen it for the user's convenience | | `extra_args` | empty | appended verbatim to `podman run`, word-split. Escape hatch: do not commit it | | `pi_config` | `1` | bridge `~/.pi/agent/{models,settings}.json` with the credential as `$VAR` | | `pi_token_env` | `AI_API_TOKEN` | the variable the bridged config reads | @@ -57,6 +59,7 @@ directories — the host one is bind-mounted read-only onto the container one at | `mount` | — | repeatable; `SRC[:DEST][:ro\|rw]`, default `DEST=/mnt/` | | `install` | — | repeatable; apt packages, each distinct set is its own image tag | | `label` | — | repeatable; `K=V` on the container | +| `publish` | — | repeatable; **ingress**: `[ADDR:]HOST:CONTAINER` forwarded into the container. One port per entry, no ranges, no `/udp`; an address needs three parts. `8000`, `18080:3000`, `0.0.0.0:18080:3000` (LAN-visible: warned, do not commit) | Anything else warns (`unknown key`) and is ignored. @@ -64,6 +67,7 @@ Anything else warns (`unknown key`) and is ignored. ``` egress --net none|allow|full --allow HOST[:PORT] --allow-file F --proxy HOST:PORT --no-gate +ingress --publish [ADDR:]HOST:CONTAINER (no `-p`: that flag belongs to the harness) files --mount SRC[:DEST][:ro] --no-cwd --workdir DIR|auto --read-only --home-cache --ephemeral-home --agents | --no-agents --agents-dir PATH env --env NAME --env NAME=VALUE --env-file F (F should be 0600) @@ -128,6 +132,10 @@ fails loudly, which is the feature. that with `--workdir /tmp/...`; the tmpfs covers it. * The host path may not contain `:` at all — podman cannot parse such a mount spec, and ai-sandbox refuses rather than passing the error along. +* `publish` is the only **ingress** setting there is, and it is not an allowlist entry. `allow = + 100.64.0.1:5432` lets the sandbox reach a service on your machine; `publish = 5432:5432` lets your + machine reach a port inside the container. Confusing the two is how a database ends up exposed. + Read `references/port-and-loopback.md` before writing either. * Nothing is inherited but `TERM LANG LC_ALL LC_CTYPE TZ` (plus `HOME`, set to the container home, `AISB=1`, `AISB_SESSION`). A tool that needs `PATH`, a dotfile or anything else from your real home needs an explicit grant. diff --git a/.agents/skills/right-sandbox/scripts/survey.sh b/.agents/skills/right-sandbox/scripts/survey.sh index f6f4762..af89d17 100755 --- a/.agents/skills/right-sandbox/scripts/survey.sh +++ b/.agents/skills/right-sandbox/scripts/survey.sh @@ -128,8 +128,21 @@ devports="$(grep -rIhoE --include='vite.config.*' --include='next.config.*' --in '(--[Pp][Oo][Rr][Tt][[:space:]=]+[0-9]{4,5})|([Pp][Oo][Rr][Tt][[:space:]]*[=:][[:space:]]*"?[0-9]{4,5})' . 2>/dev/null \ | grep -oE '[0-9]{4,5}$' | sort -u | tr '\n' ' ')" [[ -n "$devports" ]] && note "ports named in dev-server configs: $devports" -note "outbound (sandbox -> a service on this machine) is grantable: 100.64.0.1:." -note "inbound (host -> a server the sandbox started) is dropped by the ruleset -- references/port-and-loopback.md" +exposed="$(files_matching Dockerfile 'Dockerfile.*' | xargs -r grep -hoiE '^EXPOSE[[:space:]]+[0-9]+' 2>/dev/null \ + | grep -oE '[0-9]+' | sort -u | tr '\n' ' ')" +[[ -n "$exposed" ]] && note "EXPOSEd in the Dockerfile: $exposed" +# The address matters as much as the port: a dev server pinned to loopback answers nothing on a +# published port, so quote the project's own setting back when it says 127.0.0.1. +binds="$(grep -rIhoE --include='vite.config.*' --include='next.config.*' --include='webpack*.js' \ + --include='gunicorn*' --include='Procfile' --include='settings.py' --include='*.toml' \ + '(host|HOST|BIND|bind|--host|-b)[^0-9A-Za-z]{0,4}(0\.0\.0\.0|127\.0\.0\.1|\[::1\]|localhost)' . 2>/dev/null \ + | grep -oE '0\.0\.0\.0|127\.0\.0\.1|\[::1\]|localhost' | sort | tr '\n' ' ')" +[[ -n "$binds" ]] && note "addresses the project's own config binds to: $binds" +note "outbound (sandbox -> a service on this machine): allow = 100.64.0.1:." +note "inbound (host -> a server the sandbox runs): publish = -- loopback unless an address" +note " is written in the spec. The two are not interchangeable: publish is ingress, allow is egress." +note " a published port only reaches a server listening on 0.0.0.0 inside (measured), so a config that" +note " binds loopback above needs that said in the same change. references/port-and-loopback.md" # --------------------------------------------------------------------------- environment names section "environment variable NAMES the project references (no values)" @@ -174,8 +187,9 @@ fi # --------------------------------------------------------------------------- existing policy section "existing policy" KV_KEYS="net harness workdir cpus memory pids tmpfs_size base_image node_version pi_version \ -base_ttl_days base_auto_update selinux home_cache gate_addr gate_port extra_args pi_config \ -pi_token_env pi_token_auto pi_agent_dir thinking agents agents_dir agents_dest allow env mount install label" +base_ttl_days base_auto_update selinux home_cache gate_addr gate_port publish publish_addr extra_args \ +pi_config pi_token_env pi_token_auto pi_agent_dir thinking agents agents_dir agents_dest \ +allow env mount install label" for f in .ai-sandbox/config .ai-sandbox/allow; do [[ -r "$f" ]] && found "$f ($(wc -l < "$f") lines)" || note "$f: absent" done @@ -193,6 +207,23 @@ if [[ -r .ai-sandbox/config ]]; then fi if [[ "$k" == net && "$v" == full ]]; then warn "net = full in a committed config: that is an escape hatch, not a policy"; fi if [[ "$k" == extra_args ]]; then warn "extra_args in a committed config: the podman escape hatch -- it belongs on one command line"; fi + if [[ "$k" == publish ]]; then + case "$v" in + */*) warn "publish = $v has a protocol suffix: one port per entry, no /udp" ;; + *-*) warn "publish = $v is a range: ai-sandbox takes one port per entry, so a reviewed line is never a block of ports" ;; + \[*) note "publish = $v -- an ipv6 bind in brackets, then host:container" ;; + *:*:*) note "publish = $v -- addr:host:container; anything not loopback is an open door and warns on every run" ;; + *:*) note "publish = $v -- host port -> container port, bound to 127.0.0.1 (the default)" ;; + *[!0-9]*) warn "publish = $v is not a port, host:container or addr:host:container -- ai-sandbox refuses it" ;; + *) note "publish = $v -- 127.0.0.1:$v -> container $v; the server must bind 0.0.0.0 inside" ;; + esac + fi + if [[ "$k" == publish_addr ]]; then + case "$v" in + 127.*|localhost|::1|\[::1\]) note "publish_addr = $v -- loopback, the safe default" ;; + *) warn "publish_addr = $v in a repo config widens every bare publish line in this directory -- the user's config, if anywhere" ;; + esac + fi done < .ai-sandbox/config fi if [[ -r .ai-sandbox/allow ]]; then @@ -225,7 +256,8 @@ else fi section "next" -note "1. decide net / allow / env / mount / install / limits from the above (SKILL.md step 2)" +note "1. decide net / allow / env / mount / install / publish / limits from the above (SKILL.md step 2)" note "2. write .ai-sandbox/allow and .ai-sandbox/config, commented, names only" note "3. verify with ai-sandbox --print-policy and --dry-run (no build, no container, no network)" -note "4. then one live probe per endpoint the project really needs" +note "4. then one live probe per endpoint the project really needs -- and from the host too if you" +note " wrote publish: that port exists only while a sandbox runs, and only 0.0.0.0 inside answers it" diff --git a/AGENTS.md b/AGENTS.md index 96f62f0..0092778 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,13 @@ read-only with no escape hatch (`agents`/`--agents`/`--agents-dir`, and `--no-ag a sandbox that could write your skills could write instructions you follow later on the host. See `agents_src_of()` and the tests in `tests/unit/09_agents.sh` / `tests/e2e/12_sandbox.sh`. +There is exactly one **ingress** control: `--publish` / `publish` forwards a host port into the +container so a server the sandbox runs can be previewed from the host. It binds `127.0.0.1` unless the +spec names an address (measured: podman's bare `-p` binds every interface), takes one port per entry, +refuses to start under `--net none` rather than run with nothing listening, and the in-container +ruleset names the ports it opened. It changes nothing about egress — the same run that answers a +browser still refuses every domain outside the allowlist (`tests/e2e/13_publish.sh`). + There is a second program, `bin/ai-sandbox-autophase`: the driver for a `.agents/phases/` pipeline. It runs every executor agent *and the `.agents/validate.sh` gate* through the sandbox, and does the commits and pushes on the host — the git remote @@ -139,6 +146,19 @@ networking or the image, `make e2e` green too. so it is written on the *first* seed too, not only by the merge on the second run. * `split_target()` / `classify_allows()` — the mapping from what the user typed to what the firewall and the proxy enforce. +* `resolve_publish()` / `publish_rules()` — the one ingress path, whose two ends have to agree: the + canonical `addr:host:container` spec built on the host, and the container-side accept rule rendered + from `AISB_PUBLISH_PORTS`. Three things are load-bearing. (1) The bind default is loopback — widen + it and a preview in a committed config becomes a listener on the LAN, which is the mistake the flag + exists to avoid. (2) The rule belongs in the `input` chain and never the `output` chain; + `tests/unit/06_entrypoint.sh` asserts which chain, because a publish rule in `output` is an egress + hole wearing an ingress flag. (3) The port list is validated again *inside* the entrypoint before it + reaches nft, and nft is fussy about order (`… counter accept comment "x"`, verdict first): get it + wrong and the ruleset refuses to install, the sandbox exits 77 with the feature dead, and every + `--dry-run` still looks perfect. Only `make e2e` catches that class, so a change here is an e2e + change. Measured, and in `docs/networking.md` for a reason: pasta delivers the connection over + loopback (the `aisb-publish` counter stays at 0 while the port serves), and a server bound to + `127.0.0.1` inside answers nothing on the published port. * `build_argv()` — every isolation flag lives here. * `build_in()` / `build_progress()` — a build is minutes of apt, a Node tarball and an npm install, so it has to announce itself and keep announcing: silence is what a hang diff --git a/PLAN.md b/PLAN.md index 5fa9be7..fc7a0b3 100644 --- a/PLAN.md +++ b/PLAN.md @@ -256,6 +256,52 @@ destination too. It is refused now, with the reason, instead of podman's. (`--mo type=bind,src=…,dst=…` parses it happily; switching the mount form for one character is not worth the relabel-option churn that comes with it.) +### 14 — Publishing a port: the one control that runs the other way + +Everything up to here gates egress, which left an everyday request unanswered: the agent starts a +dev server *inside* the sandbox and a human wants to look at it. The documented answer used to be +"run the server on the host", which is good advice until the agent has to drive the server — seed the +database it starts, migrate then serve, hit its own endpoints, read its logs. So: `--publish` / +`publish`, ingress, with the cost named in the flag rather than discovered later. + +Two measurements decided the shape. A bare podman `-p 8000:8000` binds **every interface** on the +host, so a port committed in a repo config would also be a port opened on the LAN — hence the default +bind address is `127.0.0.1`, an address in the spec is written as `addr:host:container` and warns, +and `publish_addr` exists as a machine-wide override nobody should put in a project file. And the +`input` chain, which reads as `policy drop` with no room for a forward, turns out not to block it: +`nft -a list chain inet aisb input` while a published port serves traffic shows the +`aisb-publish` rule at **0 packets**, because pasta forwards into the namespace over loopback and +`iifname "lo" accept` carries it. The explicit rule is therefore not what makes publishing work here; +it is what makes it work on a host that delivers on the interface, and what makes +`nft list table inet aisb` answer "what can reach this container" instead of leaving that question to +a flag on the host. + +A third measurement became the documentation, because it is what makes a configured preview look +broken: a server bound to `127.0.0.1` *inside* answers 200 from inside and refuses the published port +from the host. pasta delivers to the container's own address, so the project's dev command has to bind +`0.0.0.0` — `--host 0.0.0.0`, `runserver 0.0.0.0:8000` — and the skill is told to say so in the same +change that writes the port. + +The grammar is deliberately smaller than podman's: `port`, `host:container`, `addr:host:container`, +one port per entry, no ranges and no `/udp`, because this list is reviewed line by line and a range is +a line nobody reads. An address requires all three parts, so `127.0.0.1:8000` is refused with the +grammar in the message instead of silently meaning whichever of the two things podman would have +picked. No `-p` alias: `-p` belongs to the harness (`claude -p` prints), and an unknown flag already +passes through. Refusals follow the usual rule — `--net none` cannot forward a port and podman +discards the mapping while starting the container anyway, so "it ran" would mean "nothing is +listening"; and a host port already in use is named before the container starts, not after a build. +`pick_port()` skips ports this run publishes, or the gate would bind the port the sandbox asked for +first and lose the run. + +The unit tests check the shape (`--publish 127.0.0.1:8000:8000`, the `# ingress:` header, which chain +the rule lands in, a non-numeric port never reaching nft as code) and the e2e file checks the only +question that matters: a real container with `enforce=nft`, a host `curl` that gets 200, **the same +run still refusing a non-allowlisted domain at 403**, the server running as uid 1000 with no +capabilities so it cannot widen its own port, and the host listener gone once the container exits. +That test paid for itself immediately: the first version of the rule put `comment` before `accept`, +nft refused it, and the sandbox exited 77 — correctly refusing to run unenforced, and silently +killing the feature, with `--dry-run` looking perfect. + ## Follow-ups (not yet done) * **`--net unix` mode**: `--network=none` plus a unix-socket proxy and an in-container @@ -285,5 +331,14 @@ worth the relabel-option churn that comes with it.) * **Bridging extensions/skills on request**: `--pi-mount-ext` or similar for the extension paths named in `enabledExtensionPaths`, which are host paths and therefore currently dropped; needs the same explicit-grant shape as `--mount`. +* **Published ports in the driver**: `.ai-sandbox/config` already reaches every child unit, so a + pipeline can publish — but two units running in sequence want the same port, and a long run holding + one is a decision the driver should make out loud (`--publish` per unit, or a range handed out from + a pool) rather than inherit. +* **`publish = :8000`** (podman's "pick a free host port"): useful for parallel sandboxes, and it + needs the chosen port reported back to the terminal, since nothing in the config says what to open + in the browser. +* **UDP publishing**: deliberately left out of v1. It drags in the protocol suffix the parser + refuses, and the sandboxed use cases (dev servers, APIs, hot-reload) are all TCP. * **`models.json` review surface**: `inspect` could print the exact diff between host config and bridged config, so a change is reviewable before a run rather than after. diff --git a/README.md b/README.md index 0d88577..214a474 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,37 @@ If you bridge your pi config (below), ai-sandbox tells you when the model endpoi your own `models.json` points at is not in the allowlist -- pi would otherwise start and sit there unable to answer, which reads like a broken sandbox. +### Opening a port: ingress, on purpose + +Everything above is egress. To look at a server the sandbox is running -- a browser on +your machine hitting the dev server the agent started -- publish a port: + +```bash +ai-sandbox --publish 8000 # host 127.0.0.1:8000 -> container 8000 +ai-sandbox --publish 18080:8000 # a different host port +ai-sandbox --publish 0.0.0.0:18080:8000 # every interface: warned about, on purpose +``` + +| | | +|---|---| +| Default address | `127.0.0.1`. A bare port never lands on your network; `publish_addr` changes the default, an address in the spec overrides it. | +| Shape | `port`, `host:container`, or `addr:host:container`. One port per entry: no ranges, no `/udp` -- this list is reviewed line by line. An address means three parts, so `127.0.0.1:8000` is refused rather than guessed, and podman's `*` shorthand is refused too: writing `0.0.0.0` makes the next reader see what is open. | +| Cost | It is **ingress**: whatever that server serves, or accepts, is reachable from that address. Egress is untouched: the same run that answers your browser still refuses a domain outside the allowlist (`tests/e2e/13_publish.sh`). | +| In the container | The ruleset accepts tcp on the published ports and says so (`nft list table inet aisb`), and the entrypoint logs the ports it opened. | +| Needs a network | `--net none` + `--publish` is a refusal, not a warning: measured, podman discards port mappings and starts the container anyway. | +| The server must bind `0.0.0.0` | Measured: pasta delivers the connection to the container's own address, so `--bind 127.0.0.1` inside answers on the inside and answers nothing on the outside. `npm run dev -- --host 0.0.0.0`, `uvicorn --host 0.0.0.0`. | + +```ini +# .ai-sandbox/config -- the dev server this project runs, previewed from the host +publish = 8000 # http://127.0.0.1:8000 +publish = 18080:3000 # vite, on a host port that is free +``` + +The reverse -- a sandboxed process reaching a database, queue or mock API you run on +your own machine -- is egress and needs no new flag: `allow = 100.64.0.1:`, where +`100.64.0.1` is the address that stands for the host's loopback. See +[`docs/networking.md`](docs/networking.md). + ## Environment Nothing is inherited except `TERM LANG LC_ALL LC_CTYPE TZ`. diff --git a/bin/ai-sandbox b/bin/ai-sandbox index e162642..36c54fa 100755 --- a/bin/ai-sandbox +++ b/bin/ai-sandbox @@ -199,6 +199,36 @@ NETWORK Domains are resolved once, on the host, at start, and DNS is opened up. Weaker than the gate for CDNs. +PORTS (INGRESS) + Everything above is egress: what the sandboxed process may reach. This is the other + direction, for looking at a server the sandbox is running. + --publish [ADDR:]HOST:CONTAINER + Forward a host port into the container, so a client on the + host -- a browser, curl, an API tester -- reaches a server + the sandboxed process listens on. Repeatable. + 8000 host 8000 -> container 8000 + 18080:8000 host 18080 -> container 8000 + 0.0.0.0:18080:8000 the same on every host interface + ADDR defaults to 127.0.0.1 (publish_addr / AISB_PUBLISH_ADDR + to change it), because a port opened in a committed config + must not quietly be a port opened on your network. Anything + not loopback warns. One port per entry: no ranges, no + /udp -- this list is reviewed line by line, and a range is a + line nobody reads. An address means three parts, so + '127.0.0.1:8000' is refused rather than guessed, and podman's '*' is + refused too -- typing 0.0.0.0 is the warning. + It is ingress, and that is the cost: whatever that server + serves, or accepts, is reachable from outside the container. + Needs --net allow or --net full. With --net none podman drops + port mappings with a warning to its own stderr, so + ai-sandbox refuses to start instead. + The server must listen on 0.0.0.0 inside the container: pasta + delivers a published connection to the container's own + address, so a 127.0.0.1-only server answers 'connection + refused' (measured; docs/networking.md). + There is no -p alias on purpose: -p belongs to the harness + (claude -p prints), and an unknown flag already passes to it. + FILESYSTEM The current directory is bind-mounted at the path it has on the host and is the working directory, so pwd, prompts and window titles name the project instead of @@ -309,6 +339,7 @@ CONFIG 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, AISB_THINKING, + AISB_PUBLISH (comma list, same shapes as --publish), AISB_PUBLISH_ADDR (127.0.0.1), AISB_AGENTS, AISB_AGENTS_DIR, AISB_AGENTS_DEST, ... Base images: AISB_BASE_IMAGE, AISB_BASE_TTL_DAYS (7; 0 every build, -1 never), AISB_BASE_AUTO_UPDATE (0 keeps the recorded digest), AISB_BASE_PULL_FALLBACK @@ -331,9 +362,9 @@ EOF # config files # =========================================================================== declare -A CFG=() -declare -a CFG_allow=() CFG_env=() CFG_mount=() CFG_install=() CFG_label=() +declare -a CFG_allow=() CFG_env=() CFG_mount=() CFG_install=() CFG_label=() CFG_publish=() -cfg_accum_keys="allow env mount install label" +cfg_accum_keys="allow env mount install label publish" cfg_read_kv() { # `key = value` file local file="$1" line key val @@ -355,7 +386,8 @@ cfg_read_kv() { # `key = value` file 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|base_ttl_days|base_auto_update|\ - pi_config|pi_token_env|pi_token_auto|pi_agent_dir|thinking|agents|agents_dir|agents_dest) CFG["$key"]="$val" ;; + pi_config|pi_token_env|pi_token_auto|pi_agent_dir|thinking|agents|agents_dir|agents_dest|\ + publish_addr) CFG["$key"]="$val" ;; *) warn "$file: unknown key: $key" ;; esac done < "$file" @@ -388,6 +420,7 @@ load_config() { 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 + for e in ${AISB_PUBLISH:-}; do [[ -n "$e" ]] && CFG_publish+=("$(trim "$e")"); done IFS=$' \t\n' return 0 } @@ -403,12 +436,13 @@ OPT_TTY="" OPT_INTERACTIVE="" OPT_PRINT_EP=0 OPT_PRINT_CF=0 OPT_PASSTHROUGH=0 OPT_AGENTS="" OPT_AGENTS_DIR="" OPT_PI_CONFIG="" OPT_PI_TOKEN_AUTO="" OPT_PI_TOKEN_ENV="" OPT_THINKING="" declare -a OPT_allow=() OPT_env=() OPT_mount=() OPT_install=() OPT_label=() OPT_allow_files=() +declare -a OPT_publish=() 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 agents no-agents agents-dir + net allow allow-file proxy no-gate publish env env-file mount no-cwd agents no-agents agents-dir 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 @@ -447,6 +481,8 @@ parse_args() { --proxy) need "$1" "${2:-}"; OPT_PROXY="$2"; shift 2 ;; --proxy=*) OPT_PROXY="${1#*=}"; shift ;; --no-gate) OPT_GATE=1; shift ;; + --publish) need "$1" "${2:-}"; OPT_publish+=("$2"); shift 2 ;; + --publish=*) OPT_publish+=("${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 ;; @@ -508,7 +544,7 @@ parse_args() { # resolve # =========================================================================== declare -A POL=() -declare -a P_allow=() P_env=() P_mount=() P_install=() P_label=() +declare -a P_allow=() P_env=() P_mount=() P_install=() P_label=() P_publish=() cfgv() { local k="$1" d="$2"; printf '%s' "${CFG[$k]:-$d}"; } @@ -630,6 +666,134 @@ resolve_workdir() { # sets POL[workdir] and POL[workdir_note]; needs POL[cwd] fi } +# --------------------------------------------------------------------------- +# published ports (ingress) +# --------------------------------------------------------------------------- +# Every other control in this file is egress. This one is the other direction: a listener +# on the host that forwards into the container, so a browser on the host can look at the +# dev server the sandbox is running. +# +# What it does and does not cost. It opens nothing outward: the netfilter ruleset, the +# gate and the allowlist are untouched, and a published container port is reachable only +# through the address named in the spec. What it does mean is that a client outside the +# container can reach a process inside it, so whatever that server serves (or accepts) is +# reachable from that address. That is the point of the flag and the whole of its cost, +# which is why the default address is loopback and a non-loopback one is in the spec text +# rather than in a default. +# +# Shapes, and the address is what keeps them unambiguous: +# +# 8000 host 8000 -> container 8000, on publish_addr (127.0.0.1) +# 18080:8000 host 18080 -> container 8000 +# 0.0.0.0:18080:8000 the same, on every host interface +# +# An address first means three parts, so `127.0.0.1:8000` is refused with a message +# instead of meaning two different things. Ranges and /udp are podman's syntax, not ours: +# this list is a policy a human reviews, and a range is a line nobody reads. +# +# Measured on the reference host (docs/networking.md): pasta forwards a published +# connection into the namespace over loopback, so the entrypoint's `iifname "lo" accept` +# already carries it and the input hook sees no packet for it. The explicit accept in +# gen_rules() is for a host where it arrives on the container's own interface instead, and +# so that `nft list table inet aisb` says which ports were opened. +declare -a PUB_SPEC=() PUB_HOST_PORTS=() PUB_CTR_PORTS=() +PUB_LAN=0 + +pub_is_loopback() { # an address the LAN cannot reach + local a="${1#\[}"; a="${a%\]}" + case "$a" in + 127.*|localhost|ip6-localhost|ip6-loopback|::1) return 0 ;; + *) return 1 ;; + esac +} + +pub_port_ok() { [[ "$1" =~ ^[0-9]{1,5}$ ]] && (( 10#$1 >= 1 && 10#$1 <= 65535 )); } + +resolve_publish() { # fills PUB_SPEC / PUB_HOST_PORTS / PUB_CTR_PORTS / PUB_LAN + PUB_SPEC=(); PUB_HOST_PORTS=(); PUB_CTR_PORTS=(); PUB_LAN=0 + local spec addr rest host p hp cp bind_arg seen=" " seen_ctr=" " n + local -a parts + local bind="${POL[publish_addr]:-127.0.0.1}" + for spec in ${P_publish[@]+"${P_publish[@]}"}; do + spec="$(trim "$spec")" + [[ -n "$spec" ]] || continue + [[ "$spec" != */* ]] || die "--publish: no protocol suffix in '${spec}': one port per entry" + # Split on ':' -- except that an ipv6 address is full of them, so a leading bracketed + # group is taken off first and what is left is the ordinary two fields. + addr="" + if [[ "$spec" == \[* ]]; then + [[ "$spec" == *]:* ]] || die "--publish: expected [addr]:host:container, got '${spec}'" + addr="${spec%%]:*}]" + rest="${spec#*]:}" + else + rest="$spec" + fi + # `read -a` drops a trailing empty field, so '8000:' would quietly become 8000:8000. + [[ "$rest" != *: ]] || die "--publish: empty field in '${spec}'" + parts=() + IFS=: read -r -a parts <<< "$rest" || true + n=${#parts[@]} + if [[ -n "$addr" ]]; then + (( n == 2 )) || die "--publish: cannot read '${spec}' + with an address it is three parts: [addr]:host-port:container-port" + elif (( n == 3 )); then # 0.0.0.0:18080:8000 + addr="${parts[0]}" + parts=("${parts[1]}" "${parts[2]}") + n=2 + elif (( n != 1 && n != 2 )); then + die "--publish: cannot read '${spec}' + write one of: 8000 18080:8000 0.0.0.0:18080:8000" + fi + for p in "${parts[@]}"; do + [[ -n "$p" ]] || die "--publish: empty field in '${spec}'" + done + hp="${parts[0]}"; cp="${parts[1]:-$hp}" + if [[ "$hp" == *[:.]* ]]; then + die "--publish: '${hp}' looks like an address, and an address needs three parts: + addr:host-port:container-port e.g. 0.0.0.0:8000:8000 ([ipv6] in brackets)" + fi + pub_port_ok "$hp" || die "--publish: host port is not a port number: '${hp}' + one port per entry, 1-65535, no ranges: --publish 18080:8000" + pub_port_ok "$cp" || die "--publish: container port is not a port number: '${cp}'" + hp=$((10#$hp)); cp=$((10#$cp)) + if [[ -z "$addr" ]]; then + addr="$bind" + else + host="${addr#\[}"; host="${host%\]}" # [::1] and ::1 are the same address to bind + [[ "$host" =~ ^[0-9A-Za-z_.:%-]+$ ]] \ + || die "--publish: '${addr}' is not an address to bind (an IP, or [ipv6])" + # `8000:8000:8000` parses as addr:host:container and then hands podman a "bind address" + # of 8000, which fails there with an error that mentions none of our words. An address has + # to look like one. (`*` for "every interface" is podman's shorthand and is refused here on + # purpose: writing 0.0.0.0 makes the person typing it read what they are opening.) + case "$host" in + *:*|*.*|localhost|ip6-localhost|ip6-loopback) ;; + *) die "--publish: '${addr}' is not an address in '${spec}' + write a port (8000), a pair (18080:8000) or an address with two ports + (0.0.0.0:18080:8000, or [::1]:18080:8000)" + esac + addr="$host" + fi + pub_is_loopback "$addr" || PUB_LAN=1 + [[ "$seen" != *" ${addr}:${hp} "* ]] \ + || die "--publish: host port ${addr}:${hp} is published twice (two container ports?)" + seen+="${addr}:${hp} " + PUB_HOST_PORTS+=("$hp") + if [[ "$seen_ctr" != *" ${cp} "* ]]; then + seen_ctr+="${cp} " + PUB_CTR_PORTS+=("$cp") + fi + # An ipv6 bind keeps its brackets on the way to podman, which splits on ':' too. + bind_arg="$addr" + [[ "$addr" == *:* && "$addr" != \[* ]] && bind_arg="[$addr]" + PUB_SPEC+=("${bind_arg}:${hp}:${cp}") + done + return 0 +} + +# The port a published mapping binds on the host. Nothing secret: a port number. +publish_text() { join ' ' ${PUB_SPEC[@]+"${PUB_SPEC[@]}"}; } + resolve_opts() { POL[cwd]="$PWD" POL[net]="${OPT_NET:-${AISB_NET:-$(cfgv net allow)}}" @@ -685,6 +849,9 @@ resolve_opts() { POL[agents_src]="" [[ "${POL[agents]}" == 1 ]] && POL[agents_src]="$(agents_src_of)" POL[gate_addr]="${AISB_GATE_ADDR:-$(cfgv gate_addr 100.64.0.1)}" + # The address a bare --publish 8000 binds on the host. Loopback: a repo config that + # opens a port for preview must not be a port opened on the local network. + POL[publish_addr]="${AISB_PUBLISH_ADDR:-$(cfgv publish_addr 127.0.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}" @@ -700,6 +867,7 @@ resolve_opts() { 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[@]}"}) + P_publish=(${CFG_publish[@]+"${CFG_publish[@]}"} ${OPT_publish[@]+"${OPT_publish[@]}"}) local f line for f in ${OPT_allow_files[@]+"${OPT_allow_files[@]}"}; do @@ -710,7 +878,8 @@ resolve_opts() { done < "$f" done - dedup P_allow; dedup P_env; dedup P_mount; dedup P_install; dedup P_label + dedup P_allow; dedup P_env; dedup P_mount; dedup P_install; dedup P_label; dedup P_publish + resolve_publish set_selinux "${POL[selinux]}" } @@ -733,6 +902,33 @@ validate_opts() { if [[ "${POL[net]}" == full ]]; then warn "--net full: the container has unrestricted internet access" fi + # Published ports: they need a network to arrive on, and a non-loopback bind needs a + # word. Both are said here rather than at the container, because "it started" is the + # worst shape of this: measured, `--network=none -p 8000:8000` prints one line about + # discarding the mapping and runs the container regardless. + if ((${#PUB_SPEC[@]})); then + if [[ "${POL[net]}" == none ]]; then + die "--publish needs a network: --net none cannot forward a port at all. + podman discards the mapping and starts the container anyway, so the run would look + fine and nothing would be listening. Use --net allow, or drop the publish entries." + fi + if (( PUB_LAN )); then + warn "--publish: $(publish_text) + a non-loopback address means anything that can route to this machine can reach the + server the sandbox runs. Loopback is the default because that is every client the + pattern needs; if you meant it, the value says so." + fi + # The host port has to be free now, or podman fails to bind at container start -- + # after any build. Previews never check: they never bind anything either. + if (( OPT_DRY == 0 && OPT_PRINT == 0 && OPT_PRINT_EP == 0 && OPT_PRINT_CF == 0 )); then + local pp + for pp in "${PUB_HOST_PORTS[@]}"; do + port_free "$pp" || die "--publish: host port ${pp} is already in use + give the container a different host port (--publish OTHER:${pp}), or find what holds + it (ai-sandbox ps lists sandbox containers left behind by --keep)." + done + fi + fi # An explicit --agents-dir that resolves to nothing is a typo, so it is an error. The # *default* pointing at a directory this host does not have is normal: the bridge # switches itself off, it does not fail a run. @@ -898,6 +1094,7 @@ write_policy_files() { printf 'AISB_ALLOW_DNS=%s\n' "$ALLOW_DNS" printf 'AISB_PROXY_IPS=%s\n' "${PROXY_IPS[*]-}" printf 'AISB_PROXY_PORTS=%s\n' "${PROXY_PORTS[*]-}" + printf 'AISB_PUBLISH_PORTS=%s\n' "${PUB_CTR_PORTS[*]-}" printf 'AISB_PI_THINKING=%s\n' "${POL[thinking_eff]:-}" } > "$SESSION_MOUNT/policy.env" chmod 644 "$SESSION_MOUNT/policy.env" @@ -938,6 +1135,26 @@ GID_TARGET="${AISB_GID:-1000}" UNENFORCED="${AISB_UNENFORCED:-0}" KEEP_ROOT="${AISB_KEEP_ROOT:-0}" +# Ingress for the ports the host publishes into this container. It changes nothing on the +# reference host -- measured, pasta forwards a published connection into the namespace over +# loopback, so the iifname "lo" accept already carries it and the input hook sees no packet +# for it. Two reasons the rule is there anyway: another host (or a bridge network) can +# deliver it on the container's own interface, where policy drop would leave a port that +# looks configured and answers nothing; and a ruleset that names the opened ports is the +# audit trail, so `nft list table inet aisb` answers "what can reach this container". +# +# Ports only, and validated again here: the policy file is written by the host and mounted +# read-only, but a ruleset is not somewhere to trust a string you spliced. +publish_rules() { + local p pub="" + for p in ${AISB_PUBLISH_PORTS:-}; do + [[ "$p" =~ ^[0-9]{1,5}$ ]] || continue + [[ ",$pub," == *",$p,"* ]] || pub+="${pub:+,}$p" + done + [[ -n "$pub" ]] || return 0 + echo " meta l4proto tcp tcp dport { $pub } counter accept comment \"aisb-publish\"" +} + gen_rules() { local dest ports fam echo 'table inet aisb {' @@ -975,6 +1192,7 @@ gen_rules() { 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' + publish_rules echo ' }' echo '}' } @@ -1003,6 +1221,9 @@ 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}" +# The other direction, when the host was asked to forward one. stderr like every other +# line the sandbox says about itself: stdout belongs to the command that ran. +[[ -z "${AISB_PUBLISH_PORTS:-}" ]] || log "ingress: input accepts tcp ${AISB_PUBLISH_PORTS} (ports published by the host)" if [[ "$KEEP_ROOT" == 1 ]]; then log "WARNING: --insecure-keep-root: running as uid $(id -u) with capabilities" @@ -1782,6 +2003,9 @@ 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 + # A port this run publishes will not be free by the time the container starts, and the + # gate is bound first: take the next one rather than lose the run over it. + if [[ " ${PUB_HOST_PORTS[*]-} " == *" $p "* ]]; then p=$((p + 1)); continue; fi port_free "$p" && { printf '%s\n' "$p"; return 0; } p=$((p + 1)) done @@ -1942,6 +2166,9 @@ build_argv() { --label "aisb.net=${POL[net]}" --label "aisb.harness=${POL[harness]}") local l for l in ${P_label[@]+"${P_label[@]}"}; do ARGV+=(--label "$l"); done + # Which ports this container was given on the host, for 'podman ps' and 'ai-sandbox clean': + # a published port outliving its session is a listener nobody is watching. + ((${#PUB_CTR_PORTS[@]})) && ARGV+=(--label "aisb.publish=${PUB_CTR_PORTS[*]// /,}") [[ "${OPT_KEEP:-0}" == 1 ]] || ARGV+=(--rm) ARGV+=(--hostname aisb) ARGV+=(--userns "keep-id:uid=${AISB_UID},gid=${AISB_GID}") @@ -1955,6 +2182,11 @@ build_argv() { ARGV+=(--pids-limit "${POL[pids]}" --memory "${POL[memory]}" --cpus "${POL[cpus]}") ARGV+=(--tmpfs "/tmp:rw,nosuid,nodev,size=${POL[tmpfs]}") ARGV+=(--network "$net_arg") + # Published ports: ingress, decided per port, bound to loopback unless the spec named an + # address (see resolve_publish). podman/pasta does the forwarding; the in-container + # ruleset is told which ports to accept so it can say so in its own listing. + local pspec + for pspec in ${PUB_SPEC[@]+"${PUB_SPEC[@]}"}; do ARGV+=(--publish "$pspec"); done ARGV+=(--stop-signal SIGTERM --pull missing) if [[ -n "${OPT_TTY:-}" ]]; then @@ -2037,6 +2269,8 @@ print_argv() { "${POL[net]}" "${PROXY_IPS[0]-none}:${PROXY_PORTS[0]-none}" \ "$(printf '%s ' ${GATE_DOMAINS[@]+"${GATE_DOMAINS[@]}"})" \ "$(printf '%s ' ${DIRECT4[@]+"${DIRECT4[@]}"})" + # ingress on its own line, because it is the one thing here that lets something in + printf '# ingress: publish=[%s]\n' "$(printf '%s ' ${PUB_SPEC[@]+"${PUB_SPEC[@]}"})" printf '# env (%d, passed via 0600 file): %s\n' "${#ENV_NAMES[@]}" \ "$(printf '%s ' ${ENV_NAMES[@]+"${ENV_NAMES[@]}"})" # A fallback is worth a line here: the invocation on its own only shows /project, not @@ -2065,6 +2299,7 @@ print_policy() { printf 'container=%s\n' "${POL[name]}" printf 'net=%s\n' "${POL[net]}" printf 'proxy=%s\n' "${PROXY_IPS[*]-none}:${PROXY_PORTS[*]-none}" + printf 'publish=%s\n' "$(publish_text)" printf 'harness=%s\n' "${POL[harness]}" printf 'command=%s\n' "$(printf '%s ' ${COMMAND[@]+"${COMMAND[@]}"})" printf 'workdir=%s\n' "${POL[workdir]}" @@ -2277,6 +2512,20 @@ cmd_doctor() { chk INFO "no allowlist configured yet (--allow, .ai-sandbox/allow, $AISB_CONFIG_DIR/allow)" fi + # Ingress, reported like the allowlist: a port opened in a config file is a decision the + # next reader should see without starting anything. Doctor never binds anything, so the + # occupancy question is asked of the machine here and now, as a warning, not a refusal. + if ((${#PUB_SPEC[@]})); then + chk INFO "publish (ingress): $(publish_text)" + (( PUB_LAN )) && chk WARN "a published port with a non-loopback address is reachable from your network" + local dpp + for dpp in "${PUB_HOST_PORTS[@]}"; do + if ! port_free "$dpp"; then + chk WARN "host port $dpp is already in use -- this run would fail to bind it" + fi + done + 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)" diff --git a/config/config.example b/config/config.example index 60dc4d8..72d4045 100644 --- a/config/config.example +++ b/config/config.example @@ -19,6 +19,26 @@ net = allow # used if it is busy. # gate_port = 18081 +# --- published ports (ingress) ---------------------------------------------- +# Everything else here is egress. 'publish' opens a listener on the host that +# forwards into the container, so a browser on the host can look at a dev server the +# sandbox is running. Repeatable; shapes are 'port', 'host:container' and +# 'addr:host:container'. One port per entry: no ranges, no /udp. +# +# The cost is ingress: whatever that server serves, or accepts, is reachable from the +# address it is bound to. Egress is untouched either way. +# publish = 8000 # 127.0.0.1:8000 -> container 8000 +# publish = 18080:3000 # 127.0.0.1:18080 -> container 3000 +# publish = 0.0.0.0:18080:3000 # every interface -- warned about, do not commit it +# +# A server inside the container has to listen on 0.0.0.0 to be reachable this way: +# pasta delivers the connection to the container's own address, not to its loopback. +# +# The address a bare 'publish = 8000' binds on the host. Machine-wide, not per-project: +# leave it loopback in a repo config. Set it in this file only, and only if you know +# what is routed to you. +# publish_addr = 127.0.0.1 + # --- harness --------------------------------------------------------------- # Default in-container command: pi | codex | claude | none | "" # harness = pi diff --git a/contrib/ai-sandbox.bash b/contrib/ai-sandbox.bash index fdce449..8fc0441 100755 --- a/contrib/ai-sandbox.bash +++ b/contrib/ai-sandbox.bash @@ -25,7 +25,7 @@ _ai_sandbox_completions() { local subs="build update doctor gate ps clean inspect version help" # flags that consume the next word, so their values are not mistaken for # positional (harness) arguments. - local value_flags=" --net --allow --allow-file --proxy --mount --workdir --agents-dir + local value_flags=" --net --allow --allow-file --proxy --publish --mount --workdir --agents-dir --env --env-file --base-image --install --harness --pi-token-env --cpus --memory --pids --tmpfs-size --name --label " @@ -50,7 +50,7 @@ _ai_sandbox_completions() { COMPREPLY=(); _aisb_fill < <(compgen -W "$hosts" -- "$cur") (( ${#COMPREPLY[@]} )) || COMPREPLY=( "$cur" ) # don't fall back to filenames return ;; - --proxy) COMPREPLY=( "$cur" ); return ;; + --proxy|--publish) COMPREPLY=( "$cur" ); return ;; # values are ours to type, not to guess --allow-file|--env-file) COMPREPLY=(); _aisb_fill < <(compgen -f -- "$cur"); return ;; --workdir) COMPREPLY=(); _aisb_fill < <(compgen -W "auto" -- "$cur") # 'auto' mirrors the host path (( ${#COMPREPLY[@]} )) || _aisb_fill < <(compgen -d -- "$cur"); return ;; @@ -76,7 +76,7 @@ _ai_sandbox_completions() { COMPREPLY=() _aisb_fill < <(compgen -W \ - "--net --allow --allow-file --proxy --no-gate --mount --no-cwd --workdir --home-cache --ephemeral-home --read-only --agents --no-agents --agents-dir --env --env-file --base-image --install --harness --pi-token-env --no-pi-token-env --no-pi-config --cpus --memory --pids --tmpfs-size --shell --interactive --no-interactive --no-tty --keep --name --label --no-build --dry-run --print-policy --print-entrypoint --print-containerfile --allow-unenforced --insecure-keep-root --strict-args -h --help -V" \ + "--net --allow --allow-file --proxy --no-gate --publish --mount --no-cwd --workdir --home-cache --ephemeral-home --read-only --agents --no-agents --agents-dir --env --env-file --base-image --install --harness --pi-token-env --no-pi-token-env --no-pi-config --cpus --memory --pids --tmpfs-size --shell --interactive --no-interactive --no-tty --keep --name --label --no-build --dry-run --print-policy --print-entrypoint --print-containerfile --allow-unenforced --insecure-keep-root --strict-args -h --help -V" \ -- "$cur") } diff --git a/docs/networking.md b/docs/networking.md index 9f63dc1..239a2c4 100644 --- a/docs/networking.md +++ b/docs/networking.md @@ -50,6 +50,43 @@ 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. +## Publishing a port (ingress) + +`--publish` is the one control that runs the other way: podman/pasta puts a listener on the +host and forwards what arrives into the container, so a client on the host can reach a server +the sandboxed process listens on. Nothing about the egress model changes -- the same run that +answers your browser still refuses every domain outside the allowlist +(`tests/e2e/13_publish.sh` asserts exactly that, in one container). + +Binding and parsing decisions, and why: + +| decision | reason | +|---|---| +| default bind address `127.0.0.1` | measured: a bare `-p 8000:8000` binds `*:8000`, i.e. every interface. A port in a committed config must not quietly be a port on the LAN. `publish_addr` or an explicit `addr:` changes it, and a non-loopback address warns. | +| one port per entry, no ranges, no `/udp` | this list is a policy a human reviews; a range is a line nobody reads. | +| `*` ("every interface") is refused; `0.0.0.0` is accepted | podman's shorthand hides what it opens. Making someone type the address is the whole warning. | +| an address requires three parts | `0.0.0.0:8000` means two different things in podman's grammar. Refusing it with a message beats guessing. | +| `--net none` + `--publish` refuses | measured: podman prints one line about discarding the mapping and starts the container regardless. "It ran" with nothing listening is the worst outcome here. | +| a busy host port refuses the run | measured before the container starts, because podman's own error arrives after any build. | +| the ruleset accepts the published ports on the `input` hook | see below: it changes nothing on the reference host, and it is the audit trail on every host. | + +Where the packet actually arrives was the interesting measurement. The `input` chain is +`policy drop`, so a port forward looks doomed from the reading of it -- and it is not: + +| Probe | Result | Consequence | +|---|---|---| +| `--net allow --publish H:8000`, server on `0.0.0.0`, `curl` from the host | **200**, with `enforce=nft` in the same run | the existing ruleset does not block a published port | +| `nft -a list chain inet aisb input` while that port serves traffic | `aisb-publish` counter: **0 packets** | pasta forwards the connection into the namespace over **loopback**, so `iifname "lo" accept` carries it | +| server bound to `127.0.0.1` **inside**, port published | host: connection refused; inside: 200 | pasta delivers to the container's own address, not to its loopback → dev servers must bind `0.0.0.0` | +| `ss -ltnp` on the host while published | `LISTEN 127.0.0.1:H ... pasta.avx2` | the listener is pasta's, on loopback, and it is gone when the container exits | +| container's own view of its interface | `wlp4s0` with the **host's** LAN address | pasta mirrors the host address into the namespace, which is why a *remote* host cannot reach the published port: only pasta's own socket on the host accepts, and it accepts on the address we named | + +So `publish_rules()` in the entrypoint is deliberately not what makes publishing work here. +It is there because (a) a different host, or a bridge network, can deliver the packet on the +container's own interface, where `policy drop` would leave a port that looks configured and +answers nothing, and (b) `nft list table inet aisb` should answer "what can reach this +container" instead of leaving that question to a flag on the host. + ## Measurements | Probe | Result | Consequence | @@ -60,6 +97,8 @@ name itself. | `--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 | +| host → container via `-p 127.0.0.1:H:C`, `--net allow`, ruleset installed | **works**; the `input` hook sees 0 packets for it | ingress arrives on `lo` — see "Publishing a port" above | +| `-p H:C` with no address | binds `*:H` on the host | the default bind address in `ai-sandbox` is `127.0.0.1`, not podman's | | `--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 | diff --git a/docs/recipes.md b/docs/recipes.md index de779dc..2c345ad 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -159,6 +159,53 @@ pi --allow registry-1.docker.io --allow auth.docker.io --allow production.cloudf pi --allow aipi.reeseapps.com --allow 10.20.0.5:5432 ``` +## Ports: two directions, two answers + +**Outbound — a service on your machine** (dev postgres, redis, a mock API, a local model +server). That is egress, so it is an allow entry, not a flag. `100.64.0.1` is the address that +stands for the host's loopback inside the container: + +```ini +# .ai-sandbox/allow +100.64.0.1:5432 # postgres from `docker compose up db`, on the host + +# .ai-sandbox/config -- point the app at the mapped address (no secret is a URL like this) +env = DATABASE_URL=postgresql://dev@100.64.0.1:5432/app_dev +# HTTP on anything but 80/443/8443 also has to skip the proxy, or the gate is asked for it: +env = NO_PROXY=100.64.0.1,localhost,127.0.0.1,::1 +env = no_proxy=100.64.0.1,localhost,127.0.0.1,::1 +``` + +**Inbound — a server the sandbox runs, to be seen from the host.** Publish it. Bound to +loopback by default, so `http://127.0.0.1:8000` in your browser works and nothing on your +network can reach it: + +```bash +# the one-off preview: the agent runs the server, you look at it +ai-sandbox --allow aipi.reeseapps.com --publish 8000 -- npm run dev -- --host 0.0.0.0 + +# a host port that is free, a different container port +ai-sandbox --publish 18080:3000 -- npm run dev -- --host 0.0.0.0 + +# from a project's own config, so `ai-sandbox` alone is the invocation +# .ai-sandbox/config: publish = 8000 +# publish = 18080:3000 +``` + +Four things worth knowing before it looks broken (all measured, all in +[`networking.md`](networking.md)): + +* the server must listen on `0.0.0.0` **inside** — `--host 0.0.0.0`, `uvicorn --host + 0.0.0.0`, `runserver 0.0.0.0:8000`. A `127.0.0.1`-only server answers inside and refuses the + published port. +* egress is unaffected. A published port is ingress: the run keeps its allowlist, its + unprivileged uid and its ruleset, and the ruleset names the ports it opened. +* `--net none` refuses to publish rather than start with nothing listening; a host port that + is already taken is named before the container starts, not after the build. +* a non-loopback bind (`publish = 0.0.0.0:18080:3000`) means exactly what it says: anything that + can route to this machine can reach that server. It warns, and it does not belong in a + committed config — that file outlives the run that justified it. + ## Git ```bash diff --git a/docs/threat-model.md b/docs/threat-model.md index 1d378e3..193f9fe 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -107,6 +107,21 @@ Be honest about these, and design your work accordingly. *in the sandbox* by default; `--validate-in host` hands that judgement to you and warns on every run. +14. **A published port is a door you opened.** `--publish` (and `publish` in a config + file) puts a listener on the host that forwards into the container, so a client + outside can reach a server the agent is running. That is the feature — preview a dev + server, poke a local API — and it is also the whole risk: whatever that server serves, + and whatever it accepts, is reachable from the address it is bound to, by things other + than you. The default binds `127.0.0.1`, because a bare podman `-p` binds every + interface (measured); a spec with a non-loopback address warns on every run and should + not be committed. Egress stays enforced either way: a published port is ingress, not a + loosened allowlist, and the same run that answers your browser still refuses a domain + outside it (`tests/e2e/13_publish.sh`). The agent inside cannot widen its own port or + open a new one — the mapping is decided on the host before the container starts and the + command runs with no capabilities. What it *can* do is serve whatever it likes on a port + you asked for. Publish the port of the server you meant to preview, and treat that port + as published content for as long as the run lasts. + ## Residual risk rating *Accidental destruction and ambient credential leakage: largely eliminated.* diff --git a/tests/e2e/13_publish.sh b/tests/e2e/13_publish.sh new file mode 100644 index 0000000..e921980 --- /dev/null +++ b/tests/e2e/13_publish.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# tests/e2e/13_publish.sh -- ingress: a port the sandbox listens on, reached from the host. +# +# Publish is the one place where something arrives *from* the host, so it is tested with a +# real container and a real client. --dry-run can print --publish all day and the port can +# still answer nothing, and the ruleset can reject the very rule that carries it (it did, +# once: nft wants the verdict before the comment, and the sandbox correctly refused to start +# at exit 77 -- correctly, but nobody sees that as "the feature works"). +# +# Two of the assertions below are the measurements quoted by docs/networking.md: +# * with the full enforcement ruleset installed, a published port answers on the host; +# * a server bound to 127.0.0.1 *inside* does not, because pasta delivers the connection +# to the container's own address, not to its loopback. +# If either changes on a new host, the docs are wrong and this file is what says so. +source "$TESTS/lib.sh" + +PROJ="$WORK/proj"; mkdir -p "$PROJ"; cd "$PROJ" || exit 1 +export AISB_CONFIG_DIR="$WORK/cfg-empty"; mkdir -p "$AISB_CONFIG_DIR" + +if ! curl -m8 -sS -o /dev/null http://example.com 2>/dev/null; then + echo " (skipped: no outbound internet, so the enforced run this needs cannot start)" + summary; exit 0 +fi + +free_port() { python3 -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1]);s.close()'; } +listening() { ss -H -ltn 2>/dev/null | grep -c "[:.]${1}\$"; } + +# Start a sandbox in the background running an HTTP server, and hold the container open +# until the host writes $PROJ/.stop -- the port only exists while the container does, so the +# probe and the run have to overlap. $AISB_TEST_BIND says what the server binds to inside. +hold_server() { # hold_server [extra flags...] + local out="$1" hp="$2" bind="$3"; shift 3 + rm -f "$PROJ/.stop" + "$AISB" --net allow --allow example.com --env "AISB_TEST_BIND=$bind" "$@" --publish "$hp:8000" -- \ + bash -lc ' + python3 -m http.server 8000 --bind "$AISB_TEST_BIND" >/tmp/srv.log 2>&1 & + for i in $(seq 40); do curl -sf -m1 -o /dev/null http://127.0.0.1:8000/ && break; sleep 0.25; done + echo "inside=$(curl -s -o /dev/null -w %{http_code} -m2 http://127.0.0.1:8000/)" + echo "egress_denied=$(curl -s -o /dev/null -w %{http_code} -m8 http://example.org 2>/dev/null)" + echo "egress_allowed=$(curl -s -o /dev/null -w %{http_code} -m15 http://example.com 2>/dev/null)" + echo "uid=$(id -u) capeff=$(grep CapEff /proc/self/status | cut -f2)" + i=0; while [ $i -lt 160 ] && [ ! -f .stop ]; do sleep 0.25; i=$((i+1)); done + echo "srvlog=$(tail -1 /tmp/srv.log | cut -c1-60)"' > "$out" 2>&1 & +} +# Probe from the host, which is the whole point: nothing inside this container can answer +# the question "can a browser on the host reach it". +host_probe() { # host_probe [tries] -> the last http code seen + local hp="$1" n="${2:-60}" i code=000 + for ((i = 0; i < n; i++)); do + code="$(curl -s -m2 -o /dev/null -w '%{http_code}' "http://127.0.0.1:$hp/" 2>/dev/null || echo 000)" + [[ "$code" == 200 ]] && break + sleep 0.25 + done + printf '%s' "$code" +} +finish() { touch "$PROJ/.stop"; wait "$1" 2>/dev/null; sleep 1; } + +t_name "a published port answers on the host, with enforcement installed" +HP="$(free_port)" +hold_server "$WORK/a.out" "$HP" 0.0.0.0 +CODE="$(host_probe "$HP")" +finish $! +A="$(cat "$WORK/a.out")" +assert_eq "200" "$CODE" "the host reaches the server the sandbox is running" +printf '%s' "$A" | grep -q 'enforce=nft' && _ok "the ruleset installed -- the publish rule is valid nft" \ + || _bad "the ruleset installed" "$(printf '%s' "$A" | grep -A3 FATAL | head -4)" +printf '%s' "$A" | grep -q 'ingress: input accepts tcp 8000' && _ok "the entrypoint says which ports it opened" \ + || _bad "the entrypoint reports the ingress" +printf '%s' "$A" | grep -q 'srvlog=.*"GET / HTTP/1.1" 200' && _ok "the request landed in the server's own log" \ + || _bad "the request landed in the server log" "$(printf '%s' "$A" | grep srvlog)" + +# The promise the feature has to keep: it is ingress *only*. The same run that served the +# host still refused a non-allowlisted domain and still ran unprivileged. +printf '%s' "$A" | grep -q 'egress_denied=403' && _ok "egress outside the allowlist is still refused by the gate" \ + || _bad "egress still refused" "$(printf '%s' "$A" | grep egress_denied)" +printf '%s' "$A" | grep -q 'egress_allowed=200' && _ok "and the allowlist still works in the same run" \ + || _bad "allowlisted egress" "$(printf '%s' "$A" | grep egress_allowed)" +printf '%s' "$A" | grep -q 'uid=1000' && _ok "the server runs as the sandbox user" || _bad "uid" +printf '%s' "$A" | grep -q 'capeff=0000000000000000' && _ok "with no capabilities -- it cannot widen its own port" \ + || _bad "capeff" "$(printf '%s' "$A" | grep capeff)" +assert_eq "0" "$(listening "$HP")" "the host listener is gone when the container exits (no door left watching nothing)" + +t_name "a server bound to loopback inside is not reachable from the host" +# Measured, and the doc says it: pasta delivers a published connection to the container's own +# address, so `npm run dev` with its default 127.0.0.1 answers inside and answers nothing +# outside. A preview that is silently not reachable is worse than one that says bind 0.0.0.0. +HP2="$(free_port)" +hold_server "$WORK/b.out" "$HP2" 127.0.0.1 +CODE2="$(host_probe "$HP2" 12)" +finish $! +B="$(cat "$WORK/b.out")" +printf '%s' "$B" | grep -q 'inside=200' && _ok "the server does listen -- inside its own loopback" || _bad "listens inside" +[[ "$CODE2" != 200 ]] && _ok "and the published port does not reach it (bind 0.0.0.0 inside)" \ + || _bad "a loopback-only server was reachable from the host" "code=$CODE2 -- update docs/networking.md" + +t_name "--net none refuses to publish instead of starting with nothing listening" +snapshot_containers +sb_timeout 30 --net none --publish "$(free_port)" -- true +assert_rc 1 "--net none + --publish is an error" +assert_contains "cannot forward a port" "and it says why: podman drops the mapping and runs anyway" +assert_no_new_containers "and no container was started to find that out" + +t_name "a host port already in use is named before the container starts" +HP3="$(free_port)" +python3 -m http.server "$HP3" --bind 127.0.0.1 --directory "$WORK" >/dev/null 2>&1 & +SRV=$! +sleep 1 +snapshot_containers +sb_timeout 30 --net allow --allow example.com --publish "$HP3:8000" -- true +assert_rc 1 "a busy host port refuses the run" +assert_contains "already in use" "and names the port" +assert_no_new_containers "without starting a container first" +kill "$SRV" 2>/dev/null + +summary diff --git a/tests/unit/01_args.sh b/tests/unit/01_args.sh index ff47846..51224a2 100755 --- a/tests/unit/01_args.sh +++ b/tests/unit/01_args.sh @@ -33,6 +33,43 @@ sb --allow; assert_rc 1 "--allow with no value" sb --allow-file /nope/missing; assert_rc 1 "--allow-file must exist" sb --mount /nope/missing --dry-run; assert_rc 1 "--mount path must exist" +t_name "publish takes a port, a pair, or an address and a pair" +sb --dry-run $A --publish 8000; assert_rc 0 "a bare port parses" +sb --dry-run $A --publish 18080:8000; assert_rc 0 "host:container parses" +sb --dry-run $A --publish 0.0.0.0:8000:8000; assert_rc 0 "addr:host:container parses" +sb --dry-run $A --publish '[::1]:8000:8000'; assert_rc 0 "bracketed ipv6 parses" +sb --dry-run $A --publish=8000; assert_rc 0 "--publish= form parses" +sb --strict-args --publish; assert_rc 1 "--publish is a known flag that needs a value" +sb --strict-args --pub 8000 --dry-run $A; assert_contains "did you mean --publish" "a near miss suggests --publish" + +# Every one of these is a port list that *looks* right and is not. Silently reading one as +# something else is how a config ends up publishing a port nobody wrote. +t_name "publish specs that are refused" +for bad in 0 99999 abc 8000/udp 8000:8000:8000:8000 '::1:8000:8000' '8000:' ':8000' '8000::9000' '0.0.0.0:8000' '*:8000:8000' '0.0.0.0:8000:8000:8000'; do + sb_timeout 15 --dry-run $A --publish "$bad" + assert_rc 1 "refuses '$bad'" +done +# Three fields where the first is not an address: podman would get 'bind on 8000' and fail with an +# error that names none of our words, so the address has to look like an address here. +sb_timeout 15 --dry-run $A --publish 8000:8000:8000 +assert_rc 1 "refuses a port written where an address belongs" +assert_contains "is not an address" "and says the three shapes that are valid" +sb_timeout 15 --dry-run $A --publish all:8000:8000 +assert_rc 1 "refuses a word for an address" +sb --dry-run $A --publish box.internal:8000:8000 +assert_contains "--publish box.internal:8000:8000" "a dotted host is taken as an address (and warned: not loopback)" +assert_contains "anything that can route to this machine" " ... by the same warning an explicit IP gets" +sb_timeout 15 --dry-run $A --publish 18080:8000 --publish 18080:9000 +assert_rc 1 "two container ports on one host port is refused" +assert_contains "published twice" "and says which" +sb_timeout 15 --dry-run --net none --publish 8000 +assert_rc 1 "--net none refuses to publish" +assert_contains "cannot forward a port" "because podman would drop the mapping and run anyway" + +# -p is the harness's flag (claude -p prints), so it must stay a pass-through. +sb --dry-run $A -p 8000 +assert_contains "pi -p 8000" "-p belongs to the harness, not to the sandbox" + t_name "fail-closed network policy" sb --dry-run; assert_rc 1 "allow mode with no allowlist refuses to start" assert_contains "Refusing to start wide open" "explains that it refuses to be wide open" diff --git a/tests/unit/02_config.sh b/tests/unit/02_config.sh index 5c0d162..c3556f1 100755 --- a/tests/unit/02_config.sh +++ b/tests/unit/02_config.sh @@ -91,6 +91,44 @@ for last in '# a trailing comment' '' ' '; do assert_contains "api.openai.com" "user target survives a file ending on [$last]" assert_contains "pypi.org" "repo target survives a file ending on [$last]" done + +t_name "publish accumulates like the other list keys" +cat > "$CFG/config" <<'EOF' +publish = 8000 +EOF +cat > "$REPO/.ai-sandbox/config" <<'EOF' +publish = 18080:8000 +EOF +sb --print-policy --net allow --allow example.com +assert_contains "publish=127.0.0.1:8000:8000" "user config publishes" +assert_contains "127.0.0.1:18080:8000" "repo config adds to it rather than replacing it" +sb_env AISB_PUBLISH=9000,9100:80 --print-policy --net allow --allow example.com +assert_contains "127.0.0.1:9000:9000" "AISB_PUBLISH is a comma list of the same shapes" +assert_contains "127.0.0.1:9100:80" " ... including host:container" +sb --print-policy --net allow --allow example.com --publish 9200 +assert_contains "127.0.0.1:9200:9200" "a flag adds to both files" +sb --print-policy --net allow --allow example.com --publish 8000 +N="$(printf '%s' "$OUT" | grep -o '127.0.0.1:8000:8000' | wc -l | tr -d ' ')" +assert_eq "1" "$N" "the same port from two sources collapses to one" + +t_name "publish_addr is the default bind address, and only that" +cat > "$CFG/config" <<'EOF' +publish_addr = 127.0.0.2 +EOF +rm -f "$REPO/.ai-sandbox/config" +sb --print-policy --net allow --allow example.com --publish 8000 +assert_contains "publish=127.0.0.2:8000:8000" "config file sets the address a bare port binds" +sb_env AISB_PUBLISH_ADDR=127.0.0.3 --print-policy --net allow --allow example.com --publish 8000 +assert_contains "127.0.0.3:8000:8000" "AISB_PUBLISH_ADDR beats the config file" +sb_env AISB_PUBLISH_ADDR=localhost --print-policy --net allow --allow example.com --publish 8000 +assert_contains "publish=localhost:8000:8000" "a name is accepted where an address goes" +printf '%s' "$OUT" | grep -qF 'route to this machine' \ + && _bad "localhost warned about as if it were the LAN" || _ok "localhost counts as loopback: no LAN warning" +sb --print-policy --net allow --allow example.com --publish 0.0.0.0:8000:8000 +assert_contains "publish=0.0.0.0:8000:8000" "an address written in the spec is the address used" +printf '%s' "$OUT" | grep '^publish=' | grep -qF '127.0.0.2' \ + && _bad "publish_addr overwrote an explicit address" || _ok "publish_addr only fills in a bare port" + # The fix must not trade one silence for another: with no target anywhere, the run still # refuses, and says why. printf '# nothing but a header here\n' > "$CFG/allow" diff --git a/tests/unit/03_argv.sh b/tests/unit/03_argv.sh index 3b39f1e..edfe0c5 100755 --- a/tests/unit/03_argv.sh +++ b/tests/unit/03_argv.sh @@ -197,6 +197,43 @@ pos_cmd="$(printf '%s' "$line" | tr ' ' '\n' | grep -n -m1 '^hello$' | cut -d: - if [[ -n "$pos_img" && -n "$pos_lbl" && $pos_lbl -lt $pos_img ]]; then _ok "extra flags precede the image"; else _bad "extra flags precede the image" "img=$pos_img label=$pos_lbl"; fi if [[ -n "$pos_cmd" && -n "$pos_img" && $pos_img -lt $pos_cmd ]]; then _ok "the command follows the image"; else _bad "the command follows the image" "img=$pos_img cmd=$pos_cmd"; fi +t_name "published ports reach the invocation, bound to loopback" +sb --dry-run $A --publish 8000 +assert_contains "--publish 127.0.0.1:8000:8000" "a bare port is bound to loopback on both ends" +assert_contains "# ingress: publish=[127.0.0.1:8000:8000" "the dry run says what it will let in" +assert_contains "--label aisb.publish=8000" "the container is labelled with the port it publishes" +sb --dry-run $A --publish 18080:8000 +assert_contains "--publish 127.0.0.1:18080:8000" "host and container ports are separate" +sb --dry-run $A --publish 8000 --publish 18080:9000 +assert_count 2 "--publish " "two entries, two flags" +sb --dry-run $A --publish 0.0.0.0:18080:8000 +assert_contains "--publish 0.0.0.0:18080:8000" "an explicit address is passed through" +assert_contains "anything that can route to this machine" "and said out loud as an open door" +sb --dry-run $A --net full --publish 8000 +assert_contains "--publish 127.0.0.1:8000:8000" "--net full can publish too" + +# The promise the flag has to keep: it is ingress only. Everything that gates egress must be +# byte-identical with a port open, or "preview the app" would be a way to turn the policy off. +t_name "publishing does not touch egress enforcement" +sb --dry-run $A --publish 8000 +assert_contains "--network pasta:--map-host-loopback,100.64.0.1" "the gate address is still the only door" +assert_contains "--cap-add=NET_ADMIN" "the entrypoint still gets NET_ADMIN to install the ruleset" +assert_contains "--cap-drop=ALL" "and still drops everything else" +assert_contains "HTTP_PROXY=http://100.64.0.1:18081" "the proxy env is still set" +assert_contains "--security-opt no-new-privileges" "no-new-privileges survives" + +# An ipv6 bind keeps its brackets: podman splits the spec on ':' too, and an address full of +# them has to arrive as one field or the port lands inside the address. +sb --dry-run $A --publish '[::1]:8000:8000' +assert_contains '\[::1\]:8000:8000' "the ipv6 bind keeps its brackets in the invocation" + +# --publish must precede the image like every other flag, or podman reads it as the command. +sb --dry-run $A --publish 8000 -- echo hello +line="$(printf '%s' "$OUT" | tail -1)" +pos_img="$(printf '%s' "$line" | tr ' ' '\n' | grep -n -m1 'localhost/ai-sandbox' | cut -d: -f1)" +pos_pub="$(printf '%s' "$line" | tr ' ' '\n' | grep -n -m1 -e '--publish' | cut -d: -f1)" +if [[ -n "$pos_img" && -n "$pos_pub" && $pos_pub -lt $pos_img ]]; then _ok "--publish precedes the image"; else _bad "--publish precedes the image" "img=$pos_img publish=$pos_pub"; fi + t_name "dry run is side-effect free" sb --dry-run $A # Ask about the container *this* invocation would have created, not about every sandbox on diff --git a/tests/unit/05_policy.sh b/tests/unit/05_policy.sh index 0002d7c..2ccd923 100755 --- a/tests/unit/05_policy.sh +++ b/tests/unit/05_policy.sh @@ -41,6 +41,21 @@ assert_rc 1 "non-numeric port rejected" sb --print-policy --net allow --allow api.openai.com:8443 assert_contains "gate_domains=api.openai.com" "domain+port still routed through the gate" +t_name "publish is its own line, because it is a different direction" +sb --print-policy --net allow --allow example.com +assert_contains "publish=" "published ports are reported even when there are none" +assert_eq "publish=" "$(printf '%s' "$OUT" | grep '^publish=')" " ... and the line is empty, not 'none'" +sb --print-policy --net allow --allow example.com --publish 8000 --publish 18080:9000 +assert_contains "publish=127.0.0.1:8000:8000 127.0.0.1:18080:9000" "canonical specs: address, host port, container port" +sb --print-policy --net allow --allow example.com --publish 0.0.0.0:8000 +assert_rc 1 "a two-part spec with an address is not guessed at" + +# An allow entry and a published port are answers to different questions; a config that +# reads like one must not quietly become the other. +sb --print-policy --net allow --allow 100.64.0.1:5432 --publish 8000 +assert_contains "direct=100.64.0.1 5432" "the allow entry stayed an egress rule" +assert_contains "publish=127.0.0.1:8000:8000" "and the published port stayed ingress" + t_name "mixed list" sb --print-policy --net allow --allow api.openai.com --allow 10.0.0.5:5432 --allow files.pythonhosted.org assert_contains "api.openai.com" "domain 1" diff --git a/tests/unit/06_entrypoint.sh b/tests/unit/06_entrypoint.sh index d36fe56..24e9420 100755 --- a/tests/unit/06_entrypoint.sh +++ b/tests/unit/06_entrypoint.sh @@ -41,9 +41,21 @@ printf '::1 8080\n' > "$POL/direct6.list" RULES="$AISB_STATE_DIR/rules.f" # Render the ruleset by extracting the generator and feeding it the policy above. +# Render the ruleset the way the entrypoint would. publish_rules() is a function of its own +# in there, so it comes along: extracting only gen_rules() would ask a half-program to run. +render_rules() { # render_rules -> the ruleset on stdout + local g="$WORK/render.$$.sh" + { awk '/^publish_rules\(\) \{/{f=1} f{print} f&&/^\}$/{exit}' "$EP" + awk '/^gen_rules\(\) \{/{f=1} f{print} f&&/^\}$/{exit}' "$EP" + printf 'POLICY_DIR=%q\nAISB_PROXY_IPS="100.64.0.1"\nAISB_PROXY_PORTS="18081"\nAISB_ALLOW_ICMP=1\nAISB_ALLOW_DNS=0\nAISB_PUBLISH_PORTS=%q\n' "$POL" "$1" + printf 'gen_rules\n'; } > "$g" + bash "$g" 2>"$WORK/render.err" +} + GEN="$WORK/gen.sh" -awk '/^gen_rules\(\) \{/{f=1} f{print} f&&/^\}$/{exit}' "$EP" > "$GEN" -{ printf 'POLICY_DIR=%q\nAISB_PROXY_IPS="100.64.0.1"\nAISB_PROXY_PORTS="18081"\nAISB_ALLOW_ICMP=1\nAISB_ALLOW_DNS=0\n' "$POL" +awk '/^publish_rules\(\) \{/{f=1} f{print} f&&/^\}$/{exit}' "$EP" > "$GEN" +awk '/^gen_rules\(\) \{/{f=1} f{print} f&&/^\}$/{exit}' "$EP" >> "$GEN" +{ printf 'POLICY_DIR=%q\nAISB_PROXY_IPS="100.64.0.1"\nAISB_PROXY_PORTS="18081"\nAISB_ALLOW_ICMP=1\nAISB_ALLOW_DNS=0\nAISB_PUBLISH_PORTS=""\n' "$POL" printf 'gen_rules\n'; } >> "$GEN" bash "$GEN" > "$RULES" 2>"$WORK/gen.err" && _ok "rules render" || _bad "rules render" "$(tail -3 "$WORK/gen.err")" @@ -58,6 +70,29 @@ grep -q 'ip daddr 100.64.0.1 tcp dport 18081' "$RULES" && _ok "gate endpoi grep -q 'aisb-est' "$RULES" && _ok "established traffic accepted" || _bad "established rule" grep -c 'accept comment "aisb-direct"' "$RULES" | grep -qx 3 && _ok "exactly 3 direct rules" || _bad "direct rule count: $(grep -c 'aisb-direct' "$RULES")" +t_name "the published ports appear in the input chain, and nowhere else" +# Nothing published means no ingress rule at all: the ruleset must not open a port that was +# never asked for, and an empty set must not render an empty port list (nft refuses those). +PUBRULES="$(render_rules "8000 18080 8000")" +# Is a line matching present inside the named chain? An ingress rule in the output +# chain would be an egress hole, which is the one thing this feature must never become. +in_chain() { # in_chain + awk -v c="chain $1 {" -v re="$2" 'index($0, c){inside=1; next} /^ }/{inside=0} + inside && $0 ~ re {found=1} END{exit !found}' <<< "$PUBRULES" +} +printf '%s' "$PUBRULES" | grep -qF 'comment "aisb-publish"' && _ok "a rule names the published ports" || _bad "aisb-publish rule" "$(printf '%s' "$PUBRULES" | sed -n '/chain input/,/}/p')" +printf '%s' "$PUBRULES" | grep -qF 'tcp dport { 8000,18080 }' && _ok "both ports, de-duplicated" || _bad "port set" "$(printf '%s' "$PUBRULES" | grep aisb-publish)" +in_chain input 'aisb-publish' && _ok "the publish rule is in the input chain" || _bad "the publish rule is in the input chain" +in_chain output 'aisb-publish' && _bad "the publish rule is also an egress hole" || _ok "publishing opens no egress path" +in_chain input 'policy drop' && _ok "input still defaults to drop with a port open" || _bad "input policy drop survived" +printf '%s' "$PUBRULES" | grep -qF 'aisb-default-drop' && _ok "the egress default-drop is untouched" || _bad "default-drop rule" +# The policy file is host-written and mounted read-only, but a ruleset is not somewhere to +# splice an unvalidated string: anything that is not a port is dropped on the floor. +BADRULES="$(render_rules '8000 nft delete table inet aisb accept')" +printf '%s' "$BADRULES" | grep -qF 'delete table' && _bad "a port value reached the ruleset as code" || _ok "a non-numeric port never reaches the ruleset" +printf '%s' "$BADRULES" | grep -qF 'tcp dport { 8000 }' && _ok " ... and the valid port in it still lands" || _bad "valid port kept" "$(printf '%s' "$BADRULES" | grep aisb-publish)" +render_rules '' | grep -qF 'aisb-publish' && _bad "a rule appeared with nothing published" || _ok "no publish rule when nothing is published" + t_name "no DNS hole when the gate is in front" grep -E 'th dport 53' "$RULES" >/dev/null && _bad "DNS is closed in gate mode" || _ok "DNS is closed in gate mode"