Files
ai-sandbox/AGENTS.md
T
ducoterra b273bfef09 name the sandbox's working directory, and drive phases through it
Two features, one shared reason: what the sandbox calls things decides
what a run tells you afterwards.

The cwd is now mounted at the path it has on the host and that is the
working directory, so pwd, an error the agent quotes, a commit message and
the window title a harness derives from the cwd all name this project
instead of reading the same as every sandbox in the session. The mount is
unchanged: same directory, same rw, same :z, still the only writable one.

Only the destination changed, and a destination is where a bind mount can
do damage -- it hides whatever the image already had there. So
mirror_block_of() refuses the paths the image or the sandbox owns (/usr,
/etc, /var, /opt, /bin, /sbin, /lib*, /boot, /proc, /sys, /dev, /root,
/run, /tmp, /home, /home/sandbox*) and / itself, while /home/<user>/...
mirrors: the one standard hierarchy where work lives, and the one whose
per-user tree never meets the container's /home/sandbox volume or the
read-only .agents share inside it.

/tmp is what set the shape, and it was measured rather than assumed: with
--tmpfs /tmp and -v /tmp/x:/tmp/x in one run podman puts the tmpfs on top,
so pwd prints the right path, writes succeed, and the project is not there.
A failure that quiet earns a refusal plus a line of output instead:
workdir_note in --print-policy, a comment in --dry-run, one log line on a
real run. --workdir DIR still chooses the mount point; onto a refused path
it is honoured (it was asked for) and warned about. /project, the one
directory baked into the image, is every fallback, so a workdir always
exists -- --no-cwd included.

ai-sandbox-autophase runs a .agents/phases/ pipeline with the split the
sandbox forces: every executor agent and the .agents/validate.sh gate
inside, the commits and the pushes outside. The gate is in the writable
mount, so running it on the host is running agent-editable bash on the
host; the git remote is deliberately unreachable in there, because an agent
that can push can ship what you are about to review. It runs your launcher,
not a copy of it -- the definition is read out of your rc files and never
called, which is what lets --status and --dry-run promise no token fetch,
no build, no container. Host git runs with an empty hooks path and no ext::
transport, and delivery is refused if a sandboxed unit rewrote .git/config
or planted a hook. Needs --no-interactive to go with --no-tty: an
unattended run must not leave stdin open on your keyboard, and the two are
now separate choices on both sides.

Tests and tooling, most of it forced by what the two changes exposed:
mirror_block_of is extracted from the script and asked about /usr and
/home/sandbox, which a test cannot create (the 06_entrypoint.sh pattern),
and both real branches are driven from a scratch dir under $HOME, since
$TMPDIR is always the fallback -- which is why the whole suite had only
ever seen /project. e2e/12 holds a real container to the same promise,
including that /project, /home/sandbox and a home-volume marker survive a
mirrored run. The "nothing was started" checks now compare the set of
container ids before and after instead of counting every sandbox on the
machine, so a session you have open is not a test failure; the pty cases
fall back to python3 where util-linux's script is not installed.
shellcheck covers both programs, the completion file and the fixtures, so
the completion builds are word-by-word now, and a colon in the project path
is refused with the reason instead of podman's "invalid option type".

558 unit assertions, 719 with e2e; lint and bash -n clean.

LLM-Generated: true
2026-10-28 10:06:31 -04:00

12 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: 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

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