Files
ai-sandbox/AGENTS.md
T
ducoterra a91744f390 stop the driver hanging on a terminal and on a launcher
ai-sandbox-autophase could sit forever, printing nothing, in two places
that are both before the first unit -- which is why both looked like a
script that "does nothing".

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

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

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

LLM-Generated: true
2026-10-29 21:07:39 -04:00

200 lines
13 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 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.
* `split_target()` / `classify_allows()` — the mapping from what the user typed to
what the firewall and the proxy enforce.
* `build_argv()` — every isolation flag lives here.
* `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 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).