Everything else in the file gates egress, which left an everyday request unanswered: the agent starts a dev server inside the sandbox and a human wants to look at it. This puts a listener on the host that forwards into the container, so a browser, curl or an API tester reaches the server the sandboxed process runs. `--publish [ADDR:]HOST:CONTAINER`, config `publish` and AISB_PUBLISH, repeatable, with the cost named in the flag: it is ingress, so whatever that server serves is reachable from the address it binds. The default address is 127.0.0.1 because a bare podman -p binds every interface (measured) -- a port committed in a repo config must not quietly be a port on the LAN. publish_addr changes the default machine-wide; a spec with a non-loopback address warns on every run. The grammar is smaller than podman's on purpose: one port per entry, no ranges, no /udp, no `*` -- this list is reviewed line by line, and typing 0.0.0.0 is the whole warning. An address means three parts, so `127.0.0.1:8000` is refused rather than guessed. No -p alias: that flag belongs to the harness (claude -p prints). Fail closed, as elsewhere: --net none cannot forward a port and podman discards the mapping while starting the container anyway, so "it ran" would mean "nothing is listening"; a host port already in use is named before the container starts, not after a build; pick_port() will not hand the gate a port this run publishes. The container is labelled with the ports it was given, and doctor reports them. Inside, publish_rules() accepts tcp on the published ports in the input chain and never the output one. Measured (docs/networking.md): pasta delivers the connection over loopback, so that rule counts 0 packets on the reference host; it is there for a host or a bridge network that delivers on the interface, and so that `nft list table inet aisb` answers "what can reach this container". The ports are validated again inside the entrypoint, because a ruleset is not somewhere to splice an unvalidated string. And the measurement that makes a configured preview look broken: a server bound to 127.0.0.1 inside answers inside and refuses the published port outside, so dev servers must bind 0.0.0.0 -- both skills say so in the change that writes the port. Tests. Unit: the grammar and every port list that looks right and is not, config/env/flag precedence and dedup, the canonical spec in --print-policy, the `# ingress:` header, the flags reaching the invocation before the image, egress enforcement byte-identical with a port open, which chain the rule lands in and that a non-numeric port never reaches nft as code. e2e (tests/e2e/13_publish.sh): one real container with enforce=nft where the host gets its 200 and the same run still refuses a domain outside the allowlist at 403, the server runs as uid 1000 with no capabilities so it cannot widen its own port, and the host listener is gone when the container exits. LLM-Generated: true
250 lines
17 KiB
Markdown
250 lines
17 KiB
Markdown
# AGENTS.md
|
|
|
|
Working notes for anyone (human or agent) changing this repository.
|
|
|
|
## What this project is
|
|
|
|
One bash script, `bin/ai-sandbox`, that runs another program — usually an LLM
|
|
coding harness — inside a rootless podman container with a strict, explicit policy:
|
|
the current directory as the only writable mount plus the host `~/.agents` read-only
|
|
(so the harness still has its skills), no inherited environment, no root or
|
|
capabilities inside the container, resource caps, and egress limited to an
|
|
allowlist that the sandboxed process cannot lift.
|
|
|
|
The `.agents` share is the one exception to "nothing but the project directory", so it is
|
|
read-only with no escape hatch (`agents`/`--agents`/`--agents-dir`, and `--no-agents`):
|
|
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
|
|
is not in the allowlist and must never be, because an agent that can push can ship what
|
|
you are about to review. It owns no policy: the allowlist, the token and every other
|
|
choice come from the user's own launcher command (a function/alias found in their rc
|
|
files, or `--pi-cmd`). It is documented in the README and `docs/recipes.md`.
|
|
|
|
## Non-negotiables
|
|
|
|
1. **Single script.** The Containerfile, the container entrypoint, the squid
|
|
config, the nftables rules and the bridged pi config are all generated from
|
|
`bin/ai-sandbox` (`containerfile()`, `entrypoint_script()`, `write_squid_conf()`,
|
|
`gen_rules()` inside the entrypoint, `pi_config_bridge()`). Keep it that way; do
|
|
not split policy into files that can drift from the script. (The rule is about
|
|
*policy*: `bin/ai-sandbox-autophase` is a second program, and it generates none of
|
|
those artifacts and holds none of those choices — it delegates all of them to the
|
|
user's launcher.)
|
|
2. **Fail closed.** Any path where the sandbox cannot guarantee its policy must
|
|
refuse to start rather than degrade: no allowlist in `allow` mode, missing
|
|
policy file in the entrypoint (exit 78), ruleset that cannot be installed
|
|
(exit 77), `--env NAME` where NAME is unset, a bridged pi config whose credential
|
|
variable is unset *or empty*. Escape hatches exist but must be explicitly named
|
|
(`--allow-unenforced`, `--insecure-keep-root`, `--net full`, `--no-pi-config`).
|
|
3. **Never leak a secret.** Values go through a `0600` env file, never argv.
|
|
Nothing that prints (`--dry-run`, `--print-policy`, the session log, error
|
|
messages) may echo a value. If you add output, add a test that a canary value
|
|
does not appear in it. The pi bridge is the case that tests this hardest: it reads
|
|
a file that may hold a live key, so it rewrites `apiKey` to `"$VAR"`, drops
|
|
`!command` values instead of running them, never touches `auth.json`, and
|
|
`tests/unit/08_pi_config.sh` asserts a canary secret from the host agent directory
|
|
appears nowhere in ai-sandbox state.
|
|
4. **No hardcoded machine paths, usernames or ports.** Everything is overridable
|
|
via `AISB_*` or a config file; defaults must work for any user on any host.
|
|
5. **Measure, don't assume, about podman.** Rootless networking varies by version
|
|
and distro. Run `tests/spike_network.sh` on a new host and update
|
|
`docs/networking.md` when reality disagrees with the design.
|
|
6. **Nothing about a build may be silently mutable.** The base enters the
|
|
Containerfile as a digest, and that digest is part of `image_hash()`; Node is
|
|
version-pinned and checksum-verified; `pi` is version-pinned. A refresh is an
|
|
explicit, dated event (`update`); a build with no digest is announced as `UNPINNED`
|
|
and labelled that way; preview modes must never reach the network. If you add a
|
|
dependency, decide which of those three it is and cover it in
|
|
`tests/unit/07_base.sh` or `tests/e2e/10_image.sh`.
|
|
|
|
## Layout
|
|
|
|
```
|
|
bin/ai-sandbox the application
|
|
bin/ai-sandbox-autophase pipeline driver: agent + gate in the sandbox, git on the host
|
|
install.sh symlink both + optional alias
|
|
contrib/ai-sandbox.bash bash completion (flags mirrored from parse_args; keep in sync)
|
|
config/*.example documented examples (never loaded directly)
|
|
docs/networking.md the measurements behind the enforcement model
|
|
docs/threat-model.md what is and is not protected -- keep it honest
|
|
docs/recipes.md per-harness, per-language and pipeline setups
|
|
tests/run.sh runner: unit (no containers) | --e2e (real containers)
|
|
tests/fixtures/phase-lib contract fixture for the driver: the phased-execution library's
|
|
function names and call shapes, so 10_autophase.sh runs with no
|
|
skill installed and no containers
|
|
tests/spike_network.sh re-measure the host
|
|
```
|
|
|
|
## Commands
|
|
|
|
```bash
|
|
make test # unit tests -- fast, no containers, no network
|
|
make e2e # plus real container/egress tests (needs network + the image)
|
|
make lint # shellcheck in a container
|
|
make style # bash -n on every shell file
|
|
make check # lint + unit
|
|
./bin/ai-sandbox --dry-run --allow example.com --print-policy # introspection
|
|
```
|
|
|
|
Before finishing a change: `make check` green, and if you touched enforcement,
|
|
networking or the image, `make e2e` green too.
|
|
|
|
## Conventions
|
|
|
|
* bash ≥ 4.4, `set -Eeuo pipefail` in the main script; `set -uo pipefail` in the
|
|
test runner (assertions report, they do not abort).
|
|
* Option flow is always: defaults → config files → `AISB_*` env → CLI flags.
|
|
The built-in defaults live in `resolve_opts()`; the top of the script only sets
|
|
variables that have no config-file counterpart. Breaking this is the single
|
|
easiest way to introduce a precedence bug.
|
|
* `POL[...]` holds resolved policy, `P_*` arrays resolved lists, `OPT_*` raw flags,
|
|
`CFG_*` config-file values. Do not read raw env vars deep in the code. A helper
|
|
called as `X="$(helper)"` runs in a subshell: it can return text, but any `POL[..]`
|
|
it assigns is discarded — return a value and assign it in the caller (see
|
|
`base_pin_digest()` + `base_ref_of()`).
|
|
* Preview paths (`--dry-run`, `--print-policy`, `--print-entrypoint`,
|
|
`--print-containerfile`) must not build images, start containers, write state or
|
|
reach the network. That is why digest resolution has a `cache_only` mode, and why
|
|
`inspect` (asks the machine now) is a separate surface from `--print-*` (which stays
|
|
deterministic and scriptable).
|
|
* Tests use `t_name` + `assert_*` from `tests/lib.sh`; each file gets its own
|
|
`AISB_STATE_DIR`/`AISB_CACHE_DIR`/`AISB_CONFIG_DIR`. Never let a test touch the
|
|
real `~/.config`, `~/.local/state` or `~/.cache`, and never override `HOME`
|
|
(podman storage lives there).
|
|
|
|
## Areas that deserve the most care
|
|
|
|
* `entrypoint_script()` — the enforcement half. It is rendered into the image and
|
|
runs as root there. A bug here is a security bug. `tests/unit/06_entrypoint.sh`
|
|
checks the generated rule shape; `tests/e2e/11_net.sh` checks it in a container.
|
|
Its pi-seeding block is passed to `bash -c` inside single quotes, so a single quote
|
|
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. (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
|
|
*top* of a bind of the same path. Extend the list with paths the image or the sandbox
|
|
owns, never with paths a project happens to live under, and keep `/project` reachable:
|
|
it is the fallback for every refusal and for `--no-cwd`, and the image always creates it.
|
|
A fallback must be announced (`workdir_note` in `--print-policy`, a comment in
|
|
`--dry-run`, one log line on a real run). Tests: `tests/unit/03_argv.sh` drives both
|
|
branches, asks the extracted function about the paths a test cannot create (`/usr`,
|
|
`/home/sandbox`), and `tests/e2e/12_sandbox.sh` proves it in a container.
|
|
* `bin/ai-sandbox-autophase` — four things are load-bearing, each a quiet way
|
|
to break the pipeline:
|
|
- **It shadows five functions of the sourced pipeline library** — `pi` (a child is a
|
|
container), `first_prompt`/`fix_prompt` (the agent is told what it cannot do in
|
|
there), `run_validation` (the gate runs where the agent runs) and `push_phase`
|
|
(every push, including the library's own, goes through `ap_push_pending`, so the
|
|
fingerprint guard, the credential redaction and the error contract cover all of
|
|
them) — and nothing else: unit order, retries, provider backoff, report recovery,
|
|
the scoped commit and notifications stay the library's. The `declare -F` gate
|
|
refuses to start if the library stops providing any of them, so a renamed function
|
|
fails loudly instead of quietly running around a guard. Shadow order matters: the
|
|
launcher is resolved *before* `pi()` is defined, or a launcher named `pi` resolves
|
|
to the shim that wraps it (and a PATH program is captured as an absolute path for
|
|
the same reason).
|
|
- **Nothing runs on the host against a repository the agent just edited.** `ap_git_env`
|
|
exports an empty `core.hooksPath`, `GIT_PAGER=cat`, `GIT_CONFIG_NOSYSTEM` and
|
|
`protocol.ext.allow=never`; `ap_git_fingerprint` hashes `.git/config` plus every
|
|
file in `.git/hooks/` before the first unit and `ap_git_guard` re-checks it before
|
|
every delivery, refusing the run unless `--trust-git-changes`. Git executes those
|
|
paths, so a `pre-commit` planted inside the sandbox is otherwise code running as
|
|
the user who invoked the pipeline.
|
|
- **The rc probe reads a definition and never calls it.** `ap_probe_rc` sources one
|
|
file in a throwaway `bash -ic` (interactive, because `~/.bashrc` guards on `$-`) with
|
|
`exit` neutered, and returns the text. `--status`/`--dry-run` must therefore never
|
|
fetch a token or start a container. The captured body is embedded in each child's
|
|
launcher script with `printf %q` per argument — the prompt is one argument that can
|
|
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
|
|
exists to prevent. Nothing printed may carry a value: `ap_redact` covers literal
|
|
`--env NAME=value` and credentials inside remote URLs, which is exactly what a git
|
|
error quotes back. Tests: `tests/unit/10_autophase.sh` (canary token, canary
|
|
credential in the remote URL, the composed command checked against the real
|
|
`ai-sandbox --dry-run`, and one run against the real library).
|