Command sandbox
run_bash normally executes directly on the host: the only guardrails are
approval and the hardline blocklist in internal/agent/exec_tool.go. The
command sandbox adds a real isolation boundary underneath that: approved
run_bash commands execute inside a rootless Podman container with a
filesystem that only sees the project tree plus the repo’s managed yottacode
worktree root. Network access defaults to host networking, deferring egress
policy to whatever your organization already enforces at the network layer β
see the Network bullet under “What’s isolated, and what isn’t” below.
yottacode itself still runs on the host. File tools, git tools, GitHub tools, MCP tools, the TUI, and provider traffic are not inside the container.
Status: GA. Enable by setting
[sandbox] backend = "podman"in config. Requirespodmaninstalled and onPATH; if Podman or the required image cannot start, sandboxed tools fail closed and never fall back to host execution. The oldsandboxexperimental flag is recognized as a no-op for compatibility and can be removed at your convenience.
Architecture
---
title: Command sandbox runtime boundary
---
flowchart LR
%% Author: YottaDynamics | Scope: current command sandbox
User[User approval] --> Agent[Agent tool loop]
Agent --> RB[RunBashTool]
RB --> Blocklist{Hardline blocklist}
Blocklist -- blocked --> Deny[Return BLOCKED]
Blocklist -- allowed --> Seam[agent.Sandbox interface]
Seam -- nil sandbox --> Host[/Host /bin/sh -c/]
Seam -- podman default profile --> Exec[podman exec -w cwd]
Seam -- podman documents profile --> DocExec[podman exec -w cwd]
Exec --> Container[(Default session container)]
DocExec --> DocContainer[(Documents session container)]
Container --> Mount[(Project + managed worktree mounts)]
DocContainer --> Mount
Mount --> Worktrees[(Managed worktree root)]
Container -. network=host default .-> Net[Host network]
DocContainer -. network=host default .-> Net
Agent --> FileTools[read/write/edit tools]
FileTools --> HostFS[(Host filesystem)]
Agent --> GitTools[git and GitHub tools]
GitTools --> HostNet[(Host/network)]
Legend: solid arrows are execution or filesystem access paths; dotted arrows
are denied-by-default capabilities. The boundary is the agent.Sandbox
interface in internal/agent/sandbox.go; the concrete Podman lifecycle lives in
internal/sandbox/podman.go so the core agent package does not import Podman.
---
title: Dispatch sandbox container ownership
---
flowchart TB
%% Author: YottaDynamics | Scope: current dispatch + sandbox interaction
Parent[Parent session] --> ParentContainer[(Parent session container)]
Parent --> ReadOnly[Read-only dispatch workers]
ReadOnly --> ParentContainer
Parent --> WriteA[Write worker A worktree]
Parent --> WriteB[Write worker B worktree]
WriteA --> ContainerA[(Worker A container)]
WriteB --> ContainerB[(Worker B container)]
ContainerA --> MountA[(Worker A worktree mount)]
ContainerB --> MountB[(Worker B worktree mount)]
ParentContainer --> ParentMount[(Parent cwd mount)]
Write workers get their own lazy sandbox manager because their worktrees are different
absolute paths than the parent session’s mounted cwd. Read-only workers reuse
the parent registry and therefore the parent sandbox manager; their concurrent
commands share those profile containers’ memory/cpus/pids_limit budget.
Implementation map
| Area | Files | Responsibility |
|---|---|---|
| Sandbox seam | internal/agent/sandbox.go, internal/agent/exec_tool.go | Defines Sandbox and optional ProfiledSandbox, keeps nil as host execution, labels sandboxed commands, and annotates Podman infrastructure exit code 125. |
| Podman lifecycle | internal/sandbox/podman.go, internal/sandbox/detect.go | Starts one rootless container for a requested manager profile, builds podman exec, validates mounts, detects local Podman/image state, and tears containers down. |
| Session wiring | internal/agentruntime/runtime.go, internal/agentruntime/sandbox_manager.go | Creates a lazy per-profile sandbox manager when [sandbox].backend = "podman"; never falls back to host execution on profile creation failure. |
| Dispatch inheritance | internal/agent/dispatch_tool.go | Gives each write worker a worker-scoped sandbox mounted at that worker’s worktree; read-only workers reuse the parent registry. |
| Worktree guard | internal/agent/enter_worktree_tool.go | Allows mid-session swaps only when the live sandbox can see the target worktree path; yottacode-managed worktrees are pre-mounted. |
| TUI control | internal/tui/sandbox_picker.go, internal/tui/cmd_sandbox.go | Persists sandbox mode, toggles live auto mode when requested, probes Podman/image availability, and tells users a restart/new session is required for backend changes. |
| Config/docs | internal/config/config.go, docs/sandbox.md | Owns defaults, validation, and user-facing contract. |
Startup and command flow
- Config loads onto defaults, then
config.Validaterejects invalid sandbox backends, networks, missing Podman resource limits, and zero CPU/PID limits whenbackend = "podman". - The TUI or oneshot entry point checks
[sandbox].backend = "podman". RegisterCoreCwdToolsinjects a stable sandbox handler into command-capable tools. A nil value keeps the previous host behavior.- Each tool chooses its profile from intent:
run_bashusesdefault, whilecreate_documentdocx/pdf andread_documentPDF subprocess paths usedocuments. Native document paths do not shell out. - On first use of a profile,
SandboxManagercallssandbox.NewPodmanSandbox, using the latestconfig.tomlfor profiles that are not live yet. That lets a user fix[sandbox].documents_imageand retry a document tool without rebuilding the whole tool registry. The manager validates the mount root, removes any leftover container with the deterministic profile name, startspodman run -d ... sleep infinity, and returns anagent.Sandboximplementation. - Each
run_bashcall still checks the hardline blocklist before the sandbox sees the command. Allowed commands run throughSandbox.Commandwith the current cwd. - On cancellation,
PodmanSandbox.Commandbest-effort kills the marked process inside the container before killing the localpodman execclient. Session teardown closes the manager, which removes every live profile container.
/sandbox
The fastest way to change the setting is /sandbox in the TUI. It opens a
three-row picker:
β― β Sandbox run_bash, with auto-allow
Sandbox run_bash, with regular permissions
No sandbox- Sandbox, with auto-allow β when the sandbox experiment is already active,
persists
[sandbox].backend = "podman", restores the default networked sandbox settings (network = "host", defaultdnsresolvers), and turns on this session’s live auto mode for edits.run_bash,git_commit,git_checkpoint, androllbackstill prompt because they stay in auto mode’s safety floor. - Sandbox, with regular permissions β when the sandbox experiment is already active, persists the same podman backend/default network settings and leaves live auto mode untouched.
- No sandbox β persists
backend = "none", today’s default; if this picker previously enabled auto-allow, it also turns that live auto mode back off.
The picker shows separate configured/live state (Configured: sandbox on/off and
Active: sandbox on/off). If config already says Podman but the current session
was started before the sandbox container existed, it renders Active: sandbox off β restart required; sandbox rows are marked for the next session and cannot be
selected again until yottacode is restarted. This prevents a config-only change
from looking like live isolation.
The backend selection does not hot-swap the running session. The tool
registry gets its Sandbox once during session startup, and the Podman
container is created with that session’s cwd mounted. Restart yottacode or start
a new session for backend changes to affect command execution. Enter (or the
[A] Apply selection action) writes config and always says restart/new session
is required so users do not mistake a config write for a live isolation change.
If you choose No sandbox from an active sandboxed session, the picker persists
backend = "none" for the next session but continues to show Active: sandbox on β restart required to disable for the current one.
The picker also runs a local, network-free detection pass (podman image exists <image>) for both [sandbox].image and [sandbox].documents_image. It shows a
hard warning if Podman is missing. Missing images are non-fatal because Podman
can pull them on first use; if the pull/start fails, the tool fails closed with
the Podman error instead of falling back to the host.
The release workflow publishes both sandbox images when a GitHub Release is
published, tagging them with the release tag (for example v0.4.0), a UTC date
tag, and latest. Each image workflow also logs out of GHCR and verifies an
anonymous podman pull after publishing so a private or unlinked package fails
in CI instead of at user startup.
Config
[sandbox]
backend = "podman" # "none" (default) | "podman"
image = "ghcr.io/yottadynamics/yottacode-sandbox:latest"
documents_image = "ghcr.io/yottadynamics/yottacode-documents:latest"
network = "host" # "none" | "host" (default)
dns = ["1.1.1.1", "8.8.8.8"] # default resolvers passed as podman --dns
mounts = ["."] # project-relative only; cannot escape root
env_passthrough = [] # opt-in credential injection, e.g. ["GITHUB_TOKEN"] β value comes from
# the OS env or ~/.yottacode/.env (see Credentials below)
memory = "4g"
cpus = 4
pids_limit = 256
disk = 4096 # MB; writable-layer quota where the host storage driver supports itWhen backend = "podman", image, documents_image, memory, positive cpus, and positive
pids_limit are required. This prevents Podman from receiving empty or zero
resource-limit flags. image is the default profile used by run_bash;
documents_image is the profile automatically used by document subprocess tools.
Unused profiles reload this block before their first container starts, so fixing
documents_image mid-session is enough for the next document-tool attempt. A
backend change (podman β none) still needs a new session.
What’s isolated, and what isn’t
- One container per used profile per session, not per command β
podman run -d ... sleep infinityon first profile use,podman execfor every sandboxed command, andpodman rm -fon session end. A fresh container per command would forget installed packages and background state between commands. A crashed session (or one interrupted mid-teardown) can leave its profile container behind; startup pruning reclaims staleyc-*containers after a grace period. Running containers are only pruned when yottacode can confirm that the labeled owner process is gone, so active sessions and unlabeled legacy containers are left alone. - Filesystem: the project root is mounted at the same absolute path inside
the container as on the host. Optional
mountsentries are project-relative subpaths; absolute paths and..escapes are rejected so config cannot widen the container’s filesystem view outside the project root. Host-side file tools still edit the same tree directly. Project/worktree mounts use SELinux shared relabeling (:z) so multiple yottacode sessions can mount the same checkout without stealing access from one another. yottacode also mounts the repo’s managed worktree root (~/.yottacode/worktrees/<slug>/) soenter_worktreecan move a live sandboxed session into a yottacode-managed worktree without remounting the container, and a single shared cache directory (~/.yottacode/sandbox-go-cache/) used to persist Go’s build/module cache across sessions β see therun_testsnote below. - Network:
--network=hostis the intentional default, not a temporary stopgap. It shares the host’s network stack directly rather than sitting behind an in-house egress allowlist β with no separate network namespace to police, whatever network-layer policy your organization already enforces (a transparent corporate proxy, a firewall) already covers sandboxed commands the same as any other host process, at no extra config cost. If you route through an explicit (non-transparent) corporate proxy, forward it into the sandbox withenv_passthrough = ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY"]. For a hard deny-all independent of any external trust, choosenetwork = "none". There is no in-house allowlist mode, and none is planned β building and maintaining one would duplicate infrastructure most deployments already have (see roadmap/sandbox-podman.md). For networked sandboxes, yottacode passes default--dnsresolvers (1.1.1.1,8.8.8.8) so rootless Podman does not inherit a host loopback stub such as[::1]:53that is unreachable inside the container. Overridednswith your VPN/corporate resolver IPs if public DNS is not appropriate. Prefer runtime DNS config over baking/etc/resolv.confinto a custom image, because Podman regenerates resolver state for each run. - Credentials: nothing is injected by default.
env_passthroughnames a variable to make available inside the container as an env var of the same name; the value comes from yottacode’s own process environment, which already includes anything set in~/.yottacode/.envor<repo>/.yottacode/.env(the same file used for LLM provider API keys β seedocs/providers.md) by the time a session starts. So to givegh,git,npm, or another sandboxed CLI a credential, add e.g.GITHUB_TOKEN=ghp_...to~/.yottacode/.envand listGITHUB_TOKENinenv_passthroughβ no shell export required. Delivery is a per-session Podman secret (podman secret create+--secret NAME,target=NAME), not a container-level-e NAME=value: the value never appears in Podman’s argv, inpodman inspect, or in the container’s on-disk config, unlike a baked env var. Since most CLIs read credentials from an env var rather than a file,run_bash’s exec wrapper re-exports each mounted secret (export NAME="$(cat /run/secrets/NAME)") at the start of every command. Secrets are named<container-name>-secret-<VAR>, are labeled with the owning process the same way containers are (see the pruning bullet above), and are removed on session Close β a startup sweep reclaims any left behind by a crash, mirroring container pruning. - Hardening: the container uses
--userns=keep-id,--cap-drop=ALL,--security-opt=no-new-privileges, private cgroups, no swap beyond the memory limit, anoexec,nosuid,nodev/tmp, SELinux:Zbind labels, and configuredpids_limit/memory/cpuscaps.diskadditionally caps the container’s writable-layer disk usage (--storage-opt size=), so a runaway or malicious command can’t fill the host disk the way it could with only memory/CPU/pids bounded β this only works on theoverlaystorage driver on XFS with pquota (most Linux hosts run ext4 and don’t), so yottacode probes for it once per process and silently skips the flag when unsupported rather than failing sandbox startup. The same graceful-degradation posture applies topids_limit/memory/cpusthemselves: on a host without the corresponding cgroup controllers delegated to this process (some nested-container/CI/ cloud-VM setups), those flags would otherwise fail every container start β yottacode probes for that too and, if unsupported, starts the container without resource limits rather than not starting it at all. Neither probe result is surfaced as a live notice (container creation is lazy, on first tool use, not at session startup, so there’s no startup-time hook to report through); checkpodman inspecton the running container if you need to confirm which limits actually applied on your host. Sandboxedrun_testskeeps/tmpnoexec and pointsTMPDIR,GOTMPDIR,HOME,XDG_CACHE_HOME, andXDG_CONFIG_HOMEat a per-workspace, container-internal scratch directory (/var/tmp/yottacode-go/<workspace>/), avoiding repo-root cache pollution from.cache/,.config/,.local/,.yottacode/tmp/,.scratch/, orgo/. Unsandboxed hostrun_testsapplies the same repo-clean environment for Go commands under~/.yottacode/host-go/<workspace>/. Both paths also setGOTELEMETRY=off, keeping Go telemetry counters out of the checked-out tree even when the host environment would normally resolve them under the repository. GOCACHEandGOMODCACHEfor sandboxed runs point at the shared, host-mounted~/.yottacode/sandbox-go-cache/directory above β not the per-workspace scratch dir β so Go’s build and module cache survive container recreation across sessions. Every session starts a fresh container (see “One container per used profile per session” above), so without this, the first Go command of every session paid a fullgo mod downloadplus full recompile; measured on this repo’s own dependency set, that’s roughly a minute cold vs. a couple of seconds once the cache is warm. The directory is shared across every repo, worktree, and session on the machine, exactly like a host’s own$GOCACHE/$GOMODCACHEβ both are content-addressed and safe for concurrent use, so nothing about sandboxing changes that trust model. It grows like any Go cache directory, unbounded and outsidedisk’s writable-layer quota (a bind mount isn’t part of the container’s own overlay filesystem, so--storage-opt size=never sees it); reclaim space withgo clean -cache -modcacheusing those two env vars, or delete the directory outright.run_bash,run_tests,create_document’s docx/pdf paths, andread_document’s PDF path are sandboxed. Git, GitHub, MCP, provider calls, and the other file tools still run on the host. The hardline blocklist stays outside the sandbox because a blocked command can still destroy the mounted project tree. Seedocument-generation.mdfor how the document tools route theirpandoc/pdftotextcalls through the documents profile whilerun_bashandrun_testsstay on the default profile.
Dispatch interaction
When dispatch write-workers run, each gets its own lazy sandbox manager mounted at its own worktree whenever the parent session has podman sandboxing on. Read-only dispatch workers do not get separate managers; they share the parent tool registry and parent sandbox manager.
This is also what unlocks unattended run_bash/run_tests for background
(unattended) dispatch write workers: those calls are denied outright when the
worker runs on the host (see dispatch.md), and
allowed once the worker’s own container bounds their blast radius.
This means concurrent read-only workers’ sandboxed commands and the parent
session’s own sandboxed commands all execute inside the same per-profile
containers. Size [sandbox] resource limits with that in mind if read-only
workers run tests or linters concurrently.
Worktree interaction
enter_worktree is allowed only when every already-live sandbox container can
see the target cwd. The normal yottacode-managed path is safe: session containers
mount both the repo checkout and ~/.yottacode/worktrees/<slug>/, so a later
podman exec -w <managed-worktree> has a visible working directory. The guard
still refuses opaque or third-party sandbox implementations that cannot prove the
new path is mounted; otherwise the next run_bash would fail inside Podman even
though host-side file tools could read the worktree.
Changing an image for a profile that is already live also needs a new session: the running container keeps the image it started with. The live-reload behavior only applies to profiles that have not created a container yet.
Known limitations
- No in-house network allowlist β
network = "host"ornetwork = "none"only, by design (see the Network bullet above); optionaldnsentries override resolver IPs but do not restrict egress. - No credential-stripping egress proxy.
- No disk-quota enforcement on most Linux hosts β
diskonly takes effect on theoverlaystorage driver on XFS with pquota (ext4, the common case, doesn’t support it); memory/CPU/pids limits are unaffected. - The default
ghcr.io/yottadynamics/yottacode-sandbox:latestimage includes Go, git, make, gcc/glibc headers for cgo, and common archive/diff tools. It is enough for yottacode’s owngo test/go vetflow, but project-specific stacks can still set[sandbox].imageto a custom image. - Not tested on macOS Podman machine latency; Linux rootless Podman is the supported path today.
- Cancellation/timeout does not reliably kill a command’s full descendant
tree when the command backgrounds and orphans a process via the classic
double-fork idiom (e.g.
(cmd &) &,nohup cmd & disown, or anything that daemonizes itself).run_bash’s cancellation kills the marked top-level process and any descendants still structurally reachable at that moment, which covers a plaincmd &, but a nested background job can land in its own new process group before cancellation fires β verified directly against a real container: killing the top-level process’s group did not reach it. Closing this gap properly needs per-exec cgroup-scoped kill (cgroup.kill), which in turn needs cgroup delegation to an unprivileged,--cap-drop=ALLprocess inside the container β not reliably available today (the same class of host-dependent limitationpids_limit/memory/cpusalready probe for and degrade on, see Hardening above) β so it isn’t implemented yet. Impact is bounded to the container’s own resource budget for the rest of the session; it is not a host escape.