Compare commits

...
6 Commits
Author SHA1 Message Date
ducoterra 144e322391 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
2026-10-30 22:53:57 -04:00
ducoterra ac2f310d10 Say what an image build is doing, and keep saying it
A build is the slow part of a first run and the thing --install triggers: apt, a Node
tarball, an npm install. Every byte of podman's transcript used to go to a log file, so
minutes of silence looked exactly like a wedge -- and a wedge is what people interrupt,
leaving a half-built image.

One line per build step, plus a notice every AISB_BUILD_QUIET_SECS (15) that a step has
shown the terminal nothing, whether it is producing output the view drops or no output at
all. AISB_BUILD_OUTPUT=progress|full|quiet picks how much the terminal sees; the complete
transcript stays in $AISB_STATE_DIR/build-*.log in all three, because the progress view is
a filter and a failed build has to be diagnosable from the raw apt output. The first line
names the extra packages going in, which is what answers "why is this run building when
the last one did not".

podman build writes its transcript to *stdout* -- the same stdout the
$(ensure_image ...) call reads back as the image ref -- so the transcript is routed onto
stderr and the ref stays one clean line. A failed build now names the package apt could
not find instead of pointing at a file to go read, and reports the time it took.
Resolving a digest by pulling the base says so too: that is a download of the base image.

tests/unit/11_build.sh stubs podman for both heartbeat paths and checks the ref, the
failure text and that a canary credential appears nowhere.

LLM-Generated: true
2026-10-30 22:53:57 -04:00
ducoterra 90d98dcc9e Keep the sandbox's own chatter off the stream the command owns
stdout inside the container belongs to the command that ran. Two readers take it as
data: ai-sandbox-autophase's preflight reads its answer from that stream, and
progress.mjs reads the child pi's JSON event stream from it. Five notices in the
pi-seeding block went there instead of stderr, and the steady state (host file
unchanged, so cur = prev) prints one on *every* pi-bridged run -- the driver refused a
working sandbox on every project. They all go to stderr now, like log() and fail().

The preflight takes the aisb-ok marker from wherever it appears instead of insisting it
come first: a banner from an older image or from the user's own launcher is warned about
and quoted back, because that stream is parsed as data in every unit. It still refuses on
a non-zero exit, on silence, and on a launcher that runs no command -- the failures that
would repeat in every unit -- and quotes the foreign stdout instead of a bare exit code.

The same block, second contract: the thinking level is policy, so a home the sandbox
creates gets it on the *first* seed, not one run later via the merge.

tests/unit/06_entrypoint.sh runs the seeding block with the two streams split and asserts
stdout empty in every state (cold, steady, edited inside, unparseable, level changed), and
greps the entrypoint for any [aisb] line without >&2. tests/unit/10_autophase.sh adds the
noisy and the yapping launcher fixtures.

LLM-Generated: true
2026-10-30 22:53:57 -04:00
ducoterra 8b70967907 Stop a trailing comment in an allow file killing every run
cfg_read_allowlist() ended on `[[ -n "$line" ]] && CFG_allow+=(...)`, so a file whose
last line is a comment or blank returned 1 from the loop. load_config() calls it twice
in a non-final position, so under `set -e` every run died with exit 1 and no output at
all -- --dry-run and --print-policy included, with nothing to explain it.

Reading a file now always succeeds. An allowlist with nothing in it stays
validate_opts()'s call to refuse, with the message that says why.

