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
5.2 KiB
5.2 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
- 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. - Fail closed. Any path where the sandbox cannot guarantee its policy must
refuse to start rather than degrade: no allowlist in
allowmode, missing policy file in the entrypoint (exit 78), ruleset that cannot be installed (exit 77),--env NAMEwhere NAME is unset. Escape hatches exist but must be explicitly named (--allow-unenforced,--insecure-keep-root,--net full). - Never leak a secret. Values go through a
0600env 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. - 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. - Measure, don't assume, about podman. Rootless networking varies by version
and distro. Run
tests/spike_network.shon a new host and updatedocs/networking.mdwhen reality disagrees with the design. - 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;piis version-pinned. A refresh is an explicit, dated event (update); a build with no digest is announced asUNPINNEDand 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 intests/unit/07_base.shortests/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 pipefailin the main script;set -uo pipefailin 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 inresolve_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 asX="$(helper)"runs in a subshell: it can return text, but anyPOL[..]it assigns is discarded — return a value and assign it in the caller (seebase_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 acache_onlymode, and whyinspect(asks the machine now) is a separate surface from--print-*(which stays deterministic and scriptable). - Tests use
t_name+assert_*fromtests/lib.sh; each file gets its ownAISB_STATE_DIR/AISB_CACHE_DIR/AISB_CONFIG_DIR. Never let a test touch the real~/.config,~/.local/stateor~/.cache, and never overrideHOME(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.shchecks the generated rule shape;tests/e2e/11_net.shchecks 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.