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
111 lines
6.1 KiB
Markdown
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.
|