Skip to content
yottacode v0.4.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 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 = true in config, and set [sandbox] backend = "podman". The flag alone does not turn it on. Requires podman installed and on PATH.

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

AreaFilesResponsibility
Sandbox seaminternal/agent/sandbox.go, internal/agent/exec_tool.goDefines Sandbox, keeps nil as host execution, labels sandboxed run_bash, and annotates Podman infrastructure exit code 125.
Podman lifecycleinternal/sandbox/podman.go, internal/sandbox/detect.goStarts one rootless container per session/worker, builds podman exec, validates mounts, detects local Podman/image state, and tears containers down.
Session wiringinternal/tui/run.go, internal/oneshot/oneshot.goCreates the session sandbox only when the experimental flag is enabled and [sandbox].backend = "podman"; never falls back to host execution on startup 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.goRefuses mid-session worktree swaps while a sandbox is active because the running container cannot be remounted.
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 both gates: experimental sandbox is enabled and [sandbox].backend = "podman".
  3. sandbox.NewPodmanSandbox validates the mount root, removes any leftover container with the deterministic session name, starts podman run -d ... sleep infinity, and returns an agent.Sandbox implementation.
  4. RegisterCoreCwdTools injects that sandbox into RunBashTool. A nil value keeps the previous host behavior.
  5. 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.
  6. On cancellation, PodmanSandbox.Command best-effort kills the marked process inside the container before killing the local podman exec client. Session teardown calls Close, 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, 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 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      = 256

When 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 infinity on session start, podman exec for every run_bash call, and podman rm -f on 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 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.
  • Network: --network=none by default. There is no allowlist mode yet; it is all-or-nothing via network = "host".
  • Credentials: nothing is injected by default. env_passthrough forwards 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, a noexec,nosuid,nodev /tmp, SELinux :Z bind labels, and configured pids_limit/memory/cpus caps.
  • run_bash, 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 this same seam β€” the first tools besides run_bash to 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" or network = "host" only.
  • No credential-stripping egress proxy.
  • No published yottacode/sandbox base image yet β€” bring your own via [sandbox].image.
  • Not tested on macOS Podman machine latency; Linux rootless Podman is the supported path today.