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, max_log_body_bytes, extra_env, extra_ca_env Proxy Settings
[proxy.credentials.<name>] enabled, source.env/file/value Proxy Credentials
[proxy.redaction] enabled, default_action, max_scan_bytes, rules Content Redaction
[proxy.filter] default_action, ask_timeout, cache_decisions, rules Proxy Mode docs
[sandbox] isolation, base_path, use_embedded, hide_env_files, 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

Unrecognized Keys

A key devsandbox does not recognize is ignored by the decoder, so keep_containers instead of keep_container leaves the setting at its default. Every config file that is loaded - the global config, each matching include, and a trusted .devsandbox.toml - is checked at startup, and every unrecognized key is reported on stderr under its full dotted path:

unknown config keys in /home/user/.config/devsandbox/config.toml, ignored: sandbox.docker.keep_containers, tools.git.mod

[tools.<name>] and [proxy.credentials.<name>] are checked too, against the settings the tool or the credential injector reads - plus mount_mode for a tool that mounts anything. Injector names are yours to choose, so any name under [proxy.credentials] is accepted; a tool section naming no built-in tool, such as [tools.gti], is reported as one unknown key rather than key by key.

The key stays ignored, so nothing else changes about how the sandbox is built. The launch does pause: warnings raised before the workload starts are shown again as a block and confirmed, so an ignored key cannot scroll past unread. See Startup Warnings for the prompt and the --yes flag that skips it.

Values of the Wrong Type

A key devsandbox recognizes but whose value does not fit is an error, and the launch stops:

failed to load config: invalid configuration: [tools.mise]: ignore_global_config: expected a boolean, got a string

This is deliberately stricter than the unknown-key warning. An unknown key was never going to configure anything, but ignore_global_config = "true" reads as a setting that is in force when it is not: accepting the file and falling back to the default would apply something you did not write. Fix the value or remove the key.

The whole section is rejected, not just the offending key, so a partly applied section can never reach the sandbox. devsandbox doctor reports the same error in its config row.

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

# Bytes of each request/response body recorded in the proxy request log
# Default: 262144 (256 KiB). 0 records no bodies at all. Maximum 2097152 (2 MiB).
max_log_body_bytes = 262144

max_log_body_bytes bounds only what is written to the log: the body itself always reaches its destination whole. Entries cut by the bound are marked req_body_truncated / resp_body_truncated, so a short body and a truncated one are distinguishable. The maximum is 2 MiB - past that a single log record exceeds what devsandbox logs proxy will read back, so a larger value is rejected at startup rather than producing records nothing can read. A project .devsandbox.toml may only lower the limit, never raise it or set it to 0: that file is writable from inside the sandbox, so either direction would be the sandbox choosing how much of its own traffic is kept. The restriction is on that file alone - the global config and any [[include]] file are host-owned and merge in both directions. See Proxy: Body Capture Limit.

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 the standard proxy environment variables automatically - the same set on every backend, listed in Proxy Environment Variables. 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 the standard CA bundle environment variables automatically - listed in CA Certificate. 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"
max_scan_bytes = 10485760 # Largest request body the scan will buffer (default 10 MiB)

max_scan_bytes bounds the body held in host memory while the scan runs. A decision needs the whole body, so a request past the limit - or one whose body stops arriving for 30 seconds - is blocked rather than forwarded on a partial scan. Raise it if a workflow legitimately uploads more than that through the proxy - in the global config or an [[include]] file, both of which are host-owned. A project .devsandbox.toml, which is writable from inside the sandbox, may only lower it.

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

# Hide .env files from the sandbox (default: true)
# When true, matching files are overlaid with /dev/null.
# Set to false if sandboxed tools need to read .env files directly.
# hide_env_files = 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"

hide_env_files (default true) is the setting behind the .env row of the Security Model: files matching .env and .env.* in the project are overlaid with /dev/null so sandboxed code cannot read them. Which files are in scope - and which are not - is described under Environment Files.

