From 144e322391fda19c6939ddc1bdea0e192fd88255 Mon Sep 17 00:00:00 2001 From: ducoterra Date: Fri, 30 Oct 2026 22:53:57 -0400 Subject: [PATCH] Add --publish: the one ingress control Everything else in the file 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. This puts a listener on the host that forwards into the container, so a browser, curl or an API tester reaches the server the sandboxed process runs. `--publish [ADDR:]HOST:CONTAINER`, config `publish` and AISB_PUBLISH, repeatable, with the cost named in the flag: it is ingress, so whatever that server serves is reachable from the address it binds. The default address is 127.0.0.1 because a bare podman -p binds every interface (measured) -- a port committed in a repo config must not quietly be a port on the LAN. publish_addr changes the default machine-wide; a spec with a non-loopback address warns on every run. The grammar is smaller than podman's on purpose: one port per entry, no ranges, no /udp, no `*` -- this list is reviewed line by line, and typing 0.0.0.0 is the whole warning. An address means three parts, so `127.0.0.1:8000` is refused rather than guessed. No -p alias: that flag belongs to the harness (claude -p prints). Fail closed, as elsewhere: --net none cannot forward a port and podman discards the mapping while starting the container anyway, so "it ran" would mean "nothing is listening"; a host port already in use is named before the container starts, not after a build; pick_port() will not hand the gate a port this run publishes. The container is labelled with the ports it was given, and doctor reports them. Inside, publish_rules() accepts tcp on the published ports in the input chain and never the output one. Measured (docs/networking.md): pasta delivers the connection over loopback, so that rule counts 0 packets on the reference host; it is there for a host or a bridge network that delivers on the interface, and so that `nft list table inet aisb` answers "what can reach this container". The ports are validated again inside the entrypoint, because a ruleset is not somewhere to splice an unvalidated string. And the measurement that makes a configured preview look broken: a server bound to 127.0.0.1 inside answers inside and refuses the published port outside, so dev servers must bind 0.0.0.0 -- both skills say so in the change that writes the port. Tests. Unit: the grammar and every port list that looks right and is not, config/env/flag precedence and dedup, the canonical spec in --print-policy, the `# ingress:` header, the flags reaching the invocation before the image, egress enforcement byte-identical with a port open, which chain the rule lands in and that a non-numeric port never reaches nft as code. e2e (tests/e2e/13_publish.sh): one real container with enforce=nft where the host gets its 200 and the same run still refuses a domain outside the allowlist at 403, the server runs as uid 1000 with no capabilities so it cannot widen its own port, and the host listener is gone when the container exits. LLM-Generated: true --- .agents/skills/doc-sandbox/SKILL.md | 12 +- .../doc-sandbox/references/source-map.md | 14 +- .agents/skills/right-sandbox/SKILL.md | 46 ++- .../references/port-and-loopback.md | 167 ++++++++--- .../right-sandbox/references/settings-map.md | 14 +- .../skills/right-sandbox/scripts/survey.sh | 44 ++- AGENTS.md | 20 ++ PLAN.md | 55 ++++ README.md | 31 +++ bin/ai-sandbox | 261 +++++++++++++++++- config/config.example | 20 ++ contrib/ai-sandbox.bash | 6 +- docs/networking.md | 39 +++ docs/recipes.md | 47 ++++ docs/threat-model.md | 15 + tests/e2e/13_publish.sh | 115 ++++++++ tests/unit/01_args.sh | 37 +++ tests/unit/02_config.sh | 38 +++ tests/unit/03_argv.sh | 37 +++ tests/unit/05_policy.sh | 15 + tests/unit/06_entrypoint.sh | 39 ++- 21 files changed, 993 insertions(+), 79 deletions(-) create mode 100644 tests/e2e/13_publish.sh 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"