Configuration¶
Complete configuration reference for devsandbox.
devsandbox can be configured via a TOML file at ~/.config/devsandbox/config.toml.
Getting Started¶
Generate a default configuration file:
This creates ~/.config/devsandbox/config.toml with documented defaults.
Quick Reference¶
| Section | Key Fields | Details |
|---|---|---|
[proxy] |
enabled, port, mitm, extra_env, extra_ca_env |
Proxy Settings |
[proxy.credentials.<name>] |
enabled, source.env/file/value |
Proxy Credentials |
[proxy.redaction] |
enabled, default_action, rules |
Content Redaction |
[proxy.filter] |
default_action, ask_timeout, cache_decisions, rules |
Proxy Mode docs |
[sandbox] |
isolation, base_path, use_embedded, config_visibility |
Sandbox Settings |
[sandbox.docker] |
dockerfile, keep_container, resources (deprecated) |
Isolation Backend |
[sandbox.resources] |
memory, cpus, pids |
Resource Limits |
[sandbox.mounts.rules] |
pattern, mode |
Custom Mounts |
[overlay] |
default |
Overlay Settings |
[port_forwarding] |
enabled, auto_detect, rules |
Port Forwarding |
[tools.git] |
mode, mount_mode |
Tool Settings |
[tools.mise] |
mount_mode, ignore_global_config |
Tool Settings |
[tools.docker] |
enabled, socket |
Tool Settings |
[tools.portal] |
notifications |
Tool Settings |
[tools.kitty] |
mode, extra_capabilities |
Kitty Terminal |
[tools.herdr] |
mode |
herdr Terminal Workspace |
[tools.zellij] |
enabled |
Zellij Terminal Multiplexer |
[logging] |
attributes, receivers |
Remote Logging |
[[include]] |
if, path |
Per-Project Configuration |
Configuration Reference¶
Proxy Settings¶
[proxy]
# Enable proxy mode by default
# Can be overridden with --proxy flag
enabled = false
# Default proxy server port
# Can be overridden with --proxy-port flag
port = 8080
Prerequisite (bwrap and krun). Proxy mode locks the sandbox's egress down
deny-by-default, which needs nft or iptables on the host with the nf_tables
(or ip_tables) and nf_conntrack kernel modules loaded. The modules cannot be
autoloaded from an unprivileged user namespace, so a proxy launch aborts when
the lockdown cannot be applied rather than running with open egress. Check with
devsandbox doctor (the proxy: firewall row); remediate with
sudo modprobe nf_tables nf_conntrack or by installing nftables. Launches
without proxy mode need none of this. Proxy-mode bwrap sandboxes are also
IPv4-only - pasta is invoked with -4, so the IPv4 rule set has no second
address family to miss. See Proxy Mode.
Proxy Extra Environment Variables¶
When proxy mode is active, devsandbox sets standard proxy environment variables
(HTTP_PROXY, HTTPS_PROXY, YARN_HTTP_PROXY, etc.) automatically. For tools
with non-standard proxy configuration, add custom variable names:
Each variable in extra_env is set to the proxy URL (e.g., http://10.0.2.2:8080)
when proxy mode is active.
Proxy Extra CA Environment Variables¶
When proxy mode is active with HTTPS interception, devsandbox sets standard CA bundle
environment variables (SSL_CERT_FILE, NODE_EXTRA_CA_CERTS, REQUESTS_CA_BUNDLE,
CURL_CA_BUNDLE, GIT_SSL_CAINFO) automatically. For tools with non-standard CA
bundle configuration, add custom variable names:
Each variable in extra_ca_env is set to the CA certificate path
(e.g., /tmp/devsandbox-ca.crt for bwrap, /etc/ssl/certs/devsandbox-ca.crt for Docker)
when proxy mode is active.
Proxy Credentials¶
Inject authentication credentials into requests for specific domains. Credentials are read from a configurable source (host env var, file, or static value) and added to matching requests. The token never enters the sandbox.
See Proxy: Credential Injection for how this works and when to use it.
Minimal configuration using the built-in github preset:
[proxy.credentials.github]
enabled = true
# Optional: override the default token source
# [proxy.credentials.github.source]
# env = "DEVSANDBOX_GITHUB_TOKEN"
# file = "~/.config/devsandbox/github-token"
# value = "github_pat_..."
# Optional: replace any header value already on the request.
# Useful when a CLI inside the sandbox (e.g. `gh`) refuses to run without a
# token set - pass a placeholder via env_passthrough while the real token
# stays on the host and is swapped in by the proxy.
# overwrite = true
Custom injector for any other service - no built-in preset required:
[proxy.credentials.gitlab]
enabled = true
host = "gitlab.com"
header = "PRIVATE-TOKEN"
value_format = "{token}"
[proxy.credentials.gitlab.source]
env = "GITLAB_TOKEN"
Fields under [proxy.credentials.<name>]:
| Field | Type | Default | Required when enabled = true |
|---|---|---|---|
enabled |
bool | false |
- |
host |
string (exact or glob) | preset value or "" |
yes |
header |
string (canonicalized) | preset value or "" |
yes |
value_format |
string with {token} placeholder |
preset value or "{token}" |
no |
overwrite |
bool | false |
no |
preset |
string | "" (or section name if it matches a built-in) |
no |
[...source] sub-table |
env / file / value |
preset's default source | no |
Built-in presets:
| Preset | host |
header |
value_format |
Default source |
|---|---|---|---|---|
github |
api.github.com |
Authorization |
Bearer {token} |
env = "GITHUB_TOKEN" (with GH_TOKEN fallback when no explicit source is set) |
A section named after a built-in preset (e.g. [proxy.credentials.github]) auto-applies the preset; user fields override preset defaults.
Source priority: value > env > file. Set exactly one for clarity.
Specificity ordering: when multiple injectors could match the same host, the most-specific one wins (exact > longer literal > shorter glob), tie-broken by name alphabetically.
Overwrite: overwrite = false (default) preserves any existing value of the configured header - safer, but does nothing when the sandboxed tool sets its own. overwrite = true unconditionally replaces the header. Combine with a placeholder env var via [sandbox.environment.<NAME>] to satisfy tools that refuse to start without a token.
Content Redaction¶
Scan outgoing requests for secrets and block or replace them. Only requests that reach the proxy are scanned, and HTTPS only with MITM enabled - see Proxy: Redaction Coverage for the limits, and Proxy: Content Redaction for actions, behavior, and when to use each.
Rule Types¶
Source-based - match exact secret values:
[[proxy.redaction.rules]]
name = "api-key"
action = "block" # Optional: override default_action
[proxy.redaction.rules.source]
env = "API_SECRET_KEY" # Or: file, env_file_key, value
Pattern-based - match regex patterns:
Source types:
| Field | Description |
|---|---|
env |
Host environment variable |
file |
File path (supports ~, whitespace trimmed) |
env_file_key |
Key in project .env file |
value |
Static value in config |
Redaction rules are always additive when merging configs. The default_action uses most-restrictive-wins (block > redact > log).
Log Skip¶
Suppress matching requests from the proxy log entirely (both logs/proxy/requests.jsonl and any configured remote log dispatcher). The request still passes through - only the log entry is dropped. See Proxy: Skipping Log Entries for the use case and behavior.
[[proxy.log_skip.rules]]
pattern = "telemetry.example.com"
# scope defaults to "host", type auto-detects glob
[[proxy.log_skip.rules]]
pattern = "*/v1/traces"
scope = "url"
type = "glob"
Fields:
| Field | Description | Default |
|---|---|---|
pattern |
Pattern to match (exact / glob / regex). Required. | - |
scope |
What to match against: host, path, or url. |
host |
type |
Pattern type: exact, glob, or regex. Auto-detected as regex when the pattern contains regex metacharacters. |
glob |
Skip is absolute: matched entries are dropped even when the request errored, was blocked by the security filter, or triggered a redaction rule. Rules are evaluated in order; first match wins.
Avoiding GitHub Rate Limits¶
On macOS, mise downloads tool releases from GitHub inside a Docker container. Unauthenticated requests are limited to 60/hour. Enable credential injection with a read-only GitHub token to raise this to 5,000/hour.
See Use Cases: Avoiding GitHub Rate Limits for setup instructions.
Isolation Backend¶
[sandbox]
# Isolation backend: "auto", "bwrap", "docker", or "krun"
# - "auto" (default): bwrap on Linux, docker on macOS
# - "bwrap": bubblewrap (Linux only)
# - "docker": Docker containers (Linux, macOS)
# - "krun": libkrun microVM (experimental, opt-in; never auto-selected)
isolation = "auto"
# Docker-specific settings (only used when isolation = "docker")
[sandbox.docker]
# Path to Dockerfile used to build the sandbox image.
# Defaults to ~/.config/devsandbox/Dockerfile (auto-created with FROM ghcr.io/zekker6/devsandbox:latest)
# Can be an absolute path or relative to project directory.
# dockerfile = "/path/to/custom/Dockerfile"
# Keep container after exit for fast restarts (default: true)
# When true: containers are reused, startup ~1-2s
# When false: containers are removed on exit
keep_container = true
krun microVM backend (experimental)¶
bwrap and Docker share the host kernel, so a kernel-level Linux exploit escapes
both. The krun backend runs the same sandbox image inside a libkrun
microVM (podman --runtime krun), placing the workload behind a hardware
virtualization boundary (KVM on Linux, Hypervisor.framework on macOS) with its
own guest kernel. Use it when running genuinely untrusted code, where a
host-kernel exploit must not be able to reach the host.
It reuses the Docker image build, the [sandbox.docker] settings (Dockerfile),
the backend-neutral [sandbox.resources] limits, and the same tool bindings and
proxy wiring. Notes:
- Opt-in only.
autonever selectskrun; you must request it explicitly with--isolation krunorisolation = "krun". The microVM needspodman, thekrunruntime, and/dev/kvm(or Apple Silicon HVF) that most hosts lack, and it trades startup speed for a hardware boundary that only matters for untrusted code, so it is never picked automatically. - Ephemeral. Each launch boots a fresh microVM (no
keep_containerreuse) - a clean guest kernel every run. - VM resource defaults. When no resource limits are configured, krun
applies sane microVM defaults (
memory = "4g",cpus = "2") so the guest is neither starved nor oversized. Setmemory/cpusunder[sandbox.resources]to override; explicit values are always respected. (The docker backend keeps the engine default when unset - the krun defaults do not apply to it.) - No
pidslimit.pidsis ignored on krun, and a launch that configures it prints a warning saying so. A pids limit caps a container's process cgroup, but a krun sandbox is a microVM: the flag would cap the host-side VMM's own threads rather than the processes inside the guest, whose PID space belongs to the guest kernel and cannot be limited from the host. Use the bwrap or docker backend when you need a pids limit.memoryandcpusare unaffected and apply normally. - Runs rootless. The backend uses rootless
podmanwith--userns=keep-id, so files the workload writes to the project directory come back owned by you (not a subuid). Overlay/tmpoverlaytool dirs use copy-on-start rather than kernel overlayfs (the guest rejects an overlayfs mount over virtio-fs; the docker backend uses the same copy for its own reasons). Atmpoverlaydir is reset to the host source on every run - the copy target is cleared first (preserving any nested read-only bindings), so writes from a previous run never persist, matching tmpoverlay's discard-on-exit semantics. - Prerequisites:
podman, acrunbuilt with libkrun (provides thekrunOCI runtime), and access to/dev/kvmon Linux (bare-metal or a host with nested virtualization) or Apple Silicon on macOS. For proxy mode on Linux you also neednftoriptableswith thenf_tables/nf_conntrackmodules loaded (usually already present) - the egress lockdown uses it to port-scope guest access to the proxy, and a krun + proxy launch fails closed without it.devsandboxfails fast with installation guidance for the podman/runtime/KVM prerequisites; the firewall is checked at lockdown time instead, so a missing binary or an unloadable module aborts a krun proxy launch once the guest is up rather than before it. Check it up front withdevsandbox doctor, which reports the firewall as the backend-neutralproxy: firewallrow on Linux (that row gates bwrap proxy mode too, where it is verified pre-launch). On Arch:pacman -S krun. Sanity check:podman run --rm --runtime krun docker.io/library/alpine true. - Disable the Docker tool. Set
[tools.docker] enabled = false- mounting your Docker socket into a microVM meant for untrusted code hands the guest your host Docker, defeating the isolation. - Management commands.
devsandbox doctorreports the krun prerequisites (podman, thekrunruntime,/dev/kvm, aplatformrow when the host OS or CPU architecture cannot run krun at all, and on Linux a systempastabinary for rootless podman networking and/etc/subuid+/etc/subgidranges for--userns=keep-id; thenft/iptablesfirewall proxy mode needs is the separate top-levelproxy: firewallrow, shared with bwrap) as informational rows - unmet oneswarnrather thanerror, since krun is opt-in and must not faildoctorfor bwrap/docker users, and their remediation is printed under a "How to fix" block below the table.devsandbox sandboxes listandsandboxes prunecover krun sandboxes: they are ephemeral (--rm), so they are tracked from on-disk metadata like bwrap rather than enumerated from a container engine.devsandbox forwardis best-effort for krun in this release - the session is registered for forwarding, but reaching a listener inside the guest through the microVM network namespace is not yet validated (see the status note below).
Proxy networking (Linux). On Linux, proxy mode works: the MITM proxy binds to
host loopback (never LAN-reachable) and the guest reaches it through the pasta
gateway 10.0.2.2, mapped to the host's loopback per-VM - the same model the bwrap
backend uses. Allowlist filtering and HTTPS MITM behave as on the other backends.
On macOS proxy mode is refused fail-closed (see Status): the egress lockdown
that forces guest traffic through the proxy is Linux-only, so krun + proxy there
would run with open egress. Run krun without proxy on macOS, or run on Linux.
Security boundary (read this). The microVM gives the workload its own guest
kernel behind a hardware (KVM/HVF) boundary, so a host-kernel exploit cannot
reach the host - that is the reason to use krun. Two things to be aware of in
this experimental release:
- Egress lockdown (host-side, validated on a
/dev/kvmhost). In proxy mode the guest's egress is locked to the proxy gateway with a deny-by-default firewall in the VMM netns (vianft, falling back toiptables): it drops all egress except loopback, established/related return traffic, and TCP to the gateway (10.0.2.2) on the proxy port. Deny-by-default is what makes this structural - a destination is reachable only if a rule names it, so the LAN (router UI, NAS, a LAN DNS resolver used for direct DNS exfiltration), cloud metadata (169.254.169.254) and every non-proxy port of the gateway are all closed without being individually enumerated. Route surgery (keep a/32to the gateway, delete the default route) is applied alongside it, but the firewall is the guarantee: route surgery alone leaves the connected LAN subnet route intact, and--map-host-loopbackmaps every port of the gateway to the host's127.0.0.1(pasta has no port-scoped host-loopback option). If neithernftnoriptablesis available the launch aborts rather than run open. Under libkrun the guest uses TSI (transparent socket interception) - it has no routable interface, so itsconnect()calls are executed by the VMM process in the VMM's pasta network namespace and obey that namespace's routing table. The lockdown therefore runs host-side: after the microVM boots,devsandboxenters the VMM netns (viansenter --user --net, as rootless userns-root) and applies the rules there. The in-guest shim does not touch routes (an in-guestip route delhas nothing to act on under TSI); it only waits for the host to finish (a sentinel in the sandbox home) before running any guest-influenced code, so untrusted code never runs while direct egress is still open. The lockdown is fail-closed (the microVM is torn down and the launch aborts if any step errors), scoped to krun + proxy on Linux. No in-guestNET_ADMINis granted. (The lockdown is IPv4 only: the guest is given IPv4 only - the pasta invocation passes-4- so there is no IPv6 route or IPv6 host-loopback map for the IPv4 firewall to miss. This bounds data exfiltration, not host compromise - the kernel boundary is unaffected either way.) - Status: experimental. The egress lockdown is validated on a
/dev/kvmhost; theforwardpath and macOS (HVF) remain unvalidated. On macOS (HVF) proxy mode is refused fail-closed because the egress lockdown is Linux-only - krun + proxy there would run with open egress. Run krun without proxy on macOS, or run on Linux for the full proxy egress lockdown.
Resource Limits¶
[sandbox.resources] caps what the sandbox may consume. It is backend-neutral -
the same block applies whether you run bwrap, docker or krun.
[sandbox.resources]
# Memory limit (base 1024; a bare integer means bytes). Empty means unlimited.
memory = "4g"
# CPU limit, as a number of cores. "0.5" is half a core. Empty means unlimited.
cpus = "2"
# Maximum number of processes/threads. Zero means unlimited.
pids = 2048
All three fields are optional. Defaults differ by backend:
| Backend | Default when unset | pids |
|---|---|---|
bwrap |
no limits | enforced |
docker |
no limits (the engine default applies) | enforced |
krun |
memory = "4g", cpus = "2" |
not enforceable (see below) |
Only krun applies defaults. On bwrap and docker an unset field means unlimited, so the sandbox behaves exactly as it did before you added the block.
memory bounds the sandbox's resident memory on every backend. What it does
to swap differs, so on a host with swap the same value is not the same
guarantee everywhere:
| Backend | What memory = "4g" bounds |
Runaway allocator on a host with swap |
|---|---|---|
bwrap |
4g resident; swap unbounded | reclaimed into swap and throttled there, and it can keep growing in swap |
docker, krun |
4g resident plus at most 4g swap | killed once resident + swap reaches 8g |
bwrap sets systemd's MemoryMax= and nothing else, which leaves
memory.swap.max at max. docker and krun pass --memory with --memory-swap
unset, and the engine then defaults the combined memory+swap ceiling to twice
the memory value - so swap is capped at the memory value again. On a host with
no swap the two are equivalent: the sandbox is OOM-killed as soon as its
resident set crosses the cap.
Size the value against the resident memory you want to allow, and on a
swap-enabled host expect a bwrap sandbox to be throttled into swap where a
docker one would be killed. memory = "0" is accepted as docker's documented
spelling of unlimited, but it is rejected on bwrap, where a systemd
MemoryMax=0 means no memory at all.
On bwrap, limits are enforced through a systemd transient scope, which needs cgroup v2 and a systemd user manager with the relevant controllers delegated. A limit that cannot be enforced aborts the run rather than running unlimited - see Sandboxing: Resource Limits for the requirements and the failure modes. docker and krun need none of that.
The older [sandbox.docker.resources] block is deprecated but still honored,
and it stays scoped to the container backends: docker and krun read it,
bwrap does not. That scoping is deliberate. bwrap never honored the
docker-scoped section, and it refuses to launch when a configured limit cannot be
enforced - so applying the old section to it would turn a config written for the
docker backend into a failed launch on any host missing the systemd requirements
above. Opting bwrap into enforcement takes an explicit [sandbox.resources]
block. For docker and krun the two blocks are merged field by field, and the
newer section wins on any field it sets - setting only pids in
[sandbox.resources] does not discard a memory value you already have under
[sandbox.docker.resources]. Using the deprecated block prints a one-line
warning at startup. It has no pids field; use [sandbox.resources] for that.
OOM Tracking¶
No configuration needed. On bwrap it is active whenever resource limits are enabled, which is what gives the sandbox a cgroup of its own; on docker and krun the container always has one, so it is always active.
Three things happen when a kill is observed:
- A message on stderr as it happens, naming whether the sandbox itself went down or a process inside it was killed while the sandbox carried on. The second case is the common one under a memory limit, and it is the one that used to look like an agent quietly disappearing.
- A
sandbox.oomaudit event, with the kill count and whether it was fatal. It goes wherever your logging destinations point. - A record in the sandbox's metadata, which
devsandbox sandboxes listshows in itsSTATUScolumn asoom-killed(the sandbox died) oroom-kills(N)(it survived). This is what outlives the killed process; it is cleared when the next session on that sandbox starts, and it is in--jsonoutput too.
All three backends are watched the same way - an inotify watch on the cgroup's
memory.events - but what that cgroup can see differs, and three cases are not
covered:
- A container with
keep_container = false. That launch is an anonymousdocker run --rm, so the engine cannot be asked for its PID or its ID and there is nothing to resolve a cgroup from. The default (keep_container = true) is covered, including kills of processes started bydocker exec. - An OOM inside a krun guest. The host cgroup bounds the microVM, so a host OOM kill of the VM is reported, but memory pressure inside the guest is decided by the guest kernel and never reaches a host counter.
- macOS. The container engine's PIDs belong to the VM its daemon runs in, and there is no host cgroup hierarchy to resolve them against.
A bwrap sandbox with no limits is not watched at all, and gets a weaker signal
instead: if it exits on SIGKILL, devsandbox reports that and says explicitly that
it cannot tell whether the OOM killer was responsible. Configure a memory limit
to get an answer. A sandbox that is watched and exits on SIGKILL with no OOM
counter behind it gets nothing said about it - its own cgroup already answered the
question, so that is an ordinary kill -9 and devsandbox stays as quiet about it
as a shell does.
Sandbox Settings¶
[sandbox]
# Base directory for sandbox data
# Defaults to ~/.local/share/devsandbox
# base_path = "~/.local/share/devsandbox"
# Use embedded bwrap and pasta binaries (Linux only, default: true)
# When false, only system-installed binaries are used.
# use_embedded = true
# Pass host environment variables into the sandbox.
# Listed variables are copied from the host; unset variables are silently skipped.
# env_passthrough = ["MY_API_KEY", "CUSTOM_TOOL_CONFIG"]
# Control visibility of .devsandbox.toml inside the sandbox
# - "hidden" (default): config file is not visible to sandboxed processes
# - "readonly": config file is visible but read-only
# - "readwrite": config file is visible and writable
config_visibility = "hidden"
Sandbox Environment Variables¶
[sandbox.environment.<NAME>] sets explicit env vars inside the sandbox using the same source model as proxy credentials (value / env / file, priority value > env > file).
[sandbox.environment.GH_TOKEN]
value = "placeholder"
[sandbox.environment.PINNED_TOKEN]
env = "HOST_VAR_NAME"
[sandbox.environment.FROM_FILE]
file = "~/.config/devsandbox/token"
Resolution semantics:
value = "x"- literal string passed to the sandbox.env = "X"with hostXunset - variable is skipped entirely (same asenv_passthrough).env = "X"with hostXset - the host value is passed (empty string is passed as empty).file = "..."that cannot be read - startup error.
Conflict with env_passthrough: declaring the same variable name in both env_passthrough and environment is a configuration error - devsandbox fails startup with a message naming the variable. Declare each variable in exactly one place.
Custom Mounts¶
Control how specific paths are mounted. See Sandboxing: Custom Mounts for mount modes, pattern syntax, and mount ordering.
[[sandbox.mounts.rules]]
pattern = "~/.config/myapp"
mode = "readonly"
[[sandbox.mounts.rules]]
pattern = "**/secrets/**"
mode = "hidden"
[[sandbox.mounts.rules]]
pattern = "~/.cache/myapp"
mode = "overlay"
Note: The hidden mode only works for files - a directory cannot be replaced with /dev/null, and no other mode conceals
a directory's contents either (readonly, overlay, and tmpoverlay all keep the host files readable). To keep a
directory's contents out of the sandbox, write a pattern that matches the files inside it: **/secrets/** above hides
every file under any secrets directory at any depth (the directory entries themselves stay visible). A pattern that
resolves to the directory alone - secrets/**, ~/secrets - hides nothing, and devsandbox warns at startup when one does.
Port Forwarding¶
Forward TCP/UDP ports between host and sandbox. Requires network isolation (proxy mode).
[port_forwarding]
enabled = true
# Inbound: host can connect to services running inside sandbox
[[port_forwarding.rules]]
name = "devserver"
direction = "inbound"
protocol = "tcp"
host_port = 3000
sandbox_port = 3000
# Outbound: sandbox can connect to services on host
[[port_forwarding.rules]]
name = "database"
direction = "outbound"
host_port = 5432
sandbox_port = 5432
Directions¶
| Direction | Description | Example Use Case |
|---|---|---|
inbound |
Host connects to sandbox (host:port → sandbox:port) | Access dev server from browser |
outbound |
Sandbox connects to host (via gateway IP 10.0.2.2) | Connect to database in Docker |
Fields¶
| Field | Required | Default | Description |
|---|---|---|---|
name |
No | Auto | Identifier for the rule |
direction |
Yes | - | inbound or outbound |
protocol |
No | tcp |
tcp or udp |
host_port |
Yes | - | Port on host side (1-65535) |
sandbox_port |
Yes | - | Port on sandbox side (1-65535) |
Note: Port forwarding requires proxy mode. On bwrap, only proxy mode gives the sandbox its own network namespace (via pasta); without it the sandbox shares the host network stack and its ports are already reachable on 127.0.0.1, so there is nothing to forward. Docker and krun always place the workload in its own network namespace, but neither wires static rules - see backend support below.
Backend support: static [[port_forwarding.rules]] are wired for the bwrap backend only - they become pasta port arguments at launch, and a bwrap run with rules but no network isolation fails with an error. Auto-detection (auto_detect) also covers krun + proxy sessions, where it is best-effort (see krun microVM backend). The Docker backend does not wire port forwarding in either form.
Outbound rules must be declared. In proxy mode the sandbox's egress is locked down deny-by-default, and the gateway 10.0.2.2 is reachable only on ports a rule names: the proxy port, plus one port per configured outbound rule. Every other host loopback port is closed at the gateway. Before the lockdown, --map-host-loopback made every host loopback port reachable at 10.0.2.2 whether or not it was configured, so a host service you reached that way without a rule now needs one. Declaring the rule is the fix - the accept it adds is scoped to exactly that port and protocol.
Examples¶
Development Server (inbound)
Access a web server running inside the sandbox from your host browser:
[[port_forwarding.rules]]
name = "nextjs"
direction = "inbound"
host_port = 3000
sandbox_port = 3000
Then run devsandbox --proxy npm run dev and open http://localhost:3000 on host.
Database Access (outbound)
Connect to PostgreSQL running on the host from inside the sandbox:
[[port_forwarding.rules]]
name = "postgres"
direction = "outbound"
host_port = 5432
sandbox_port = 5432
Inside sandbox, connect to 10.0.2.2:5432 (pasta gateway IP on Linux) or host.docker.internal:5432 (Docker backend on macOS).
Dynamic Port Forwarding¶
[port_forwarding]
# Automatically detect and forward listening ports inside the sandbox
auto_detect = false
# How often to scan for new listening ports
scan_interval = "2s"
# Ports to never auto-forward (e.g., internal services)
exclude_ports = [22, 80, 443]
Ports can also be forwarded on-the-fly to running sandboxes. See Sandboxing: Runtime Port Forwarding for devsandbox forward and auto-detect behavior.
Overlay Settings¶
Global overlayfs settings control the default mount mode for all tool bindings:
[overlay]
# Default mount mode for tool bindings.
# Accepted values: "split" (default), "overlay", "tmpoverlay", "readonly", "readwrite"
#
# split - configs → tmpoverlay (discarded on exit); caches/data/state → persistent overlay
# overlay - all bindings → persistent overlay (writes saved to sandbox home)
# tmpoverlay - all bindings → tmpoverlay (writes discarded on sandbox exit)
# readonly - all bindings → read-only bind mount to host
# readwrite - all bindings → read-write bind mount to host
default = "split"
Why split Is the Default (Supply Chain Security)¶
The split default protects your host from malicious packages installed inside the sandbox. A compromised package running under a sandboxed package manager (npm, pip, cargo, mise, etc.) could attempt to poison your host configs - for example, injecting a backdoor into ~/.gitconfig, ~/.npmrc, or shell startup files.
With split, config directories are mounted via tmpoverlay: any writes to them are discarded when the sandbox exits. Caches, data, and state directories use a persistent overlay so tool installations survive across sessions without touching your real host paths. Your host config files are never modified by sandboxed processes.
Use a broader mode only when you explicitly trust the project and need write-through to the host.
Tool-Specific Configuration¶
Each tool can have its own configuration section under [tools.<name>].
Git¶
[tools.git]
# Git access mode:
# - "readonly" (default): safe gitconfig with only user.name/email, no credentials
# - "readwrite": full access with credentials, SSH keys, GPG keys
# - "disabled": no git configuration (git commands work without user config)
mode = "readonly"
# Override the global [overlay] default for this tool's bindings (optional).
# Accepted values: "split", "overlay", "tmpoverlay", "readonly", "readwrite"
# mount_mode = "readwrite"
Mode Details:
| Mode | gitconfig | Credentials | SSH Keys | GPG Keys | Use Case |
|---|---|---|---|---|---|
readonly |
Safe copy | No | No | No | Default, maximum isolation |
readwrite |
Full | Read-only | Read-only | Read-only | Trusted projects, push/sign |
disabled |
None | No | No | No | Fully anonymous git |
In readwrite mode, SSH and GPG directories are mounted read-only to protect private keys
while still allowing git operations that need them.
Mise¶
[tools.mise]
# Ignore the host's global mise config (~/.config/mise/config.toml) inside the
# sandbox. Default false (the global config is respected). Set true when a large
# global config with `@latest` npm:/go:/pipx: tools makes the sandbox hang or OOM:
# on a proxy/egress-locked sandbox mise resolves each `@latest` spec over the
# network at every shell start, which times out and can exhaust guest memory.
# With this on, only the project `.mise.toml`, the image's system config (baked
# node), and `~/.config/mise/settings.toml` apply. Covers bwrap, docker, and krun.
ignore_global_config = true
# Override the global [overlay] default for mise's bindings (optional).
# The global default ("split") is recommended: mise data/cache directories use a
# persistent overlay so installed tools survive across sessions, while mise config
# files are protected via tmpoverlay. See Per-Tool Mount Mode Override below.
# mount_mode = "overlay"
ignore_global_config scopes shell and runtime mise invocations inside the sandbox.
The docker backend's boot-time project-tool install already runs with
MISE_GLOBAL_CONFIG_FILE=/dev/null regardless of this setting, so it is unaffected
either way.
Per-Tool Mount Mode Override¶
Each tool supports a mount_mode field that overrides the global [overlay] default for that tool's bindings:
[tools.git]
mount_mode = "readwrite" # Override global default for this tool
[tools.claude]
mount_mode = "disabled" # Don't mount any claude config into the sandbox
Valid per-tool values: split, overlay, tmpoverlay, readonly, readwrite, disabled.
The disabled value prevents the tool's config/cache/data directories from being mounted entirely - the tool won't have access to any host configuration. This is useful for tools you have installed on the host but don't want visible inside the sandbox.
Migrating Overlay Data to Host¶
Accumulated overlay data can be promoted to the host filesystem. See Sandboxing: Migrating Overlay Data for the devsandbox overlay migrate command reference.
Docker¶
[tools.docker]
# Enable Docker socket proxy (disabled by default)
# When enabled, provides read-only access to Docker daemon
enabled = false
# Path to host Docker socket (optional)
# On Linux: defaults to /run/docker.sock
# On macOS: auto-detected (Docker Desktop, OrbStack, Colima)
# Set explicitly to override auto-detection:
# socket = "/path/to/docker.sock"
Note: Docker access is read-only. You can list/inspect containers, view logs, and exec into running containers, but cannot create, delete, or modify containers. Only Unix socket access is supported; TCP connections to remote Docker daemons are not proxied.
Security Warning: Enabling Docker socket forwarding grants the sandbox read access to all Docker state and the ability to exec into any container on the host. See Docker Socket Forwarding for details.
See docs/tools.md for full details on allowed operations.
XDG Desktop Portal (Linux only)¶
[tools.portal]
# Allow sandboxed apps to send desktop notifications via xdg-desktop-portal.
# Requires: xdg-dbus-proxy, xdg-desktop-portal + a backend (e.g., -gtk, -kde)
# Default: true (enabled when requirements are met)
notifications = true
When enabled, a filtered D-Bus proxy exposes only the notification portal interface to the sandbox. No other D-Bus services are accessible.
See docs/tools.md for requirements and details.
Remote Logging¶
Proxy request logs can be forwarded to remote destinations for centralized logging and monitoring. Multiple receivers can be configured simultaneously.
Global Attributes¶
Add custom attributes to all log entries:
[logging]
[logging.attributes]
environment = "development"
hostname = "myworkstation"
team = "platform"
These attributes are included in:
- OTLP resource attributes
- Syslog structured data
Local Syslog¶
Send logs to the local syslog daemon:
[[logging.receivers]]
type = "syslog"
facility = "local0" # local0-local7, user, daemon, etc.
tag = "devsandbox" # Syslog tag/program name
Facility options: kern, user, mail, daemon, auth, syslog, lpr, news, uucp, cron, authpriv,
ftp, local0-local7
Remote Syslog¶
Send logs to a remote syslog server:
[[logging.receivers]]
type = "syslog-remote"
address = "logs.example.com:514"
protocol = "udp" # "udp" or "tcp"
facility = "local0"
tag = "devsandbox"
OpenTelemetry (OTLP)¶
Send logs to an OpenTelemetry collector.
HTTP Protocol¶
[[logging.receivers]]
type = "otlp"
endpoint = "http://localhost:4318/v1/logs"
protocol = "http"
batch_size = 100
flush_interval = "5s"
# Optional: non-secret metadata headers (stored verbatim in the config)
headers = { "X-Team" = "platform" }
Authenticating to an Auth-Enforced Endpoint¶
To send logs to an endpoint that requires an auth header, use header_sources
instead of headers. Sources resolve at runtime from a host environment
variable, a file, or a literal value, so secrets stay out of the config file
(and out of the sandbox - header sources are resolved on the host).
[[logging.receivers]]
type = "otlp"
endpoint = "https://otel.example.com/v1/logs"
protocol = "http"
# Resolve Authorization from a host env var (e.g. OTLP_AUTH_TOKEN="Bearer abc…")
[logging.receivers.header_sources.Authorization]
env = "OTLP_AUTH_TOKEN"
# Or read from a file (whitespace is trimmed; ~ is expanded)
[logging.receivers.header_sources."X-API-Key"]
file = "~/.config/devsandbox/otlp-key"
Each source must set exactly one of value, env, or file (priority:
value > env > file). If a source resolves to an empty string (e.g. the
env var is unset or the file is empty), startup fails - devsandbox will not
silently send unauthenticated logs.
When the same header name appears in both headers and header_sources, the
source value wins.
gRPC Protocol¶
[[logging.receivers]]
type = "otlp"
endpoint = "localhost:4317"
protocol = "grpc"
insecure = true # Disable TLS for local testing
batch_size = 100
flush_interval = "5s"
OTLP with TLS¶
[[logging.receivers]]
type = "otlp"
endpoint = "otel.example.com:4317"
protocol = "grpc"
insecure = false # Enable TLS (default)
batch_size = 100
flush_interval = "5s"
OTLP Resource Attributes¶
OTLP logs automatically include these resource attributes:
| Attribute | Value |
|---|---|
service.name |
devsandbox |
service.version |
Build version (e.g., 1.0.0) |
service.commit |
Git commit hash |
service.dirty |
true if built with uncommitted changes |
service.dirty_hash |
Hash of uncommitted changes (if dirty build) |
Plus any custom attributes from [logging.attributes].
Multiple Receivers¶
Configure multiple receivers to send logs to different destinations:
[logging]
[logging.attributes]
environment = "development"
# Send to local syslog
[[logging.receivers]]
type = "syslog"
facility = "local0"
tag = "devsandbox"
# Also send to OTLP collector
[[logging.receivers]]
type = "otlp"
endpoint = "http://localhost:4318/v1/logs"
protocol = "http"
batch_size = 100
flush_interval = "5s"
# And to remote syslog for compliance
[[logging.receivers]]
type = "syslog-remote"
address = "compliance-logs.internal:514"
protocol = "tcp"
facility = "local1"
tag = "devsandbox-audit"
Logging Errors¶
If remote logging fails (network issues, authentication errors, etc.), errors are logged locally to:
View logging errors:
Audit Logging¶
Every dispatched log entry - proxy request logs, isolator (builder/mounts/docker) logs, the new wrapper banners, and synthesized lifecycle/security events - carries a fixed set of per-session fields suitable for ad-hoc audit query.
Per-entry session fields¶
| Field | Type | Source |
|---|---|---|
session_id |
UUIDv7 string | Generated once per devsandbox claude invocation. Sortable by time. |
sandbox_name |
string | Auto-resolved when proxy is enabled (e.g., bold-falcon-12); may be empty when proxy is disabled and --name was not passed. |
sandbox_path |
string | Sandbox root directory under ~/.local/share/devsandbox/. |
project_dir |
string | The user's working directory mounted into the sandbox. |
isolator |
string | bwrap or docker. |
pid |
int | Wrapper process PID. |
devsandbox_version |
string | Build-injected internal/version.Version. |
These fields are injected by the dispatcher at write time. They appear on both OTLP (as record attributes) and syslog (inside the existing Fields JSON object - see "Syslog payload shape" below).
Lifecycle events¶
Two synthesized entries bookend each session.
session.start (level: info, event=session.start) is emitted once after the dispatcher and notice sink are wired up. Payload:
| Field | Description |
|---|---|
host |
os.Hostname() |
host_user |
user.Current().Username |
proxy_enabled |
bool |
proxy_port |
int (omitted when proxy is disabled) |
proxy_mitm |
bool |
filter_mode |
off / allow / block / ask |
filter_rule_count |
int |
redaction_rule_count |
int |
log_skip_rule_count |
int |
credential_injectors |
[]string - names only, no resolved values |
command |
wrapped command argv joined with spaces |
tty |
bool - was stdin a terminal at startup |
start_time |
RFC3339 timestamp |
session.end (level: info, event=session.end) is emitted from a deferred function before the dispatcher is closed. Payload:
| Field | Description |
|---|---|
exit_code |
int - 0 on normal exit, the wrapped command's exit code on failure (extracted from *exec.ExitError), -1 on signal-driven shutdown, 1 on generic error |
duration_ms |
int - time.Since(start).Milliseconds() |
end_time |
RFC3339 timestamp |
proxy_request_count |
int - non-skipped requests handled by the proxy (0 if proxy disabled) |
Security events¶
Each event is dispatched through the same path with event=<name> set as a Field. Secret values are deliberately excluded from every event - only metadata (rule names, header names, hosts) appears.
| Event | Level | Trigger | Payload |
|---|---|---|---|
proxy.filter.decision |
info (allow) / warn (block, ask) |
Filter engine evaluates a request | host, method, path (path-only - query string stripped), rule_action, rule_id, default_action_used |
proxy.redaction.applied |
info |
One event per match when the redaction engine rewrites or blocks | host, secret_kind (rule name), location (url / body / header:<name>), rule_id |
proxy.credential.injected |
info |
Credential injector successfully writes an auth header | host, injector (name), header_name |
proxy.mitm.bypass |
info |
First CONNECT to a host in no-MITM mode (deduped per host per session) | host, reason (currently always global) |
mount.decision |
info |
One event per successfully resolved mount, emitted from the mounts engine | source, dest, mode (readonly / readwrite / tmpoverlay / overlay / hidden), policy (persistent / scratchpad / runtime), pattern |
notice.overflow |
warn |
The notice ring buffer (256 entries) overflowed before the dispatcher was attached | dropped (count), component=wrapper |
Note on filter decision volume: by default, only block / ask decisions emit events. allow decisions are gated behind [logging] log_filter_decisions = true so the audit log isn't flooded by routine traffic. Enable for short audit windows only.
Wrapper notice events¶
User-facing wrapper output (notice.Info / notice.Warn / notice.Error) - startup banners, MITM warnings, container lifecycle messages, proxy runtime errors - is forwarded through the dispatcher with component=wrapper and the configured level. Lines emitted before the dispatcher is wired up are buffered (max 256 entries) and drained when the dispatcher attaches.
Syslog payload shape¶
The existing syslog writer JSON-encodes each entry via json.Marshal(entry), producing one record per syslog line in the shape:
{
"Timestamp": "2026-04-29T10:00:00Z",
"Level": "info",
"Message": "session.start",
"Fields": {
"event": "session.start",
"session_id": "01HF...",
"sandbox_name": "bold-falcon-12",
"host": "...",
"proxy_enabled": true,
...
}
}
Per-session fields and event-specific fields appear inside Fields. json.Marshal sorts map keys deterministically, so syslog text is grep-friendly.
Example LogsQL queries (VictoriaLogs)¶
# All deny decisions in the last hour
{event="proxy.filter.decision"} | unpack_json | rule_action="block"
# Every secret redaction by rule
{event="proxy.redaction.applied"} | unpack_json | stats count() by (secret_kind)
# Sessions that ran with MITM disabled
{event="session.start"} | unpack_json | proxy_mitm="false"
# A single session's full audit trail
{session_id="01HF..."} | sort by (Timestamp)
Configuration flag¶
[logging]
# When true, every filter decision (allow/block/ask) emits a
# proxy.filter.decision event. When false (default), only block and ask
# decisions emit events.
log_filter_decisions = false
Complete Example¶
# ~/.config/devsandbox/config.toml
[sandbox]
# Use auto-detection (bwrap on Linux, docker on macOS)
isolation = "auto"
# Use custom location for sandbox data
# base_path = "/data/devsandbox"
# Docker settings (used when isolation = "docker")
[sandbox.docker]
# Uses default Dockerfile at ~/.config/devsandbox/Dockerfile
# Uncomment to use a custom Dockerfile:
# dockerfile = "/path/to/custom/Dockerfile"
keep_container = true # Keep containers for fast restarts
# Resource limits, honored by every isolation backend
[sandbox.resources]
memory = "4g"
cpus = "2"
pids = 2048
[proxy]
# Enable proxy mode by default for this machine
enabled = true
port = 8080
# Inject GitHub token into API requests (keeps token out of sandbox)
[proxy.credentials.github]
enabled = true
# Scan outgoing requests for secrets
[proxy.redaction]
enabled = true
default_action = "block"
[[proxy.redaction.rules]]
name = "anthropic-key"
[proxy.redaction.rules.source]
env = "ANTHROPIC_API_KEY"
[overlay]
# Default mount mode for all tool bindings (split is the secure default)
default = "split"
[tools.git]
# Use readonly mode for most projects
mode = "readonly"
[tools.mise]
# Use default mount mode (split): mise data persists, config files are protected
[port_forwarding]
# Enable port forwarding for dev server access
enabled = true
# Forward dev server for browser access
[[port_forwarding.rules]]
name = "devserver"
direction = "inbound"
host_port = 3000
sandbox_port = 3000
# Custom mount rules
[[sandbox.mounts.rules]]
pattern = "~/.config/myapp"
mode = "readonly"
[[sandbox.mounts.rules]]
pattern = "**/credentials/**"
mode = "hidden"
[logging]
# Custom attributes for all log entries
[logging.attributes]
environment = "development"
hostname = "dev-laptop"
user = "alice"
# Local syslog for immediate visibility
[[logging.receivers]]
type = "syslog"
facility = "local0"
tag = "devsandbox"
# OTLP for centralized monitoring
[[logging.receivers]]
type = "otlp"
endpoint = "http://otel-collector.internal:4318/v1/logs"
protocol = "http"
batch_size = 50
flush_interval = "10s"
headers = { "X-Team" = "platform" }
Environment Variables¶
Some settings can be overridden via environment variables:
| Variable | Description |
|---|---|
DEVSANDBOX_DEBUG |
Enable debug output (1 to enable) |
Per-Project Configuration¶
devsandbox supports two mechanisms for per-project settings:
Conditional Includes¶
Add [[include]] blocks to your global config to apply different settings based on project location:
# ~/.config/devsandbox/config.toml
# Default settings
[proxy]
enabled = false
# Work projects: enable proxy by default
[[include]]
if = "dir:~/work/**"
path = "~/.config/devsandbox/work.toml"
# Client projects: strict filtering
[[include]]
if = "dir:~/clients/acme/**"
path = "~/.config/devsandbox/acme.toml"
Pattern syntax:
dir:prefix required*matches any single directory level**matches any number of directories (recursive)~expands to home directory
Include file format:
- Same structure as main config
- Nested
[[include]]blocks are ignored - Missing include files produce a warning and are skipped. Parse errors in include files are fatal.
Local Config Files¶
Create a .devsandbox.toml in your project root:
Security: Local configs require trust approval. When you first run devsandbox in a directory with .devsandbox.toml, you'll see a prompt:
Local config found: .devsandbox.toml
[proxy]
enabled = true
[tools.git]
mode = "readwrite"
Trust this configuration? [y/N]:
If the file changes, you'll be prompted again.
Managing trust:
# List trusted directories
devsandbox trust list
# Trust config in current directory (for CI/scripts)
devsandbox trust add
# Trust config in a specific directory
devsandbox trust add /path/to/project
# Remove trust for current directory
devsandbox trust remove
# Remove trust for a specific directory
devsandbox trust remove /path/to/project
Non-interactive mode: When running non-interactively (e.g., via an AI assistant or in CI), untrusted local configs are skipped with a warning. Pre-approve configs with devsandbox trust add before running in non-interactive mode.
Config Priority¶
Settings are merged in this order (later overrides earlier):
- Built-in defaults (secure defaults, no proxy, bwrap on Linux / docker on macOS)
- Global config (
~/.config/devsandbox/config.toml) - Matching includes (in order they appear)
- Local config (
.devsandbox.toml) - Command line flags (highest priority)
CLI flag examples:
# Override proxy setting from config
devsandbox --proxy # Enable even if config has enabled = false
# Override port
devsandbox --proxy --proxy-port 9090
# Ephemeral mode - remove sandbox state after exit
devsandbox --rm # Docker: don't keep container; bwrap: remove sandbox home
Merge rules:
- Scalar values: later source wins
- Maps (
[tools]): deep merge - Arrays (
[[proxy.filter.rules]]): concatenate (later rules have higher priority) - Redaction: most-restrictive-wins - later configs can enable but never disable;
default_actiontakes the higher severity (block>redact>log); rules are always additive
See Also¶
- Sandboxing - security model, custom mounts, overlay filesystem details
- Proxy Mode - proxy usage, log viewing, HTTP filtering
- Tools - tool-specific behavior (git modes, mise, Docker socket proxy)
- Use Cases - practical workflows using these configuration options