Sandbox#
By default the bash tool runs as your user on the host. With
[bash] sandbox = "auto" (or "podman" / "docker"), every bash
call instead runs inside a per-project container with the project cwd
bind-mounted at /work. The agent’s shell can’t see ~, ~/.ssh,
sibling repos, or anything else outside the project.
The sandbox is off by default. Turn it on per project (or globally in your user config) when you want the safety.
Threat model#
The thing the sandbox addresses: bash escaping the project
directory. rm -rf ~, cat ~/.ssh/id_rsa, find / -name "*.env"
— all blocked because ~ and / are the container’s views, not
the host’s.
The sandbox does not address:
- A malicious sophisticated attacker who finds a container-runtime bug and breaks out. Docker/podman are not security boundaries against root-in-container exploits.
- The agent reading sensitive files via the
read/greptools. Those run in the ensō process on the host — but when the sandbox is on, they’re confined to cwd +additional_directoriesby a parallel guard. See path confinement below. - Network exfiltration. By default the container inherits the host
network; set
network = "none"if you want it offline.
If your threat model is “the model writes confidently-wrong destructive
commands,” the sandbox solves that. If it’s “an adversary has
compromised the model and is trying to exfiltrate,” you want a
multi-layered story — sandbox + denied web_fetch domains + sensitive
files in .ensoignore + network = “none”.
Configuration#
[bash]
sandbox = "auto" # "off" | "auto" | "podman" | "docker"
[bash.sandbox_options]
image = "alpine:latest"
init = ["apk add --no-cache git curl jq make"]
network = "" # "" inherits runtime default; "none" = offline
extra_mounts = ["~/.cache/go-build:/root/.cache/go-build:rw"]
env = []
# name = "..." # override auto-generated name
# uid = "..." # override default user (rarely needed)
# workdir_mount = "/work"sandbox = "auto" prefers podman (rootless, no daemon) and falls
back to docker. To pin one explicitly use "podman" or "docker".
Per-project containers#
Each project gets its own container, named
enso-<basename>-<6-hex-of-cwd>:
~/dev/enso → enso-enso-d3a2c1
~/dev/api → enso-api-7f8b09The 6-hex suffix is computed from the absolute path so two projects
named frontend at different paths don’t collide.
Containers are persistent across ensō runs. First start pays the
image-pull and init cost; subsequent runs podman start instantly.
State (installed packages, modified rootfs) carries over until the
config changes.
Init commands#
init runs once after container creation. Re-runs only when
image, init list, mounts, env, network, or workdir change — tracked
via an enso.init-hash label on the container. Edit the init list,
restart ensō, and the container is rebuilt.
If init fails, the half-baked container is removed immediately so it won’t be reused.
Examples:
# Go project
init = [
"apk add --no-cache git make gcc musl-dev",
"wget -qO- https://go.dev/dl/go1.22.linux-amd64.tar.gz | tar -C /usr/local -xz",
"ln -sf /usr/local/go/bin/go /usr/local/bin/go",
]# Node project — use a richer base image instead of installing on top of alpine
image = "node:20-alpine"
init = ["apk add --no-cache git make"]# Python project
image = "python:3.12-slim"
init = ["apt-get update && apt-get install -y --no-install-recommends git make"]Path confinement of file tools#
When the sandbox is enabled, read, write, edit, grep, and
glob are also restricted: they refuse paths that don’t resolve
under cwd or one of [permissions] additional_directories. This
mirrors the bash sandbox at the host-tool level so the model can’t
bypass it via path arguments.
Symbolic links are not followed for the confinement check — a
symlink at ./.env pointing at /etc/passwd is rejected based on
the lexical path, not the resolved one.
Managing containers#
enso sandbox list # show every enso-managed container, all projects
enso sandbox stop # stop the current project's container (keeps state)
enso sandbox rm # stop and remove the current project's container
enso sandbox prune # remove every enso-managed container, all projectslist filters by the enso.managed=true label; you’ll never see
unrelated containers in the output.
prune only touches containers with the enso.managed=true label, so
your dev databases, redis, postgres, etc. are safe.
File ownership#
On Linux with rootless podman, the host user maps to the container’s root via user namespaces — bind-mount writes from inside the container land as your user on the host. No special config needed.
On Linux with docker (root daemon), bind-mount writes default to
root-owned. Set uid = "1000:1000" (or whichever your UID is) in
[bash.sandbox_options] to make new files match your host user.
On macOS Docker Desktop: handles UID translation automatically; no config needed.
Failure modes#
- Runtime not installed: ensō refuses to start with a clear message naming both supported runtimes.
- Image pull fails: ensō surfaces the runtime error and exits. Common causes: registry rate limits, no internet, invalid image name.
- Init script fails: container is removed and ensō reports which init line failed.
- Container survives across ensō runs but you want to nuke it:
enso sandbox rm(current project) orenso sandbox prune(all).
Daemon mode caveat#
The daemon path doesn’t currently expose [bash] sandbox. Each
enso run --detach can target a different cwd, but the registry is
shared across sessions and per-session sandboxing isn’t in v1 scope.
Use enso run or enso tui (in-process) if you need the sandbox.