Skip to content

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:

devsandbox config init

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:

[proxy]
enabled = true
extra_env = ["GRADLE_OPTS_PROXY", "MY_CUSTOM_PROXY"]

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:

[proxy]
enabled = true
extra_ca_env = ["MY_TOOL_CA_BUNDLE", "CUSTOM_SSL_CERT"]

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.

[proxy.redaction]
enabled = true
default_action = "block"  # "block", "redact", or "log"

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:

[[proxy.redaction.rules]]
name = "openai-keys"
action = "redact"
pattern = "sk-[a-zA-Z0-9]{20,}"

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.

[sandbox]
isolation = "krun"

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. auto never selects krun; you must request it explicitly with --isolation krun or isolation = "krun". The microVM needs podman, the krun runtime, 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_container reuse) - 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. Set memory/cpus under [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 pids limit. pids is 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. memory and cpus are unaffected and apply normally.
  • Runs rootless. The backend uses rootless podman with --userns=keep-id, so files the workload writes to the project directory come back owned by you (not a subuid). Overlay/tmpoverlay tool 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). A tmpoverlay dir 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, a crun built with libkrun (provides the krun OCI runtime), and access to /dev/kvm on Linux (bare-metal or a host with nested virtualization) or Apple Silicon on macOS. For proxy mode on Linux you also need nft or iptables with the nf_tables/nf_conntrack modules 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. devsandbox fails 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 with devsandbox doctor, which reports the firewall as the backend-neutral proxy: firewall row 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 doctor reports the krun prerequisites (podman, the krun runtime, /dev/kvm, a platform row when the host OS or CPU architecture cannot run krun at all, and on Linux a system pasta binary for rootless podman networking and /etc/subuid+/etc/subgid ranges for --userns=keep-id; the nft/iptables firewall proxy mode needs is the separate top-level proxy: firewall row, shared with bwrap) as informational rows - unmet ones warn rather than error, since krun is opt-in and must not fail doctor for bwrap/docker users, and their remediation is printed under a "How to fix" block below the table. devsandbox sandboxes list and sandboxes prune cover krun sandboxes: they are ephemeral (--rm), so they are tracked from on-disk metadata like bwrap rather than enumerated from a container engine. devsandbox forward is 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/kvm host). In proxy mode the guest's egress is locked to the proxy gateway with a deny-by-default firewall in the VMM netns (via nft, falling back to iptables): 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 /32 to 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-loopback maps every port of the gateway to the host's 127.0.0.1 (pasta has no port-scoped host-loopback option). If neither nft nor iptables is available the launch aborts rather than run open. Under libkrun the guest uses TSI (transparent socket interception) - it has no routable interface, so its connect() 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, devsandbox enters the VMM netns (via nsenter --user --net, as rootless userns-root) and applies the rules there. The in-guest shim does not touch routes (an in-guest ip route del has 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-guest NET_ADMIN is 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/kvm host; the forward path 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.oom audit 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 list shows in its STATUS column as oom-killed (the sandbox died) or oom-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 --json output 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 anonymous docker 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 by docker 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 host X unset - variable is skipped entirely (same as env_passthrough).
  • env = "X" with host X set - 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:

~/.local/share/devsandbox/<project>/logs/internal/logging-errors.log

View logging errors:

devsandbox logs internal --type logging

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:

# /path/to/project/.devsandbox.toml

[proxy]
enabled = true

[tools.git]
mode = "readwrite"

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):

  1. Built-in defaults (secure defaults, no proxy, bwrap on Linux / docker on macOS)
  2. Global config (~/.config/devsandbox/config.toml)
  3. Matching includes (in order they appear)
  4. Local config (.devsandbox.toml)
  5. 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_action takes 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

Back to docs index | Back to README