Compare commits
6
Commits
209f167e43
...
144e322391
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
144e322391 | ||
|
|
ac2f310d10 | ||
|
|
90d98dcc9e | ||
|
|
8b70967907 | ||
|
|
9347bca78f | ||
|
|
a91744f390 |
@@ -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. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Executable
+166
@@ -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"
|
||||
Reference in New Issue
Block a user