LLM-Generated: true
2026-10-30 22:50:04 -04:00
ducoterra 9347bca78f Lint the skill scripts with everything else
make lint and make style now walk .agents/skills/*/scripts/*.sh, so a skill helper
is held to the same shellcheck pass as the sandbox, the tests and the installer.

LLM-Generated: true
2026-10-30 22:49:52 -04:00
ducoterra a91744f390 stop the driver hanging on a terminal and on a launcher
ai-sandbox-autophase could sit forever, printing nothing, in two places
that are both before the first unit -- which is why both looked like a
script that "does nothing".

The rc probe ran `timeout 15 bash -ic` in front of the user's terminal.
An interactive bash calls tcsetpgrp() to take that terminal, is in its
background, and is stopped for trying; a stopped process does not take
SIGTERM, so the timeout waited in front of it and the driver waited on
the command substitution behind it. It now runs in its own session
(setsid), with stdin closed, under `timeout -k`, and reads the
definition from a temp file rather than a pipe, because a grandchild an
rc file leaves behind holds a pipe's EOF open long after the shell it
came from has been reaped.

A launcher is called *with* the run, and a function ending at
`--env NAME` -- the shape everyone writes for typing by hand, including
every example this repo printed -- discards the flags and the
in-container command. The container then starts the image's default
harness with no prompt and, with --no-interactive gone, the terminal
attached: an agent waiting for keystrokes behind a pipe nobody reads.
`ap_forwards_args` checks the captured definition, --status and the
preflight name it with the one-line fix, and every child now starts with
stdin on /dev/null so a launcher cannot put the sandbox back on the
keyboard. Aliases and whole command lines need no forwarding: the shell
appends the words to those.

Tests: a launcher fixture that drops its arguments, one that leaves a
process behind, and shape assertions on ap_probe_rc.

LLM-Generated: true
2026-10-29 21:07:39 -04:00
26 changed files with 1820 additions and 122 deletions
+7 -5
View File
@@ -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:<port>` |
| 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
@@ -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
@@ -73,6 +75,8 @@ not** — grep for these in the installed script (`readlink -f "$(command -v ai-
| question | read |
|---|---|
| where does my launcher come from | `ap_resolve_launcher()`, `ap_probe_rc()` (sources an rc file in a throwaway `bash -ic` and returns the *definition*; it never calls it), `ap_rc_list()`, `AISB_AP_RC_FILES` |
| a launcher that ignores what it was asked to run | `ap_forwards_args()` / `ap_warn_forwarding()`: a **function** must end in `"$@"`, because every child is `NAME --no-tty --no-interactive -- <command>`. Aliases and whole command lines need nothing (`ap_build_run()`). Named in `--status`, refused by `ap_preflight()` |
| "it just sits there and does nothing" | two causes, both at the very start of a run: the launcher dropped the command so the container is running an interactive harness with no prompt (`ap_forwards_args()`), or the rc probe is waiting on the terminal (`ap_probe_rc()`: `setsid`, `</dev/null`, `timeout -k 5 15`, transcript into a file, because an interactive bash takes the terminal, gets stopped, and a stopped process ignores SIGTERM). Every child starts with stdin on `/dev/null` |
| how the child command is built | `ap_build_runner()` / `ap_build_run()` — `printf %q` per argument; `--sandbox-args` defaults `--no-tty --no-interactive`; the prompt is one argument that may contain quotes, newlines and `$` |
| what it shadows from the phase library | `ap_install_overrides()`: it first requires the library to provide `find_root next_unit phase_entries phase_next_unit unit_phase is_phase_end count_units sweep_finished_phases execute_unit unit_report notify_task commit_phase push_phase` (plus `first_prompt`/`fix_prompt`, kept as `ap_lib_*`), then shadows `pi`, `first_prompt`, `fix_prompt`, `run_validation`, `push_phase` — and nothing else. Order matters: the launcher resolves *before* `pi()` exists, or a launcher named `pi` would resolve to the shim |
| why a push was refused | `ap_git_fingerprint()`, `ap_git_guard()`, `ap_git_env()`, `ap_push_pending()`; `--trust-git-changes` is the (loud) override |
@@ -87,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.
@@ -110,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:<port>` (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. |
+33 -13
View File
@@ -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 = <gateway host>`; 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:<published port>` + 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 = <port>` (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 or cidr>:<port>` (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 <dir> && 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:<port>` (the mapped loopback
address), plus a client that does not send that request to the egress proxy.
* **your machine → the sandbox**: `publish = <port>`, 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
@@ -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:<port>` — an ordinary allow entry | egress |
| **inbound** | your browser on the host reaches a server the sandbox started | `publish = <port>` / `--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 = <other>:<container>` 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.
@@ -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/<basename>` |
| `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.
+38 -6
View File
@@ -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:<port>."
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:<port>."
note "inbound (host -> a server the sandbox runs): publish = <port> -- 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"
+72 -1
View File
@@ -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
@@ -125,10 +132,46 @@ networking or the image, `make e2e` green too.
anywhere in there silently closes the argument and the rest runs in the *outer* shell
— valid bash, wrong program, clean `bash -n` and clean shellcheck. Nothing in it may
use `'`; jq programs there are written unquoted (`.defaultThinkingLevel=\$lvl`), and
`tests/unit/06_entrypoint.sh` fails the build if that ever changes.
`tests/unit/06_entrypoint.sh` fails the build if that ever changes. (It bit once while
editing a comment: one apostrophe in a word like `pi's` is enough.)
**Stdout inside the container belongs to the command that ran.** Everything the sandbox
says about itself goes to stderr — `log()` and `fail()` do, and so must every new line,
because the driver's preflight reads its answer from that stream and progress.mjs reads the
child pi event stream from it. The pi-seeding notices were on stdout once, and the steady
state (host file unchanged, so `cur = prev`) prints one on *every* pi-bridged run: it made
`ai-sandbox-autophase` refuse a working sandbox on every project. `tests/unit/06_entrypoint.sh`
now runs the seeding block with the two streams split and asserts stdout empty in each state
(cold, steady, edited-in-sandbox, unparseable, level changed), and greps the entrypoint for
any `[aisb]` line without `>&2`. Same block, second contract: the thinking level is policy,
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
looks like from the terminal, and an interrupted build is a half-built image. Note that
`podman build` writes its transcript to **stdout** — the same stdout that the
`$(ensure_image ...)` call reads back as the image ref — so the transcript is routed
onto stderr. Print it
to stdout and the build log comes back spliced into the ref. Whatever the terminal shows
(`AISB_BUILD_OUTPUT=progress|full|quiet`), the complete transcript belongs in
`$AISB_STATE_DIR/build-*.log`, because the progress view is a filter and a failed build
has to be diagnosable from the raw apt output. Tests: `tests/unit/11_build.sh` (a stub
podman, both heartbeat paths, the ref stays one clean line, a canary value appears
nowhere).
* `resolve_workdir()` / `mirror_block_of()` — what the project is called inside the
container. Mirroring the host path is the convenience; the deny list is the safety, because
a bind hides whatever the image has at that destination and podman puts `--tmpfs /tmp` on
@@ -168,6 +211,34 @@ networking or the image, `make e2e` green too.
contain quotes, newlines and `$` at once, so nothing may build that command line by
concatenating raw text. The body is *never printed*: `ap_launcher_preview` names a
function rather than echoing it, because its body is the `pass` call.
- **The probe must not be able to touch, or wait on, the terminal.** It is
`setsid` + `</dev/null` + `timeout -k 5 15` with the transcript in a **temp file**,
and all three are load-bearing: an interactive bash started from a terminal calls
`tcsetpgrp()`, is in that terminal's background, and is **stopped** for trying — and a
stopped process does not take SIGTERM, so a plain `timeout` waited in front of it
forever and the driver waited behind it before printing anything. A pipe is the other
half: `$(...)` waits for EOF, which any grandchild the rc files leave behind (an
agent, a helper daemon) holds open after `timeout` has reaped the shell it was
watching. Only SIGKILL reaps a stopped process, hence `-k`. Read the definition into a
file and none of that can hold the run. Tests: the shape assertions plus the
backgrounding fixture in `tests/unit/10_autophase.sh`.
- **A function launcher is called *with* the run, so it must forward its arguments.**
Every child is `NAME --no-tty --no-interactive -- <in-container command>` with stdin
on `/dev/null`; a function ending at `--env NAME` — the shape everyone writes for
typing by hand — silently discards all of it, and the container then runs the image's
default harness with no prompt, which on an attached terminal is an agent waiting for
keystrokes behind a pipe nobody reads. `ap_forwards_args` checks the captured
definition (aliases and command lines need it not: the shell appends), `--status` and
the preflight name it, and every child starts `</dev/null` so the launcher cannot put
the terminal back. Keep the `"$@"` in every launcher this repo prints — `install.sh`,
`README.md`, `docs/recipes.md`.
- **The preflight reads the marker, not the first line.** `ap_preflight` asks for one
sandboxed shell and takes the line starting `aisb-ok` wherever it appears, because a banner
from an older image or from a launcher of the user's own is not a broken sandbox — it is
warned about and quoted back, since the same stream is parsed as data in every unit. It
still refuses on a non-zero exit, on silence, and on a launcher that runs no command: those
are the failures that would repeat in every unit. Tests: the noisy and yapping launcher
fixtures in `tests/unit/10_autophase.sh`.
- **The push is the delivery contract.** `ap_push_pending` runs at start, at every
phase boundary and at end of run; start is a warning, the boundary and the end are
fatal, because exiting 0 with the work unpushed is the silent miss this script
+2 -1
View File
@@ -24,11 +24,12 @@ lint: ## shellcheck every shell file, in a container (nothing installed locally)
$(BINS) install.sh contrib/ai-sandbox.bash \
tests/run.sh tests/lib.sh tests/spike_network.sh \
$$(ls tests/unit/*.sh tests/e2e/*.sh tests/fixtures/*.sh tests/fixtures/phase-lib/*.sh) \
$$(ls .agents/skills/*/scripts/*.sh 2>/dev/null) \
&& echo "shellcheck: clean"
style: ## bash-style syntax check of every shell file (fast, no deps)
@for f in $(BINS) install.sh contrib/*.bash tests/*.sh tests/unit/*.sh tests/e2e/*.sh \
tests/fixtures/*.sh tests/fixtures/phase-lib/*.sh; do \
tests/fixtures/*.sh tests/fixtures/phase-lib/*.sh .agents/skills/*/scripts/*.sh; do \
[ -f "$$f" ] || continue; bash -n "$$f" || exit 1; \
done; echo "bash -n: clean"
+66 -2
View File
@@ -171,8 +171,17 @@ A script cannot see an interactive shell's functions, so `sandbox-pi` (default,
`--pi-cmd`/`AISB_AP_PI_CMD`) is looked up in `~/.bashrc.d/*`, `~/.bashrc`,
`~/.bash_aliases`, `~/.profile`: the rc file is sourced in a throwaway `bash -ic`
with `exit` neutered and the *definition* returned -- never called, which is what lets
`--status`/`--dry-run` promise no token fetch, no image build, no container. Each child
then re-sources and runs it, so `pass` runs per unit rather than once a night.
`--status`/`--dry-run` promise no token fetch, no image build, no container. That probe
is a `setsid`, `</dev/null`, `timeout -k` read into a **file**: an interactive bash
started from a terminal takes that terminal, is stopped for trying, and a stopped process
does not take the SIGTERM meant for it, so a piped probe could wedge the whole script in
silence -- and any grandchild the rc files leave behind holds a pipe's EOF open long
after the shell it came from is gone. A function launcher is called *with* the run
(`NAME --no-tty --no-interactive -- pi <args> <prompt>`, stdin on `/dev/null`), so it has
to forward its arguments; one that ends at `--env NAME` drops the command, and is named
by `--status` and refused by the preflight rather than running the image default with no
prompt. Each child then re-sources and runs it, so `pass` runs per unit rather than once
a night.
Integration is by shadowing five functions of the sourced library (`pi`,
`first_prompt`, `fix_prompt`, `run_validation`, `push_phase`) behind a `declare -F`
@@ -247,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
@@ -276,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.
+81 -6
View File
@@ -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:<port>`, 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`.
@@ -233,6 +264,34 @@ distinct policy and tagged by content hash: git, curl, jq, build tools, python3,
PKG` adds packages and produces a different tag, so variants coexist, and
`--base-image` swaps the base.
### A build is not a hang
A run whose policy names an image that does not exist yet — a first run, a new
`--install`, a refreshed base, a new release of ai-sandbox itself — builds that image
before it starts anything: apt, a Node tarball, an `npm install`. Tens of seconds to a
few minutes, said out loud as it happens:
```console
ai-sandbox: building the sandbox image with 2 extra package(s): rustc cmake -- one build per policy change, reused by every run after it
ai-sandbox: build: localhost/ai-sandbox:ubuntu-1a2b3c4d5e6f (transcript: ~/.local/state/ai-sandbox/build-sandbox-1a2b3c4d5e6f.log)
ai-sandbox: build: [4/21] RUN apt-get update && apt-get install -y --no-install-recomme
ai-sandbox: build: still on [4/21] RUN apt-get update && apt-get install -y --no-in (52s, 61s total)
ai-sandbox: built localhost/ai-sandbox:ubuntu-1a2b3c4d5e6f in 84s
```
One line per build step, plus a notice every `AISB_BUILD_QUIET_SECS` (15) that a step has
shown nothing — a step downloading a toolchain and a step wedged look identical from the
terminal, and only the clock tells them apart. The names go in the first line because
that is what answers "why is *this* run building when the last one did not".
Whatever the terminal shows, the **complete** transcript of every build is written to
that file under `AISB_STATE_DIR`, and the last 25 lines of it are printed when a build
fails — with the package names called out, since a name apt has never heard of is what
`--install` usually gets wrong and it drowns in the package lists.
`AISB_BUILD_OUTPUT=full` streams podman's raw transcript instead; `=quiet` shows only the
announcements. None of it goes to stdout: `podman build` writes its progress there, and
this script's stdout is the image ref the run is then started with.
### The base is pinned by digest
A tag is a moving pointer: `FROM ubuntu:24.04` means "whatever Canonical points at
@@ -274,7 +333,8 @@ ai-sandbox ps | clean [--all] [--volumes[=GLOB]] | inspect | gate up|status|logs
```
State lives under `~/.local/state/ai-sandbox` (session dirs, `log` of every run
with no secret values, build logs). Config in `~/.config/ai-sandbox`.
with no secret values, and `build-*.log` — the full transcript of each image build,
which is what to read when a build fails). Config in `~/.config/ai-sandbox`.
`/home/sandbox` is a named volume (`aisb-home`) so npm and tool caches survive
between runs. It outlives the image, so after changing the image you may be looking
@@ -349,13 +409,26 @@ sandbox-pi () {
export AI_API_TOKEN=$(pass aipi/dev-token)
ai-sandbox --allow aipi.reeseapps.com --allow github.com \
--allow pi.dev --allow release-assets.githubusercontent.com \
--env AI_API_TOKEN
--env AI_API_TOKEN "$@"
}
```
That last `"$@"` is the whole contract. A function launcher is called *with* the run: the
flags, then `--`, then the command to run inside, and one that ends at `--env AI_API_TOKEN`
drops all of it. It then starts the image's default harness with no prompt — and a `pi`
with no prompt on the other side of a launcher that attached your terminal sits waiting
for keystrokes while its output goes into a pipe nobody is watching. The driver names a
launcher like that in `--status` and refuses to start the pipeline; aliases and whole
command lines do not need it, because the shell appends the words to those by itself.
A script cannot see an interactive shell's functions, so the driver looks the name up in
`~/.bashrc.d/*`, `~/.bashrc`, `~/.bash_aliases` and `~/.profile` (or `AISB_AP_RC_FILES`)
and takes the *definition* — probing never calls it, so a preview cannot fetch a token.
Reading that definition is done in a throwaway session of its own, with no stdin and a
bounded read: an interactive bash started from your terminal takes your terminal, gets
stopped for it, and a stopped process ignores the timeout sent to it, so the probe used to
be able to wedge the whole script before it printed a single line.
Each child then runs exactly what your shell would have, `pass` included, once per unit
so an overnight run is not holding yesterday's token. An alias or a plain program works
too; `--pi-cmd` also accepts a whole command line.
@@ -367,10 +440,12 @@ ai-sandbox-autophase --dry-run # the exact child and gate commands
ai-sandbox-autophase # every pending task, in pipeline order
```
Every child is started as `<your command> --no-tty --no-interactive -- pi <args> <prompt>`:
your flags first, then the two that keep an unattended run off your terminal (no TTY to
corrupt the JSON stream, no stdin for your keystrokes to fall into), then `--`, after
which ai-sandbox reads nothing but the command.
Every child is started as `<your command> --no-tty --no-interactive -- pi <args> <prompt>`,
with its stdin on `/dev/null`: your flags first, then the two that keep an unattended run
off your terminal (no TTY to corrupt the JSON stream, no stdin for your keystrokes to fall
into), then `--`, after which ai-sandbox reads nothing but the command. Those two are
flags, and a launcher is free to lose them, so the `/dev/null` is what actually guarantees
the second one.
| flag | |
|---|---|
+429 -20
View File
@@ -34,6 +34,18 @@ AISB_IMAGE_REPO="${AISB_IMAGE_REPO:-localhost/ai-sandbox}"
AISB_GATE_IMAGE_REPO="${AISB_GATE_IMAGE_REPO:-localhost/ai-sandbox-gate}"
AISB_PI_PACKAGE="${AISB_PI_PACKAGE:-@earendil-works/pi-coding-agent}"
# How much of an image build to show while it runs. A build is the slow part of a first
# run and the thing --install triggers: apt, a Node tarball, an npm install. It has to
# look like work, because minutes of silence look like a wedge and a wedge is what
# people interrupt. progress = one line per step plus a notice while a step runs without
# showing anything worth reading; full = podman's whole transcript; quiet = neither. The
# complete transcript is written to $AISB_STATE_DIR/build-*.log in all three.
AISB_BUILD_OUTPUT="${AISB_BUILD_OUTPUT:-progress}"
# How long a build may go without showing the terminal anything (a step working quietly,
# or a step producing output the progress view drops) before it says it is still going.
# 0 turns the notice off, and a build that is genuinely stuck looks stuck again.
AISB_BUILD_QUIET_SECS="${AISB_BUILD_QUIET_SECS:-15}"
# The knobs below are deliberately NOT defaulted here. A value set in the
# environment has to beat a config file, and a config file has to beat the
# built-in default -- so the built-in defaults live in resolve_opts() and the
@@ -187,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
@@ -226,7 +268,9 @@ IMAGE
registry digest at build time; REF may itself already be
a digest (name@sha256:...) to take that decision away.
--install PKG Extra apt package in the image. Repeatable.
Each distinct set gets its own cached image tag.
Each distinct set gets its own cached image tag, so a new set
means a build: the run names the packages going in, says
which step it is on, and says so again if a step goes quiet.
--harness NAME Default in-container command: pi | codex | claude | none.
PI CONFIG
@@ -295,11 +339,18 @@ 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
(0 forbids the 'podman pull' used to learn a digest when skopeo is absent),
AISB_SKOPEO.
Builds: AISB_BUILD_OUTPUT = progress (default: one line per build step, plus a notice
whenever the terminal has seen nothing for a while, so a slow step is not a hung one)
| full (podman's whole transcript) | quiet. AISB_BUILD_QUIET_SECS (15) is that
threshold, 0 to drop the notice. A build's complete transcript is written to
$AISB_STATE_DIR/build-*.log either way, and the last 25 lines of it are always shown,
with the offending package named, when a build fails.
EXIT STATUS
The exit code of the in-container command. 77/78: the sandbox refused to start
@@ -311,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
@@ -335,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"
@@ -348,6 +400,13 @@ cfg_read_allowlist() { # one target per line
line="$(trim "${line%%#*}")"
[[ -n "$line" ]] && CFG_allow+=("$line")
done < "$file"
# A loop returns the status of the last command its body ran, and here that is the `&&`
# above: a file whose last line is a comment or blank would return 1. load_config() calls
# this twice in a non-final position, so under `set -e` every run would die with exit 1
# and no output at all -- previews included. A blank last line is not an error, so reading a
# file always succeeds; an allowlist with nothing in it is validate_opts()'s call to
# refuse, with a message, rather than a silent death here.
return 0
}
load_config() {
@@ -361,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
}
@@ -376,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
@@ -420,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 ;;
@@ -481,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}"; }
@@ -603,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)}}"
@@ -658,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}"
@@ -673,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
@@ -683,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]}"
}
@@ -706,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.
@@ -871,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"
@@ -911,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 {'
@@ -948,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 '}'
}
@@ -976,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"
@@ -994,6 +1242,13 @@ command -v setpriv >/dev/null || fail "setpriv is missing from the image; rebuil
# the entrypoint has no CAP_DAC_OVERRIDE, so root could not write there anyway. It is
# also the honest identity -- these are the user's own files.
#
# Stdout belongs to the command that was asked for. Everything the sandbox says about
# itself goes to stderr, because two readers take that stream as data: ai-sandbox-autophase's
# preflight reads its own answer from it, and progress.mjs reads the child pi's JSON event
# stream from it. A notice on stdout is not cosmetic -- it is a line the reader has to
# explain. tests/unit/06_entrypoint.sh runs this block with stdout split off and fails if it
# is not empty, and checks every `[aisb]` line in the entrypoint for the redirect.
#
# The two files are managed differently, on purpose.
#
# models.json is a catalog, so it is managed whole: replaced when it is missing or still
@@ -1045,12 +1300,12 @@ if [[ -d "$POLICY_DIR/pi" ]]; then
echo "[aisb] pi config: cannot read $f -- its mode or SELinux label blocks this container, and nothing inside can repair it (remove it from the host, or use --ephemeral-home)" >&2
elif [ -z "$cur" ]; then
install -m 600 "$src" "$tgt" && printf %s "$want" > "$mark"
echo "[aisb] pi config: seeded $f"
echo "[aisb] pi config: seeded $f" >&2
elif [ "$cur" = "$prev" ]; then
install -m 600 "$src" "$tgt" && printf %s "$want" > "$mark"
echo "[aisb] pi config: refreshed $f from the host config"
echo "[aisb] pi config: refreshed $f from the host config" >&2
else
echo "[aisb] pi config: left $f alone (changed inside the sandbox; delete it to take the host version again)"
echo "[aisb] pi config: left $f alone (changed inside the sandbox; delete it to take the host version again)" >&2
fi
fi
@@ -1060,8 +1315,23 @@ if [[ -d "$POLICY_DIR/pi" ]]; then
tgt="$dst/$f"; wrote="$dst/.aisb-$f.wrote"; tmp="$dst/.aisb-$f.tmp"
rm -f "$dst/.aisb-$f.seeded" # the whole-file marker is .wrote now
if [ ! -e "$tgt" ]; then
install -m 600 "$src" "$tgt" && cp "$tgt" "$wrote"
echo "[aisb] pi config: seeded $f"
# The level is policy, so it goes in on the very first seed too. Copying the host
# file alone left a fresh sandbox home running one unit at the default of pi itself,
# with the merge on the next run quietly correcting it -- policy that waits a run is
# not policy.
if [ -n "$lvl" ]; then
first="$(jq --arg lvl "$lvl" .defaultThinkingLevel=\$lvl "$src" 2>/dev/null)"
else
first="$(jq . "$src" 2>/dev/null)"
fi
if [ -n "$first" ] && printf "%s\n" "$first" > "$tmp" && install -m 600 "$tmp" "$tgt"; then
:
else
install -m 600 "$src" "$tgt" # not JSON we could rewrite, so take it as it is
fi
rm -f "$tmp"
cp "$tgt" "$wrote"
echo "[aisb] pi config: seeded $f" >&2
elif [ ! -r "$tgt" ]; then
echo "[aisb] pi config: cannot read $f -- its mode or SELinux label blocks this container, and nothing inside can repair it (remove it from the host, or use --ephemeral-home)" >&2
else
@@ -1086,7 +1356,7 @@ if [[ -d "$POLICY_DIR/pi" ]]; then
if [ -n "$merged" ] && printf "%s\n" "$merged" > "$tmp" \
&& install -m 600 "$tmp" "$tgt" && rm -f "$tmp" && cp "$tgt" "$wrote"; then
if [ -n "$lvl" ] && [ "$had" != "$lvl" ]; then
echo "[aisb] pi config: thinking -> $lvl (policy: --thinking, or host)"
echo "[aisb] pi config: thinking -> $lvl (policy: --thinking, or host)" >&2
fi
fi
fi
@@ -1170,7 +1440,11 @@ base_digest_online() { # $1=ref -> digest from the registry, empty when unreacha
# That is fine for an update and rude for an ordinary run, so it can be turned off
# with AISB_BASE_PULL_FALLBACK=0 (the tests do exactly that, to stay offline).
if [[ -z "$d" && "${AISB_BASE_PULL_FALLBACK:-1}" == 1 ]]; then
if podman pull -q "$ref" > /dev/null 2>&1; then
# The second thing that can sit silent for a minute: this is a download of the whole
# base image. podman's progress lines go to stderr and are left there; only the image
# id on stdout is dropped, since the digest is what this function is after.
log " no skopeo on the host: resolving the digest this way pulls $ref first"
if podman pull "$ref" > /dev/null; then
d="$(podman image inspect "$ref" --format '{{.Digest}}' 2>/dev/null)"
fi
fi
@@ -1378,6 +1652,14 @@ ensure_image() { # $1=sandbox|gate
[[ "${OPT_IMAGE_EXISTS_OK:-0}" == 1 ]] && podman image exists "$ref" && { printf '%s\n' "$ref"; return 0; }
if podman image exists "$ref"; then printf '%s\n' "$ref"; return 0; fi
[[ "${OPT_NO_BUILD:-0}" == 1 ]] && die "image $ref is not built (and --no-build was given). Run: $PROG build"
# Named before the first thing that can take a minute (resolving the Node checksum is
# a network call of its own), and naming the extra packages says why this run builds
# and the last one did not.
local why="building the sandbox image"
if ((${#P_install[@]})); then
why+=" with ${#P_install[@]} extra package(s): ${P_install[*]}"
fi
log "$why -- one build per policy change, reused by every run after it"
ctx="$AISB_CACHE_DIR/build/sandbox-$(image_hash)"
rm -rf "$ctx"; mkdir -p "$ctx"
containerfile > "$ctx/Containerfile"
@@ -1392,6 +1674,7 @@ ensure_image() { # $1=sandbox|gate
ref="$(gate_image_ref)"
if podman image exists "$ref"; then printf '%s\n' "$ref"; return 0; fi
[[ "${OPT_NO_BUILD:-0}" == 1 ]] && die "gate image is not built (and --no-build was given). Run: $PROG build --gate"
log "building the egress gate image (squid)"
ctx="$AISB_CACHE_DIR/build/gate-$(sha12 "$ref")"
rm -rf "$ctx"; mkdir -p "$ctx"
gate_containerfile > "$ctx/Containerfile"
@@ -1402,17 +1685,112 @@ ensure_image() { # $1=sandbox|gate
printf '%s\n' "$ref"
}
# One line of "still here": which step, how long that step has taken, how long the build
# has taken. Silence and a hang look identical from the terminal, and this is the only
# way to tell them apart while a step is doing something we are not printing.
build_beat() { # $1=step label (may be empty) $2=epoch the step began $3=epoch the build began
if [[ -n "$1" ]]; then
log "build: still on ${1:0:52} ($(( $(date +%s) - $2 ))s, $(( $(date +%s) - $3 ))s total)"
else
log "build: still working ($(( $(date +%s) - $3 ))s so far)"
fi
}
# The live view of a build: podman's transcript in on stdin, one readable line per step
# out on stdout (the caller puts that on *our* stderr), and a notice every so often while
# a step is running -- whether it is producing output we choose not to print (an apt
# resolving 33 MB of package lists) or no output at all (a tarball downloading). Always
# exits 0: the pipeline's status is podman's business, not this one's.
build_progress() { # $1=epoch seconds at build start $2=seconds between notices
local t0="$1" quiet="$2" line rc pos instr step="" t_step t_last shown
local -a topt=()
if (( quiet > 0 )); then topt=(-t "$quiet"); fi
t_step="$(date +%s)"; t_last="$t_step"
while :; do
if IFS= read -r "${topt[@]}" line; then
shown=0
case "$line" in
STEP\ *)
pos="${line#STEP }"; instr="${pos#*: }"
step="[${pos%%:*}] ${instr:0:64}"
t_step="$(date +%s)"
log "build: $step"; shown=1
;;
# The pull is the other half of "what is happening now": before the first STEP
# there is a download of the base image, and it is not a step. Layer results are
# not ("--> <hash>" says nothing a numbered STEP line does not, cache hit included).
"Trying to pull "*|"Getting image source"*|"Copying "*|"Writing manifest "*|COMMIT*)
log "build: ${line:0:88}"; shown=1
;;
# Anything that reads as a failure gets through even here. A build that dies
# quietly is worse than forty lines of apt.
"Error:"*|"error:"*|"E: "*|"Err: "*|"dpkg:"*|"npm error "*|"npm ERR!"*|*"exit status"*)
log "build: ${line:0:120}"; shown=1
;;
esac
if (( shown )); then
t_last="$(date +%s)"
elif (( quiet > 0 )) && (( $(date +%s) - t_last >= quiet )); then
build_beat "$step" "$t_step" "$t0"; t_last="$(date +%s)"
fi
else
rc=$?
if (( rc > 128 )); then # a read timeout, not the end of the output
build_beat "$step" "$t_step" "$t0"; t_last="$(date +%s)"
continue
fi
break # end of the transcript
fi
done
return 0
}
build_in() {
local ctx="$1" ref="$2"; shift 2
local logf
local mode="${AISB_BUILD_OUTPUT:-progress}" logf rc=0 secs t0 t1
case "$mode" in
progress|full|quiet) ;;
*) warn "AISB_BUILD_OUTPUT='$mode' is not progress, full or quiet; using progress"
mode=progress ;;
esac
secs="${AISB_BUILD_QUIET_SECS:-15}"
if [[ ! "$secs" =~ ^[0-9]+$ ]]; then
warn "AISB_BUILD_QUIET_SECS='$secs' is not a number; using 15"; secs=15
fi
mkdir -p "$AISB_STATE_DIR"
logf="$AISB_STATE_DIR/build-$(basename "$ctx").log"
if podman build -t "$ref" "$@" "$ctx" > "$logf" 2>&1; then
log "built $ref"
# Opened before the build starts: a state directory that cannot hold a log should fail
# here rather than halfway through a five-minute build, with the transcript lost.
: > "$logf" || die "cannot write the build log at $logf -- nothing was built"
log "build: $ref (transcript: $logf)"
t0="$(date +%s)"
# podman build writes its transcript to *stdout*, and ensure_image()'s callers read
# stdout as the image ref -- so the transcript is routed onto stderr. tee keeps every
# byte in the log file whatever the terminal gets, and with pipefail rc is the worst of
# the stages: only podman and tee can fail here, and tee only if the log went away.
case "$mode" in
full) podman build -t "$ref" "$@" "$ctx" 2>&1 | tee "$logf" >&2 || rc=$? ;;
quiet) podman build -t "$ref" "$@" "$ctx" > "$logf" 2>&1 || rc=$? ;;
*) podman build -t "$ref" "$@" "$ctx" 2>&1 | tee "$logf" \
| build_progress "$t0" "$secs" >&2 || rc=$? ;;
esac
t1="$(date +%s)"
if (( rc == 0 )); then
log "built $ref in $((t1 - t0))s"
rm -rf "$ctx"
else
# The reason, not just the fact: in progress mode the apt/dpkg output that explains a
# failure was deliberately not shown, and it is three hundred lines up in the log.
tail -25 "$logf" >&2
die "image build failed for $ref (full log: $logf)"
# The commonest failure by far is a name apt has never heard of, and it drowns in the
# package lists. Saying "a package could not be installed" is not feedback; naming it
# is, and that is the one thing --install gets wrong.
local -a miss=()
mapfile -t miss < <(sed -n 's/^E: Unable to locate package //p' "$logf")
if ((${#miss[@]})); then
die "image build failed for $ref: no such apt package: ${miss[*]} (check the name; full transcript: $logf)"
fi
die "image build failed for $ref after $((t1 - t0))s (full transcript: $logf)"
fi
}
@@ -1625,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
@@ -1785,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}")
@@ -1798,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
@@ -1880,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
@@ -1908,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]}"
@@ -2120,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)"
@@ -2295,9 +2701,12 @@ cmd_update() {
printf 'gate image %s -> %s\n' "$old_gref" "$new_gref"
fi
fi
# No "built ..." here: a build that ran has already reported itself with the time it
# took, and a build that was not needed has nothing to say. These two lines name what
# to run against, whichever happened.
local ref gref
ref="$(ensure_image sandbox)"; log "built $ref"
if (( want_gate )); then gref="$(ensure_image gate)"; log "built $gref"; fi
ref="$(ensure_image sandbox)"; log "sandbox image: $ref"
if (( want_gate )); then gref="$(ensure_image gate)"; log "gate image: $gref"; fi
printf 'the previous image is kept for rollback; remove it with: podman rmi %s\n' "$old_ref"
}
+123 -11
View File
@@ -91,6 +91,14 @@ LAUNCHER
'sandbox-pi'. Resolved in this order: an exported function, a
function or alias in your rc files, then PATH. A value with
spaces is taken verbatim: --pi-cmd 'ai-sandbox --allow api.example'
A FUNCTION must forward its arguments, because this is what a
run appends to it:
NAME --no-tty --no-interactive -- pi <pi args> <prompt>
so the definition has to end in ... --env AI_API_TOKEN "\$@"
One that drops them runs the image's default harness with no
prompt instead, which says nothing and waits forever. A launcher
like that is named by --status and refused by the preflight.
--rc-files LIST Colon-separated rc files to look in.
Default: ~/.bashrc.d/*, ~/.bashrc, ~/.bash_aliases, ~/.profile
--sandbox-args ARGS Flags added to the launcher for every run.
@@ -215,10 +223,32 @@ ap_rc_list() {
# _AP_PATH=<PATH as the rc files left it>
# _AP_FN | _AP_ALIAS (absent when NAME is defined nowhere in that file)
# <the definition>
#
# Three things here exist because of one bug: an interactive bash started from a terminal
# takes the terminal. It calls tcsetpgrp(), it is in the background of that terminal, so
# the kernel stops it -- and a stopped process does not take SIGTERM, so `timeout` waited
# in front of it forever while the driver waited behind it with nothing on screen. Run
# from a script (no terminal) it never happened, which is how it survived. Hence:
#
# * setsid, so the probe has no controlling terminal to fight over at all; it is still
# interactive, which is the part the rc guards look at;
# * stdin from /dev/null, so nothing it sources can read from the run's keyboard;
# * the transcript into a FILE rather than a pipe: a command substitution waits for the
# pipe's EOF, which is held open by any grandchild the rc files leave behind (an
# agent, a daemon, an editor helper) even after timeout has reaped the shell it was
# watching. A file has no such promise -- timeout returns, we read what is there.
# * `timeout -k`, because SIGKILL is the one signal a stopped process cannot sit on.
#
# setsid is util-linux and is present wherever rootless podman is; without it the probe
# still runs, just without that protection.
AP_SETSID=()
command -v setsid > /dev/null 2>&1 && AP_SETSID=(setsid)
ap_probe_rc() { # <name> <file>
local name="$1" file="$2"
local name="$1" file="$2" tmp rc=0
tmp="$(mktemp "${TMPDIR:-/tmp}/aisb-ap-probe.XXXXXX")" || return 1
AISB_AP_P_NAME="$name" AISB_AP_P_FILE="$file" \
timeout 15 bash -ic '
timeout -k 5 15 ${AP_SETSID[@]+"${AP_SETSID[@]}"} bash -ic '
exit() { return "${1:-0}"; }
. "$AISB_AP_P_FILE" </dev/null >/dev/null 2>&1
shopt -s expand_aliases 2>/dev/null || true
@@ -228,13 +258,22 @@ ap_probe_rc() { # <name> <file>
elif alias -- "$AISB_AP_P_NAME" >/dev/null 2>&1; then
printf "_AP_ALIAS\n"; alias -- "$AISB_AP_P_NAME"
fi
' 2>/dev/null
' > "$tmp" 2>/dev/null < /dev/null || rc=$?
# A probe that had to be killed is worth naming: "the launcher is not defined in your rc
# files" would be the wrong thing to leave you with when the real story is that this one
# file blocked for 15s, and the file is the one thing you can act on.
if (( rc == 124 || rc == 137 )); then
ap_warn "$file took longer than 15s to read (something in it waits) -- skipped"
fi
cat "$tmp" 2>/dev/null
rm -f "$tmp"
}
AP_KIND="" # function | alias | program | arguments
AP_LAUNCHER="" # resolved name, for messages
AP_SOURCE="" # where it was found
AP_DEF="" # captured definition text, when there is one
AP_ARG_FORWARD=1 # 0 when a captured function cannot pass the command line on
AP_CHILD_PATH="" # PATH as the rc files left it
AP_CHILD=() # the launcher itself; AP_RUN below is what a child is started with
@@ -274,6 +313,35 @@ ap_build_runner() {
AP_CHILD=(bash -c "$(printf '%s\n' "${body[@]}")" "$PROG-launcher")
}
# A captured FUNCTION has to hand its arguments on: every run appends its own sandbox
# flags, then --, then the in-container command, and a definition that never mentions
# "$@" throws all of that away. It fails silently, which is what makes it worth checking
# for: the container then starts the image's default harness with no prompt, and with a
# terminal on the other side of the launcher that is an interactive agent waiting for
# input nobody will type, behind a pipe that swallows its output. An alias needs no
# check -- the shell appends the words to an alias by itself -- and neither does a
# program or a whole command line, for the same reason.
#
# The test is on the definition text, never on a call: a probe must not fetch a token.
# Anything matching $@ / $* / ${1 / $1 counts as forwarding; a false pass is a
# preflight failure with a diagnosis, a false alarm is only ever a warning.
ap_forwards_args() { # <definition text> -> 0 when it can pass the command line on
printf '%s' "$1" | grep -qE '\$[@*]|[$][{@#0-9]|\$[1-9]'
}
ap_check_forwarding() {
AP_ARG_FORWARD=1
[[ "$AP_KIND" == function ]] || return 0
ap_forwards_args "$AP_DEF" || AP_ARG_FORWARD=0
}
# Said once per run, wherever the launcher was reported. Never prints the definition:
# the body of a launcher is the thing that fetches the token.
ap_warn_forwarding() {
(( AP_ARG_FORWARD )) && return 0
ap_warn "$AP_LAUNCHER does not forward its arguments, so the run flags and the in-container command this script appends to it are dropped -- it runs the image's default harness with no prompt instead, which waits for a terminal nothing sends. End the function with \"\$@\" (see --help)."
}
ap_resolve_launcher() {
local name="$AISB_AP_PI_CMD" f out kind path def
AP_CMD_WORDS=()
@@ -297,6 +365,7 @@ ap_resolve_launcher() {
if declare -F -- "$name" >/dev/null 2>&1; then
AP_KIND=function; AP_LAUNCHER="$name"; AP_SOURCE="the calling shell"
AP_DEF="$(declare -f -- "$name")"
ap_check_forwarding
ap_build_runner
return 0
fi
@@ -311,6 +380,7 @@ ap_resolve_launcher() {
def="$(printf '%s\n' "$out" | tail -n +3)"
if [[ "$kind" == "_AP_FN" ]]; then AP_KIND="function"; else AP_KIND="alias"; fi
AP_LAUNCHER="$name"; AP_SOURCE="$f"; AP_DEF="$def"; AP_CHILD_PATH="$path"
ap_check_forwarding
ap_build_runner
return 0
fi
@@ -454,7 +524,12 @@ writable mount and your home is a sandbox home, not the host one.
Never edit the gate to make it pass.
'
pi() { "${AP_RUN[@]}" -- pi "$@"; }
# Every child starts with its stdin closed. `--no-interactive` in the sandbox flags is
# what normally keeps podman from attaching the terminal, but a launcher is the user's
# code and can drop that flag -- and ai-sandbox attaches stdin when it has a terminal,
# which is how a run ends up waiting for keystrokes behind a pipe that shows nothing.
# /dev/null is the only honest stdin for an unattended run.
pi() { "${AP_RUN[@]}" -- pi "$@" </dev/null; }
first_prompt() {
local text
@@ -474,9 +549,9 @@ writable mount and your home is a sandbox home, not the host one.
run_validation() { # <logfile>
if [[ "$AISB_AP_VALIDATE" == host ]]; then
bash .agents/validate.sh >"$1" 2>&1
bash .agents/validate.sh >"$1" 2>&1 </dev/null
else
"${AP_RUN[@]}" -- bash .agents/validate.sh >"$1" 2>&1
"${AP_RUN[@]}" -- bash .agents/validate.sh >"$1" 2>&1 </dev/null
fi
}
@@ -622,20 +697,50 @@ ap_git_plan() {
# preflight: one throwaway container, before any agent tokens are spent
# --------------------------------------------------------------------------
ap_preflight() {
local out errf rc=0
local out got="" line errf rc=0
ap_log "preflight: one sandboxed shell, to prove the launcher, the image and the policy"
errf="$(mktemp)"
out="$("${AP_RUN[@]}" -- bash -c 'printf "aisb-ok aisb=%s git=%s user=%s cwd=%s\n" "${AISB:-none}" "$(git --version 2>/dev/null | cut -d" " -f3 || echo none)" "$(id -un)" "$PWD"' 2>"$errf")" || rc=$?
if (( rc != 0 )) || [[ "$out" != aisb-ok* ]]; then
# </dev/null for the same reason as pi() below: this call has the terminal on its stdin
# and the answer in a pipe, so anything that reads stdin inside the container would sit
# there forever with nothing on screen to explain it.
out="$("${AP_RUN[@]}" -- bash -c 'printf "aisb-ok aisb=%s git=%s user=%s cwd=%s\n" "${AISB:-none}" "$(git --version 2>/dev/null | cut -d" " -f3 || echo none)" "$(id -un)" "$PWD"' 2>"$errf" </dev/null)" || rc=$?
# The answer is the marker line, wherever it turned up. stdout inside the container belongs
# to the command the driver asked for and the sandbox's own notices belong on stderr; but an
# image built before that was settled, or a launcher that echoes, can still put a line in
# front. Name that loudly -- the same stream is read as data by progress.mjs in every unit
# -- but do not refuse a sandbox that answered for it. The refusal is for a non-zero exit,
# for silence, and for a launcher that runs nothing.
while IFS= read -r line; do
[[ "$line" == aisb-ok* ]] && { got="$line"; break; }
done <<< "$out"
if (( rc != 0 )) || [[ -z "$got" ]]; then
printf '%s: preflight failed (exit %s): the launcher does not give a working sandbox.\n' "$PROG" "$rc" >&2
printf ' command: %s\n' "$(ap_child_preview)" >&2
tail -n 12 "$errf" | sed 's/^/ | /' >&2
if (( AP_ARG_FORWARD == 0 )); then
printf ' %s is a function that ignores its arguments, so the flags above and everything after -- were dropped\n' "$AP_LAUNCHER" >&2
printf ' and the container ran the image default harness with no prompt instead of the shell asked for. Forward them:\n' >&2
printf ' %s() { ai-sandbox --allow ... --env NAME "$@"; }\n' "$AP_LAUNCHER" >&2
elif [[ -z "${out//[[:space:]]/}" ]]; then
printf ' the sandbox started and answered nothing at all: a launcher that runs no command, or one\n' >&2
printf ' that prints its answer somewhere other than stdout, fails exactly like this. It has to run\n' >&2
printf ' follows the -- it was given (a function needs "$@" at the end; see --help).\n' >&2
else
printf ' stdout carried no aisb-ok answer at all; this is what came back instead:\n' >&2
printf '%s\n' "$out" | awk -v max=6 '{ if (++n <= max) printf " > %s\n", $0 }' >&2
fi
rm -f "$errf"
ap_die "Fix the launcher or the sandbox before running the pipeline: every unit would fail this way. (--no-preflight to skip the check.)"
fi
rm -f "$errf"
echo " $out"
if [[ "$out" == *"git=none"* ]]; then
printf ' %s\n' "$got"
if [[ "$out" != "$got" ]]; then
ap_warn "the sandbox put lines of its own on stdout before answering. That stream is data: the agent"
ap_warn "stream is parsed from it, and so is this check. Notices belong on stderr -- rebuild the"
ap_warn "image if they read [aisb], or move any echo in your launcher there. Continuing: it answered."
printf '%s\n' "$out" | awk -v max=4 '!/^aisb-ok/ { if (++n <= max) printf " > %s\n", $0 }' >&2
fi
if [[ "$got" == *"git=none"* ]]; then
ap_warn "no git inside the sandbox -- the agent cannot read its own diff. Add it to the image (--install git) or expect blind fixer runs."
fi
return 0
@@ -656,6 +761,10 @@ ap_report_config() {
# --pi-cmd may carry a literal --env value, and nothing that prints repeats a value.
printf 'launcher : %s\n' "$(printf '%s' "$AISB_AP_PI_CMD" | ap_redact)"
printf ' = %s, from %s\n' "$AP_KIND" "${AP_SOURCE:-nowhere}"
if (( AP_ARG_FORWARD == 0 )); then
printf ' ! %s drops the arguments appended to it; the child command cannot reach the\n' "$AP_LAUNCHER"
printf ' sandbox, so this run would fail the preflight. End the function with "$@".\n'
fi
printf 'child : %s\n' "$(ap_child_preview)"
if [[ "$AISB_AP_VALIDATE" == host ]]; then
printf 'gate : .agents/validate.sh, on the host (see --validate-in)\n'
@@ -839,6 +948,9 @@ main() {
ap_resolve_launcher
ap_build_run
ap_install_overrides # after the resolve: see the note on ap_install_overrides
# Previews report it in their config block; a real run has no config block, so it hears
# it here instead -- before the first container, not after an hour of nothing.
[[ "$AP_MODE" == status || "$AP_MODE" == dryrun ]] || ap_warn_forwarding
# Preview modes stop here: no image build, no container, no state written, no
# network -- and no sweep of finished phase directories either, which writes.
local root
+20
View File
@@ -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 | "<your command>"
# harness = pi
+3 -3
View File
@@ -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")
}
+39
View File
@@ -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 |
+68 -4
View File
@@ -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
@@ -243,15 +290,23 @@ The nightly case: `.agents/phases/todo/` full of task files, nobody watching, an
agent that must not be able to reach the git remote or touch the host.
```bash
# ~/.bashrc.d/ai-sandbox.bash -- the launcher, the way you already write it
# ~/.bashrc.d/ai-sandbox.bash -- the launcher, the way you already write it, plus the
# "$@" that lets a run hand it the command to sandbox
sandbox-pi () {
export AI_API_TOKEN=$(pass aipi/dev-token)
ai-sandbox --allow aipi.reeseapps.com --allow github.com \
--allow pi.dev --allow release-assets.githubusercontent.com \
--env AI_API_TOKEN
--env AI_API_TOKEN "$@"
}
```
A function written for typing by hand usually stops at `--env AI_API_TOKEN`, and that is
the one thing the driver cannot work with silently: everything after the function name is
the run, so dropping it starts the image's default harness with no prompt. With a terminal
behind it that is an interactive agent waiting for input, its output going into a pipe the
driver is reading — a run that appears to do nothing at all. `--status` names a launcher
that takes no arguments and the preflight refuses it; the fix is the `"$@"` above.
```bash
cd ~/Projects/foo
ai-sandbox-autophase --status # what it resolved, what is pending, the git plan
@@ -270,7 +325,11 @@ sandbox-pi --no-tty --no-interactive -- pi --no-approve --session-dir .agents/ph
`pass` runs again per unit, so a run that starts at 23:00 is not holding a token
fetched at 09:00. Reading the definition is all the probe does: `--status` and
`--dry-run` never call the launcher, fetch a token, build an image or start a
container.
container. It reads it in a session of its own with no stdin — an interactive bash
started from a terminal tries to take that terminal, gets stopped for trying, and a
stopped process does not take the timeout meant for it. Every child is started with its
stdin on `/dev/null` for the same reason in the other direction: nothing inside the
sandbox can be waiting for your keyboard.
| you want | flag |
|---|---|
@@ -320,9 +379,14 @@ A project whose tests need something the image lacks gets it from the launcher:
sandbox-pi () {
export AI_API_TOKEN=$(pass aipi/dev-token)
ai-sandbox --allow aipi.reeseapps.com --allow pypi.org --allow files.pythonhosted.org \
--install 'python3-pip ripgrep' --env AI_API_TOKEN
--install python3-pip --install ripgrep --env AI_API_TOKEN "$@"
}
```
A new `--install` set is a new image tag, so the first run after changing it builds: that
one run says which packages are going in and reports every step until the image exists
(`AISB_BUILD_OUTPUT=quiet` if you would rather it did not talk). Every later run starts
without a word about the image, because there is nothing left to do.
`doctor`, `inspect`, `clean` and the rest of ai-sandbox work unchanged underneath it —
`--status` prints the policy the launcher will apply, without applying it.
+15
View File
@@ -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.*
+3 -1
View File
@@ -76,6 +76,8 @@ echo "(that is what ai-sandbox-autophase looks for, since a script cannot see a
echo "function it did not define):"
echo " sandbox-pi () {"
echo " export AI_API_TOKEN=\$(pass aipi/token)"
echo " ai-sandbox --allow api.example.com --env AI_API_TOKEN"
echo ' ai-sandbox --allow api.example.com --env AI_API_TOKEN "$@"'
echo " }"
echo ' (the "$@" is not decoration: a run appends its flags, then --, then the command'
echo " to sandbox, and a function that drops them runs the image default with no prompt)"
echo "then: cd your/project && ai-sandbox-autophase --status"
+115
View File
@@ -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 <out-file> <host-port> <in-container bind> [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 <port> [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
+37
View File
@@ -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"
+60
View File
@@ -76,3 +76,63 @@ echo 'bogus_key = 1' >> "$CFG/config"
sb --print-policy --net none
assert_contains "unknown key: bogus_key" "unknown key warns"
assert_rc 0 "unknown key is not fatal"
t_name "an allow file may end on a comment or a blank line"
# Regression: cfg_read_allowlist() ended on its `[[ -n "$line" ]]`, so a file whose last
# line yields no target returned 1. load_config() calls it in a non-final position, so under
# `set -e` every run died with exit 1 and *zero* bytes of output -- --dry-run and
# --print-policy included. The test above ends its file on "host # trailing comment", which
# yields a target and so never exercised this.
for last in '# a trailing comment' '' ' '; do
printf '# user-wide allowlist\napi.openai.com\n%s\n' "$last" > "$CFG/allow"
printf 'pypi.org\n%s\n' "$last" > "$REPO/.ai-sandbox/allow"
sb --print-policy --net allow
assert_rc 0 "user allow file ending on [$last] is not fatal"
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"
printf '# nothing but a header here\n' > "$REPO/.ai-sandbox/allow"
sb_timeout 20 --print-policy --net allow
assert_rc 1 "a header-only allow file still refuses to start"
assert_contains "no egress allowlist configured" "and refuses out loud"
+37
View File
@@ -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
+15
View File
@@ -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"
+97 -2
View File
@@ -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 <AISB_PUBLISH_PORTS value> -> 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 <regex> 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 <chain> <regex>
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"
@@ -138,6 +173,66 @@ jq -n --slurpfile T "$M_IN/broken.json" --slurpfile B /dev/null --slurpfile N "$
-f "$MJQ" > /dev/null 2>&1 \
&& _bad "unparseable input fails loudly for the caller to handle" || _ok "unparseable input fails loudly for the caller to handle"
t_name "the sandbox keeps its own chatter off the stream the command owns"
# stdout inside the container is data. ai-sandbox-autophase reads its preflight answer from
# it and progress.mjs reads the child pi event stream from it, so every word the sandbox says
# about itself belongs on stderr. Five notices in this very block were once on stdout, and one
# of them fires on every pi-bridged run -- which made the driver refuse a working sandbox.
# Hence a test that runs the block and separates the two streams, not one that reads it.
if grep -nE '(echo|printf).*\[aisb\]' "$EP" | grep -v '>&2' | grep -q .; then
_bad "every [aisb] line in the entrypoint is redirected" \
"$(grep -nE '(echo|printf).*\[aisb\]' "$EP" | grep -v '>&2' | head -2 | tr '\n' ' ')"
else
_ok "every [aisb] line in the entrypoint is redirected"
fi
# The extracted block, run as the entrypoint runs it (bash -c with the policy directory, the
# level and the merge program as $1..$3). Only this snippet sees the substituted HOME, and it
# starts no container, so podman's own HOME is untouched.
SP="$WORK/seed"; mkdir -p "$SP/pi"
printf '{ "providers": { "gateway": { "models": [ { "id": "turbo" } ] } } }\n' > "$SP/pi/models.json"
printf '{ "theme": "dark/dark" }\n' > "$SP/pi/settings.json"
SEED_OUT="$WORK/seed.out"; SEED_ERR="$WORK/seed.err"
seed_run() { # <level>
HOME="$SP/home" bash -c "$(cat "$INNER")" aisb-seed "$SP" "${1-xhigh}" "$(cat "$MJQ")" \
> "$SEED_OUT" 2> "$SEED_ERR"
}
seed_says() { grep -qF -- "$1" "$SEED_ERR"; }
seed_stdout() { [[ -s "$SEED_OUT" ]] && printf '%s' "$(tr '\n' ';' < "$SEED_OUT")"; }
seed_level() { jq -r '.defaultThinkingLevel // "absent"' "$SP/home/.pi/agent/settings.json" 2>/dev/null; }
seed_run xhigh; RC=$?
assert_eq 0 "$RC" "seeding runs outside a container"
[[ -z "$(seed_stdout)" ]] && _ok "and it writes nothing on stdout (cold seed)" \
|| _bad "stdout belongs to the command (cold seed): $(seed_stdout)"
seed_says "seeded models.json" && _ok "the notice went to stderr" || _bad "the notice went to stderr"
seed_says "seeded settings.json" && _ok "including the settings one" || _bad "including the settings one"
# the level is policy, so a home we create must not run one unit at the default of pi itself
assert_eq "xhigh" "$(seed_level)" "the first seed already carries the thinking policy"
seed_run xhigh; RC=$?
assert_eq 0 "$RC" "and on the next run"
[[ -z "$(seed_stdout)" ]] && _ok "nothing on stdout once the files exist (the steady state)" \
|| _bad "steady-state stdout is not clean: $(seed_stdout)"
seed_says "refreshed models.json from the host config" \
&& _ok "the line that broke the driver is on stderr" || _bad "the line that broke the driver is on stderr"
sed -i 's/turbo/juggernaut/' "$SP/home/.pi/agent/models.json"
seed_run xhigh
[[ -z "$(seed_stdout)" ]] && _ok "an in-sandbox edit is reported without touching stdout" \
|| _bad "edited-file stdout: $(seed_stdout)"
seed_says "left models.json alone" && _ok "and the explanation still arrives" || _bad "and the explanation still arrives"
printf '{oops' > "$SP/home/.pi/agent/settings.json"
seed_run xhigh
[[ -z "$(seed_stdout)" ]] && _ok "even a repair is announced off stdout" || _bad "broken-json stdout: $(seed_stdout)"
seed_says "not usable JSON" && _ok "with the warning on stderr" || _bad "with the warning on stderr"
seed_run low
assert_eq "low" "$(seed_level)" "a changed level is applied over the merged file"
seed_says "thinking -> low" && _ok "and announced there too" || _bad "and announced there too"
[[ -z "$(seed_stdout)" ]] && _ok "with stdout still empty" || _bad "level-change stdout: $(seed_stdout)"
t_name "privilege drop is the last thing that happens"
tail -5 "$EP" | grep -q 'setpriv' && _ok "exec setpriv at the end" || _bad "setpriv last: $(tail -3 "$EP" | tr '\n' ' ')"
grep -q -- '--bounding-set=-all' "$EP" && _ok "bounding set cleared" || _bad "bounding set cleared"
+141 -3
View File
@@ -51,6 +51,9 @@ sandbox-pi() {
printf 'LAUNCH argc=%s cmd=%s\n' "\${#argv0[@]}" "\${cmd[0]:-none}"
local a
for a in "\${argv0[@]}"; do printf 'A:%s\n' "\$a"; done
# what this child was started with on stdin: the driver closes it, so an agent inside
# the sandbox cannot end up reading the terminal of an unattended run
printf 'STDIN:%s\n' "\$(readlink /proc/self/fd/0)"
} >> "\$trace"
(( \${#cmd[@]} )) || return 0
"\${cmd[@]}"
@@ -59,6 +62,25 @@ EOF
cat > "$WORK/rc/aliases.bash" <<'EOF'
alias sandbox-alias='sandbox-pi'
EOF
# The launcher exactly as it is often written for interactive use -- a fixed policy, and
# nothing after it. It works when *you* type its name, and drops the whole in-container
# command when something appends one, which is every run the driver makes. Kept as a
# fixture so the driver is checked to name it instead of hanging on it.
cat > "$WORK/rc/stubby.bash" <<'EOF'
stubby-pi() {
export AI_API_TOKEN='CANARY-STUBBY-4f2c81'
ai-sandbox --allow api.example.com --env AI_API_TOKEN
}
EOF
# An rc file that leaves a grandchild behind, the way one that starts an agent or a
# helper daemon does. The definition probe must not wait for it: reading a definition is
# not a conversation with the file's process tree.
cat > "$WORK/rc/daemon.bash" <<'EOF'
daemon-pi() {
sleep 10 &
ai-sandbox "$@"
}
EOF
cat > "$WORK/rc/broken.bash" <<'EOF'
broken-sandbox() {
local -a cmd=()
@@ -67,6 +89,28 @@ broken-sandbox() {
return 125
}
EOF
# A launcher that puts a notice of its own on stdout before the command runs -- the shape the
# sandbox entrypoint had, where the pi-config notice came first on every bridged run. The
# sandbox does answer, so the run is workable: what the driver owes the user is a warning that
# names the pollution (the same stream is parsed as data every unit), not a refusal.
cat > "$WORK/rc/noisy.bash" <<'EOF'
noisy-pi() {
local -a cmd=()
while (($#)); do case "$1" in --) shift; cmd=("$@"); break ;; *) shift ;; esac; done
printf 'LAUNCH cmd=%s\n' "${cmd[0]:-none}" >> "${AISB_AP_TRACE:-/dev/null}"
printf '[aisb] pi config: refreshed models.json from the host config\n'
"${cmd[@]}"
}
EOF
# The same talking with no answer in it. That is a broken launcher, and it still has to stop
# the run -- with the foreign stdout quoted back rather than a bare exit code.
cat > "$WORK/rc/yap.bash" <<'EOF'
yap-pi() {
local -a cmd=()
while (($#)); do case "$1" in --) shift; cmd=("$@"); break ;; *) shift ;; esac; done
printf 'hello, I am a launcher that runs nothing\n'
}
EOF
# --- the harness and the git shim -------------------------------------------
cat > "$WORK/bin/pi" <<'EOF'
@@ -147,6 +191,7 @@ GTRACE="$WORK/git.trace"
MARKER="$WORK/token.marker"
PROMPTS="$WORK/prompts.log"
: > "$TRACE"; : > "$GTRACE"; : > "$MARKER"; : > "$PROMPTS"
printf 'the driver stdin, which no child is allowed to see\n' > "$WORK/stdin.marker"
DEF_RC="$WORK/rc/sandbox.bash:$WORK/rc/aliases.bash:$WORK/rc/00-nothing.bash"
# A prefix assignment in front of a shell function sticks around for the rest of the
@@ -156,8 +201,11 @@ ap_run() { # [args...]
local cmd="${T_CMD-sandbox-pi}" rc="${T_RC-$DEF_RC}" lib="${T_LIB-$LIB_FIX}"
local validate="${T_VALIDATE-sandbox}" sargs="${T_SANDBOX_ARGS---no-tty --no-interactive}"
local push="${T_PUSH-1}" pre="${T_PREFLIGHT-1}" notes="${T_NOTES-1}" gfail="${T_GITFAIL-}"
local ppush="${T_PHASE_PUSH-}" dryout="${T_DRYOUT-}" plant="${T_PLANT-}"
unset T_CMD T_RC T_LIB T_VALIDATE T_SANDBOX_ARGS T_PUSH T_PREFLIGHT T_NOTES T_GITFAIL T_PHASE_PUSH T_DRYOUT T_PLANT
local ppush="${T_PHASE_PUSH-}" dryout="${T_DRYOUT-}" plant="${T_PLANT-}" stdin_src="${T_STDIN-$WORK/stdin.marker}"
unset T_CMD T_RC T_LIB T_VALIDATE T_SANDBOX_ARGS T_PUSH T_PREFLIGHT T_NOTES T_GITFAIL T_PHASE_PUSH T_DRYOUT T_PLANT T_STDIN
# stdin is a file with a known name, never the test runner's own: the launcher records
# what it was started with, and that is how "a child never inherits the terminal" is
# checked. A driver that forwards stdin would show up as this path in the trace.
OUT="$(env "PATH=$WORK/bin:$PATH" \
"AISB_AP_LIB=$lib" "AISB_AP_RC_FILES=$rc" "AISB_AP_PI_CMD=$cmd" \
"AISB_AP_VALIDATE=$validate" "AISB_AP_SANDBOX_ARGS=$sargs" \
@@ -166,7 +214,7 @@ ap_run() { # [args...]
"FAKE_PI_PROMPTS=$PROMPTS" "REAL_GIT=$REAL_GIT" "PHASE_NOTIFY=0" \
${gfail:+"FAKE_GIT_FAIL=$gfail"} ${ppush:+"PHASE_PUSH=$ppush"} ${dryout:+"AISB_AP_DRYOUT=$dryout"} \
${plant:+"FAKE_PI_PLANT_HOOK=$plant"} \
"$APBIN" "$@" 2>&1)" && RC=0 || RC=$?
"$APBIN" "$@" 2>&1 < "$stdin_src")" && RC=0 || RC=$?
return 0
}
gt() { grep -c "^$1" "$GTRACE" 2>/dev/null || true; } # count of git calls by verb
@@ -210,6 +258,67 @@ T_CMD='' ap_run --status
assert_rc 1 "an explicitly empty launcher is an error, not a silent fallback"
assert_contains "no sandbox launcher" "and says so"
# Extract one function from the driver itself, so a test asks the real code a question
# instead of restating its answer (the way aisb_fn() does for bin/ai-sandbox).
ap_fn() { # <name>
awk -v fn="$1" '$0 ~ "^" fn "\\(\\) \\{" {f=1} f {print} f && /^\}$/ {exit}' "$APBIN"
}
t_name "a launcher that drops its arguments is named, and never hangs a run"
# The shape every interactive sandbox-pi takes: a fixed policy and no "$@". Appended to,
# it runs the image's default harness with no prompt, so an unattended run of the real
# thing sat in front of an agent waiting for keystrokes, behind a pipe that showed
# nothing. The driver has to say so, in the preview and before the first unit.
: > "$TRACE"
T_CMD=stubby-pi T_RC="$WORK/rc/stubby.bash" ap_run --status
assert_rc 0 "--status with a launcher that takes no arguments"
assert_contains "drops the arguments appended to it" "the preview says so"
assert_contains "End the function with" "and says how to fix it"
assert_absent "CANARY-STUBBY" "without repeating what the function exports"
assert_absent "export AI_API_TOKEN" "and without printing its body"
T_CMD=stubby-pi T_RC="$WORK/rc/stubby.bash" ap_run --max-units 1
assert_rc 1 "a run with it is refused, not left waiting"
assert_contains "preflight failed" "at the preflight, before any unit"
assert_contains "ignores its arguments" "with the reason named"
assert_contains 'ai-sandbox --allow ... --env NAME "$@"' "and the fix written out"
if grep -q 'cmd=pi' "$TRACE"; then _bad "no unit runs"; else _ok "no unit runs"; fi
# the fixture launcher in the tests forwards "\$@", and must not be nagged about it
ap_run --status
assert_absent "drops the arguments" "a launcher that forwards them is not flagged"
t_name "reading the rc files cannot be stopped, and cannot be held open"
# An interactive bash started from a terminal takes that terminal: it calls tcsetpgrp(),
# gets SIGTTOU because it is in the background, and stops -- and a stopped process does
# not take SIGTERM, so the probe waited on it forever with the driver waiting behind it.
# Everything a run does starts in this function, previews included, so the whole script
# looked wedged with nothing printed. The three defences are asserted here by shape;
# stopping a process is not something a test can arrange without a terminal of its own.
probe_src="$(ap_fn ap_probe_rc)"
for want in 'AP_SETSID' '< /dev/null' 'timeout -k 5 15' 'mktemp' '> "$tmp"'; do
if printf '%s' "$probe_src" | grep -qF -- "$want"; then
_ok "the probe is bounded, file-backed and off the terminal ($want)"
else
_bad "the probe is bounded, file-backed and off the terminal" "missing: $want"
fi
done
if grep -qF 'AP_SETSID=(setsid)' "$APBIN"; then
_ok "and its own session, so it has no controlling terminal to be stopped by"
else
_bad "and its own session, so it has no controlling terminal to be stopped by" "no AP_SETSID=(setsid) in $APBIN"
fi
# and the consequence a user would feel: an rc file that leaves a process behind must not
# be able to hold the read open. sleep 10 in the fixture is the stand-in for the agent or
# helper daemon a real rc file starts; if the probe reads a pipe instead of a file, this
# test does not fail fast -- it fails 10 seconds later, which is the whole point.
: > "$TRACE"
start=$SECONDS
T_CMD=daemon-pi T_RC="$WORK/rc/daemon.bash" ap_run --status
elapsed=$(( SECONDS - start ))
assert_rc 0 "--status with an rc file that backgrounds a process"
assert_contains "function, from $WORK/rc/daemon.bash" "resolved all the same"
(( elapsed < 8 )) && _ok "without waiting for what it left behind (${elapsed}s)" \
|| _bad "without waiting for what it left behind" "took ${elapsed}s"
t_name "a child is the launcher, then --, then the harness command"
: > "$TRACE"
PROJ_A="$WORK/pa"; mkproj "$PROJ_A" 1 >/dev/null
@@ -232,6 +341,15 @@ if grep -q '^LAUNCH argc=[0-9]* cmd=pi$' "$TRACE"; then
else
_bad "pi is the in-container command" "$(grep '^LAUNCH' "$TRACE" | head -3)"
fi
# Every child starts with stdin on /dev/null, whatever this test's own stdin is: the
# flag that normally keeps podman off the terminal (--no-interactive) is a flag, and a
# launcher is free to drop it.
if grep -q '^STDIN:/dev/null$' "$TRACE"; then
_ok "the child's stdin is /dev/null, not the caller's"
else
_bad "the child's stdin is /dev/null, not the caller's" "$(grep '^STDIN' "$TRACE" | head -3)"
fi
if grep -qF "stdin.marker" "$TRACE"; then _bad "the run's own stdin never reaches a child"; else _ok "the run's own stdin never reaches a child"; fi
argc="$(grep '^LAUNCH argc=.* cmd=pi$' "$TRACE" | head -1 | sed 's/.*argc=\([0-9]*\).*/\1/')"
[[ -n "$argc" && "$argc" -ge 8 ]] && _ok "the whole harness command line reached the launcher ($argc arguments)" \
|| _bad "the whole harness command line reached the launcher" "argc=$argc"
@@ -419,6 +537,26 @@ if grep -q 'cmd=pi' "$TRACE"; then _bad "no unit runs"; else _ok "no unit runs";
T_PREFLIGHT=0 ap_run --max-units 1
assert_rc 0 "--no-preflight is available for the runs that cannot afford it"
t_name "a sandbox that talks on stdout is named, not refused"
cd "$PROJ6" || exit 1
: > "$TRACE"
T_CMD=noisy-pi T_RC="$WORK/rc/noisy.bash" ap_run --max-units 1
assert_rc 0 "a launcher that answers is a working sandbox, banner or not"
assert_contains "aisb-ok" "the answer is taken from wherever it is in the stream"
assert_contains "put lines of its own on stdout" "and the pollution itself is named"
assert_contains "> [aisb] pi config: refreshed" "with the offending line quoted back"
if grep -q 'cmd=pi' "$TRACE"; then _ok "the pipeline went on to run its unit"; else _bad "the pipeline went on to run its unit: $(tr '\n' ';' < "$TRACE")"; fi
t_name "a launcher that talks instead of answering is still refused"
cd "$PROJ6" || exit 1
: > "$TRACE"
T_CMD=yap-pi T_RC="$WORK/rc/yap.bash" ap_run --max-units 1
assert_rc 1 "no aisb-ok answer means no run"
assert_contains "preflight failed" "and it says so"
assert_contains "no aisb-ok answer at all" "naming what the check was looking for"
assert_contains "> hello, I am a launcher that runs nothing" "quoting the stdout that came back instead"
if grep -q 'cmd=pi' "$TRACE"; then _bad "no unit runs"; else _ok "no unit runs"; fi
t_name "bad usage is refused before anything happens"
cd "$PROJ6" || exit 1
ap_run --not-a-flag
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env bash
# tests/unit/11_build.sh -- feedback while an image is being built.
#
# The bug this file exists for: a build is the slow part of a first run and the thing
# --install triggers, and every byte of podman's transcript used to go to a log file.
# Minutes of silence reads as a hang. So these tests ask the running script what a user
# would see, with a stub for podman rather than a container.
#
# The stub stands in for the two behaviours of the real thing that this file is about:
# 'image exists' answers no, so a build is needed, and 'build' writes its transcript to
# *stdout* (measured on podman 5: that is where podman build writes) -- which is the one
# thing the script must never let reach its own stdout, because ensure_image()'s stdout
# is read back as the image ref.
source "$TESTS/lib.sh"
PROJ="$WORK/proj"; mkdir -p "$PROJ"; cd "$PROJ" || exit 1
BIN="$WORK/bin"; mkdir -p "$BIN"
cat > "$BIN/podman" <<'EOS'
#!/usr/bin/env bash
case "${1:-} ${2:-}" in
"image exists") exit 1 ;;
"build -t")
# what the build was invoked with, so a credential smuggled into a --build-arg
# shows up in the transcript and the canary test below fails
printf 'STUB-BUILD-ARGS: %s\n' "$*"
# the packages the build was actually handed, read back out of --build-arg, so this
# transcript names what the policy under test asked for instead of a fixed guess
pkgs=""
for a in "$@"; do
case "$a" in AISB_EXTRA_PACKAGES=*) pkgs="${a#*=}" ;; esac
done
printf 'Trying to pull docker.io/library/ubuntu:24.04...\n'
printf 'Getting image source signatures\n'
printf 'STEP 1/3: FROM docker.io/library/ubuntu:24.04\n'
printf 'STEP 2/3: RUN apt-get update && apt-get install -y%s\n' "${pkgs:+ $pkgs}"
for ((i = 0; i < "${AISB_STUB_NOISE:-20}"; i++)); do
printf 'Get:%d http://archive.ubuntu.com/ubuntu noble/main amd64 curl amd64 8.5.0-1 [11 MB]\n' "$i"
sleep "${AISB_STUB_NOISE_DELAY:-0}"
done
sleep "${AISB_STUB_SLEEP:-0}"
if [[ "${AISB_STUB_RC:-0}" != 0 ]]; then
printf 'Reading package lists...\n'
for p in $pkgs; do printf 'E: Unable to locate package %s\n' "$p"; done
printf 'Error: building at STEP "RUN apt-get install": while running runtime: exit status 100\n'
exit "${AISB_STUB_RC}"
fi
printf '%s\n' '--> 1234567890ab'
printf 'STEP 3/3: CMD ["bash", "-li"]\n'
printf 'COMMIT localhost/ai-sandbox:stub\n'
printf 'Successfully tagged localhost/ai-sandbox:stub\n'
printf 'abc123def\n'
;;
*) exit 0 ;;
esac
EOS
chmod +x "$BIN/podman"
export PATH="$BIN:$PATH"
# node_sha256() fetches from nodejs.org; a unit test never touches a registry.
export AISB_NODE_SHA256=0000000000000000000000000000000000000000000000000000000000000000
# The transcript path the last run announced, which is the only way a test is supposed
# to find it -- and naming it is itself part of the feedback under test.
last_transcript() { printf '%s' "$OUT" | sed -n 's/^ai-sandbox: build: [^ ]* (transcript: \(.*\))$/\1/p' | head -1; }
log_says() { # log_says <needle> <label>
local f; f="$(last_transcript)"
if [[ -n "$f" && -r "$f" ]] && grep -qF -- "$1" "$f"; then _ok "$2"
else _bad "$2" "not in ${f:-no transcript was named}"; fi
}
log_lacks() { # log_lacks <needle> <label>
local f; f="$(last_transcript)"
if [[ -n "$f" && -r "$f" ]] && grep -qF -- "$1" "$f"; then _bad "$2" "present in $f"
else _ok "$2"; fi
}
t_name "a build says what it is doing instead of sitting silent"
sb_env AISB_INSTALL="rustc,cmake" build
assert_rc 0 "ai-sandbox build runs against the stub podman"
assert_contains "building the sandbox image with 2 extra package(s): rustc cmake" \
"it names the extra packages, which is why this run builds and the last did not"
assert_contains "one build per policy change" "and says the build is not repeated"
assert_contains "build: [1/3] FROM" "one line per build step, in order"
assert_contains "build: [2/3] RUN apt-get" "including the slow one"
assert_contains "build: [3/3]" "to the last step"
assert_contains "Trying to pull" "the base download before the first step is visible too"
assert_contains "COMMIT localhost/ai-sandbox:stub" "and the commit that names the image"
assert_contains "built localhost/ai-sandbox:" "and that it finished"
t_name "the transcript is kept in full, and named"
assert_contains "transcript: $WORK/state/build-sandbox-" "the run says where the full log is"
log_says "STUB-BUILD-ARGS:" "the log starts at the beginning of the build"
log_says "Get:1 http://archive.ubuntu.com" "the apt output that was filtered out is in the log"
log_says "Successfully tagged" "and so is the end of it"
t_name "apt's own noise is not what the terminal gets"
assert_absent "Get:1 http://archive.ubuntu.com" "a hundred lines of package lists stay in the log"
assert_absent "abc123def" "and so does the image id podman prints at the end"
t_name "the build transcript never becomes part of the image ref"
# The reason for the routing: ensure_image() is called as $(...), so podman build's
# stdout would come back spliced into the ref and into every 'podman run' after it.
rc=0; "$AISB" build > "$WORK/out" 2> "$WORK/err" || rc=$?
assert_eq 0 "$rc" "the build itself succeeded"
if [[ -s "$WORK/out" ]]; then
_bad "not one line of it lands on stdout" "$(head -2 "$WORK/out")"
else
_ok "not one line of it lands on stdout"
fi
if grep -qxE 'ai-sandbox: built [A-Za-z0-9./:@_+-]+ in [0-9]+s' "$WORK/err"; then
_ok "the ref it reports is still one clean line"
else
_bad "the ref it reports is still one clean line" "$(grep built "$WORK/err" | head -2)"
fi
t_name "a build with nothing new to show still says it is working (filtered output)"
# apt streaming 6 lines at 400ms apart is two and a half seconds of output the progress
# view deliberately drops: the heartbeat has to come from the clock, not from a read timeout.
sb_env AISB_BUILD_QUIET_SECS=1 AISB_STUB_NOISE=6 AISB_STUB_NOISE_DELAY=0.4 build
assert_rc 0 "a build that only takes a couple of seconds still succeeds"
assert_contains "still on [2/3]" "it names the step it is waiting on"
assert_contains "total)" "and how long the build has taken"
assert_absent "Get:3 http://archive.ubuntu.com" "without giving up the filtering to do it"
t_name "a build producing no output at all says it is working"
sb_env AISB_BUILD_QUIET_SECS=1 AISB_STUB_NOISE=0 AISB_STUB_SLEEP=3 build
assert_rc 0 "a build with a silent step succeeds"
assert_contains "still on [2/3]" "the heartbeat covers a step that says nothing either"
t_name "AISB_BUILD_OUTPUT=full puts the whole transcript on screen"
sb_env AISB_BUILD_OUTPUT=full build
assert_rc 0 "full output is not an error"
assert_contains "Get:1 http://archive.ubuntu.com" "the raw apt lines are shown as they come"
assert_contains "Successfully tagged" "all the way to the end"
assert_absent "still on [2/3]" "and nothing is summarised, so no heartbeat is needed"
t_name "AISB_BUILD_OUTPUT=quiet keeps the terminal clean and the log complete"
sb_env AISB_BUILD_OUTPUT=quiet build
assert_rc 0 "quiet is not an error"
assert_absent "STEP 2/3" "no step lines"
assert_absent "Get:1 http://archive.ubuntu.com" "no apt output"
assert_contains "transcript: $WORK/state/build-sandbox-" "it still says where the build went"
assert_contains "built localhost/ai-sandbox:" "and that it finished, with the time it took"
log_says "Get:1 http://archive.ubuntu.com" "quiet does not mean the transcript is lost"
t_name "a failed build names the package apt could not find"
sb_env AISB_STUB_RC=100 AISB_INSTALL="not-a-real-package" build
assert_rc 1 "a failed build fails the run"
assert_contains "no such apt package: not-a-real-package" \
"it names the package, instead of pointing at a file to go read"
assert_contains "E: Unable to locate package" "the failing lines are quoted back even in progress mode"
assert_contains "Error: building at STEP" "and the step that died is one of them"
assert_absent "built localhost/ai-sandbox" "it does not claim a build that did not happen"
t_name "a build mode that does not exist is reported, not obeyed blindly"
sb_env AISB_BUILD_OUTPUT=loud AISB_BUILD_QUIET_SECS=nope build
assert_rc 0 "a bad value is not fatal"
assert_contains "AISB_BUILD_OUTPUT='loud' is not progress, full or quiet" "it says the mode is not usable"
assert_contains "AISB_BUILD_QUIET_SECS='nope' is not a number" "and the same for the threshold"
assert_contains "build: [1/3]" "and it carries on with the default view"
t_name "nothing a build prints or writes carries a credential value"
CANARY="canary-token-value-4f2a9c"
sb_env AI_API_TOKEN="$CANARY" AISB_INSTALL="curl,jq" build
assert_rc 0 "the build ran"
assert_absent "$CANARY" "the token value is not printed"
log_lacks "$CANARY" "the build transcript holds no credential either"