Files
ai-sandbox/AGENTS.md
T
ducoterra 8ee535f6ae bridge the host pi config, with the credential by env only
The image ships pi with no provider and no preferences, so a sandboxed run
meant configuring the model twice. ai-sandbox now reads the host
~/.pi/agent/{models,settings}.json and rewrites them instead of copying them:
every provider apiKey becomes "$AI_API_TOKEN" (pi resolves an env reference
itself, so no value is written to disk), '!command' header values are dropped
rather than executed, settings that point into $HOME are dropped because that
path is not mounted, and auth.json / models-store.json / sessions / extensions
are never read.

The rewritten pair is seeded into /home/sandbox/.pi/agent at container start,
owned by uid 1000 and mode 600, and is treated as managed: a host change is
re-seeded, an edit made inside the sandbox is kept and reported, a deletion
takes the host version again.

The token follows the existing environment rules -- one variable, passed by
name through the 0600 env file, announced when auto-passed. A bridged run
whose token variable is unset or empty is refused instead of starting a pi
that cannot reach its model, and the bridge never widens the allowlist: it
warns with the exact --allow to add.

Docs and examples now use a self-hosted gateway (https://aipi.reeseapps.com)
instead of vendor API endpoints, since that is the only thing this tool is
used against. 391 assertions pass (make lint, ./tests/run.sh --e2e).

LLM-Generated: true
2026-10-27 17:29:17 -04:00

5.6 KiB

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, 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.
  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
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

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.