Two changes, one bug found through the other.
Thinking is policy now: defaultThinkingLevel is xhigh unless you say otherwise
(thinking / --thinking / AISB_THINKING, with pi's levels plus 'host' to decline
and 'off' meaning pi's own no-reasoning level). It is written into the bridged
settings and re-applied by the entrypoint on every run, so a run cannot set its
own reasoning down for the runs after it. Unknown levels are refused with the
list to pick from. Nothing is written for codex, claude or a command after --,
and --no-pi-config declines it along with the bridge, because pi's own defaults
are what that flag asks for.
The reason xhigh had never reached a sandbox is worse than a missing default:
settings.json was managed as one whole file, and pi rewrites its own settings
(a theme change, "seen the changelog"), so the first such write matched nothing
and froze every host preference out permanently -- provider, model, thinking
budgets and level included, a real session left holding just
{"lastChangelogVersion": "1.4.0"}. It is merged per key instead, against a copy
of what we last wrote: a key edited in the sandbox stays edited, a key never
touched follows the host again, an untouched key the host dropped is dropped, a
key pi invented survives, and a file with no base copy takes the host values --
which repairs the volumes already frozen. Unparseable JSON is re-seeded with a
warning since pi cannot use it either; a file the container cannot read is
reported with the fix, because nothing inside the sandbox can repair one.
One trap worth remembering: the seeding script is passed to bash -c in single
quotes, so a single quote inside it closes the argument early and the rest runs
in the outer shell -- valid bash, wrong program, clean bash -n and clean
shellcheck. tests/unit/06_entrypoint.sh extracts the seeding script and asserts
it is quote-free, and extracts the merge program to test its semantics without
a container; tests/e2e/12_sandbox.sh does the same in a real run, including
forcing the level back after the sandbox sets it to off.
LLM-Generated: true
116 lines
6.5 KiB
Markdown
116 lines
6.5 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.
|
|
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.
|