8b709679079d1e27917b64cd1501148e95663fb4
5
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
a08a69f143 |
make xhigh the sandbox's thinking, and stop settings.json freezing
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
|
||
|
|
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 |
||
|
|
8ee535f6ae |
bridge the host pi config, with the credential by env only
The image ships pi with no provider and no preferences, so a sandboxed run
meant configuring the model twice. ai-sandbox now reads the host
~/.pi/agent/{models,settings}.json and rewrites them instead of copying them:
every provider apiKey becomes "$AI_API_TOKEN" (pi resolves an env reference
itself, so no value is written to disk), '!command' header values are dropped
rather than executed, settings that point into $HOME are dropped because that
path is not mounted, and auth.json / models-store.json / sessions / extensions
are never read.
The rewritten pair is seeded into /home/sandbox/.pi/agent at container start,
owned by uid 1000 and mode 600, and is treated as managed: a host change is
re-seeded, an edit made inside the sandbox is kept and reported, a deletion
takes the host version again.
The token follows the existing environment rules -- one variable, passed by
name through the 0600 env file, announced when auto-passed. A bridged run
whose token variable is unset or empty is refused instead of starting a pi
that cannot reach its model, and the bridge never widens the allowlist: it
warns with the exact --allow to add.
Docs and examples now use a self-hosted gateway (https://aipi.reeseapps.com)
instead of vendor API endpoints, since that is the only thing this tool is
used against. 391 assertions pass (make lint, ./tests/run.sh --e2e).
LLM-Generated: true
|
||
|
|
03ab9c0cb1 |
add ai-sandbox: rootless podman sandbox for agentic development
A single bash script that runs a coding harness (pi by default) inside a rootless Ubuntu 24.04 container with only the current directory mounted at /project, an explicit egress allowlist and an explicit environment: nothing is passed in that was not named on the command line or in a config file. Network enforcement here is empirical rather than assumed (tests/spike_network.sh, docs/networking.md). The original design -- a sidecar proxy on an internal network -- does not work rootless on this host, so allow mode runs pasta with --map-host-loopback, installs an inet aisb ruleset inside the container that drops every egress path except the gate's, and relays through a squid gate bound to the host's loopback that resolves names itself and refuses anything unlisted. The entrypoint checks that the default-drop rule really exists before setpriv drops to uid 1000 with an empty capability set, and exits 77/78 rather than run unenforced. The Containerfile, the entrypoint, the squid config generator and the ruleset renderer all live in bin/ai-sandbox, and the image is tagged by content hash, so a policy change can never silently reuse a stale image. Tests are pure bash: 161 assertions start no container and touch no network, 256 run real ones, including proof that a raw socket, a stripped proxy env and DNS all stay blocked while an allowlisted host works over both http and CONNECT. LLM-Generated: true |