Sandbox
September 4, 2026 · View on GitHub
src-tauri/src/sandbox.rs + TaskSandboxDialog. Per-task macOS sandbox-exec (Seatbelt) + per-task in-process HTTPS CONNECT proxy (src-tauri/src/proxy.rs).
Scope
ONLY the agent CLI's PTY is sandboxed. AuxTerminal, setup script, run script, and archive script run unsandboxed by design — they're user-authored shell needing full reach. The carve-out is enforced by not passing task_id in pty_spawn / routing scripts through run_script which never calls sandbox::provision.
Modes (SandboxMode)
Four states, set per-task at create + editable later. Enforce is the full cage and is intentionally never weakened.
- Off — no cage.
- Monitor — allow everything, LOG every file op + network request.
- Enforce — full cage: seatbelt FS allow-list and network pinned to the loopback proxy.
- EnforceFs (serialized
"enforce-fs", UI "ENFORCING (FS)") — the filesystem cage only. Identical FS allow-list toEnforce, but the network sandbox is OFF:render_profileemits(allow network*)andprovisionstarts no proxy (sowrap_commandinjects nohttp_proxy). For users who want write/read isolation but unrestricted egress (their own egress controls, VPN, non-HTTP traffic). UI consequence: every network surface is hidden in this mode (host allow-list field in both dialogs, "Blocked hosts" section + "+ domains" copy in the footer activity popover) — only FS rows show. YOLO auto-on (the FS seatbelt is still the real boundary), accent-colored shield.
Layered model
sandbox-exec -f <profile.sb>— kernel seatbelt. Profile rendered to$TMPDIR/termic-sandbox-<wsId>.sb. Allows broadfile-read*, narrowfile-write*on task + agent dirs + caches. Secrets (~/.ssh,~/.aws,~/.gnupg,~/.netrc,~/.docker/config.json,~/.kube,~/.config/gh/hosts.yml, Keychains) ALWAYS denied.(deny network*)except loopback to the proxy — UNLESSEnforceFs, which emits(allow network*)instead.- Per-task in-process CONNECT proxy on an OS-assigned port (Rust thread inside Tauri binary). Regex hostname allowlist per CLI: claude→anthropic, gemini→google, codex→openai, muse→meta (
api.meta.aifor both the Model API and the release channel,auth.meta.com+accountscenter.meta.comfor the device-code login,lookaside.facebook.comfor the launcher's binary download, so a blocked self-update reads as a stale version) + baseline (github, npmjs, pypi, crates.io, CA OCSP) + task extras. Non-matching → HTTP 403. Stopped viaSandboxBundle::Dropon PTY teardown. Not started inEnforceFs(no network sandbox).
Key behaviors
- Pinning:
Task.sandbox_enabledcaptured at create time. Edit later viatask_set_sandbox, which persists AND SIGKILLs every live PTY (otherwise the running process holds the old profile).TaskSandboxDialogwarns before save. - YOLO interaction: when
ws.sandbox_enabled, spawn args always includeyolo_argsregardless of global YOLO toggle — the seatbelt is the real boundary. ToolbarZap: OFF→gray, ON+sandboxed→green, ON+unsandboxed→red+pulsing+warning tooltip. Code inUnifiedBar.tsx. - Muse Code brings its own seatbelt, and the two never nest. Muse wraps its shell tool with
/usr/bin/sandbox-execitself (confirmed in the 1.0.2 binary:macos_seatbelt,command wrapped with fixed macOS sandbox-exec path) and turns it on by default, alongside its own tool-approval prompt. That would be a sandbox inside a sandbox except for the YOLO rule above:isTaskCaged(task) || task.yolomeans a caged muse task ALWAYS gets--yolo, whose own description is "disable approval and sandboxing and trust this workspace for this run". So a caged task runs muse's cage off and termic's on, and an uncaged task runs muse's own, unnested. Neither combination stacks two profiles. If a future muse build stops accepting--yolo, this is the assumption that breaks, and the symptom would be muse's shell tool failing rather than anything termic reports. - Default sets baked into Rust (
builtin_rw_paths/builtin_deny_paths, per-CLIrender_filterinsandbox.rs). Projectsandbox_*fields are extras only, seeded at task creation. - Recent denies:
task_recent_denials(id, minutes?)shells tolog showfiltered to task path + "deny". Surfaced in sandbox dialog under lazy<details>.
Docker sandbox (alternate mode, experimental)
src-tauri/src/docker.rs + Settings → Docker Sandbox (DockerSection.tsx) + SandboxPicker.tsx (NewTaskDialog.tsx, TaskSandboxDialog.tsx, and Settings → Sandbox, see "Unified picker" below). A mutually-exclusive alternative to Seatbelt: the agent CLI runs inside docker run instead, and can only touch what termic bind-mounts. See docs/plans/docker-sandbox/ for the full design + research.
-
Unified picker:
SandboxPicker.tsxrenders FIVE peer cards - Off, Seatbelt's Enforcing (FS) / Monitoring / Enforcing, and Docker Container (its own full-width row, since it's a different cage MECHANISM, not another intensity level of Seatbelt's) - shared byNewTaskDialog(task creation),TaskSandboxDialog(editing an existing task), and Settings → Sandbox (the app-wide default, see below). This replaced an earlier two-tier design (an engine row, then a conditional Seatbelt submode grid underneath) once real usage showed Docker being reachable only from a second-level control read as an afterthought rather than a peer choice.SandboxSelection(lib/types.ts:SandboxMode | "docker") is the flat type;selectionFor(mode, dockerEnabled)/selectionToFields(selection)convert to/from the SAME two independent backend fields (sandbox_mode,docker_sandbox_enabled) - still no new data model, just one picker instead of two, andDockernever gets amonitorsubmode of its own (there's nothing at the container level equivalent to Seatbelt's proxy/FS-op watcher to log). InTaskSandboxDialogthe two engines keep their pre-existing, independent commit paths: Docker still commits IMMEDIATELY through its own confirm (toggleDocker/task_set_docker, always SIGKILLs), while Seatbelt stays a draft the Save button commits (taskSetSandbox/task_set_sandbox, restart-or-not choice) -choose()just decides which of the two a click should drive, rather than merging them into one commit; a Seatbelt card click there IS the final mode now (no separate submode step). Picking "Docker Container" always shows the<u>network is unrestricted for now</u>note (DockerEngineNote, same copy as Settings → Docker Sandbox) and, in the edit dialog, the "Preview command" toggle. Every confirm that CROSSES the Docker boundary (toggleDockerin either direction,leaveDockerFor) also carriesSESSION_LOSS_NOTE: an agent keeps its history inside the cage it ran in (docker-agents/<agent>in Docker mode, the real~/.claudeoutside it), so the tab's stored session id names a conversation only one of the two stores has heard of, the post-switch--resumefinds nothing, andTerminalPane'sfailedResumepath relaunches fresh. Nothing is deleted and nothing carries over, and the confirm is the last moment anyone can be told. A Seatbelt-to-Seatbelt mode change does NOT carry it, because that keeps the same store. -
App-wide default: Settings → Sandbox's "Sandbox new tasks by default" is the SAME
SandboxPicker, backed byusePrefs().globalDefaultSandboxKind(aSandboxSelection, localStorage-only - never Rust-persisted). Used byNewTaskDialogas the last fallback when seeding a new task's selection: last-used habit (newTaskLastSandboxMode, now also tracks"docker") → the project's owndefault_sandbox_mode/default_sandbox(still Seatbelt-only - a project can't default new tasks to Docker yet) → this app-wide pick. Migrated automatically from the old booleanglobalDefaultSandboxpref the first time it's read (true→"enforce",false→"off") - the new key wins once it exists, so the migration runs at most once per browser profile. -
Docker's own status icon:
DockerSandboxIcon(SandboxIcon.tsx, next toSANDBOX_VISUALS/SandboxIcon) is aContainerglyph coloredDOCKER_SANDBOX_COLOR(var(--color-ok)) - the same green Seatbelt'senforce/enforce-fsuse, since Docker mode IS a real filesystem cage too, just via a different mechanism. (An earlier version used the warning red--color-erron the mistaken assumption it was already established for Seatbelt; that red is YOLO's "on but the cage isn't actually enforced" warning color and is unrelated to any sandbox mode's own identity.) Because Docker mode always storessandbox_modeasoff(mutually exclusive with Seatbelt), every surface that used to key offeffectiveSandboxMode(task) !== "off"to decide whether to show a sandbox badge had to add an explicittask.docker_sandbox_enabledcheck FIRST - otherwise a Docker-sandboxed task showed no badge at all, reading as unsandboxed. Wired into the same four placesSandboxIconalready appeared: the sidebar row badge + its task-menu item (Sidebar.tsx), the terminal footer chip (TerminalPane.tsx'sFooterBar), the top toolbar badge (UnifiedBar.tsx), and the command palette's "Sandbox settings" suffix (CommandPalette.tsx, text-only there - no icon to swap). -
Sidebar row badge greys out when idle:
SandboxIcon/DockerSandboxIconboth take anactiveprop (defaulttrue, every other caller unaffected) -falseforces the icon to the same faint gray as OFF regardless of mode, instead of its real color. Only the sidebar row's idle badge (Sidebar.tsx,active={terminalTabs.length > 0}) passesfalse; it used to show the mode's real color at reduced opacity whether or not the task had ever been launched, which read as "this task is actively caged" even for one sitting untouched. Fill (filled shield forenforce, outline forenforce-fs/monitor) still encodes the mode either way, so the two enforce modes stay distinguishable even gray. The YOLO-dangerZapbadge in the same row is deliberately NOT included - it's a warning about a dangerous config, not a live status, so it stays red (just dimmer) even when idle. -
YOLO auto-on covers Docker too:
isTaskCaged(task)(lib/types.ts) isisSandboxEnforced(effectiveSandboxMode(task)) || task.docker_sandbox_enabled- the single check every "is this task actually caged" call site now uses instead of the Seatbelt-onlyisSandboxEnforced(effectiveSandboxMode(task)). Needed because Docker mode always storessandbox_modeasoff, so the Seatbelt-only check alone can never see a Docker-sandboxed task as caged; before this fix,--dangerously-skip-permissionsauto-on (TerminalPane.tsx's spawn args and its live-toggle effect) and the sidebar's YOLO menu item (Sidebar.tsx) silently treated a Docker-caged task as uncaged. The drag-drop TMPDIR-staging workaround (TerminalPane.tsx,sandboxed: () => isSandboxEnforced(...)) deliberately still uses the Seatbelt-only check - it has no Docker equivalent. -
Gating: two switches AND together.
Settings.docker_sandbox_enabledis the global master switch (Settings → Docker Sandbox);Task.docker_sandbox_enabledis the per-task pin, settable at CREATE time (CreateTaskArgs/CreateMultiArgs/task_open_repo/task_import_worktreeall take it now) or later viatask_set_docker(toggled fromTaskSandboxDialog, mirrorstask_set_sandbox's SIGKILL-live-PTYs behavior, and rejects anydocker_extra_argsentry that could widen the cage:--privileged,--cap-add,--network/--net,--pid,-v/--volume/--mount,--entrypoint,--user, etc viadocker::validate_extra_args). Mutually exclusive with Seatbelt at the DATA level, not just at spawn time: every creation path forcessandbox_mode/sandbox_enabledto off whendocker_sandbox_enabledis true, so a task's stored fields never claim both cages are pinned on at once (pty_spawnalso checks Docker FIRST as a second line of defense — when both happen to be on and an image is built, it skips the Seatbelt path entirely). If a task hasdocker_sandbox_enabledbut the global switch is off,pty_spawnrefuses the launch outright rather than silently falling through to an unsandboxed spawn, fail-closed. -
Image: one generic image for every agent, built from an editable Dockerfile (
docker_get_dockerfile/docker_set_dockerfile, shipssrc-tauri/assets/Dockerfile.default). Content-addressed tag (termic-sandbox:{hash}) so an edit is detected as stale; build is a background action (docker_build_image, streamsdocker-build://log/docker-build://done) that never runs synchronously on the Rust spawn path (a multi-GB build would freeze the webview on that thread). -
Rebuild nudge:
Settings.docker_rebuild_frequency(off/daily/weekly, defaultdaily). Agents in the image are unpinned/always-latest (Dockerfile.default's own header comment), so a cached rebuild is a no-op for freshness - a rebuild always runsdockerBuildImage(true)(--no-cache --pull, same as the manual "Update agents" button), never a cached build.maybeRebuildDockerImageForLaunch(src/lib/dockerDailyRebuild.ts) runs from the FRONTEND right before a Docker-mode task's agent spawns (TerminalPane.tsx, awaited beforeptySpawn), and evaluatesisRebuildDue(frequency, lastBuiltDate)againstdocker::DockerImageStatus.last_built_date(docker.rsrecords the local calendar date alongside the built tag; day-boundary math lives entirely in TS, unit-tested independent of IPC). Rather than silently rebuilding, it PROMPTS (DockerRebuildPromptDialog, resolved viauseUI().askDockerRebuild) with "Rebuild now" / "Skip for now" and an inline frequency picker (DockerRebuildFrequencyPicker, shared with Settings → Docker Sandbox) so changing the cadence doesn't need a trip to Settings. "Skip for now" launches immediately on the existing image for someone in a hurry - it isn't sticky, so the next due launch prompts again. "Rebuild now" pushes an info toast, awaits the build, then a success/error toast; a failed rebuild toasts an error and still falls through to spawning with whatever image already exists rather than blocking the launch outright. Concurrent Docker-mode launches single-flight into one prompt/rebuild rather than opening two dialogs or racing twodocker buildcalls for the same tag. Rebuilding the image is the ONLY thing that updates an agent CLI: every container runs with--rm(render_argv), so if an agent's own updater writes a new binary mid-session, that write lands in the container's throwaway layer, not the mounted per-agent config dir - the container is destroyed the moment its terminal closes and the next launch starts fresh from the image, silently reverting to whatever version was baked in at the last rebuild. -
Mounts: the worktree, its parent
.git(worktree pointer resolution), composition members, and a persistent per-agent config dir under<data_dir>/docker-agents/<agent_id>(login + sessions + MCP config, shared across every Docker task of that agent, never the host's real~/.claudeetc). NEVER mounts the whole container HOME (would shadow agent binaries baked in at build time). -
Hardening: every container runs with
--cap-drop ALL,--security-opt no-new-privileges:true, and--pids-limit 512(rendered unconditionally inrender_argv, before any task-supplied extra args). -
Runs as the host user, not root:
render_argvadds--user {host uid}:{host gid}(docker::host_uid_gid, unix-only -libc::getuid/getgidon the HOST process, since it's the same user that already owns the worktree/agent-config-dir mounts, so ownership matches exactly with no chown needed; falls back to0:0on a non-unix build, where Docker mode isn't exercised yet). This exists because Claude Code refuses--dangerously-skip-permissionswhen the process is root - Docker-mode YOLO auto-on (seeisTaskCagedabove) was silently unusable for claude until this landed.-u/--useris inUNSAFE_EXTRA_ARG_PREFIXES, so a task can never override it viadocker_extra_args. That host uid has no matching/etc/passwdentry inside the container, soHOME=/rootandUSER=agentare injected explicitly inbuild_spec's env (every agent's config still lives under/root, unchanged - nothing moved to a uid-specific home) andDockerfile.defaultrunschmod -R a+rwX /rootat build time so that uid can read/write everything baked in there. -
Known gap: network is unrestricted. Seatbelt tasks get a per-task CONNECT-proxy host allowlist (
sandbox_allowed_hosts); Docker mode never reads that list andrender_argvemits no--networkflag, so every Docker-mode container gets the default bridge network with unrestricted outbound access. A compromised agent in Docker mode can reach any host; the equivalent Seatbelt-enforced task cannot. The filesystem cage is real, the network cage is not (yet). -
Activity monitor: the host pid tree
procmon.rswalks cannot see into a container - the pid it finds is thedocker runclient, which sits nearly idle regardless of how busy the agent is, because the real work happens inside the daemon's VM.PtySlot.docker_container(the--namefromDockerSpec) letsprocmon_rootsmark which rows are Docker-sandboxed;docker::merge_stats(indocker.rs, run insideprocmon_start/procmon_sample'sspawn_blocking, never on the IPC thread) then overwrites those rows with a single batcheddocker stats --no-streamquery covering every live Docker task, and keeps its own cpu_pct history per row since the host-based numbers the platform sampler already baked in are the wrong ones.ProcRow.is_dockerbadges the row in the UI and explains why itschildrenbreakdown is always empty (the container's real process tree isn't visible from the host either). -
Cleanup:
docker::cleanup_taskruns on task archive and on everytask_set_dockercall (turning Docker off OR staying on but editingextra_args/extra_mounts) - the latter closes a gap wherekill_task_ptysonly kills the localdocker runclient (attached foreground, no-d), never the container server-side, so the OLD container used to keep running until whatever tab this was happened to respawn on its own (which triggerspty_spawn's own pre-spawncleanup_task, belt-and-suspenders for exactly this).docker::cleanup_allruns on app quit AND on app startup (same reasoning: a crash or force-quit doesn't stop an attached container either, so a previous session's abandoned containers are reaped as soon as the next launch's.setup()runs, before anything in this session could plausibly own one). -
Agent support:
agent_config()indocker.rsmaps agent id → container config-dir wiring, deriving its mount paths fromagent_dirs::state_dirs()(src-tauri/src/agent_dirs.rs) rather than its own hardcoded table - that module is the single source for "where does this agent's state live", shared with Seatbelt's defaultsandbox_allowed_paths(default_agents()inlib.rs) so the two don't hand-maintain separate copies that can drift. Docker's set is the CONFIRMED-state subset of what Seatbelt allows: Seatbelt additionally allows macOS-only extras (Library/Application Support/*, defensive XDG paths, claude's regex-covered sidecar files) that have no Docker-container equivalent and stay hand-authored indefault_agents(). grok is deferred in Docker regardless of whatagent_dirslists for it (binary + skills + config all live under~/.grokwith no clean relocation env, seedocs/plans/docker-sandbox/findings.md) — a grok login done inside a Docker task is lost on the next container run, since no persistent config dir is mounted for it. -
gh / glab inside the container:
Dockerfile.defaultinstalls both CLIs (gh from its official apt repo; glab from the latest release.deb, resolved through the GitLab API and deliberately NON-FATAL so a JSON-shape change cannot fail the build and with it every Docker task), and bakescredential.https://github.com.helper = !gh auth git-credential(plus the gist and gitlab.com equivalents) into the image's/root/.gitconfigsogit pushover HTTPS works the momentgh auth logindoes.gh auth setup-gitwould write that same config into the container's throwaway layer and have to be re-run in every single container. A Dockerfile saved before this shipped has neither CLI, so Settings → Docker Sandbox flags it (keyed on thecli.github.comapt line) and points at "Reset to default". How "customised" is decided changed, because the obvious way is wrong. It used to besaved == DEFAULT_DOCKERFILE, which answers a different question: the moment termic ships a new default (adding an agent to it), every untouched Dockerfile reports as the user's own work and the only remedy offered is "Reset to default" - the one button someone who genuinely customised theirs must not press. Provenance is stored instead, inDockerfile.originbeside the file: the generation and the shipped default it was written FROM. A saved Dockerfile still equal to its recorded origin was never edited, whatever today's default says, soread_dockerfileupgrades it in place and a customised one is left alone.DOCKERFILE_GENERATIONis the blunt instrument for a release where an old file cannot be left behind at all (a broken base image, a security fix): bumping it sweeps EVERY saved Dockerfile back to the shipped default, edits included. Generation 1 is the introduction of the mechanism, since a profile predating it has no way to tell an edit from an older termic's write. -
Your git identity: the container has no
~/.gitconfigof yours, so before this a commit made inside one failed with "Author identity unknown. *** Please tell me who you are." and had to be answered withgit configagain, in every container, forever.build_specstep 4d reads the identity the host would use for that worktree (git -C <task path> config --get user.name/user.email, repo-scoped so git resolves the whole chain, including an[includeIf "gitdir:~/work/"]block that a read of~/.gitconfigalone would miss), writes the two keys to<data>/docker/gitconfig/<task id>(one file per task, because two tasks can legitimately resolve different identities; temp + rename, so a container starting for one tab cannot read the file another tab's spawn is writing), and bind-mounts it READ-ONLY at git's XDG global config path. Nothing is mounted when the host has no identity.Two placements were rejected, and both are worth knowing because each is the obvious thing to try. Mounting the host's
~/.gitconfigat/root/.gitconfigshadows the image's own, taking outsafe.directory = *(without which git refuses every command in the bind-mounted worktree as "dubious ownership") and the gh/glab credential helpers in one go, and it dragscommit.gpgsignandcredential.helper = osxkeychaininto a container that has neither. InjectingGIT_AUTHOR_*/GIT_COMMITTER_*env is less code and silently WRONG: env outranks repo-local config, so it rewrites the identity of every repo that deliberately sets its own. The XDG file is read at the GLOBAL level, which is below the repo's.git/config(mounted, step 2) and below the image's~/.gitconfigfor any key that file sets, and it sets nouser.*. Verified in the real image: with a repo-localuser.email, the commit is attributed to the repo's, and without one, to the host's. One diagnostic trap, confirmed against the shipped image (git 2.39.5):git config --list --globaldoes NOT list this file, only~/.gitconfig, despite--globalbeing documented as reading both for read operations. Someone checking that way sees the image's four baked entries and concludes the identity never arrived.git config --list --show-origin | grep user\.andgit config --show-origin --get user.emailboth show it, and name the file the value came from. Two visibility rules go with it, because a mount nobody can see is a mount nobody can audit. The read falls back to the HOME dir when the task path is not a directory, which is what makes the line appear in the SETTINGS-level command preview at all: that panel builds fromsample_preview_task(), whose path is a deliberate placeholder, and it promises the reader that everything but the worktree path is what a real launch uses. And when no identity resolves from either place,spec.warningsgets the "No git identity found on this Mac" line, so the one case this feature cannot rescue says so in the preview instead of surfacing later as the agent's first commit failing. The XDG root follows a per-agentXDG_CONFIG_HOMEwhen an agent sets one (git looks wherever that points, so a file at the default path would never be read), and the resulting target still goes throughpersist_target_allowed, so anXDG_CONFIG_HOMEof/etcmounts nothing. -
Shared config dirs:
Settings.docker_shared_config_dirs(Settings → Docker Sandbox → "Persisted directories & environment" → the All agents row at the top of the same list, seeded bydocker::default_shared_config_dirswith.config/gh+.config/glab-cli). It lives IN that list rather than in a section of its own: "shared config dirs" and "persisted directories" were two names for one mechanism differing only in axis, and a reader had to work that out. Same chip UI (DirChips, shared with the per-agent rows so the two cannot drift into looking like different features), same patch-on-change save, no environment column because env is per agent by definition.The list itself is container dirs mounted into EVERY container, for every agent, from one host dir each under
docker::forge_config_host_dir()(<data>/docker-forge/, host layout mirroring the container path).build_specstep 4b runs each entry through the samesanitize_extra_dirthe per-agent list uses, and sets a config-dir env var when it recognises the CLI (GH_CONFIG_DIR/GLAB_CONFIG_DIR, keyed on the entry's BASENAME so moving gh's dir still points the var at wherever it landed). This is the opposite axis to the per-agent rows beneath it: what goes here belongs to the USER, not to an agent vendor. A GitHub token is the same token whichever agent pushes with it, and a per-agent copy would mean logging in once per agent AND once per clone of one. The dirs start EMPTY, so nothing crosses from the Mac until someone runsgh auth logininside a container; both CLIs fall back to a plaintext token file when no keyring is reachable, which is the case in the image, so that login is what survives--rm. A user who lists.config/ghin an agent's own extra dirs keeps the per-agent mount instead: the explicit choice outranks the shared default, and the env var still names the same container path.Not a mount of
/root/.config. Nesting would work (Docker orders mounts by destination depth, so a per-agent.config/opencodestill applies underneath a blanket mount, verified), but an empty dir over the whole tree SHADOWS what the image put there at build time (/root/.config/fish/completions/grok.fishtoday, whatever an unpinned installer drops there next) — the exact failureagent_dirs.rsdocuments for grok — and it would pool every credential any agent ever created into one directory every other agent reads. One named dir at a time, each a deliberate choice.The host's real
~/.config/ghis never mounted, for three independent reasons: on macOS gh keeps the token in the Keychain (hosts.ymlholds nooauth_tokenat all), so the mount would hand the container an unauthenticated gh; a container-sidegh auth logoutwould take out the loginforge.rsruns termic's own PR panel on; and Seatbelt hard-denies that same file, so mounting it here would make the container the looser of the two cages. -
Dropped and pasted files:
build_specstep 4c mountscrate::attachments_dir()($TMPDIR/termic-attachments) READ-ONLY at the IDENTICAL absolute path, same convention as the worktree. Two gestures share it. A file DROPPED on a terminal lives somewhere no cage can read (Seatbelt hard-denies ~/Desktop and friends; a container simply has no such path), soterminal_stage_filecopies it in and the staged path is what gets typed -TerminalPanenow gates that onisTaskCaged, notisSandboxEnforced, because a Docker task used to get the raw path inserted and every read of it failed. An image PASTED into a terminal cannot reach the agent as text at all: xterm.js sends only bytes it was given down the PTY, and the agent inside the container is a Linux process whose own clipboard reader shells out to xclip / wl-paste with no route to the Mac's pasteboard, so ⌘V with a screenshot silently did nothing. Soclipboard_image_savewrites the bytes there andTerminalPane's capture-phasepastelistener sends that path instead, escaped by the sameshellEscapePatha drop uses. See ipc.md.DOCKER ONLY, and it is a PASTE rather than typed bytes. Both halves were measured, not assumed. Outside a container the agent reads the Mac clipboard ITSELF (
osascript -e 'the clipboard as «class PNGf»') and gets the real image unaided, so intercepting there would hand it a file path where it used to get an image -TerminalPane's listener reads the LIVE task and returns immediately unlessdocker_sandbox_enabled. And the path goes throughterm.paste(), notptyWrite: driven against claude's own TUI, a TYPED path is echoed as literal text, while the identical string delivered as a paste (xterm wraps it in\x1b[200~/\x1b[201~when the agent has bracketed paste on) makes claude read the file and render[Image #1], the same attachment a native paste produces. So the agent inside the cage ends up holding the actual image, not a path to talk about. ctrl+V is handled too, and separately. It is not a paste event at all - on macOS it is byte0x16down the PTY, and it is the gesture claude binds its own image-attach action to, which inside a container always answers "no image in clipboard" (a Linux process asking xclip about a pasteboard that is not there). For a Docker taskTerminalPaneswallows it, reads the Mac pasteboard natively (clipboard_image_capture, see ipc.md) and pastes the saved path; when the clipboard holds no image it forwards the original0x16so the agent answers for itself, and outside Docker the branch never runs. The dropped-file path still writes raw bytes, so a dropped IMAGE lands as a path either way; giving drops the same treatment would change behaviour for uncaged terminals too, which is a separate decision.The location is the load-bearing part, and it is NOT the app data dir: the Seatbelt profile ends with a final, last-match-wins
(deny file-read* (subpath <data_dir>))protecting the CLI token, so a pasted image there would be unreadable in the mode most tasks run under./private/var/foldersis already onbuiltin_runtime_paths. It is canonicalized for the same reason it is mounted at the same path both sides:/varis a symlink to/private/var, and the Seatbelt rule, the Docker mount and the text typed at the prompt all have to name one string. -
User-added extra dirs:
Settings.docker_agent_extra_dirs(Settings → Docker Sandbox → "Per-agent config dirs", collapsed by default) lets a user append extra dirs to mount alongsideagent_dirs::state_dirs()'s built-in list - e.g. a custom skills dir or an MCP server's own state dir.docker::sanitize_extra_dirrejects anything absolute or containing..before it can become a mount target (a raw/root/{entry}concatenation would otherwise let a stray../../etcresolve outside/root). Read + write both go through the normaldocker_agent_dirs(read: builtin + extra +is_builtin/persist_offerable/persist_enabled, one row per REGISTERED agent, not just the known-safe ones) /settings_save(write: patches the map directly, no separate command) path - the built-in list itself is never editable. -
A capture-resume agent's session id in Docker:
post_launch_captureruns on the HOST (run_capture_command), and for a Docker task that is the wrong machine. The agent wrote its sessions inside the container, into/root/.local/share/<agent>, which is bind-mounted fromdocker-agents/<agent>/local/share/<agent>; left alone the command reads the user's own~/.local/shareand captures an id the container has never seen, so the next spawn resumes into the agent's own "no saved log" error. Reported on a Docker main-checkout muse task.run_capture_commandtherefore takes the agent id + a docker flag and pointsXDG_DATA_HOMEat the mounted parent, so the same command is correct on both sides. Only the DATA root is remapped: an agent whose capture also reads its CONFIG dir is not covered, because the first state dir is mounted AT the agent root rather than under a config parent, so no singleXDG_CONFIG_HOMEresolves correctly and guessing one would point at a directory that exists and is wrong. -
Custom-agent opt-in: for anything outside
docker::KNOWN_SAFE_AGENTS(claude/codex/copilot/agy/opencode/pi/muse) - including every custom agent a user adds -docker_agent_extra_dirsmounts NOTHING unlessSettings.docker_agent_persist_enabledis also true for that agent id. Off by default even for a brand-new agent. This is deliberate:agent_config()has no confirmed state dir for an agent it's never seen, and guessing one risks the exact failureagent_dirs.rsdocuments for grok/agy - an empty dir mounted over a path that ALSO holds a binary baked into the image at build time silently shadows it. grok itself is a PERMANENT exception (docker::persist_offerablereturnsfalsefor it, andagent_configrefuses it outright regardless of the opt-in) - its binary lives inside its own~/.grokconfig dir, so no warning text can make that combination safe. The frontend hides the opt-in toggle entirely for grok rather than offering one that can never do anything. -
Unified allow-list with Seatbelt: Docker's
build_specnow takes the SAME live-rendered list Seatbelt'ssandbox::provisionreads (live_sandbox_listsinlib.rs: global Settings defaults + the task's own pinnedsandbox_rw_paths+ the project's committed.termic.yaml, re-read fresh on every spawn) and mounts every plain entry rw at its own resolved absolute path - same convention as the worktree and composition members. Before this, switching a task from Seatbelt to Docker silently dropped every extra allowed directory; now the two engines agree on "what's allowed" and only differ in enforcement mechanism.regex:-prefixed entries are Seatbelt-only (no literal path to mount) and are skipped; a path already covered by an implicit mount (the worktree itself, a composition member) is deduped rather than mounted twice. -
Per-task extra mounts:
Task.docker_extra_mounts(Vec<String>,host_path:container_pathentries, Docker's own-vshape) is a DEDICATED field, deliberately NOT part ofsandbox_rw_paths/"Allowed paths" - that list is shared with Seatbelt vialive_sandbox_listsand has no concept of a container path, so ahost:containerentry there would be ambiguous the moment a Seatbelt task reuses the same global/project default. Unlike the worktree/git-metadata mounts (host path == container path, a gitcommondir-pointer requirement, not a choice), an extra mount's container path is a free user choice with no same-path constraint. The use case is narrow and intentional: persisting something a fresh container otherwise loses on every restart that the built-in per-agent config dir doesn't cover (a custom MCP server's own data dir, say) - not a general bind-mount escape hatch.docker::sanitize_extra_mountvalidates each entry at spawn time (host:$HOME/$WORKSPACE-expanded viasubst_path, must resolve to a non-empty absolute path; container: absolute, no.., and not underdocker::UNSAFE_MOUNT_TARGET_ROOTS-/rootand every system dir an empty mount could shadow or reach into) and silently drops a malformed entry, same as every other sandbox list parser in this file;docker::validate_extra_mountsruns the same container-path shape checks at SAVE time (task_set_docker) so a malformed entry surfaces as an error to the user instead of silently vanishing. Runs LAST amongbuild_spec's mount steps so it can dedupe against every mount staged above by either host OR container path - a mount whose container path collides with an already-claimed one (the agent config dir, say) is dropped rather than silently shadowing it. Editable post-create viatask_set_docker(id, enabled, extra_args, extra_mounts)(TaskSandboxDialog's "Extra mounts" field, saved + SIGKILLs like the rest of that command), and settable at creation too:CreateTaskArgs/CreateMultiArgs/task_open_repo/task_import_worktreeall take an optionaldocker_extra_mountsoverride (NewTaskDialog's own "Extra mounts" field, shown under the Docker card in every task-creation shape including the main-checkout/import paths) - unset falls back toSettings.docker_default_extra_mountswhen Docker mode is on,Vec::new()otherwise. -
Default extra mounts:
Settings.docker_default_extra_mounts(Settings → Docker Sandbox → "Default extra mounts", samehost_path:container_pathformat andListFieldwidget as a task's own field) is the Settings-level companion - unioned into a NEW Docker-sandboxed task'sdocker_extra_mountsat creation time (NewTaskDialogseeds its field from this, same convention assandbox_default_rw_pathsseeding a project's allow-list), then owned entirely by the task from then on: editing the default later only affects tasks created from now on, and a task can freely edit/remove/add past whatever it was seeded with. Global rather than per-agent, unlike "Per-agent config dirs" above - an extra mount's use case (persisting an MCP server's own data dir, say) isn't tied to which agent is running. Not validated atsettings_save(same lazy-validation precedent asdocker_agent_extra_dirs): a malformed entry is silently dropped bydocker::sanitize_extra_mountwherever it would actually become a mount. -
Command preview:
docker_command_preview(task_id, agent_id?)calls the exactbuild_spec/render_argvthe real spawn path uses (never a separate approximation, so it can't drift) and returns the argv + annotatedDockerSpec.mounts(each with itswhy) to the frontend. Shown behind a "Preview command" toggle inTaskSandboxDialog's Docker section. Works even before the image is built (falls back to a<image not built yet>placeholder tag) so it stays useful as a "what would this actually do" check while still setting Docker mode up. Not byte-exact for the command itself (a real launch also mints/resumes a session id and composes YOLO flags, frontend-side logic this command doesn't have access to) - but the mounts/env/hardening flags, the security-relevant part this exists to show, are identical either way. -
How Settings → Agents & Terminals relates to Docker mode: Docker mode is not a separate set of agents - it's an alternative CONTAINER for the same agents configured there. The command + args to run, any resolved CLI flags (YOLO, etc.), and
Agent.env(the per-agentKEY=VALblock, viaSpawnArgs.env/envForCli) all carry over into the container's argv/-eflags, appended after the baseTERM/COLORTERM/relocation-env so a per-agent value wins on key collision - same precedence as the unsandboxed/Seatbelt path. What does NOT carry over:Agent.sandbox_allowed_pathsis Seatbelt-only and is simply unused in Docker mode (fixed mounts, not an allow-list), and the raw inherited HOST environment (API keys sitting in your shell, say) is deliberately NOT passed through - Docker's isolation model relies on the mounted per-agent config dir for credentials/login instead.
Known gap: the webview is outside the cage
The seatbelt + CONNECT proxy cage the agent process. They do not cage the webview, which makes its own network requests as the app itself. Anything the webview can be made to fetch is egress the proxy allowlist never sees.
There was one such path (#65): img-src in tauri.conf.json allows any
https: origin, so the markdown preview could render remote images.
Previewing

used to fire a GET to an arbitrary host on render, with no click and no
prompt, even when the task is in Enforce and the agent itself cannot reach
that host.
The realistic trigger was never a scheming agent, it's prompt injection
plus untrusted markdown. An agent reads a dependency's README, a GitHub
issue, or a fetched page, and that text tells it to write the image tag. The
same applies to markdown the agent never touched: a contributor's fork, a
submodule, a vendored package. Only a GET was ever possible (no script:
script-src 'self', markdown-it runs with html:false and blocks
javascript:), so the payload was limited to what the markdown's author
could encode in a URL, plus the viewer's IP, user-agent, and timing. GitHub
and VS Code make the same tradeoff for their previews, but not on by default.
Closed in #69: gateRemoteImages() in MarkdownPreview.tsx intercepts every
http(s): <img> src before it ever reaches the DOM's src attribute,
gated on a default-OFF loadRemoteImages pref (Settings → General) or a
per-tab override set from the preview's own "blocked images" banner. The CSP
itself is unchanged, still allows https: in img-src — this is a renderer
gate, not a CSP tweak, per the note below.
Before widening the CSP again, remember it is app-wide. connect-src or
script-src would be materially worse than img-src is.
Known gap: one uncontained file read (file_read_external)
Every other renderer → filesystem read is bounded by a task root
(safe_task_path / safe_task_read_path, which reject absolute paths and
.. outright). file_read_external is the single exception, added for
GH #240: a cmd+clicked absolute path in terminal output that resolves
OUTSIDE the task has no task-relative form, so it cannot go through the
contained read, and the tab it opens is read-only.
What this adds is an arbitrary file read reachable from the webview. It is accepted, bounded three ways:
- Read only. There is deliberately no absolute-path write counterpart.
task_file_writekeeps its containment check, and the tab the read feeds isEditorState.readOnlywith its ⌘S path stubbed out. Nothing can be mutated outside a task through this. - Text only, capped. The same 2 MB ceiling as the task read, plus a UTF-8 requirement, so it is a text channel rather than a way to pull bytes out of arbitrary binaries.
- Nowhere to send it. The pinned CSP (
connect-src, seesrc/lib/cspGuard.test.ts) means an attacker who could invoke it has no egress for the result.
The residual risk is an XSS in our own UI turning into local file
disclosure. That is strictly worse than before this command existed, and is
the reason connect-src must not be widened (see the CSP rule in
CLAUDE.md). The bounds above are pinned by external_read_* tests in
src-tauri/src/lib.rs.
Known gap: Monitor mode reaches the CLI control plane
The CLI control socket (docs/plans/cli.md) is denied to Enforce /
EnforceFs agents as the final SBPL rules (socket + data-dir denies). It
is deliberately NOT denied in Monitor mode, whose contract is
observe-never-block: a monitored agent renders (allow default (with report)), so if the CLI is enabled it can reach the socket and read the
token, and that access simply shows up in the file-op / activity log. This
is the accepted trade-off of Monitoring being a pure observer; the cage
that actually enforces the boundary is Enforce/EnforceFs. (Same spirit
as the webview gap above: a documented, accepted exposure, not a leak.)
Settled: a caged agent gets NO channel to another agent
Recurring proposal, rejected 2026-08-24. The agent-to-agent protocol
(docs/cli-agent-instructions.md) has one side prompt the other when its
work is done, and an Enforce / EnforceFs agent cannot take part: it
cannot reach the socket or read the token. The task menu's "Copy agent
CLI briefing" prints a line saying so on caged tasks. That line is
correct behaviour, not a TODO.
Do not "fix" it by letting caged agents reach the control plane. The
verbs are a straight escape (new --sandbox off --yolo spawns an uncaged
agent; apply writes past the FS allow-list; attach types into an
uncaged agent), so any proposal has to narrow them, and the narrow ones
do not survive either:
- Report-back-only
send. Bounds the verb, not the payload. "Run this for me" is text, and the recipient is uncaged. - Reply-only addressing (may only send to tasks that first sent to it). Bounds the audience, not the payload, and picks the worst audience: the one correspondent it is guaranteed to have is an agent already collaborating with it, so the most likely to comply. This makes the deputy more confused, not less.
A cage with a text channel to something uncaged is not a cage. The
supported way for a caged agent to report is the one the briefing
prints: have it write a file inside its own worktree and read that from
outside. If you need the prompt-back protocol, run the task in Monitor
(which reaches the CLI by contract, see the gap above) or uncaged.
Do NOT
- Sandbox AuxTerminal, setup, run, or archive scripts.
- Expose
task_set_sandboxwithout SIGKILLing live PTYs by default.kill_live=falseis an explicit escape hatch with a warning — don't make it the default. - Widen
tauri.conf.json's CSP without reading "Known gap" above. It applies to the whole webview, not to the component you are working on. - Give caged agents any path to another agent (control plane, scoped token, notify side channel). See "Settled" above for why the narrow versions fail too.