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
default-deny network and a filesystem that only sees the project tree.
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: experimental. Enable with
--experimental sandbox,YOTTACODE_EXPERIMENTAL=sandbox, or[experimental] sandbox = truein config, and set[sandbox] backend = "podman". The flag alone does not turn it on. Requirespodmaninstalled and onPATH.
Architecture
---
title: Command sandbox runtime boundary
---
flowchart LR
%% Author: YottaDynamics | Scope: current experimental 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 sandbox --> Exec[podman exec -w cwd]
Exec --> Container[(Session Podman container)]
Container --> Mount[(Project bind mount only)]
Container -. network=none by default .-> Net[No network egress]
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 containers 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 container; their concurrent
commands share that container’s memory/cpus/pids_limit budget.
Implementation map
| Area | Files | Responsibility |
|---|---|---|
| Sandbox seam | internal/agent/sandbox.go, internal/agent/exec_tool.go | Defines Sandbox, keeps nil as host execution, labels sandboxed run_bash, and annotates Podman infrastructure exit code 125. |
| Podman lifecycle | internal/sandbox/podman.go, internal/sandbox/detect.go | Starts one rootless container per session/worker, builds podman exec, validates mounts, detects local Podman/image state, and tears containers down. |
| Session wiring | internal/tui/run.go, internal/oneshot/oneshot.go | Creates the session sandbox only when the experimental flag is enabled and [sandbox].backend = "podman"; never falls back to host execution on startup 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 | Refuses mid-session worktree swaps while a sandbox is active because the running container cannot be remounted. |
| 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 both gates: experimental
sandboxis enabled and[sandbox].backend = "podman". sandbox.NewPodmanSandboxvalidates the mount root, removes any leftover container with the deterministic session name, startspodman run -d ... sleep infinity, and returns anagent.Sandboximplementation.RegisterCoreCwdToolsinjects that sandbox intoRunBashTool. A nil value keeps the previous host behavior.- 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 callsClose, which removes the long-lived 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"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 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.
The picker also runs a local, network-free detection pass (podman image exists <image>) and shows warnings if Podman is missing or if the configured base
image has not been pulled yet.
Config
[experimental]
sandbox = true
[sandbox]
backend = "podman" # "none" (default) | "podman"
image = "registry.access.redhat.com/ubi9/ubi:9.8-1785906690"
network = "none" # "none" (default) | "host"
mounts = ["."] # project-relative only; cannot escape root
env_passthrough = [] # opt-in credential injection, e.g. ["GITHUB_TOKEN"]
memory = "2g"
cpus = 2
pids_limit = 256When backend = "podman", image, memory, positive cpus, and positive
pids_limit are required. This prevents Podman from receiving empty or zero
resource-limit flags.
What’s isolated, and what isn’t
- One container per session, not per command β
podman run -d ... sleep infinityon session start,podman execfor everyrun_bashcall, andpodman rm -fon session end. A fresh container per command would forget installed packages and background state between commands. - 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. - Network:
--network=noneby default. There is no allowlist mode yet; it is all-or-nothing vianetwork = "host". - Credentials: nothing is injected by default.
env_passthroughforwards named variables with bare-e NAME, so values do not appear in Podman’s argv. - 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. run_bash,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 this same seam β the first tools besidesrun_bashto use it.
Dispatch interaction
When dispatch write-workers run, each gets its own container mounted at its own worktree whenever the parent session has podman sandboxing on. Read-only dispatch workers do not get separate containers; they share the parent tool registry and parent sandbox.
This means concurrent read-only workers’ run_bash calls and the parent
session’s own run_bash calls all execute inside one shared container. Size
[sandbox] resource limits with that in mind if read-only workers run tests or
linters concurrently.
Worktree interaction
enter_worktree is blocked while a command sandbox is active. The session
container was created with the original cwd mounted; after a mid-session cwd
swap, podman exec -w <new-worktree> would point at a path the container cannot
see. Start yottacode directly inside the worktree (yottacode --worktree <name>)
or restart without sandbox before entering a worktree.
Known limitations
- No network allowlist β
network = "none"ornetwork = "host"only. - No credential-stripping egress proxy.
- No published
yottacode/sandboxbase image yet β bring your own via[sandbox].image. - Not tested on macOS Podman machine latency; Linux rootless Podman is the supported path today.