Set it to false, or pass --no-hide-env for a single run, when a tool inside the sandbox has to read .env itself. Both expose every matching file to sandboxed code, so prefer [sandbox.environment] or proxy credential injection when only a secret's value is needed.

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. When a rule matches a symlink to a file, the mount is applied to the resolved target so current bubblewrap versions do not reject the symlink destination. If the rule matches both the symlink and its target, devsandbox emits the mount once.

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 your identity and global
#   ignore/attributes rules, no credentials
# - "readwrite": full access with credentials, SSH keys, GPG keys; your
#   ~/.gitconfig is mounted as-is, along with the files it references
# - "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: identity + ignore rules No No No Default, maximum isolation
readwrite Yours, plus referenced files 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, and your ~/.gitconfig is mounted as it is - along with the files it references, so its ignore rules and its includes actually apply. See what readwrite carries.

What the safe copy carries

In readonly mode devsandbox generates the sandbox's ~/.gitconfig itself rather than mounting yours. It reads your fully resolved global configuration, so a value defined in an [include] or [includeIf "gitdir:..."] block is picked up like any other; conditional includes are evaluated against the project directory, so the branch that matched at launch is the one carried in. Resolving includes needs git 2.26 or newer on the host (git config --show-scope); on an older git devsandbox falls back to the top-level sections of each global config file, silently, and a value defined only inside an [include] block is not carried in. Four keys are copied:

Key Carried as
user.name Copied verbatim
user.email Copied verbatim
core.excludesFile File copied into the sandbox read-only, value repointed at the copy as ~/.gitignore.safe
core.attributesFile File copied into the sandbox read-only, value repointed at the copy as ~/.gitattributes.safe

Every other key is dropped, including credential.helper, alias.*, url.*.insteadOf, http.extraHeader, sendemail.smtpPass, user.signingkey and the include directives themselves.

The two file-valued keys fall back to git's own defaults on the host, ~/.config/git/ignore and ~/.config/git/attributes (or $XDG_CONFIG_HOME/git/... when that variable is set on your host), when the key is unset. They are carried whether or not you have a global config file at all - git honors a global ignore file on its own. This exists because XDG_CONFIG_HOME is repointed into the sandbox, so git's default location resolves to an empty in-sandbox path and an explicit value names a host path that is never mounted - and git ignores a missing excludesFile or attributesFile in silence, which would drop your global ignore rules with nothing to show for it. A file devsandbox cannot carry in is named in a warning and its key is omitted, rather than left pointing at a path that will not resolve. Absence of a default file you never configured is not reported.

Two sources are refused deliberately rather than copied. A file inside the project directory, the shared temp directory, or the sandbox home is not copied: all three are mounted read-write into the sandbox, so their contents are the sandbox's rather than the host's - a copy from the first two would also freeze a launch-time snapshot over the live file. And a key whose value came from an [include] whose target is itself inside one of those directories is ignored entirely - the sandbox can write that file, so the path it names is not something the host chose. Both cases drop the key and report the omission.

The repointed value is written ~/-relative rather than as an absolute path, because git expands a leading ~/ against $HOME and the sandbox home is not at the same absolute path on every backend.

Values are written git-quoted, so a name containing #, ; or quotes survives intact. A value carrying a control character cannot be represented on one line and is dropped with a warning rather than written in a form that would parse as something else.

Evaluating an [includeIf "gitdir:..."] condition means reading the configuration from inside the repository, which reads its local .git/config along the way. If git refuses that file - a broken config, unreadable permissions, an ownership git considers dubious - the configuration is re-read from outside the repository instead of being lost. Plain [include] blocks still expand that way; only conditional ones cannot be evaluated, and the launch warns when your configuration actually has one.

The configuration is read fresh on every launch, so editing an included file takes effect on the next one. Both ~/.gitconfig and ~/.config/git/config are honored - the latter spelled $XDG_CONFIG_HOME/git/config when that variable is set on your host, which is the only one git reads in that case; a host that keeps its identity solely in the XDG location is carried in too.

What readwrite carries

In readwrite mode devsandbox mounts your ~/.gitconfig rather than generating a copy, so nothing is dropped and nothing is rewritten - credential.helper, alias.*, user.signingkey and the rest all apply. Because the file is verbatim, every path inside it arrives spelled as the host wrote it, and git resolves that spelling against the sandbox filesystem. The files those paths name are mounted so they resolve:

