Tools¶
How development tools are made available inside the sandbox.
devsandbox makes development tools available inside the sandbox while maintaining security boundaries.
Inspecting Tools¶
Use the tools command to see which tools are available and how they're configured.
List Available Tools¶
# List tools detected on your system
devsandbox tools list
# Include unavailable tools
devsandbox tools list --all
# JSON output for scripting
devsandbox tools list --json
Example output:
┌───────────────┬───────────┬───────────────────────────────────────────────┐
│ NAME │ STATUS │ DESCRIPTION │
├───────────────┼───────────┼───────────────────────────────────────────────┤
│ claude │ available │ Claude Code AI assistant │
│ copilot │ available │ GitHub Copilot integration │
│ git │ available │ Git configuration (safe mode, no credentials) │
│ go │ available │ Go language environment isolation │
│ mise │ available │ Tool version manager (node, python, go, etc.) │
│ nvim │ available │ Neovim editor configuration │
│ oh-my-posh │ missing │ Oh My Posh prompt with sandbox indicator │
│ oh-my-zsh │ missing │ Oh My Zsh framework with sandbox indicator │
│ opencode │ available │ OpenCode AI assistant │
│ pi │ available │ Pi coding agent AI assistant │
│ portal │ available │ XDG Desktop Portal (notifications) │
│ powerlevel10k │ missing │ Powerlevel10k zsh theme │
│ rtk │ available │ rtk CLI proxy (token-optimized output) │
│ starship │ available │ Starship prompt with sandbox indicator │
│ tmux │ missing │ Tmux terminal multiplexer with sandbox indicator │
└───────────────┴───────────┴───────────────────────────────────────────────┘
Note: Tools show as "available" if their binary is found or config exists, "missing" otherwise.
Tool Details¶
View bindings, environment variables, and shell init for a specific tool:
# Show details for a tool
devsandbox tools info mise
# Show details for all tools
devsandbox tools info --all
# Print absolute paths instead of ~ notation
devsandbox tools info mise --resolve
# JSON output
devsandbox tools info mise --json
Example output:
Tool: mise
Status: available
Description: Tool version manager (node, python, go, etc.)
Binary: /home/user/.local/bin/mise
Bindings:
~/.local/bin (read-only, optional)
~/.config/mise (read-only, optional)
~/.local/share/mise (read-only, optional)
Environment Variables: (none)
Shell Init:
if command -v mise &>/dev/null; then eval "$(mise activate bash)"; fi
Verify Tool Setup¶
Check tool availability and verify binding paths exist:
# Check all tools
devsandbox tools check
# Check specific tools
devsandbox tools check mise git claude
# JSON output
devsandbox tools check --json
Example output:
Checking tools...
✓ mise (/home/user/.local/bin/mise)
✓ ~/.local/bin
✓ ~/.config/mise
✓ ~/.local/share/mise
✓ git (/usr/bin/git)
○ ~/.local/share/devsandbox/<project>/home/.gitconfig.safe (optional, missing)
○ ~/.local/share/devsandbox/<project>/home/.gitignore.safe (optional, missing)
○ ~/.local/share/devsandbox/<project>/home/.gitattributes.safe (optional, missing)
• config: ~/.gitconfig
• config: ~/.config/git/ignore
✓ claude (/home/user/.local/bin/claude)
✓ ~/.claude
✓ ~/.claude.json
✗ starship (not available)
! starship binary not found in PATH
Summary: 3/4 tools available
The config: lines are the host files the tool reads its configuration from, resolved the same way a
launch resolves them - so a core.excludesFile set from an [include] is reported at the path it
actually names. They are not bindings; the lines above them are.
Tool Management with mise¶
mise is the recommended (but optional) tool manager. If installed, all mise-managed tools are automatically available inside the sandbox.
How It Works¶
By default, mise directories are bind-mounted read-only:
~/.config/mise → Sandbox (read-only)
~/.local/share/mise → Sandbox (read-only)
~/.local/state/mise → Sandbox (read-only)
This means:
- All installed tool versions are available
- Tool configurations (
.mise.toml) are respected - New tools cannot be installed from inside the sandbox (by default)
On the container backends (docker and krun) the guest boots from a fresh image, but MISE_DATA_DIR points at the persistent sandbox home, so tools you install inside the sandbox (python, go, etc.) are installed once and reused on later runs rather than re-downloaded every launch. The image's pre-baked node is mirrored into that data dir on startup so it resolves immediately without a reinstall.
On Linux hosts the container backends also share the host's installed tools: ~/.local/share/mise/installs is mounted read-only into the guest, and on startup each host-installed version is mirrored into the sandbox data dir as a real version directory of symlinks (and the mise shims regenerated), so your host toolchain resolves inside docker/krun sandboxes without reinstalling or network access. The guest also runs the host's own mise binary when ~/.local/bin/mise is mounted and executes in the guest (falling back to the image's otherwise): mise versions can disagree about a tool's backend and on-disk layout, so binary parity is what keeps host-installed tools resolving exactly as they do on the host. A version you install inside the sandbox always takes precedence over the host copy. Three caveats:
- Host tools that were compiled locally against a newer glibc than the guest image ships may fail to run in the guest (upstream prebuilt tools - node, go, and most others - are unaffected). Fix:
mise uninstall <tool>@<version>inside the sandbox (this only removes the seeded symlink, not the host install) andmise installto get a guest-local build. - On macOS hosts nothing is shared: the guest is Linux, so host (darwin) binaries cannot run in it.
- Only the installs directory itself is shared, so a tool whose runtime lives outside it does not work in-guest. A
pipx:tool backed by a uv-managed Python is the common case: the tool is seeded as a version dir, but its interpreter (under the uv data dir) is not mounted, so it fails to start. Reinstall such a tool inside the sandbox.
A host global config (~/.config/mise/config.toml) with @latest npm:/go:/pipx: tool specs is a hazard on an egress-locked sandbox: even when the tool itself is seeded from the host, mise refreshes the remote version list behind each @latest spec over the network, some backends' lookups (npm registry, python-build) never traverse the proxy and hang to their timeout, and mise re-resolves the toolset per listed row with no negative cache - a single mise ls can turn into hundreds of doomed lookups. devsandbox defends in three layers:
- The krun + proxy guest runs mise offline (
MISE_OFFLINE=1): everything resolves instantly from installed/cached data - with host installs seeded, that is your full host toolchain. krun runs no online boot-time install of your project's tools: it is always ephemeral (keep_containerdoes not apply to it), and the onlinemise installpass only runs on the persistent-containerdockerpaths. Under krun the guest's startup work is purely local - seeding the baked and host installs into the data dir and regenerating shims, all withMISE_OFFLINE=1. A tool your.mise.tomlpins that is neither seeded nor already in the sandbox home is therefore not fetched for you: install it inside the sandbox with an explicitMISE_OFFLINE=0 mise install .... - All proxy-mode sandboxes bound remote lookups to 3s (
MISE_FETCH_REMOTE_VERSIONS_TIMEOUT=3s, was mise's 20s default; override via the sandbox env config). - The startup shim resolves nothing over the network (
MISE_OFFLINE=1for its own mise invocations).
To avoid the residual per-lookup warnings on non-krun proxy sandboxes entirely:
- Pin the versions in your global config, or prefer a per-project
.mise.tomlfor the tools a sandboxed project actually needs. - Set
ignore_global_configfor mise so the sandbox does not read your host globalconfig.tomlat all (your project.mise.toml, the baked node, and~/.config/mise/settings.tomlstill apply):
This defaults to false (the host global config is respected). It applies to every backend (bwrap, docker, krun).
Writable Mise (Overlay Mode)¶
Configure the overlay mount mode for mise to allow installing tools inside the sandbox. In ~/.config/devsandbox/config.toml:
[tools.mise]
mount_mode = "overlay" # Persist tool installations across sessions
# mount_mode = "tmpoverlay" # Discard on exit (safer)
With overlay enabled:
- Sandbox can install new tool versions
- Host mise directories remain unchanged
- Changes are isolated to the sandbox
The global [overlay] default setting controls all tools. Per-tool mount_mode overrides the global default. See Sandboxing: Overlay Filesystem for details on overlay modes and how layering works.
Supported Tools¶
Any tool installable via mise works inside the sandbox:
| Category | Examples |
|---|---|
| Languages | Node.js, Python, Go, Rust, Ruby, Java |
| Package Managers | npm, pnpm, yarn, pip, cargo, uv, poetry |
| Build Tools | make, cmake, ninja |
| Utilities | jq, yq, gh, aws-cli |
Installing Tools¶
Install tools on your host system (outside the sandbox):
# Install Node.js
mise install node@20
# Install Python
mise install python@3.12
# Install multiple tools
mise install node@20 python@3.12 go@1.22
Tools are then automatically available in all sandboxes.
Shell Configuration¶
devsandbox detects your shell from $SHELL and loads appropriate configurations:
Bash¶
~/.bashrc → Sandbox (read-only)
~/.bash_profile → Sandbox (read-only)
~/.profile → Sandbox (read-only)
Zsh¶
Fish¶
Note: Shell configurations are read-only. Changes made inside the sandbox won't persist.
Prompt Tools¶
devsandbox can display a sandbox indicator in your shell prompt. Several popular prompt tools are supported.
Environment Variables¶
Inside the sandbox, these variables are always set:
DEVSANDBOX=1- Indicates running inside a sandboxDEVSANDBOX_PROJECT=<name>- The project name
You can use these in custom prompt configurations.
Starship¶
Starship is automatically configured with a [sandbox] indicator:
Powerlevel10k¶
Powerlevel10k is configured with a custom devsandbox segment that shows
when $DEVSANDBOX is set.
Oh My Zsh¶
Oh My Zsh gets a sandbox indicator prepended to the PROMPT.
Oh My Posh¶
Oh My Posh configurations are mounted. You can add a custom segment using the $DEVSANDBOX
environment variable.
tmux¶
tmux is configured to show [SANDBOX] in the status bar with a red background when
running inside the sandbox.
Editor Support¶
Neovim¶
Neovim configuration is available inside the sandbox:
~/.config/nvim → Sandbox (read-only)
~/.local/share/nvim → Sandbox (read-only)
~/.local/state/nvim → Sandbox (read-only)
This includes:
- Your init.lua/init.vim
- Installed plugins
- LSP configurations
- Color schemes
Other Editors¶
For other editors, mount their config directories manually or use the sandbox home.
AI Coding Assistants¶
AI coding assistants execute arbitrary code - installing packages, running builds, making network requests. Running them inside devsandbox ensures they can do their job without accessing your credentials, keys, or secrets.
Shell wrappers: run agents sandboxed by default¶
Remembering to type devsandbox first is the weak point. devsandbox agent-wrappers activate prints shell functions that send supported agents through the sandbox automatically. Nothing is written to disk: you evaluate the output from your own startup file, the way mise activate works.
| Shell | Startup file | Line to add |
|---|---|---|
| fish | ~/.config/fish/config.fish |
if test -z "$DEVSANDBOX"; devsandbox agent-wrappers activate fish \| source; end |
| bash | ~/.bashrc |
if [ -z "${DEVSANDBOX:-}" ]; then eval "$(devsandbox agent-wrappers activate bash)"; fi |
| zsh | ~/.zshrc |
if [ -z "${DEVSANDBOX:-}" ]; then eval "$(devsandbox agent-wrappers activate zsh)"; fi |
The if ... $DEVSANDBOX ... guard on each line is load-bearing, not boilerplate: these startup files are bind-mounted into the sandbox, where devsandbox need not exist, so an unguarded line would fail with command-not-found on every in-sandbox shell start. See Two guards below.
The shell argument defaults to the base name of $SHELL, so devsandbox agent-wrappers activate alone works for a quick look at what would be defined. Once the line is in place:
claude # -> devsandbox claude, in the current directory
claude --resume ID # arguments pass through untouched
claude-no-ds # escape hatch: the real binary, unsandboxed
command claude # escape hatch: the real binary, unsandboxed
The wrappable agents are claude, pi, codex, opencode, and copilot (the standalone GitHub Copilot CLI). Only the ones actually installed on your host are wrapped. Because the snippet is regenerated at every shell start, it cannot go stale: install a new agent, or upgrade devsandbox into a new directory, and the next shell picks it up with nothing to re-run. With none of them on the host the output is a comment saying so, which is still valid shell - your startup file keeps working.
Wrapping only some of them. --agents narrows the set, so the agents you leave out keep running unsandboxed as usual:
| Shell | Line to add |
|---|---|
| fish | if test -z "$DEVSANDBOX"; devsandbox agent-wrappers activate fish --agents claude,codex \| source; end |
| bash | if [ -z "${DEVSANDBOX:-}" ]; then eval "$(devsandbox agent-wrappers activate bash --agents claude,codex)"; fi |
| zsh | if [ -z "${DEVSANDBOX:-}" ]; then eval "$(devsandbox agent-wrappers activate zsh --agents claude,codex)"; fi |
Values are comma-separated, given by repeating the flag, or both: --agents claude,codex and --agents claude --agents codex select the same pair. Omitting the flag wraps every supported agent, which is what the lines further up do.
Names are checked strictly. An unsupported name, or an explicitly empty --agents '', exits non-zero listing the supported agents and writes nothing to stdout - so a startup file never evaluates half a snippet. Duplicates collapse to one wrapper, and the order you type the names in does not matter: definitions are emitted in the fixed order claude, pi, codex, opencode, copilot. Selecting an agent you have not installed is not an error - it is simply not wrapped, and the next shell picks it up once you install it; select only agents you do not have and the output is that same no-wrapper comment, naming just your selection and still valid shell.
If you use herdr's agent session restore, keep claude, pi and codex in the selection. Restore works by herdr typing that agent's own resume command into the pane shell for the wrapper to intercept, so an agent you leave out has its resume run against the host agent instead - it starts fresh rather than reopening the sandboxed session.
Two guards, and why the line carries one of its own. The emitted snippet wraps every definition in a DEVSANDBOX test, so nothing is wrapped inside a sandbox and a wrapper cannot recurse. The line you paste repeats that test: startup files are mounted into the sandbox while devsandbox itself need not exist in there, so an unguarded line would fail with command-not-found on every in-sandbox shell start.
Scope, stated exactly. fish sources config.fish for non-interactive fish -c invocations too, so a fish script calling claude gets the wrapper. bash and zsh only source their rc file for interactive shells, so scripts there are unaffected.
Each wrapper calls devsandbox run-agent <agent>, which decides what to do: inside a sandbox it runs the real agent, and outside it re-enters devsandbox in the current directory.
PATH resolution, exactly where it is safe. The activation line resolves devsandbox through PATH, because it runs once per shell start and an absolute path there would break on every upgrade that moved the binary. The snippet that command emits bakes in the absolute path it resolved for itself, so no agent invocation goes through PATH: a project-local bin directory on PATH is writable by the sandbox, and resolving devsandbox through it would run that binary on the host. If the baked path disappears mid-session, the wrapper fails closed with devsandbox: no executable at <path> - reinstall devsandbox, then start a new shell to refresh the wrappers and exits 127 rather than falling back to a lookup.
The wrappers are a standalone feature, independent of herdr. herdr's native session restore builds on them, which means the line has to be in the startup file of the shell herdr opens panes with ([terminal] default_shell, falling back to $SHELL) - not only your login shell. See Agent session capture and restore.
To undo everything: remove the line from your startup file.
Claude Code¶
Claude Code is fully supported:
# Run Claude Code in sandbox
devsandbox claude
# With permissions disabled (recommended in sandbox)
devsandbox claude --dangerously-skip-permissions
Everything after devsandbox is passed to the sandboxed command. --dangerously-skip-permissions is a Claude Code flag that skips permission prompts - safe inside the sandbox because devsandbox provides the security boundary.
Configuration directories are mounted read-write to allow Claude to save settings:
~/.claude → Sandbox (read-write)
~/.config/Claude → Sandbox (read-write)
~/.claude.json → Sandbox (read-write)
These directories are isolated to the sandbox home - not your real host directories. Claude's conversation state and settings persist across sandbox sessions for the same project but are not shared with your host.
Inside a herdr pane, a direct devsandbox claude launch lets Claude's herdr
integration report its native session to herdr through the filtered proxy, so
herdr can resume it (claude --resume <id>) after a restart. Reports are
confined to ~/.claude/projects (honoring CLAUDE_CONFIG_DIR). See
herdr Terminal Workspace.
aider¶
aider works out of the box:
OpenCode¶
OpenCode is detected and supported:
Pi Coding Agent¶
Pi is fully supported:
Install via mise:
Configuration directories are mounted with credential protection:
~/.pi/agent → Sandbox (protected: settings + auth credentials)
~/.pi/agent/sessions → Sandbox (persistent: session history preserved)
The ~/.pi/agent directory (containing settings.json and auth.json) is
mounted with tmpoverlay so API keys and settings are shielded from sandbox
code. Session history in ~/.pi/agent/sessions uses a persistent overlay so
conversations survive across sandbox sessions for the same project.
Inside a herdr pane, a direct devsandbox pi launch lets Pi's herdr
integration report its agent state and native session to herdr through the
filtered proxy. Reports are confined to ~/.pi/agent/sessions (honoring
PI_CODING_AGENT_DIR), so a report naming any other path - auth.json
included - is denied. See herdr Terminal Workspace.
Codex CLI¶
Codex is fully supported:
Configuration directories are mounted with credential protection:
~/.codex → Sandbox (protected: config.toml + auth credentials)
~/.codex/sessions → Sandbox (persistent: recorded sessions preserved)
The Codex home is mounted with tmpoverlay so auth.json and config.toml are
shielded from sandbox code, while ~/.codex/sessions uses a persistent overlay
so codex resume <id> can find a session recorded in an earlier sandbox run.
Both honor CODEX_HOME, whose host value is passed through so codex resolves
the same path inside.
Inside a herdr pane, a direct devsandbox codex launch lets Codex's herdr
integration report its native session to herdr through the filtered proxy, so
herdr can resume it (codex resume <id>) after a restart. See
herdr Terminal Workspace.
GitHub Copilot¶
Two products share the name, and devsandbox mounts both:
- The standalone Copilot CLI (
npm install -g @github/copilot, invoked ascopilot) keeps its config, MCP servers, sessions and auth under~/.copilot. That directory is mounted read-write and persistent, so a sandboxedcopilotruns authenticated andcopilot --resume/--continuefinds sessions from an earlier run. It is one of the agents the shell wrappers wrap. - The older
gh copilotextension uses~/.config/github-copilotand~/.cache/github-copilot, mounted as config and cache. It also works inside editors (Neovim, VS Code) running in the sandbox.
Other AI Tools¶
Any CLI-based AI tool works in the sandbox. This includes Continue, Cline, and similar tools. If the tool runs as a CLI process, wrap it with devsandbox.
rtk CLI Proxy¶
rtk filters command output before it reaches an agent's context. It is usually installed as a Claude Code hook, so it runs on almost every command the agent issues inside the sandbox and needs both its configuration and its tracking database to be present.
rtk resolves its directories through XDG. devsandbox points XDG_CONFIG_HOME and XDG_DATA_HOME at $HOME/.config and
$HOME/.local/share, with $HOME being the host home path, so the host directories below are the same paths rtk resolves
inside the sandbox:
~/.config/rtk → Sandbox (tmpoverlay - writes discarded)
~/.local/share/rtk → Sandbox (persistent overlay - writes kept per sandbox)
| Path | Contents |
|---|---|
~/.config/rtk/config.toml |
Tracking, display, tee, telemetry and hook settings |
~/.config/rtk/filters.toml, ~/.config/rtk/filters/*.toml |
Global output filters |
~/.local/share/rtk/history.db |
SQLite tracking database behind rtk gain |
~/.local/share/rtk/trusted_filters.json |
Trust store for project-local filters |
~/.local/share/rtk/tee/, hook-audit.log |
Tee'd command output and hook rewrite audit log |
Configuration is mounted with the default tmpoverlay, so your filters and config.toml are visible but rtk config --create
inside the sandbox cannot rewrite them. The data directory gets a persistent overlay: savings recorded in the sandbox accumulate
across runs of that sandbox, while the host's history.db is never modified.
A project's own .rtk/filters.toml lives in the project directory and is writable as usual. rtk trust marks it trusted inside
the sandbox only - the host trust store is untouched.
rtk discover, rtk session and rtk learn read Claude Code history from ~/.claude/projects, which the claude tool already
mounts.
Git¶
Git access is configurable via [tools.git] in ~/.config/devsandbox/config.toml:
Modes¶
readonly (default)¶
Maximum isolation - .git directory is mounted read-only:
- View history, diff, log, status
- Branch operations (local only)
- Sanitized
~/.gitconfig: your resolved identity and global ignore/attributes rules (what the safe copy carries) - No commits (
.gitis read-only) - No SSH access
- No GPG signing
- No credential helpers
This is the safest mode for running untrusted code that needs to read repository data.
readwrite¶
Full git access for trusted projects:
- All git operations including commits
- Your
~/.gitconfigmounted verbatim, plus the files it references (see below) - SSH keys (read-only access to
~/.ssh) - GPG signing (read-only access to
~/.gnupg) - Git credentials (read-only access to
~/.git-credentials) - SSH agent forwarding (
SSH_AUTH_SOCK)
Security note: SSH and GPG directories are mounted read-only to protect private keys.
Files the host config references¶
readwrite mounts your ~/.gitconfig unchanged, so every path in it arrives inside the sandbox spelled
exactly as the host wrote it. Those paths used to name host files nothing mounted, and git ignores a missing
core.excludesFile, a missing core.attributesFile and a missing [include] target with exit 0 and no
warning - so the settings behind them simply did not apply, with nothing on screen to say so. devsandbox now
mounts the files the config names, so they resolve:
- the files
core.excludesFileandcore.attributesFilename, or git's own defaults at~/.config/git/ignoreand~/.config/git/attributeswhen those keys are unset (read from$XDG_CONFIG_HOME/git/when that variable is set on your host) - every
[include]and matching[includeIf "gitdir:..."]target that contributed a setting, together with the config files that declare them -~/.config/git/config(spelled$XDG_CONFIG_HOME/git/configwhen that variable is set on your host) included, which is the only global config a host that keeps its identity solely there has
Nothing is rewritten. The config keeps its own values, and each file is mounted where its value points inside
the sandbox. The mounts follow the same policy ~/.gitconfig itself gets, which under the default split
overlay mode means read-only for a single file: split asks for an
overlay, and overlays need a directory, so a file falls back to a read-only bind on every backend. Under
split, writes never reach the host - but they do not silently vanish either, they fail. git config --global
inside the sandbox therefore errors with Device or resource busy once any global config file is mounted,
exactly as it already did for a host with a ~/.gitconfig. Set the identity per command
(git -c user.email=...) or per repository (git config --local), which writes into the project's own
.git/config.
Setting mount_mode = "readwrite" on [tools.git], or a global [overlay] default = "readwrite", makes the
ignore and attributes files writable binds at their host paths - so a write from inside the sandbox edits
the real file, the same caveat that already applies to ~/.ssh and ~/.gnupg in this mode.
The config files themselves are the exception, and stay read-only under every mount mode: ~/.gitconfig,
~/.config/git/config, and every [include] target. devsandbox resolves those to decide which host files to
mount, so a config the sandbox could write between launches would be a config the sandbox could use to name
any host file for the next one.
If your global config lives only at ~/.config/git/config with no ~/.gitconfig, this is a change: that file
is now mounted, so git config --global writes to it and fails, where before it fell through to creating a
throwaway ~/.gitconfig inside the sandbox. In exchange the identity in it now actually applies.
This is new host surface in readwrite mode: files outside ~/.gitconfig are now mounted into the sandbox.
It is marginal next to the ~/.ssh and ~/.gnupg this mode already carries, and it reaches only files your
own global git config names, but it is a widening and worth stating. readonly and disabled are unchanged.
A named file that does not exist on the host is skipped in silence, exactly as the host's own git skips it. A
file the sandbox itself can write - one inside the project directory, the shared $TMPDIR, or the sandbox
home - is refused instead of mounted, since the sandbox would otherwise get to choose which host file the next
launch carries in. Both of those are silent, because the alternative to refusing is mounting nothing, which is
what git already does with the value. A value spelled ~user/... - the form git resolves from the password
database - cannot be resolved by devsandbox: that one is reported, because you set it and are getting less
than the config says. Spell it ~/ or absolutely.
Conditional includes on Docker and krun¶
An [includeIf "gitdir:~/work/"] condition is evaluated by git inside the sandbox, where ~ expands
against the sandbox $HOME. The docker and krun backends mount the sandbox home at /home/sandboxuser
while the project keeps its host path, so a condition written with ~ matches on the host and stops matching
in the guest. The include file is mounted, but git never reads it: the identity falls back to whatever the
outer config sets, and commits land with the wrong user.email - in the one mode where commits land at all.
Spell such a condition with an absolute path (gitdir:/home/you/work/) to keep it matching, or use the
bwrap backend, which mounts the sandbox home at the host home path and is unaffected.
Two narrower spellings hit the same $HOME-differs-per-backend limit, and bwrap is unaffected by both:
- The same file included twice under two spellings.
path = ~/inc.gitconfigandpath = /home/you/inc.gitconfigname one host file but two guest paths, and only the first spelling is mounted - the two would collide into one mount onbwrap, where they are the same path. The second inclusion is skipped in the guest, which matters only if a setting between the two includes overrides something the included file sets. Use one spelling for one file. - A
~/value that climbs back out of$HOME, such ascore.excludesFile = ~/../shared/ignore. It is mounted at the path it resolves to on the host, while git inside the sandbox resolves it against/home/sandboxuser. Those still agree whenever the host home and the guest home share a parent - the usual Linux/home/<user>case, where both land on/home/shared/ignore- and diverge only when they do not, as with a macOS/Users/<user>home, where git then looks somewhere nothing is mounted. Spelling it absolutely makes it backend-independent.
disabled¶
No git configuration, but .git remains writable:
Git commands work but without user.name/email (commits require --author). Use this if you want to allow commits without exposing any credentials.
Go¶
Go is fully supported with isolated caches:
GOPATH → ~/.local/share/devsandbox/<project>/home/go
GOCACHE → ~/.local/share/devsandbox/<project>/home/.cache/go-build
GOMODCACHE → ~/.local/share/devsandbox/<project>/home/.cache/go-mod
GOTOOLCHAIN → local (prevents auto-downloads)
Module Cache¶
Each project has its own module cache. To share modules across projects, you can symlink the cache directories (outside the sandbox).
Node.js / npm¶
Works normally with mise-installed Node.js:
Global Packages¶
Global npm packages are installed to the sandbox home:
Python¶
Works normally with mise-installed Python:
Virtual Environments¶
Create venvs inside your project directory:
Docker¶
Docker is supported via a socket proxy that provides read-only access to the host Docker daemon.
Configuration¶
Enable Docker in your project's .devsandbox.toml:
Or in the global config ~/.config/devsandbox/config.toml:
Allowed Operations¶
The Docker proxy allows:
| Operation Type | Allowed | Examples |
|---|---|---|
| Read operations | ✓ | docker ps, docker images, docker inspect |
| Container logs | ✓ | docker logs <container> |
| Container exec | ✓ | docker exec -it <container> bash |
| Container attach | ✓ | docker attach <container> |
| Create containers | ✗ | docker run, docker create |
| Delete containers | ✗ | docker rm, docker kill |
| Modify containers | ✗ | docker stop, docker restart |
| Build images | ✗ | docker build |
| Push images | ✗ | docker push |
This allows debugging and inspecting running containers without the ability to modify the Docker environment.
Socket Auto-Detection¶
On Linux, the Docker socket defaults to /run/docker.sock.
On macOS, the socket location varies by Docker runtime. devsandbox probes these paths in order and uses the first one found:
| Priority | Path | Runtime |
|---|---|---|
| 1 | ~/.docker/run/docker.sock |
Docker Desktop |
| 2 | /var/run/docker.sock |
OrbStack (symlink) |
| 3 | ~/.colima/default/docker.sock |
Colima |
To override auto-detection, set the socket path explicitly:
How It Works¶
- A Unix socket proxy is created at
$HOME/.run/<pid>/docker.sockinside the sandbox, where<pid>is the devsandbox process that owns the session. Sandbox home is shared by every session for the project, so the socket is kept per-session to stop a second session from unlinking a live one's socket. - The
DOCKER_HOSTenvironment variable is set to point to this socket - All requests are filtered before being forwarded to the host Docker socket
- Write operations are blocked with an HTTP 403 error
Limitations¶
- Only Unix socket access is supported - TCP connections to Docker daemons are not proxied
- Exec/attach sessions allow interactive terminal access to existing containers
Error Logging¶
Proxy errors are logged to ~/.local/share/devsandbox/<project>/logs/internal/tools-errors.log
Checking Docker Status¶
Example output:
Shared temp directory¶
Some tools have to hand files to a helper that runs on the host. The revdiff overlay launcher is the built-in example: it writes a sentinel and an output file, then asks kitty or herdr to run a viewer on the host that reads them back.
For that, devsandbox mounts one directory read-write at an identical path on both sides:
The path has to be the same string in both mount namespaces, because the host-side helper receives it as literal text. It is created only when an installed tool needs it - today that means only when the revdiff binary is on PATH.
This directory also bounds the completion sentinel. A launch command carries the sentinel path as text, and the clause the launcher appends is not a touch but printf "%s" "$rc" > '<sentinel>'.tmp && mv -f '<sentinel>'.tmp '<sentinel>' - so a path that was only checked for shape let sandboxed code have the host truncate and overwrite any file the invoking user can write. The path must now sit inside this directory, and the bound is taken from the same function that produces the mount, so the two cannot drift apart. A launch for which devsandbox cannot derive the directory is denied rather than accepted unbounded. The file a herdr launch script captures the viewer's stderr into is bounded the same way, for the same reason.
It is also $TMPDIR¶
The launchers that need this directory are third-party shell scripts that resolve their scratch base as ${TMPDIR:-/tmp} and offer no other hook, so $TMPDIR is pointed at it for every sandboxed process. That has a consequence worth knowing: every temporary file in the sandbox lands there, not just the handful of IPC files. Without the directory, $TMPDIR is unset and temporary files go to /tmp, which the sandbox mounts as a fresh tmpfs - private and discarded at exit.
So while it is active, temporary files are:
- visible to the host, at the same path, and writable from both sides;
- on disk, not in RAM;
- persistent across sandbox restarts unless something removes them.
Nothing else in the sandbox home behaves this way: the rest is an overlay whose writes never reach the host.
Cleanup¶
Left alone, the directory grows without bound - build caches, test scratch trees and agent scratchpads accumulate on disk forever. devsandbox reclaims it at launch:
| Situation | What happens |
|---|---|
| No other devsandbox session is using this project | The directory is emptied, restoring the lifetime you would expect of $TMPDIR. |
| Another session for the same project is live | Its files may be in use, so only entries with nothing modified in the last 7 days are removed. |
A directory belonging to a different project is never touched, and the previous location (~/.cache/devsandbox/revdiff-ipc/) is reclaimed the first time the project launches.
Because the sandbox can write here, nothing the host has to trust may live in this directory - host-owned records go under $XDG_STATE_HOME/devsandbox/ instead.
Kitty Terminal¶
When running inside kitty with remote control enabled, devsandbox runs a filtering proxy for the kitty remote-control socket. The host kitty socket is not bind-mounted into the sandbox; only the proxy socket is. Sandboxed code can perform only the kitty operations declared by enabled tools, scoped to windows the sandbox itself opened (ownership tracking).
Prerequisites¶
Add to your ~/.config/kitty/kitty.conf:
Restart kitty after changing the config.
Activation¶
The proxy starts when all of the following hold:
KITTY_LISTEN_ONis set on the host.- The
kittybinary is on PATH. - At least one enabled tool declares a kitty capability (or
mode = "enforce"is set).
If no tool declares a capability and mode = "auto" (the default), the proxy stays inactive - zero attack surface when nothing needs it. revdiff is the built-in consumer: if the revdiff binary is on PATH, the proxy activates automatically.
Configuration¶
[tools.kitty]
mode = "auto" # "auto" (default), "disabled", "enforce"
extra_capabilities = ["list_owned"] # additive only; launch_* entries are rejected
| Mode | Behavior |
|---|---|
auto |
Proxy starts iff at least one enabled tool declares a capability. |
enforce |
Proxy always starts; with no capabilities declared, every request is denied (useful to verify no tool silently uses kitty). |
disabled |
Proxy never starts; KITTY_LISTEN_ON is not exposed inside the sandbox. |
Capabilities¶
| Capability | Allows |
|---|---|
launch_overlay |
kitty @ launch --type=overlay |
launch_window |
kitty @ launch --type=window |
launch_tab |
kitty @ launch --type=tab |
launch_os_window |
kitty @ launch --type=os-window |
close_owned |
close-window scoped to windows the sandbox opened |
wait_owned |
wait scoped to windows the sandbox opened |
focus_owned |
focus-window scoped to windows the sandbox opened |
send_text_owned |
send-text scoped to windows the sandbox opened |
get_text_owned |
get-text scoped to windows the sandbox opened |
set_title_owned |
set-window-title scoped to windows the sandbox opened |
list_owned |
ls - response is filtered to owned windows only |
launch_* capabilities equal arbitrary host code execution and must be paired with command patterns declared by the tool that requests them. Shell metacharacters (;, &, |, `, $(), <, >, etc.) in sh -c payloads are rejected outright.
Command patterns also pin the program to its resolved absolute path (exec.LookPath plus symlink resolution), not just its basename. Basename matching accepted any path ending in the allowed program name, including one inside a directory the sandbox can write - and the shared temp directory is a write-through bind shared with the host at an identical path, so sandboxed code could plant its own revdiff there and have kitty run it on the host. If the binary cannot be resolved, every launch is denied rather than falling back.
There is no basename fallback left for any pattern. A pattern that pins no resolved path now requires argv[0] to be that exact spelling, which is how the wrapping shell in sh -c '<tool> ...' is constrained: a shell has no single resolved path worth pinning, so only sh and /bin/sh are accepted and the host resolves them itself. The same planted-binary path applied there - <shared temp>/sh ends in sh and was accepted as the wrapper.
The environment prefix¶
A launch command may carry a KEY=VAL prefix ahead of the program, either as /usr/bin/env 'KEY=VAL' … or as a leading shell assignment. The revdiff launcher emits one so the overlay inherits the editor from the caller's shell, whose exports the terminal's own process never saw. The same rules apply to the kitty patterns and to the herdr launch script, which share one validator.
| Variable | Accepted value |
|---|---|
EDITOR, VISUAL |
A known editor, named either bare or by an absolute path the host's own PATH lookup of that name yields, optionally followed by option words that editor is known to treat as a switch. Empty is accepted, and so is a value byte-identical to your own host EDITOR/VISUAL - both forms subject to the PATH rule below. |
REVDIFF_EXIT_CODE_ON_ANNOTATIONS |
A short scalar with no path separator. |
| Anything else | Denied. |
The value matters as much as the name: revdiff spawns whatever EDITOR names when the user opens a file in the overlay, so an unconstrained value is host code execution regardless of how narrow the key set is.
Two things bound it. The program has to be an editor - one devsandbox recognizes, or whatever your own EDITOR/VISUAL is set to on the host, which is how an editor the built-in list does not carry keeps working. Resolving the name against the host's PATH is not enough on its own: the file the editor opens sits in the project tree, which is bind-mounted read-write, so EDITOR=sh would make the sandbox's own file the program. And the path is compared literally against what the host's lookup yields, never resolved from the value itself - resolving the sandbox's path first would accept a symlink into the shared temp directory pointed at the real editor and repointed before the host runs it.
The option words are an allowlist keyed on the editor, not a shape. Editors that detach have to be told to block, so EDITOR="code --wait", subl -w and emacsclient -t all work - but every editor in the list also has at least one flag that makes its trailing operand a program rather than a document: nvim -u sources the file as the init vimrc, vim -S as a Vim script, emacs -l as Elisp, hx -c as a config, kak -e as a command to run. Since the operand is the file in the project tree, accepting any dash-prefixed word would have reinstated exactly the host execution the editor list exists to deny. The same word means different things to different programs - -c is --create-frame to emacsclient and an ex command to vim - so it is accepted only for the editors that treat it as a switch. A value that matches your host's own EDITOR/VISUAL byte for byte is taken whole, flags included: that is the setting the host would have run anyway. Only the flags ride on that match - the program word goes through the PATH rule below like any other, since "the setting the host would have run" still names a file by name, and which file that is comes from the same lookup.
Relative paths, values carrying a path argument or shell-significant characters, flags outside that editor's list, and names outside the table are refused rather than interpreted. The env program itself is pinned to /usr/bin/env or the host's own resolution of env, never to a path that merely ends in /env.
The host's PATH lookup is the anchor for every form - bare name, absolute path, and your own host setting alike - so it is checked in turn: if that lookup lands inside the project directory or the shared temp directory, the value is refused whichever way it was written. Both are bind-mounted read-write, so a program found there is one the sandbox wrote - and a project-local bin directory on PATH is ordinary (.venv/bin, node_modules/.bin, a bin direnv or mise adds). The same rule applies to env, and to the sh the kitty launch patterns accept: if the host resolves sh inside one of those directories, revdiff's kitty launch patterns are dropped entirely and every launch request is denied, since the argv reaches kitty as the sandbox wrote it.
Where a name is spelled bare, both halves of the lookup are checked - the directory entry PATH found and the file it points at. A bin/sh symlink in your project aimed at the real /bin/sh resolves outside every bound, but the file the terminal opens is the link, and the sandbox can repoint it after the check and before the launch. The revdiff binary the patterns pin is bounded the same way: an installation the host resolves inside the project directory or the shared temp directory drops the patterns, because the pin would otherwise name a file whose contents the sandbox chooses. A revdiff installed anywhere else - the usual case - is unaffected.
Both bounds cover both spellings of their directory. devsandbox learns the project directory as your shell reports it, which keeps a symlink you entered it through, while the mount follows that link to the tree behind it. A project entered as ~/proj-link is therefore refused for a program PATH resolves under ~/proj-link/.venv/bin and for one it resolves under the real path that link points at - both spellings reach the same read-write mount.
If a launch is denied and your editor is an unusual one, export EDITOR on the host before starting devsandbox - the deny line in devsandbox logs carries the rejected command line, including the assignment.
Request options¶
The command line is not the only thing a kitty request carries. kitty @ launch accepts around forty further options, and the proxy decodes every one of them: an option it does not model is a denial, not a pass-through, because an approved request is forwarded to the host socket byte for byte.
| Option | Treatment |
|---|---|
--env, --copy-env |
Denied. Both set environment variables for a process running on the host, and an agent the sandbox controls picks the program that reads them (EDITOR, LD_PRELOAD). |
--copy-cmdline |
Denied. It discards the command line the pattern allowlist just vetted and runs the source window's instead. |
--watcher |
Denied. kitty imports the named Python file into its own process. |
--stdin-source |
Denied unless none. It pipes another window's screen contents, scrollback or selection into the launched process. |
--allow-remote-control, --remote-control-password |
Denied. Either one hands the launched process control of kitty outside this proxy. |
--cwd |
Only kitty's host-resolved keywords (current, oldest, last_reported, root). An explicit path is the one cwd value the sandbox chooses, and it decides which tree the launched program reads. |
--match |
Only the host window devsandbox itself runs in (window_id:$KITTY_WINDOW_ID, read on the host side). Otherwise the sandbox picks which host tab its window lands in. |
--source-window, --next-to, --add-to-session, --os-panel |
Denied. |
--hold, --hold-after-ssh |
Denied - kitty runs the host user's shell in the window after the launched command exits, which would put a program the allowlist never approved behind an approved launch. |
--color |
Only the inline name=value form. A spec without = is a path kitty opens and parses, so it reads a host file the sandbox names. |
--marker, --logo |
Denied. A function marker spec has kitty run the named Python file (runpy.run_path), and both resolve an absolute path verbatim - including one in the temp directory shared read-write with the host. |
Titles, placement, --keep-focus |
Allowed - none of them changes what runs or what it can read. |
The owned-scoped commands are decoded the same way. send-text --all and send-text --match-tab reach past the matched window to every window of the host tab (or every window kitty has), so both are denied even when the match selector names an owned id; send-text --session-id is denied for the same reason, since a broadcast session is not scoped to owned windows; ls --output-format session is denied because its response is not in the shape the owned-id response filter can rewrite; and ls --all-env-vars is denied because that filter narrows which windows come back, not what each one discloses - an owned window still reports the environment kitty launched it with, which is the host user's. If an ls response nevertheless comes back in a shape that filter cannot parse, the sandbox gets a denial rather than the raw reply - the unfiltered body is every OS window, tab, title, working directory, foreground command line and per-window environment on the host.
Two envelope fields are refused before any of that: an encrypted request (kitty runs the command inside encrypted and ignores the outer name, so the proxy would be approving a command it cannot see) and a password-authenticated one.
The option lists come from kitty 0.46.2. A newer kitty that adds an option denies the launch naming the unknown field, rather than forwarding it unchecked.
What Gets Mounted¶
| Resource | Mode | Purpose |
|---|---|---|
Proxy socket ($HOME/.run/<pid>/kitty.sock) |
read-write (proxy is local to the sandbox home) | kitty remote-control via the filtering proxy |
kitty binary |
read-only | CLI for kitty @ launch, kitty @ ls, etc. |
The host's real kitty socket is not bind-mounted into the sandbox.
Environment Variables¶
KITTY_LISTEN_ON- rewritten tounix:$HOME/.run/<pid>/kitty.sockinside the sandbox, where<pid>is the devsandbox process owning the session. Host value is never exposed.KITTY_WINDOW_ID,KITTY_PID- passed through from host (read-only signals about the host pane).
Limitations¶
- Async / streaming kitty commands (
get-text --watcher, async kittens) are denied in this MVP. remote_control_passwordinkitty.confis unsupported - useallow_remote_control = socket-onlyinstead.
Run devsandbox tools check kitty to confirm the tool is detected and see the active mode.
herdr Terminal Workspace¶
When running inside a herdr session, devsandbox runs a filtering proxy for the herdr control socket. The host socket is not bind-mounted into the sandbox; only the proxy socket is. Sandboxed code can perform only the herdr operations declared by enabled tools, scoped to tabs and panes the sandbox itself created.
This is the same model as kitty, and the opposite of zellij: herdr speaks newline-delimited JSON with named methods, which makes real per-method filtering practical.
Why filtering is necessary¶
herdr exposes 86 methods. Handing the raw socket to sandboxed code would grant pane.read (read any pane's contents, including other terminals), pane.send_text and agent.send (type into any pane), worktree.* (mutate git state), plugin.* (load code), and server.stop. Only the methods below are reachable through the proxy; everything else is denied.
Activation¶
The proxy starts when all of:
HERDR_ENV=1on the host (that is, devsandbox is running inside a herdr session).- The
herdrbinary is on PATH. - The host control socket exists (
$HERDR_SOCKET_PATH, else~/.config/herdr/herdr.sock). - At least one enabled tool declares a herdr capability, or
mode = "enforce"is set.
Behavior change. Launching a supported agent directly - devsandbox claude, devsandbox pi, devsandbox codex - now satisfies condition 4 on its own, because that launch enables agent_reporting. Such a command inside a herdr pane previously started no proxy at all; it now starts one, binds the proxy socket into the sandbox, and prints the usual herdr proxy active notice. Since auto mode can now reach the socket check for an ordinary agent launch, an unreachable or stale host socket is a warning there rather than a launch failure - the sandbox starts without a proxy. Under mode = "enforce", where the proxy was asked for explicitly, it is still a hard error.
Configuration¶
| Mode | Behavior |
|---|---|
auto (default) |
Proxy starts only when some enabled tool declares a capability. |
disabled |
Proxy never starts and HERDR_SOCKET_PATH is not exported, so the sandboxed CLI cannot reach herdr at all. |
enforce |
Proxy always starts; with no capabilities declared, every request is denied (useful to verify no tool silently uses herdr). |
There is deliberately no extra_capabilities setting as kitty has: with this few capabilities it would configure nothing, and each grants host-visible effects.
Capabilities¶
| Capability | Permits |
|---|---|
launch_overlay |
tab.create, then pane.send_input and tab.close scoped to the tab and pane that call returned |
notify |
notification.show |
agent_reporting |
pane.report_agent_session, pane.report_agent, pane.release_agent, all confined to this sandbox's own pane and the agent devsandbox launched |
ping is permitted unconditionally, independent of declared capabilities. It is a liveness handshake that observes and mutates nothing, returning only the server's version, protocol number, and feature flags - strictly less than a successful connect(2) to the socket already reveals. Without it herdr status fails, which is a poor signal for no safety gain. One consequence: under mode = "enforce" the proxy answers ping and denies everything else.
Ownership is taken from the server's tab.create response, never from anything the client claims. pane.send_input is generic keystroke injection, so it is additionally restricted to exactly one command plus Enter, and the command must match the requesting tool's declared pattern.
Launch scripts are validated and relocated¶
Unlike kitty, where the launcher passes an inline command, herdr's pane run is invoked as sh <path> - the payload is a file. That file lives in a directory the sandbox can write, yet it executes on the host, outside the sandbox.
The proxy therefore reads the script once, validates the bytes against the declared pattern, writes them to a host-only directory (~/.cache/devsandbox/herdr-scripts/<session>, mode 0700, files 0500, never bind-mounted into the sandbox), and rewrites the request to name that copy. Validation and execution operate on the same immutable bytes, so there is no window in which the sandbox can swap the script after it passes.
The copy carries one addition devsandbox makes itself: a set -C line after the shebang. The accepted script ends by writing the launch's exit code into <sentinel>.tmp and renaming that over the sentinel, and the sentinel lives in the shared temp directory - which the sandbox can write at the same path the host sees. Without set -C a symlink planted at <sentinel>.tmp is followed by the host's own shell, so that redirect would truncate whatever the link names and write the exit code into it. Under set -C the redirect refuses an existing path instead of following it, and the rename that follows acts on the link rather than its target. The command is also handed to /bin/sh by absolute path rather than to whatever sh the pane's PATH resolves.
The script may also redirect the launched program's stderr into a file (2>'<path>', the last thing on the command line and the only redirect accepted): revdiff 0.8.23 and later capture it there because the overlay closes the moment a fast-failing viewer exits, taking the error text with it. That file is a second path the host opens for writing on sandboxed code's word, so it is confined to the shared temp directory the same way the sentinel is, and set -C covers it the same way. One extra step goes with it - the launcher creates the file with mktemp, and noclobber refuses to open a path that already exists, so devsandbox unlinks it before the run and lets the redirect create it fresh. That unlink cannot be steered outside the shared directory by a symlinked component in the middle of the path, which a purely textual bound would not catch; such a launch is denied instead.
One consequence is user-visible: the command shown in the herdr pane names the relocated copy, not the original path. Relocated scripts are removed when the sandbox exits.
"Host-only" is checked, not assumed. The relocation directory is refused if it falls inside anything the sandbox can write at the same path - the sandbox home, the shared temp directory, or the project directory, which is bind-mounted read-write at the path the host knows it by. A project directory that contains the relocation root (running devsandbox from ~/.cache, say) therefore starts the session with a warning and no relocator, and every script launch is denied for that session; the rest of the herdr capabilities are unaffected. The comparison takes each path under both its literal spelling and the one it resolves to, so a project entered through a symlink is caught as well - devsandbox is told the link's name while the mount binds the tree behind it, and only one of those two names would otherwise ever be compared.
The program named inside the script is pinned to its resolved absolute path, for the same reason described in the kitty section, and any KEY=VAL prefix ahead of it is held to the same allowlist - see The environment prefix.
Agent session capture and restore¶
herdr can remember which agent a pane was running and, after the server restarts, resume that agent by typing its own resume command back into the restored pane (claude --resume <id>, pi --session <id>, codex resume <id>). Both halves need help to work for an agent running inside devsandbox: the report has to cross the filtered proxy, and the resume command has to re-enter the sandbox instead of running the agent on the host.
What agent_reporting permits. Exactly three methods, all of which only tell herdr what the pane is running:
| Method | Sent by | Purpose |
|---|---|---|
pane.report_agent_session |
Claude, Pi, Codex | the agent's native session ID and/or transcript path |
pane.report_agent |
Pi | the same, plus a status label (idle, working, blocked) |
pane.release_agent |
Pi | the agent is exiting |
What it deliberately does not permit. No pane reads, no input injection, no host execution, no pane.clear_agent_authority. Every report is bound to two anchors derived on the host, never from the report itself: the pane ID herdr gave this devsandbox process (HERDR_PANE_ID), and the agent devsandbox was asked to launch. A report naming another pane, or claiming to be another agent, is denied. The session ID is restricted to [A-Za-z0-9._-] and capped at 128 bytes - herdr shell-quotes that value and types it into a host pane shell on restore, so it is treated as hostile input rather than trusted to a third-party quoter. The transcript path must be absolute, control-character free, restricted to [A-Za-z0-9._-] plus /, contain no .., and sit inside that agent's own session directory (~/.claude/projects, ~/.pi/agent/sessions, ~/.codex/sessions), so a report cannot name auth.json. The path carries the same charset restriction as the session ID because herdr persists a path-kind session ref exactly as it persists an ID and types either back into a host pane shell on restore - and Pi reports only a path. Confinement bounds where the path points, not what it is named, so a session under a directory whose name falls outside that charset is not captured.
Activation. agent_reporting is enabled only for a direct devsandbox <agent> launch inside a herdr pane - both anchors have to exist. Running devsandbox to get a shell and then typing claude inside it leaves the launched agent unknown, so the capability is never enabled and the in-sandbox integration's report is denied. devsandbox tools check herdr reports whether agent reporting is active and, when it is not, which anchor is missing.
Setting up restore. Capture alone is not enough: herdr's resume command is compiled into its binary as a bare claude --resume <id>, which would run the host agent against a different, host-side session store. herdr delivers that command as typed input to the pane's shell, so the shell wrappers intercept it and re-enter the sandbox.
- Add the activation line to the startup file of the shell herdr starts panes with, which is not necessarily your login shell - herdr uses
[terminal] default_shell, falling back to$SHELL.devsandbox agent-wrappers activate <shell>prints the definitions for any supported shell, and the line to add is indevsandbox agent-wrappers --help. - Restart the herdr server so new panes pick up the wrappers.
What is written on the host. Launching an agent from a herdr pane records one JSON file per pane under $XDG_STATE_HOME/devsandbox/herdr-panes/ (~/.local/state/devsandbox/herdr-panes/ when unset), directory 0700, files 0600. Each record names the pane, the agent, the project directory and the sandbox state root that launch used - it is what the restore guard below compares against. The pane ID is hashed into the filename, so an opaque herdr-supplied string never influences a path, and the store lives outside every path mounted into the sandbox, so sandboxed code cannot read or write it. Records are not pruned by devsandbox sandboxes prune or --rm; delete the directory to clear them.
Restoring into the right sandbox. A recorded session is reachable again only from the directory it was launched in: the agent's session store lives in the synthetic home under that launch's sandbox state root, and the agent keys its own sessions by project path. When a resume-shaped command reaches a pane, run-agent checks the record before re-entering, and refuses when the current directory would reach neither the same project directory nor the same sandbox state root. Resume-shaped means herdr's own argv (claude --resume ID, pi --session ID, codex resume ID) plus the agent's own equivalents you may type by hand - claude -c / claude --continue reach the same session store, so the guard sees them too. The error names the directory to change to, or - for a --worktree session - the original invocation to re-run. A record that exists but cannot be read fails closed too; a pane with no record at all re-enters normally.
Rolling it back. Remove the activation line from your rc file. Delete ~/.local/state/devsandbox/herdr-panes/ to drop the pane records. To stop herdr replaying resume commands at all, set resume_agents_on_restore = false under [session] in herdr's config. Setting mode = "disabled" under [tools.herdr] turns off the proxy, and with it capture.
Session IDs are never logged. The proxy logs one line per request carrying the method and the reason for the decision. Neither ever includes the session ID, the transcript path, or any part of a rejected value - including for denied reports, where the rejected string is the interesting one.
Limitations.
- Capture requires a direct
devsandbox <agent>launch, as above. - Interception only works for panes running fish, bash, or zsh. If herdr's
default_shellis something else, no wrapper runs and herdr types the resume command to the host agent. That fails on functionality, not on isolation: the ID names a session inside the sandbox's overlay that the host agent cannot see, so it errors or starts fresh. - An agent left out of the activation line's
--agentsselection is not wrapped, so herdr types its resume command to the host agent. Same failure mode as an unsupporteddefault_shellabove, and equally silent - keepclaude,piandcodexselected if you rely on restore. - fish's wrapper lives in
conf.d, which fish also sources for non-interactivefish -c. bash and zsh wrap interactive shells only. - Sessions launched with
--worktreefail closed on restore, from either directory. Their sandbox state is keyed to the repo root while the session runs in the worktree, so re-entering from the repo root reaches the right state root under the wrong project, and re-entering from the worktree reaches a different state root entirely. Both open a different agent session store, sorun-agentrefuses with a message naming the original invocation to re-run instead. - A resume typed from any other directory is refused for the same reason, with the directory to change to named in the error.
devsandbox --rmdestroys the sandbox state a recorded session lives in. herdr will still have the ID; the resume finds nothing and starts fresh.- A pane that previously ran an unsandboxed agent may carry a herdr-recorded host session ID. Resuming it through the wrapper looks for that ID inside the sandbox, does not find it, and starts a fresh session.
What Gets Mounted¶
| Resource | Mode | Purpose |
|---|---|---|
Proxy socket ($HOME/.run/<pid>/herdr.sock) |
read-write (proxy is local to the sandbox home) | herdr control via the filtering proxy |
Proxy socket, second path ($HOME/.config/herdr/herdr.sock) |
read-write bind mount | the path herdr's client derives on its own, for subcommands that ignore HERDR_SOCKET_PATH |
herdr binary |
read-only | CLI for herdr tab create, herdr pane run, etc. Skipped when the binary already sits under a mounted directory, as mise installs do. |
The host's real herdr socket is not bind-mounted into the sandbox, and neither is the script relocation directory.
The second socket path exists because a few subcommands bypass the environment variable. herdr session list connects directly to <config dir>/herdr.sock and reports the session stopped when that fails - which it always did in the sandbox, since the host socket is never mounted. That probe is connect(2) only, with no protocol traffic, so pointing it at the proxy makes the status correct without any request crossing the filter. It is the same filtered socket under a second name, so it grants no additional reach.
Environment Variables¶
HERDR_SOCKET_PATH- set to$HOME/.run/<pid>/herdr.sockinside the sandbox, and only while the proxy is running. The host value is never exposed.HERDR_ENV,HERDR_SESSION,HERDR_WORKSPACE_ID,HERDR_TAB_ID,HERDR_PANE_ID- passed through from the host as read-only signals about the calling pane.HERDR_WORKSPACE_IDmatters: launchers read it from the environment rather than querying the API, which is why no read-only introspection capability is needed.
Limitations¶
- Streaming methods (
events.subscribe,events.wait,pane.wait_for_output) are denied in this iteration. - Read-only introspection (
pane.list,tab.list,pane.current,session.snapshot) is denied; nothing needs it yet. Commands built on them fail with aforbiddenerror rather than degrading quietly. - Verified against herdr v0.7.4 (protocol 16). A protocol bump can change parameter shapes the filter validates; the failure mode is denial, not bypass.
Run devsandbox tools check herdr to confirm the tool is detected and see the active mode and capabilities.
Zellij Terminal Multiplexer¶
When running inside a zellij session, devsandbox can forward the session socket and ZELLIJ* env vars into the sandbox so zellij run, zellij action, etc. attach to the host multiplexer.
Disabled by default. Unlike kitty, the zellij socket has no capability filtering - exposing it gives sandboxed code unrestricted control over the host multiplexer (execute commands in any pane, read pane contents, swap layouts). Opt in only if you trust the workload.
Configuration¶
Activation¶
When enabled = true, the tool activates if all of:
ZELLIJenv var is set on the host.- The
zellijbinary is on PATH. - A zellij socket directory exists (
$ZELLIJ_SOCKET_DIR,$XDG_RUNTIME_DIR/zellij/, or/tmp/zellij-<uid>/).
What Gets Mounted¶
| Resource | Mode | Purpose |
|---|---|---|
| Zellij socket directory | read-write bind mount | IPC socket for zellij CLI commands |
zellij binary |
read-only | CLI for zellij run, zellij action, etc. |
Environment Variables¶
ZELLIJ, ZELLIJ_SESSION_NAME, ZELLIJ_PANE_ID are forwarded from the host.
Run devsandbox tools check zellij to confirm the tool is detected and see the active state.
XDG Desktop Portal (Linux only)¶
Sandboxed applications can send desktop notifications to the host via XDG Desktop Portal. This uses xdg-dbus-proxy to expose only the notification portal interface - no other D-Bus access is granted.
Requirements¶
xdg-dbus-proxybinary installed on the hostxdg-desktop-portalrunning with a backend (e.g.,xdg-desktop-portal-gtk,xdg-desktop-portal-kde)- A D-Bus session bus available (
DBUS_SESSION_BUS_ADDRESSset)
# Arch Linux
sudo pacman -S xdg-dbus-proxy xdg-desktop-portal xdg-desktop-portal-gtk
# Debian/Ubuntu
sudo apt install xdg-dbus-proxy xdg-desktop-portal xdg-desktop-portal-gtk
# Fedora
sudo dnf install xdg-dbus-proxy xdg-desktop-portal xdg-desktop-portal-gtk
Configuration¶
Notifications are enabled by default when the requirements are met. To disable:
How It Works¶
xdg-dbus-proxystarts as a background process, creating a filtered D-Bus socket- Only
org.freedesktop.portal.Desktopandorg.freedesktop.portal.Notificationinterfaces are allowed through - The proxy socket is bind-mounted into the sandbox at
$XDG_RUNTIME_DIR/.dbus-proxy/bus DBUS_SESSION_BUS_ADDRESSinside the sandbox points to the proxy socket- A
.flatpak-infofile is created soxdg-desktop-portalrecognizes the sandbox as a valid Flatpak-like application
The proxy is started before the sandbox launches and stopped when the sandbox exits. Each session runs its own proxy with its own socket on the host, so starting or exiting a second session for the same project leaves a running session's notifications working.
Checking Portal Status¶
Example output:
Limitations¶
- Linux only (bwrap backend) - not available on macOS Docker backend
- Only the notification portal is currently supported
- The host must have a running D-Bus session bus with a unix socket
Adding Custom Tools¶
To make additional tools available:
- Install via mise (recommended):
-
System tools - Tools in
/usr/bin,/usr/local/binare available -
Project-local tools - Place executables in your project directory
See Also¶
- Sandboxing - how isolation works, overlay filesystem for writable tools
- Sandboxing: Overlay Filesystem - allow mise to install tools inside the sandbox
- Configuration: Tool Settings - git, mise, and Docker config options
- Use Cases: AI Coding Assistants - workflows for Claude Code and Copilot
- Use Cases: Development Workflows - language-specific examples