Files
ai-sandbox/AGENTS.md
T
ducoterra 0242ab5b5e pin the base image to a digest, with TTL refresh and audit labels
A tag is a moving pointer, and three things conspired to make the base
immutable-in-practice while mutable-in-theory: image_hash() hashed the
string "ubuntu:24.04" rather than what it resolves to, ensure_image()
returns early when the tag exists, and podman build's pull policy is
"missing" -- so even 'build --rebuild' rebuilt on the cached base. A
Canonical patch simply never arrived, and nothing said so.

Now the tag is resolved to a registry digest (skopeo, falling back to a
pull unless AISB_BASE_PULL_FALLBACK=0), recorded per-ref under
$AISB_CACHE_DIR/base/ at 0600, and used in FROM *and* in image_hash():
refreshing the base produces a new image instead of mutating one a
sandbox may be running. Freshness is a TTL (AISB_BASE_TTL_DAYS=7, 0 every
build, -1 never) plus an explicit 'update' / 'update --check'.

Two rules keep it usable rather than merely strict. A stale pin beats a
mutable tag: offline or with AISB_BASE_AUTO_UPDATE=0 the recorded digest
is still used, and only a build with no digest at all is allowed --
announced as UNPINNED, labelled that way, warned about by doctor. And
preview modes stay deterministic: --print-* never reach the network,
inspect is the surface that asks the machine now.

Both images carry aisb.base-digest / .node / .pi-package / .pi-version
labels plus /etc/aisb/manifest.txt of resolved apt versions, which answers
"which squid was enforcing this?" after the build log is gone. Tags are
named after the base instead of the hardcoded u24- prefix.

Bug found on the way: POL[base_digest]="$(base_pin ...)" silently produced
unpinned labels, because an assignment inside a command substitution is
discarded with the subshell. Helpers return text; callers assign. Also
fixed: the manifest step used jq on npm's dependency objects as if they
were strings, and a missing Node checksum used to skip verification
quietly -- it now warns.

311 assertions pass (unit + e2e), shellcheck clean.

LLM-Generated: true
2026-10-27 15:41:13 -04:00

100 lines
5.2 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:
only the current directory mounted, no inherited environment, no root or
capabilities inside the container, resource caps, and egress limited to an
allowlist that the sandboxed process cannot lift.
## Non-negotiables
1. **Single script.** The Containerfile, the container entrypoint, the squid
config and the nftables rules are all generated from `bin/ai-sandbox`
(`containerfile()`, `entrypoint_script()`, `write_squid_conf()`, `gen_rules()`
inside the entrypoint). Keep it that way; do not split policy into files that
can drift from the script.
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. Escape hatches exist but must be
explicitly named (`--allow-unenforced`, `--insecure-keep-root`, `--net full`).
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.
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
install.sh symlink + optional alias
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 and per-language setups
tests/run.sh runner: unit (no containers) | --e2e (real 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.
* `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.