Reference in your config Carried as
core.excludesFile, core.attributesFile The file itself, mounted at the path the value names
Neither key set git's own defaults, ~/.config/git/ignore and ~/.config/git/attributes (read from $XDG_CONFIG_HOME/git/ when that variable is set on your host)
[include] / matching [includeIf "gitdir:..."] targets Each file that contributed a setting, plus the config files declaring them
~/.config/git/config ($XDG_CONFIG_HOME/git/config) Mounted whenever it contributes, which is what carries an XDG-only identity

A value spelled ~/x is mounted where $HOME/x resolves inside the sandbox; an absolute value is mounted at that path verbatim. Both are needed, because $HOME is the host home path on bwrap and /home/sandboxuser on docker and krun.

The mounts follow the same policy ~/.gitconfig itself gets, which under the default split mode means read-only for a single file: split asks for a tmpoverlay, overlays need a directory, and a file source falls back to a read-only bind on every backend. Under split the host file is never touched - but a write does not silently vanish either, it fails: git config --global inside the sandbox errors with Device or resource busy once any global config file is mounted, exactly as it already did for a host with a ~/.gitconfig. Use git -c user.email=... for one command, or git config --local, which writes into the project's own .git/config. If your identity lives only at ~/.config/git/config, this is a change: that file is now mounted, so git config --global writes to it and fails, where before it fell through to a throwaway ~/.gitconfig inside the sandbox - and in exchange the identity in it now applies at all.

Set mount_mode to readwrite on [tools.git], or [overlay] default to readwrite, and the ignore and attributes files become writable binds at their host paths instead: a write from inside the sandbox then edits the real file, as it already does for ~/.ssh and ~/.gnupg.

The config files stay read-only under every mount mode - ~/.gitconfig, ~/.config/git/config and every [include] target. devsandbox resolves those to decide which host files to mount, so one the sandbox could write between launches would let it name any host file for the next launch to carry in.

The two refusals from readonly apply here too: a file the sandbox can write (inside the project directory, the shared $TMPDIR, or the sandbox home) is not carried in, and neither is an include target sitting in one of those places. Unlike readonly, they are silent. readonly names the key it dropped because its generated config would otherwise point at a path that does not resolve; readwrite rewrites nothing, so the only alternative to refusing is mounting nothing - which is exactly what git already does with the value. A file that simply does not exist is skipped in silence for the same reason. A value spelled ~user/... is the one case that is reported: devsandbox cannot resolve the password-database form, and you set it, so you are getting less than the config says. Spell it ~/ or absolutely.

If the resolved configuration cannot be read at all - an unreadable file, or a host git older than 2.26, which has no git config --show-scope - devsandbox falls back to the top-level [include] directives it can parse and says so, since a target reachable only through a nested or conditional include is lost in that case. A host that declares no include at all reaches exactly what a working resolver would have given it, and stays silent. The core.excludesFile and core.attributesFile of a carried include are honored on that path too, in git's own precedence order - a value the declaring file sets after its [include] still wins.

Evaluating an [includeIf "gitdir:..."] condition means reading the configuration from inside the repository. If git refuses the repo's own .git/config - a malformed file, unreadable permissions, an ownership git considers dubious - the configuration is re-read from outside the repository, where no conditional include is evaluated. Plain [include] targets are still carried; a conditional one is not, so a commit in that sandbox lands with the identity the outer config sets. The launch says so, but only when your configuration actually has an includeIf.

[includeIf "gitdir:~/..."] has a residual limitation on the docker and krun backends: the condition is re-evaluated inside the sandbox against a different $HOME, so it stops matching and the identity falls back to the outer config's. One file included twice under two different spellings hits the same limit. A ~/ value that climbs out of $HOME (~/../shared/ignore) is the narrower case: it is mounted where it resolves on the host, which is still where guest git looks whenever the two homes share a parent, and misses only when they do not. See Conditional includes on Docker and krun.

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

Every tool that mounts something supports a mount_mode field, which 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. It does not stop a tool that also runs a background process: [tools.docker] has its own enabled flag for that.

A tool that mounts nothing does not accept mount_mode and reports it as an unknown key, so a section copied from another tool cannot look applied when it does nothing. [tools.docker] and [tools.go] are the two: docker's proxy socket lives in the sandbox home, which is already mounted, and Go's caches come from a shared volume that no per-tool mode gates.

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. The prompt is asked on stderr and answered on stdin, so a launch that redirects either one counts as non-interactive rather than blocking on a question nobody can see.

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