Files
ai-sandbox/AGENTS.md
T
ducoterra ed2eb462d6 share the host ~/.agents read-only, so skills come along
A sandboxed harness found none of your skills, prompt templates or
standing AGENTS.md, because those live in ~/.agents and only the project
was mounted. It is now bind-mounted at /home/sandbox/.agents by default
-- bound, never copied -- with --no-agents to opt out and --agents-dir
for a curated set, reported by --print-policy and by doctor.

Read-only is the load-bearing part and has no escape hatch: a skill is an
instruction the model follows later, on the host, so a sandbox that could
write ~/.agents had a persistence path out of itself, planted as a
helpful-looking SKILL.md. It holds even under --insecure-keep-root, the
mount lands after /home/sandbox itself so it cannot be shadowed by the
home volume or tmpfs, and an explicit --mount on the same destination
wins because podman refuses duplicates. A missing or unreadable directory
is a silent skip like the pi bridge's; an explicit --agents-dir that
resolves to nothing is a hard error.

The exposure is stated in docs/threat-model.md: the tree, its .git history
and anything pasted into a skill are readable by whatever runs in there.

tests/unit/09_agents.sh covers resolution, precedence and the
bind-never-copy canary; tests/e2e/12_sandbox.sh mounts it in a real
container, checks the host tree survives a run byte for byte and that
even root inside the sandbox cannot write it.

LLM-Generated: true
2026-10-27 20:30:53 -04:00

111 lines
6.1 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`.
## 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
```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.