Skip to content
yottacode v0.5.0 is out! πŸŽ‰ See the release notes β†—
Command sandbox

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. Requires podman installed and on PATH; if Podman or the required image cannot start, sandboxed tools fail closed and never fall back to host execution. The old sandbox experimental 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

AreaFilesResponsibility
Sandbox seaminternal/agent/sandbox.go, internal/agent/exec_tool.goDefines Sandbox and optional ProfiledSandbox, keeps nil as host execution, labels sandboxed commands, and annotates Podman infrastructure exit code 125.
Podman lifecycleinternal/sandbox/podman.go, internal/sandbox/detect.goStarts one rootless container for a requested manager profile, builds podman exec, validates mounts, detects local Podman/image state, and tears containers down.
Session wiringinternal/agentruntime/runtime.go, internal/agentruntime/sandbox_manager.goCreates a lazy per-profile sandbox manager when [sandbox].backend = "podman"; never falls back to host execution on profile creation failure.
Dispatch inheritanceinternal/agent/dispatch_tool.goGives each write worker a worker-scoped sandbox mounted at that worker’s worktree; read-only workers reuse the parent registry.
Worktree guardinternal/agent/enter_worktree_tool.goAllows mid-session swaps only when the live sandbox can see the target worktree path; yottacode-managed worktrees are pre-mounted.
TUI controlinternal/tui/sandbox_picker.go, internal/tui/cmd_sandbox.goPersists 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/docsinternal/config/config.go, docs/sandbox.mdOwns defaults, validation, and user-facing contract.

Startup and command flow

  1. Config loads onto defaults, then config.Validate rejects invalid sandbox backends, networks, missing Podman resource limits, and zero CPU/PID limits when backend = "podman".
  2. The TUI or oneshot entry point checks [sandbox].backend = "podman".
  3. RegisterCoreCwdTools injects a stable sandbox handler into command-capable tools. A nil value keeps the previous host behavior.
  4. Each tool chooses its profile from intent: run_bash uses default, while create_document docx/pdf and read_document PDF subprocess paths use documents. Native document paths do not shell out.
  5. On first use of a profile, SandboxManager calls sandbox.NewPodmanSandbox, using the latest config.toml for profiles that are not live yet. That lets a user fix [sandbox].documents_image and 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, starts podman run -d ... sleep infinity, and returns an agent.Sandbox implementation.
  6. Each run_bash call still checks the hardline blocklist before the sandbox sees the command. Allowed commands run through Sandbox.Command with the current cwd.
  7. On cancellation, PodmanSandbox.Command best-effort kills the marked process inside the container before killing the local podman exec client. 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", default dns resolvers), and turns on this session’s live auto mode for edits. run_bash, git_commit, git_checkpoint, and rollback still 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 it

When 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 infinity on first profile use, podman exec for every sandboxed command, and podman rm -f on 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 stale yc-* 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 mounts entries 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>/) so enter_worktree can 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 the run_tests note below.
  • Network: --network=host is 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 with env_passthrough = ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY"]. For a hard deny-all independent of any external trust, choose network = "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 --dns resolvers (1.1.1.1, 8.8.8.8) so rootless Podman does not inherit a host loopback stub such as [::1]:53 that is unreachable inside the container. Override dns with your VPN/corporate resolver IPs if public DNS is not appropriate. Prefer runtime DNS config over baking /etc/resolv.conf into a custom image, because Podman regenerates resolver state for each run.
  • Credentials: nothing is injected by default. env_passthrough names 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/.env or <repo>/.yottacode/.env (the same file used for LLM provider API keys β€” see docs/providers.md) by the time a session starts. So to give gh, git, npm, or another sandboxed CLI a credential, add e.g. GITHUB_TOKEN=ghp_... to ~/.yottacode/.env and list GITHUB_TOKEN in env_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, in podman 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, a noexec,nosuid,nodev /tmp, SELinux :Z bind labels, and configured pids_limit/memory/cpus caps. disk additionally 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 the overlay storage 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 to pids_limit/memory/cpus themselves: 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); check podman inspect on the running container if you need to confirm which limits actually applied on your host. Sandboxed run_tests keeps /tmp noexec and points TMPDIR, GOTMPDIR, HOME, XDG_CACHE_HOME, and XDG_CONFIG_HOME at a per-workspace, container-internal scratch directory (/var/tmp/yottacode-go/<workspace>/), avoiding repo-root cache pollution from .cache/, .config/, .local/, .yottacode/tmp/, .scratch/, or go/. Unsandboxed host run_tests applies the same repo-clean environment for Go commands under ~/.yottacode/host-go/<workspace>/. Both paths also set GOTELEMETRY=off, keeping Go telemetry counters out of the checked-out tree even when the host environment would normally resolve them under the repository.
  • GOCACHE and GOMODCACHE for 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 full go mod download plus 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 outside disk’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 with go clean -cache -modcache using those two env vars, or delete the directory outright.
  • run_bash, run_tests, create_document’s docx/pdf paths, and read_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. See document-generation.md for how the document tools route their pandoc/pdftotext calls through the documents profile while run_bash and run_tests stay 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" or network = "none" only, by design (see the Network bullet above); optional dns entries override resolver IPs but do not restrict egress.
  • No credential-stripping egress proxy.
  • No disk-quota enforcement on most Linux hosts β€” disk only takes effect on the overlay storage 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:latest image includes Go, git, make, gcc/glibc headers for cgo, and common archive/diff tools. It is enough for yottacode’s own go test/go vet flow, but project-specific stacks can still set [sandbox].image to 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 plain cmd &, 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=ALL process inside the container β€” not reliably available today (the same class of host-dependent limitation pids_limit/memory/cpus already 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.