Sandbox¶
Every Booley Flow, Specialist, and Developer Agent runs inside one Docker
container per Project folder: the Sandbox. This page covers its settings:
host-wide Sandbox Policy, the Project's [sandbox] table, network egress,
custom images, and commercial EDA provisioning. Read the known limitation
below before you rely on the Sandbox as a security boundary.
Known Sandbox escape through shared Git metadata¶
Issue #1529 tracks a known, unresolved escape hatch from the Sandbox. Shared Git metadata is writable from the container. A Developer Agent can plant executable configuration or hooks that run as the host user when Git later runs on the host, including through an editor or Booley. Stopping the Sandbox does not remove the planted metadata. The investigation also demonstrated that a substituted Git object-store symlink can redirect native host Git writes outside the Project. The exposure includes filesystem writes as well as execution of planted commands.
Doctor does not currently detect this tampering, and host-side Doctor can itself trigger it. A successful Doctor run or a review of tracked changes is not a security clearance: Git configuration and hooks need not appear in the diff.
Until this is fixed, keep Git inspection inside an already-running Sandbox after untrusted agent work, and avoid host Git operations on the affected checkout, including automatic editor Git integrations and Booley commands that invoke Git. Do not use host-side Doctor as a cleanup or validation step. For work requiring a stronger boundary, use a disposable VM or a separate machine without valuable host credentials or data; these precautions are not a fix for the vulnerability.
Host-side booley init and booley session refresh also invoke Git. There is
currently no audited list of safe host Booley commands for an affected checkout;
these precautions do not establish that opening or refreshing a Sandbox is safe.
Workspace tasks and .envrc are additional ways to execute Project-controlled
code on the host. Do not approve host execution of untrusted Project files.
Remediation of #1529 is deferred for reconsideration on 31 DEC 2026, not
promised for that date.
Host configuration (config.toml)¶
Optional global policy lives at ~/.config/booley/config.toml (or beneath
$XDG_CONFIG_HOME/booley). An absent file selects these defaults and is never
created automatically:
[sandbox]
idle_timeout_seconds = 7200
max_sessions = 4
egress_allowlist = []
egress = "proxy"
idle_timeout_seconds applies when no client Docker exec is running and the later of the last valid Booley heartbeat and container start time is older than the timeout (start time alone when the heartbeat is absent). Booley MCP calls and supervised commands update the heartbeat. Running execs used by Sandbox Attachments, such as VS Code and terminal shells, or other clients prevent reaping for their entire lifetime; the reaper's heartbeat reads do not. An inspection failure keeps the Sandbox for that pass. Closing an attachment does not reset the heartbeat timestamp.
The file is user-owned: Booley reads it but never rewrites it. Parsing is strict. Unknown tables or keys, malformed TOML, wrong types, non-positive limits, and invalid egress entries stop Host Bootstrap before mutation. Egress entries are hostnames only; schemes, paths, ports, IP literals, and wildcards are rejected.
egress chooses how issued Sandboxes reach the network: "proxy" (the
default when absent) or "unproxied". Any other value, or "unproxied" with a
non-empty egress_allowlist, stops Host Bootstrap before mutation. Under
"unproxied", Booley issues Unproxied Sandboxes: no proxy variables,
BOOLEY_EGRESS=unproxied in the spec's containerEnv, and the Booley-managed
routed network booley-unproxied-v1 instead of the egress network. The first
session start creates that network; a licensed Sandbox joins it next to its
private license network. After you switch modes, the next session start
refuses the Sandbox spec issued for the other mode and asks you to run
booley init --seed (then reopen the folder in the container, or use Dev
Containers: Rebuild Container if VS Code is running it). A running Sandbox
keeps its mode until it is recreated; until then, headless booley session
enter and booley auth refuse it with the same remedy. Like Docker's default
bridge, booley-unproxied-v1 also lets Sandboxes reach services listening on
the host's Docker gateway address.
Host config.toml [sandbox] and Project booley.toml [sandbox] have different
keys: host limits and egress belong only in the host file; Project image and
memory settings belong only in the Project file. Init and Doctor reject
host-only keys placed in Project [sandbox].
This Sandbox Policy applies to the whole Docker daemon, across all Projects
and both Goal Mode and Interactive Mode. The
timeout and admission cap cover all Booley Sandboxes, every extra
egress hostname becomes reachable from every Project, and egress applies to
every Project's issued Sandboxes.
max_sessions is checked before a start would add a live Sandbox. At the limit,
the command refuses, lists live Projects and ages, and explains how to free capacity
or raise the limit; it never evicts existing work. Use session down for a
headless Sandbox; close the VS Code window or stop its Sandbox through Dev
Containers for an editor-owned Sandbox. A pending VS Code start also
holds a slot and can be cleared with the reported session down command after
canceling the editor operation.
Sandbox ([sandbox])¶
This is the Project table in booley.toml. Host-wide idle_timeout_seconds,
max_sessions, egress_allowlist, and egress belong in
config.toml [sandbox].
One container, one memory limit:
[sandbox]
# image = "my-project-booley-sandbox:latest" # optional project image, see
# # "Custom sandbox image" below
memory = "8g" # single container memory limit, fed into the generated
# devcontainer; unset means no explicit limit
# pip_requirements = ["sim-requirements.txt"] # Python deps to bake, see below
booley doctor warns when the limit looks too small for the configured
[jobs] caps.
Commercial-license policy¶
A supported License Profile is host-owned, separately granted to the exact Project Root, and emits only the
fixed Sandbox pointer required by the built-in commercial policy. Do not put
license-server addresses, license-file paths, or environment forwarding in
booley.toml.
Host skills ([sandbox].mount_host_skills)¶
By default the sandbox carries only Booley's built-in booley-* skills (baked
into the image). Flip this on to also use your own global Claude Code or Codex skills
inside the container:
[sandbox]
mount_host_skills = true # default false
booley init --seed scans your host skill dirs — ~/.claude/skills (Claude)
and ~/.agents/skills (Codex) — resolves each entry to its real directory
(these dirs are usually full of symlinks, which would otherwise dangle inside
the container), and mounts each one read-only. Booley's own built-ins are
excluded (they are already in the image and always win a name clash), and each
skill is de-duplicated by name. The Developer Agent discovers them alongside the built-ins
on every container start. Nothing on the host is ever written. Rerun
init --seed after adding or removing host skills.
Python dependencies ([sandbox].pip_requirements)¶
The sandbox has no network while a Flow runs, so a project's Python deps must be
baked into its image, not installed on demand. booley init bakes exactly
the requirements files you list here, and nothing is auto-discovered, then
builds and automatically selects <slug>-booley-sandbox. Leave the key unset
(or empty) and the project runs the base image with no extra deps. Set
[sandbox].image only when selecting a genuinely custom image.
[sandbox]
pip_requirements = ["sim-requirements.txt", "python/tb/requirements.txt"]
The value is a list of paths to requirements files (a bare string is
ignored). Paths are relative to the Project Root; any name and any depth works.
init warns on an entry it can't find and skips it — and if every entry is
missing, the project quietly falls back to the base image, so check the init
output rather than assuming the pins landed. Two content rules still apply to
the files it bakes:
- Non-bakeable lines are skipped with a warning: editable installs (
-e), nested includes (-r/-c), and local/file:paths. The isolated build can't reach those paths, so install them at runtime instead. - Pins on the packages the base image manages (
fusesoc,edalize, and Booley itself) are dropped with a warning, not baked: a design repo pinning an olderfusesocwould otherwise silently downgrade the driver out from under the flows.
Everything else you pin wins. That list of managed packages is short on
purpose: your project owns its verification stack. Pinning cocotb==1.5.1
replaces the base image's curated cocotb==2.1.0, and init says so:
pin 'cocotb==1.5.1' overrides the base image's cocotb==2.1.0 (project wins)
That line is a note, not a warning — nothing is blocked. But understand what
you took on: the base image validates its cocotb (imports, VPI library
present) at build time, and that check does not re-run on your layer. A project
pin is a commitment to test the resulting combination yourself, and the first
booley flow sim after the rebuild is that test.
Where the file lives when upstream has no requirements.txt. Plenty of
projects keep their test pins in tox.ini, setup.py, or a CI workflow, none
of which this knob reads. Transcribe them into a real file. Two homes work, and
the choice is about whether the pins should survive a fresh clone:
- Tracked, in the repo (e.g.
sim-requirements.txtat the root, or next to the testbenches) — the file is a normal source file, reviewed and cloned like any other. Preferred when you can add a file to the repo. - Inside the Booley Directory (e.g.
.booley/sim-requirements.txt) — legal, and the right call under stealth mode when you do not want a Booley-shaped file in the tracked tree. The catch is durability:.booley/is excluded from the host repo, so a fresh clone has no such file andbooley initthere produces a base-image project with none of your pins. See what survives a fresh clone. Do not name.booley/docker/requirements.txt: that path is the generated output of this knob.
Either way the file is an image-build input, consumed only by booley init
(Step 9b). It must exist on disk before the build, and changing it does nothing
until you re-run booley init and recreate the container.
This is also the supported way to pin a cocotb 1.x stack, or to add BFM
packages (cocotbext-*) the base image doesn't carry — with rules of its own:
see Baking the cocotb stack into the
image.
Sandbox egress mode (BOOLEY_EGRESS)¶
Inside a Sandbox, Booley points every agent and tool at its egress proxy
(booley-proxy) by setting the HTTP_PROXY family of variables. A Sandbox
that cannot use that proxy, such as a Codespace you wrote the devcontainer
spec for, declares an Unproxied Sandbox instead. Booley-issued Sandboxes
are proxied unless the host sets [sandbox] egress = "unproxied" in
config.toml, which issues
BOOLEY_EGRESS=unproxied for you; set the variable yourself only in
hand-written specs.
BOOLEY_EGRESS |
Effect |
|---|---|
unset or proxy |
Default. Booley sets the proxy variables when the environment did not deliver them. |
unproxied |
Booley sets no proxy variable; agents and nested MCP servers inherit none. |
| anything else | booley and booley-mcp refuse to start (exit 2) and name proxy and unproxied. booley doctor still runs and FAILs. |
Set it in the spec's containerEnv, not remoteEnv: a bare docker exec
drops remoteEnv, and the Sandbox would silently fall back to proxy. The
variable is read only inside a Sandbox (BOOLEY_CONTAINER=1) and is never
inferred from platform signals such as CODESPACES.
It is a declaration, not a security boundary. Any process in a Sandbox can set
it for itself; in a Sandbox Booley issued under host egress = "proxy" the
internal network still denies direct egress, and Booley never removes a proxy
variable the Sandbox delivered, so doing so only stops Booley adding its own
proxy variables in that process and turns Doctor's unresolved-proxy FAIL into
the waivable WARN for that run.
Doctor reports the mode in the Sandbox checks:
proxyand the proxy host resolves: PASS.proxyand the proxy host does not resolve: FAIL. Start the Sandbox from a Booley-issued devcontainer spec, or declareBOOLEY_EGRESS=unproxied.unproxied: WARNsandbox.unproxied, because Booley does not mediate egress. Accept it by waiving that check ID indoctor-waivers.toml.- Any other value: FAIL naming the two valid values.
Custom sandbox image¶
To add project-specific EDA tools to every sandbox container, hand-author a
Dockerfile at .booley/docker/Dockerfile that extends booley-sandbox,
then run plain booley init:
FROM booley-sandbox
USER root
RUN apt-get update \
&& apt-get install -y --no-install-recommends my-tool \
&& rm -rf /var/lib/apt/lists/*
USER agent
The shipped base and flavor images finish as the unprivileged agent user.
Switch to root only for OS-package installation, then restore agent before
the image is used as a Sandbox.
$ booley init
[--] manual edits detected in docker/{Dockerfile} and image
'myproj-booley-sandbox-<root-digest>' is not built — using your files as the build
input (leaving them untouched)
[OK] built myproj-booley-sandbox-<root-digest>
That is one pass, not a recipe: init builds the image from your file
verbatim and re-seeds the devcontainer spec later in the same run. The image
name is derived automatically from the Project slug and canonical-root digest.
A matching manual .booley/docker/Dockerfile is built above the selected
Booley wheel overlay, so no matching [sandbox].image entry is written or needed. Set [sandbox].image only to select a genuinely
custom name or a Booley flavor image.
What init decides, and how to steer it:
| Configured selection and recipe | Initialization behavior |
|---|---|
Standard or Booley flavor with matching manual FROM |
Build the selected wheel overlay, then the exact manual recipe under this Project's private image name. |
Manual FROM mismatches the selection or is ambiguous |
Report the selection and parsed parent, preserve the recipe, and leave it unused. |
Your .booley/docker/ state |
What booley init does |
|---|---|
No Dockerfile, [sandbox].pip_requirements set |
Generates Dockerfile + requirements.txt and builds the derived image |
| Hand-authored Dockerfile, image not built | Builds the derived image from your file untouched |
| Hand-authored Dockerfile, image already built | Leaves it alone while current; rebuilds it verbatim when inherited Booley provenance is stale |
[sandbox].image naming an image Booley doesn't recognise |
Skips the step entirely — you manage that image |
"Hand-authored" means: the file does not carry init's generated header, or it
carries one but its contents do not match the stamped self-hash (you edited
it), or it contains the line # booley:keep. Add # booley:keep to a file
that started life generated and that you now want to own — it is the explicit
"hands off" flag, and it works for requirements.txt too:
# booley:keep
FROM booley-sandbox-riscv
RUN pip install --no-cache-dir -r /tmp/reqs.txt
Hand-authored recipes remain conservative: Booley preserves their bytes and exact ancestry and cannot move their Project dependencies below its final wheel overlay. The same inherited-provenance check rebuilds a Project image left behind by a parent update. Manual ownership prevents file rewriting; it does not freeze stale parent layers into the Sandbox.
Booley infers ancestry only from a Dockerfile with one unambiguous FROM. For
a multi-stage or variable-based recipe, declare the managed direct parent
without changing Docker semantics:
# booley:parent=booley-sandbox-riscv
FROM booley-sandbox-riscv AS tools
FROM tools AS runtime
The directive is lifecycle metadata. Booley verifies the resulting image label against that parent's current immutable Docker image ID; it never treats a tag or matching Booley version as proof of ancestry.
Published Booley flavors use the registry equivalent of that rule: their label records the base's digest-qualified GHCR reference, and init verifies it against the pulled base's repository digests. It does not compare Docker image IDs from the publisher and consumer, because those local identities are not registry provenance.
Use the post-setup hook for per-worktree preparation. Use a custom image for EDA tools that must exist in every container before commands run.
Changing an already-built image is a lifecycle operation, not a config
edit. For a headless Booley-managed Sandbox, booley session refresh plans
the minimal invalid image closure, builds and verifies transaction candidates
while the prior Sandbox remains available, then pins the new immutable image ID,
recreates the Sandbox, and probes its installed Booley wheel before discarding
the predecessor. For generated recipes, a Python-only change rebuilds only the
final wheel overlay; compatible EDA, RISC-V, and Project-dependency substrates
retain their exact immutable IDs. A matching manual recipe remains above the
wheel overlay and its descendants rebuild when that wheel changes. A failed recreation or probe restores the prior Sandbox. If VS
Code owns the running Sandbox, refresh the image with
booley init --force and use Dev Containers: Rebuild Container. Explicit
external images remain your responsibility: rebuild or pull them, run
booley init --seed, then recreate and probe the Sandbox.
RISC-V toolchain image (booley-sandbox-riscv)¶
RISC-V CPU-core projects (ibex, picorv32, biriscv, …) compile firmware/tests
with a RISC-V cross-compiler before simulating, so Booley ships a prebuilt
booley-sandbox-riscv image: the base sandbox plus:
- a multilib RISC-V GNU toolchain (xPack prebuilt, newlib) exposing both
riscv64-unknown-elf-andriscv32-unknown-elf-prefixes and covering rv32i / rv32im / rv32imc (ilp32) plus the rv64 ABIs; srec_cat(srecord), which ibex's.vmemgeneration hard-depends on, anddtc(device-tree-compiler);- Spike (
riscv-isa-sim), the official upstream reference ISS for differential testing (not lowRISC'sibex-cosimfork that Ibex's UVM co-simulation needs); - the ratified RISC-V International spec set baked in for offline use at
$BOOLEY_RISCV_DOCS(/opt/riscv-docs): the unprivileged and privileged ISA manuals (riscv-isa-unprivileged.{pdf,html}andriscv-isa-privileged.{pdf,html}), the external debug spec, and the ELF psABI; the ISA manuals follow the newest date-named upstream release, checked at each Booley release currency audit (ADR 0071);pdftotextis included for shell and Developer Agent text extraction.
Reserve about 6 GB of Docker storage for the complete RISC-V demo stack, plus Project artifacts and temporary upgrade/build data. The current Linux/AMD64 image occupies 2.02 GB on the measured containerd store and exposes a 4.48 GB filesystem; its standard-image layers are shared rather than additive.
Point a project at it with the normal image selector:
[sandbox]
image = "booley-sandbox-riscv"
CSR instructions require an explicit zicsr. With GCC 15, a Project
defaulting to -march=rv32imc fails to assemble every
csrr / csrw. Override the ISA at the build invocation, not in the sources:
make -C sw/... ARCH=rv32imc_zicsr # simple-system style firmware
make -C sw/... RV_ISA=rv32im_zicsr # CoreMark's variable name
The right variable name is the project's own; grep its makefiles for rv32i.
Keep the override at the call site (the post-setup hook).
Project Initialization owns its private selected image; shared flavor and runtime-base
artifacts remain immutable inputs. Internally, both user-facing
images are assembled from reusable substrates in
ghcr.io/boldaxolotl/booley-sandbox-base; those substrate tags are an
implementation detail, not additional [sandbox].image choices.
When Booley is running from a source checkout, it reuses compatible local standard and RISC-V substrates and installs the requested checkout's wheel in a final overlay. An installed official distribution pulls and verifies only the selected complete standard or RISC-V image.
To build or refresh the host flavor by hand (Bootstrap acquires or reuses the
contract-compatible runtime base; add --refresh-base to rebuild it explicitly):
./src/booley/data/docker/build-riscv.sh
Or pull the pre-built image from the GitHub Container Registry:
docker pull ghcr.io/boldaxolotl/booley-sandbox-riscv:latest
docker tag ghcr.io/boldaxolotl/booley-sandbox-riscv:latest booley-sandbox-riscv
When the Project also declares Python dependencies, keep the RISC-V selector and declare the requirements normally:
[sandbox]
image = "booley-sandbox-riscv"
pip_requirements = ["requirements.txt"]
Booley automatically selects the generated Project image, installs those dependencies on the RISC-V substrate, then applies the same final wheel overlay. All selection surfaces—Project Initialization, Session start, refresh, and Doctor—resolve that generated image name consistently.
Baking the cocotb stack into the image¶
The base sandbox pins cocotb 2.x with a curated BFM set (cocotbext-axi,
cocotbext-uart). A project that needs different pins, most often cocotb
1.x, which TestFactory and cocotb.utils.get_time_from_sim_steps still
require, bakes its own via
[sandbox].pip_requirements.
Bake the project's full pinned test stack, not the subset the testbench looks
like it needs. Module-scope imports like cocotb_test.simulator and pytest
(often only for an unused pytest entry point) run at import time, so dropping
them makes cocotb fail to import the testbench at all. Copy the project's own
pin set (tox.ini / requirements.txt) wholesale.
Check the pinned stack against Python 3.14, which the Sandbox runs:
distutilsis gone (removed in Python 3.12). Packages of the cocotb-1.x era still dofrom distutils.spawn import find_executable—cocotb-test0.2.0 does. Nobody listssetuptoolsas a dependency, so add it explicitly. If a pinned stack dies on a missingdistutils, putsetuptoolsin the requirements file and rebuild.-
Probe the import path the testbench actually uses.
import cocotb_testsucceeds whileimport cocotb_test.simulator— the line the testbench runs — raises, because the submodule is where the dead import lives. Verify with the real thing:booley session enter -- python -c "import cocotb_test.simulator, cocotb; print(cocotb.__version__)"
What a pin cannot fix: the simulator pairing. cocotb's run loop is compiled against the simulator, so a cocotb build incompatible with the image's current Verilator can fail even after its Python dependencies import successfully. If simulation stalls at time zero, follow the diagnosis and recovery in TROUBLESHOOTING.md.
Commercial EDA provisioning¶
The initial policy supports Vivado 2025.2 on Linux x86-64 only. A Project can request host provisioning as follows:
[eda.vivado]
provisioning = "host"
provisioning is image (the default) or host. For host, the exact
Project Root must have a host-issued Grant selecting one Installation
Registration before a Sandbox can be created. That Grant is the sole source of
the installation name and host path; Project configuration cannot select
either. The host administrator manages registrations and grants using
booley eda installation register and booley eda grant add.
Successful ordinary booley init runs remember their canonical Project Root
in the host-owned Project Inventory. booley projects joins those roots with
their Grants and reports each root as present, missing, or uninitialized.
Use booley projects discover <root>... to import existing initialized
Projects; discovery scans only the roots named on the command line and does not
follow directory symlinks. Discovery stops descending at each initialized
Project to avoid scanning its RTL, vendor, and build trees, so nested Projects
are not imported by that scan. To import a nested Project, run
booley projects discover <nested path> on it directly.
booley projects forget <project> removes an
obsolete remembered root only after its live Grants have been revoked.
Forget then releases that root's issued-image keeper tag when no container uses
its immutable image; an in-use keeper is retained while the root is forgotten.
Docker inspection or removal failure preserves the inventory entry for retry.
If a present root's keeper was released, run booley init before using it again.
Forget then lists what the Project still holds in Docker, with sizes: images
named by its own tags or recorded as built for it, its agent state volumes, and
containers or networks labelled for it. Each entry says whether a container
uses it or another tag shares it. Forget removes none of the listed resources. A state volume
holds a copy of the agent's login and the agent's history.
booley projects forget --purge <project> also removes the unused ones; it
accepts a root that is already forgotten or deleted, unless a live Grant holds it.
For keepers left by earlier forgotten Projects, preview with
booley projects prune-keepers, then apply that exact preview with
booley projects prune-keepers --confirm <digest>.
EDA administration prints human-readable confirmations by default. Add
--json to a public leaf operation when a script needs the stable structured
record.
License Profiles are host-owned and never appear in Project configuration. A licensed Sandbox receives only a fixed FlexNet relay pointer; it cannot choose an upstream server or arbitrary license environment. Invalid, missing, drifted, or revoked authority fails closed before Flow execution.
Source: docs/user/SANDBOX.md on GitHub