Skip to content

Proxy Mode

Network isolation, traffic inspection, and HTTP filtering.

Proxy mode routes HTTP/HTTPS traffic through a local MITM (Man-in-the-Middle) proxy for inspection and logging.

How strongly that routing is enforced depends on the isolation backend. bwrap and krun both confine the sandbox behind a deny-by-default egress firewall and fail closed if it cannot be applied; Docker only points the container at the proxy through environment variables, which a process is free to ignore. The sections below describe bwrap backend behavior by default - read Backend-Specific Behavior before relying on proxy mode as a security boundary.

Why Use Proxy Mode?

  • Audit AI agent activity - See exactly what API calls your AI coding assistant makes
  • Debug network issues - Inspect request/response headers and bodies
  • Security monitoring - Detect unexpected network connections
  • Compliance - Log external communications for review (see per-backend enforcement)

When to Enable Proxy Mode

Enable proxy when Skip proxy when
You want to monitor AI agent network activity You trust the code and don't need traffic visibility
You need credential injection (GitHub tokens) Tools use certificate pinning that breaks MITM
You need port forwarding (requires network isolation) You want the fastest possible startup
You need content redaction (secret scanning) You only need filesystem isolation
You need HTTP filtering (domain whitelist/blacklist)

Requirements (bwrap backend)

Proxy mode on the bwrap backend requires passt/pasta for network namespace creation. This is the only feature that requires passt - basic sandboxing works without it.

devsandbox includes an embedded pasta binary - no system packages required. To use a system-installed pasta instead, set use_embedded = false in configuration.

That pasta must support --map-host-loopback (passt 2023-08 or newer). It is what maps the proxy gateway 10.0.2.2 to the host, and the egress lockdown's only permitted destination is that gateway - so on an older pasta the sandbox would be left with a deny-by-default firewall whose one exception points nowhere, and every connection would hang. Proxy mode refuses to launch on such a pasta rather than produce that. The embedded binary always supports it; only use_embedded = false (or a failed extraction) can reach an older one. Non-proxy launches are unaffected.

It also requires iproute2 and nft or iptables, plus the kernel's nf_tables (or ip_tables) and nf_conntrack modules, for the egress lockdown. Module autoload is not permitted from an unprivileged user namespace, so an unloaded module is a launch failure, not a silent degrade: a proxy-mode launch aborts rather than run with open egress. devsandbox doctor reports this as the proxy: firewall row, which applies to both bwrap and krun.

# Arch Linux
sudo pacman -S iproute2 nftables

# Debian/Ubuntu
sudo apt install iproute2 nftables

# Fedora
sudo dnf install iproute nftables

# If the modules are not loaded
sudo modprobe nf_tables nf_conntrack

The check runs as a pre-flight before the sandbox is started, by applying the real rule set in a throwaway namespace. On a host that cannot enforce it the launch fails immediately with proxy mode needs an enforceable egress lockdown, naming the missing piece - not after pasta and bwrap have already started, and never with the workload running.

Other backends: The Docker backend does NOT require pasta - it uses per-session Docker networks instead. The krun backend requires a system-installed pasta (podman networking; the embedded binary does not satisfy podman) plus nft or iptables for its egress lockdown. See Backend-Specific Behavior.

Optionally install the system package as a fallback:

# Arch Linux
sudo pacman -S passt

# Debian/Ubuntu
sudo apt install passt

# Fedora
sudo dnf install passt

Verify installation:

devsandbox doctor

The doctor output shows whether each binary is embedded or system-installed.

Enabling Proxy Mode

Command Line

# Enable proxy mode for this session
devsandbox --proxy

# With custom port
devsandbox --proxy --proxy-port 9090

# Run a command with proxy
devsandbox --proxy npm install

Configuration File

Enable proxy mode by default in ~/.config/devsandbox/config.toml:

[proxy]
enabled = true
port = 8080

Transparent Proxy Mode (No MITM)

By default, the proxy performs MITM (Man-in-the-Middle) interception on HTTPS connections, using a generated CA certificate. This enables full traffic inspection, credential injection, and content redaction for HTTPS.

If you don't need HTTPS inspection - for example, when tools have certificate pinning or you only need network isolation with HTTP logging - you can disable MITM:

Command Line

devsandbox --proxy --no-mitm

Configuration File

[proxy]
enabled = true
mitm = false

What Changes

Feature MITM enabled (default) MITM disabled
HTTP filtering/logging Full Full
HTTPS body/header inspection Full None
HTTPS credential injection Works Does not work
HTTPS content redaction Works Does not work
HTTPS request logging Full request/response CONNECT hostname only
CA certificate injection Yes Skipped
Network isolation Yes Yes

When MITM is disabled, the proxy logs warnings at startup if credential injectors, redaction rules, or filter rules are configured - since these features cannot inspect encrypted HTTPS traffic.

When to disable MITM:

  • Tools with certificate pinning that reject the proxy CA
  • You only need network isolation and HTTP (not HTTPS) logging
  • You don't need credential injection, content redaction, or HTTPS filtering

If you're running AI coding assistants (Claude Code, aider, etc.), keep MITM enabled - it's required for credential injection and secret scanning.

Backend-Specific Behavior

All three backends point the sandbox at the same proxy and intercept what reaches it, but they differ in the mechanism - and, more importantly, in whether a process inside the sandbox can bypass the proxy and reach the network directly.

bwrap backend

  • Enforcement: Enforced, fail-closed - route surgery plus a deny-by-default egress firewall
  • Network isolation: pasta creates a new network namespace with its own network stack (IPv4 only in proxy mode)
  • Gateway address: Traffic is routed through 10.0.2.2 (pasta virtual gateway)
  • CA certificate path (inside sandbox): /tmp/devsandbox-ca.crt
  • Requirement: pasta, which is embedded and extracted on first use - a system passt/pasta package is only needed when use_embedded = false or extraction fails - plus iproute2 and nft or iptables with nf_tables/nf_conntrack loaded

Inside the namespace devsandbox adds a /32 route to the gateway and deletes the default route, then applies a firewall that DROPS by default and permits only loopback, established/related return traffic, and TCP to the gateway on the proxy port. A process that ignores HTTP_PROXY has no path to an external address and its connections fail. Deny-by-default is what makes this structural rather than a list of blocked destinations: hosts on the sandbox interface's own connected subnet (deleting the default route does not remove the on-link route), cloud metadata at 169.254.169.254, direct DNS, and every non-proxy port of the gateway - --map-host-loopback maps the whole gateway address to the host's loopback, not just the proxy port - lose their direct path without being enumerated.

The lockdown scopes the path, not the destination. It reduces the sandbox to exactly one way out - the proxy - and what may be reached through that way is a separate decision, made by HTTP filtering. Filtering is off until you set default_action, and with it off the proxy connects wherever it is asked to. So a request for http://169.254.169.254/, a LAN address, or a 127.0.0.1 port is refused as a direct socket and still served if it is made through the proxy - by the proxy process, which runs on the host outside the sandbox namespace. That is the point at which such a request becomes visible, loggable, and refusable, which is what the lockdown buys; it is not a destination filter on its own. If reaching those destinations should fail rather than be logged, set default_action = "block" and allowlist what the sandbox legitimately needs.

Three properties make it a boundary rather than a default:

  • It lands before the workload exists. The rules are applied by pasta's wrapper prologue, which runs as root in pasta's user namespace holding CAP_NET_ADMIN over the netns, before it execs bwrap. There is no window in which sandboxed code runs with egress open.
  • It is fail-closed. A missing nft/iptables, an unloaded nf_tables/nf_conntrack, an undiscoverable default route device, or any failing rule aborts the launch with a devsandbox: egress lockdown: diagnostic on stderr. There is no config key that degrades back to unenforced egress.
  • The sandbox cannot undo it. bwrap always runs with --unshare-user, and no bwrap path adds capabilities back. The kernel's capability check walks a namespace's ancestors, never its descendants, so a process in bwrap's child user namespace holds no CAP_NET_ADMIN over a netns owned by pasta's user namespace and cannot flush the table. Unsharing its own netns yields a namespace with only lo and no egress path either.

The namespace is given IPv4 only (pasta is invoked with -4 whenever the lockdown is rendered), so there is no second address family for the IPv4 ruleset to miss. Direct DNS is deliberately not excepted - the proxy resolves hostnames itself, and permitting :53 to the gateway would re-open a DNS-tunnel exfiltration channel. Configured outbound port forwarding rules keep working: each one adds an accept for exactly its port and protocol on the gateway. A host loopback port that is not declared as an outbound rule is no longer reachable at 10.0.2.2 - closing that exposure is the point, and declaring the rule is the supported fix.

Pasta's automatic namespace-to-host port forwarding (-T/-U, which default to auto) is switched off for every protocol without a configured outbound rule. Left on, it binds each host-listening port inside the sandbox namespace on loopback, and loopback is the one interface the firewall has to permit - so the host's own 127.0.0.1 services would have stayed directly reachable from inside the sandbox at 127.0.0.1:<port> while every other direct destination was refused. Inbound forwarding (--tcp-ports/--udp-ports) is unaffected.

The rule set is shared with krun (internal/egress), so the two backends cannot drift apart. What still separates them is the kernel boundary, not the proxy: krun gives the workload its own guest kernel, bwrap shares the host's.

krun backend

  • Enforcement: Enforced - route surgery plus a deny-by-default egress firewall; Linux only
  • Network isolation: pasta network namespace around the microVM (system pasta, via rootless podman)
  • Gateway address: 10.0.2.2 (pasta gateway mapped to the host loopback the proxy binds to)
  • CA certificate path (inside guest): /etc/ssl/certs/devsandbox-ca.crt
  • Requirement: podman with the krun runtime, system pasta, and nft or iptables

krun applies the same rule set as bwrap, from a different position. Under libkrun the guest has no routable interface of its own, so the lockdown is applied host-side in the VMM's pasta namespace: the same route surgery, paired with a firewall that DROPS by default and permits only loopback, established/related return traffic, and TCP connections to the gateway on the proxy port. LAN hosts, cloud metadata endpoints, and direct DNS have no direct path out - and, as under bwrap, the lockdown scopes the path, not the destination: what the proxy itself will connect to is decided by HTTP filtering. The guest is given IPv4 only, so there is no IPv6 route around the IPv4 rules, and the in-guest workload does not start until the host signals the lockdown is complete.

The lockdown is fail-closed: if nft/iptables is missing or any lockdown command fails, the launch aborts instead of running with open egress. Proxy mode with krun on macOS is refused outright - the lockdown is not implemented for Hypervisor.framework, and degrading to unenforced egress silently is not an option. Run krun proxy mode on Linux, or use krun without proxy mode.

Docker backend

  • Enforcement: Advisory only - proxy environment variables, no network-level enforcement
  • Network isolation: A per-session Docker network is created (no pasta required)
  • Gateway address: host.docker.internal (Docker's built-in host access)
  • CA certificate path (inside container): /etc/ssl/certs/devsandbox-ca.crt
  • Requirement: Docker daemon running (no additional dependencies)

The per-session network separates sandboxes from each other, but it is an ordinary bridge network with outbound access: nothing removes the container's default route and no firewall rules are applied. Routing to the proxy comes solely from HTTP_PROXY/HTTPS_PROXY (plus tool-specific variants) set in the container environment. Any process that ignores those variables, or opens a socket directly, reaches the network without being filtered, redacted, or logged. Treat proxy mode on Docker as instrumentation for cooperating tools, not as a containment boundary - use bwrap or krun when the proxy has to hold against uncooperative code.

Aspect bwrap krun Docker
Enforcement Enforced, fail-closed (no default route + deny-by-default firewall) Enforced, fail-closed (no default route + deny-by-default firewall) Advisory (env vars only)
Network isolation pasta namespace pasta namespace around the microVM Per-session Docker network
Gateway IP 10.0.2.2 10.0.2.2 host.docker.internal
CA cert location /tmp/devsandbox-ca.crt /etc/ssl/certs/devsandbox-ca.crt /etc/ssl/certs/devsandbox-ca.crt
Extra dependency passt/pasta (embedded), nft/iptables podman + krun, system pasta, nft/iptables None (Docker only)
Address family IPv4 only IPv4 only IPv4 + IPv6
Proxy mode platforms Linux Linux only (refused on macOS) Linux, macOS

How It Works (bwrap)

  1. Network Isolation - pasta creates a new network namespace with its own network stack
  2. Gateway Setup - Traffic is routed through a virtual gateway (10.0.2.2)
  3. Proxy Server - A local HTTP/HTTPS proxy runs on the host
  4. Traffic Enforcement - Before the workload starts, the default route is removed and a deny-by-default firewall is applied in the namespace, so a process that ignores HTTP_PROXY has no path to an external address. Any step failing aborts the launch - see bwrap backend
  5. TLS Interception - A generated CA certificate enables HTTPS inspection

Network Architecture

flowchart TB
    subgraph host["Host System"]
        proxy["Proxy Server<br/>127.0.0.1:8080"]
        internet(["Internet"])
        proxy --> internet
    end
    subgraph sandbox["Sandbox (netns)"]
        app["Application<br/>HTTP_PROXY=10.0.2.2:8080"]
        gateway["Gateway<br/>10.0.2.2"]
        app --> gateway
    end
    gateway -. "pasta NAT<br/>10.0.2.2 → 127.0.0.1" .-> proxy

CA Certificate

A CA certificate is automatically generated for HTTPS interception and stored at:

~/.local/share/devsandbox/<project>/.ca/ca.crt

Inside the sandbox, the certificate is available at /tmp/devsandbox-ca.crt (bwrap backend) or /etc/ssl/certs/devsandbox-ca.crt (Docker and krun backends) and automatically configured via environment variables:

Variable Purpose
NODE_EXTRA_CA_CERTS Node.js
REQUESTS_CA_BUNDLE Python requests
CURL_CA_BUNDLE curl
GIT_SSL_CAINFO Git HTTPS
SSL_CERT_FILE General SSL/TLS

Tools with Certificate Pinning

Some tools implement certificate pinning and won't work with the MITM proxy:

  • Mobile app backends
  • Some cloud SDKs
  • Security-focused applications

Viewing Logs

Proxy Request Logs

View HTTP/HTTPS traffic captured in proxy mode:

# View all proxy logs for current project
devsandbox logs proxy

# View last 50 requests
devsandbox logs proxy --last 50

# Follow/tail logs in real-time
devsandbox logs proxy -f

Filtering Logs

# Filter by time
devsandbox logs proxy --since 1h          # Last hour
devsandbox logs proxy --since today       # Since midnight
devsandbox logs proxy --since 2024-01-15  # Since specific date
devsandbox logs proxy --until 2024-01-15T12:00:00

# Filter by content
devsandbox logs proxy --url /api          # URL contains "/api"
devsandbox logs proxy --method POST       # Only POST requests
devsandbox logs proxy --status 200        # Specific status code
devsandbox logs proxy --status 400-599    # Status code range
devsandbox logs proxy --status ">=400"    # Comparison
devsandbox logs proxy --errors            # All errors (status >= 400)

# Combine filters
devsandbox logs proxy --method POST --url /api --since 1h

Output Formats

# Table format (default)
devsandbox logs proxy

# Compact one-line format
devsandbox logs proxy --compact

# JSON output (for scripting)
devsandbox logs proxy --json

# Include request/response bodies
devsandbox logs proxy --body

# Show summary statistics
devsandbox logs proxy --stats

# Disable colors (for piping)
devsandbox logs proxy --no-color

Example Output

Table format:

┌──────────┬────────┬────────┬──────────┬────────────────────────┐
│   TIME   │ METHOD │ STATUS │ DURATION │          URL           │
├──────────┼────────┼────────┼──────────┼────────────────────────┤
│ 10:30:05 │ GET    │ 200    │ 150ms    │ https://api.example.com│
│ 10:30:06 │ POST   │ 201    │ 89ms     │ https://api.example.com│
└──────────┴────────┴────────┴──────────┴────────────────────────┘

Compact format:

10:30:05 GET  200 150ms https://api.example.com/users
10:30:06 POST 201  89ms https://api.example.com/orders

Stats output:

Summary:
  Total requests: 150
  Success (2xx):  120 (80.0%)
  Redirect (3xx): 10 (6.7%)
  Client err (4xx): 15 (10.0%)
  Server err (5xx): 5 (3.3%)
  Avg duration: 245ms

Log Storage

Logs are stored as gzip-compressed JSONL files:

~/.local/share/devsandbox/<project>/logs/
├── proxy/
│   ├── requests_20240115_0000.jsonl.gz
│   ├── requests_20240115_0001.jsonl.gz
│   └── ...
└── internal/
    ├── proxy_20240115_0000.log.gz
    └── logging-errors.log

Log Rotation

  • Files rotate when they reach 50MB
  • Maximum 5 files kept per type
  • Older files are automatically pruned

Log Entry Format

Each log entry contains:

{
  "ts": "2024-01-15T10:30:05.123Z",
  "method": "POST",
  "url": "https://api.example.com/users",
  "req_headers": {
    "Content-Type": [
      "application/json"
    ],
    "Authorization": [
      "Bearer ..."
    ]
  },
  "req_body": "eyJ1c2VyIjogImpvaG4ifQ==",
  "status": 201,
  "resp_headers": {
    "Content-Type": [
      "application/json"
    ]
  },
  "resp_body": "eyJpZCI6IDEyM30=",
  "duration_ns": 89000000,
  "error": ""
}

Note: Request/response bodies are base64-encoded.

Internal Logs

View proxy server errors and warnings:

# View all internal logs
devsandbox logs internal

# Filter by log type
devsandbox logs internal --type proxy    # Proxy server logs
devsandbox logs internal --type logging  # Remote logging errors

# Follow internal logs
devsandbox logs internal -f

# Show last N lines
devsandbox logs internal --last 100

Debugging the Request/Response Lifecycle

Set DEVSANDBOX_DEBUG=1 to log a per-request lifecycle trace to the internal proxy log. This is the fastest way to diagnose hung or timed-out requests (for example a streaming client failing with SSE response headers timed out):

DEVSANDBOX_DEBUG=1 devsandbox --proxy claude
# reproduce the issue, then in another shell:
devsandbox logs internal --type proxy -f

Each intercepted request emits up to three lines:

DEBUG CONNECT chatgpt.com:443 -> MITM
DEBUG request: POST chatgpt.com:443/backend-api/codex/responses
DEBUG response: POST https://chatgpt.com:443/backend-api/codex/responses status=200 content-type="" streaming=false time_to_headers=412ms (body streamed, not buffered)

time_to_headers is how long the upstream took to return response headers. Response bodies are streamed to the client and captured asynchronously, so the proxy never buffers them - the response line is emitted as soon as headers arrive.

How to read it:

  • No CONNECT line for the host - traffic is not reaching the proxy, or is tunneled instead of intercepted (transparent mode). The proxy cannot be the cause; check network/DNS and that proxy mode is enabled.
  • request line but no matching response line within the client timeout - the upstream (or the proxy-to-upstream connection) never returned headers. The stall is upstream, not in the proxy.
  • response line with a large time_to_headers - the upstream itself was slow to respond. A small time_to_headers but a client that still times out points to the client or the connection after headers, not the proxy.
  • streaming reports only whether the response advertised a streaming Content-Type (e.g. text/event-stream); it is informational. Bodies stream through regardless of Content-Type (codex's responses carry an empty one).

Query strings are stripped from these lines so tokens are never logged.

Remote Logging

Proxy logs can be forwarded to remote destinations. See Configuration - Remote Logging for setup instructions.

HTTP Filtering

HTTP filtering allows you to control which requests are allowed, blocked, or require user approval.

How It Works

Filtering is enabled by setting default_action which determines what happens to requests that don't match any rule:

Default Action Behavior
block Block unmatched requests (whitelist behavior)
allow Allow unmatched requests (blacklist behavior)
ask Prompt user for each unmatched request

Quick Start

# Whitelist behavior - only allow specific domains, block everything else
devsandbox --proxy --filter-default=block \
  --allow-domain="*.github.com" \
  --allow-domain="api.anthropic.com"

# Blacklist behavior - block specific domains, allow everything else
devsandbox --proxy --filter-default=allow \
  --block-domain="*.tracking.io" \
  --block-domain="ads.example.com"

# Ask mode - interactive approval for unmatched requests
devsandbox --proxy --filter-default=ask

Configuration File

Add filter rules to ~/.config/devsandbox/config.toml:

[proxy.filter]
# Enable filtering with default action for unmatched requests
default_action = "block"  # whitelist behavior
ask_timeout = 30
cache_decisions = true

[[proxy.filter.rules]]
pattern = "*.github.com"
action = "allow"
scope = "host"

[[proxy.filter.rules]]
pattern = "api.anthropic.com"
action = "allow"
scope = "host"

[[proxy.filter.rules]]
pattern = "*.internal.corp"
action = "block"
scope = "host"
reason = "Internal network blocked"

AI Agent Filtering Example

Lock down an AI coding assistant to only communicate with known services:

[proxy.filter]
default_action = "block"

[[proxy.filter.rules]]
pattern = "api.anthropic.com"
action = "allow"
scope = "host"

[[proxy.filter.rules]]
pattern = "*.github.com"
action = "allow"
scope = "host"

[[proxy.filter.rules]]
pattern = "registry.npmjs.org"
action = "allow"
scope = "host"

You can generate filter rules from a "known good" session using devsandbox proxy filter generate (see Generate Filter Rules).

Pattern Types

Default is glob. Patterns containing regex characters (^$|()[]{}\+) are auto-detected as regex.

Type Example Description
glob *.example.com Glob patterns ( and ?) - default*
exact api.example.com Exact string match
regex ^api\.(dev\|prod)\.com$ Regular expressions

Scopes

Default is host.

Scope Description Example Match
host Request host only - default api.example.com
path Request path only /api/v1/users
url Full URL https://api.example.com/v1/users

Ask Mode

In ask mode, unmatched requests require user approval via a separate monitor terminal. This is particularly useful when running AI agents autonomously - you can approve or block each request the agent makes that reaches the proxy, giving you real-time control over that traffic. Like every other proxy feature, this is bounded by how strongly the backend routes traffic through the proxy - see Backend-Specific Behavior.

Step 1: Start the sandbox with ask mode:

devsandbox --proxy --filter-default=ask

The sandbox will display:

Filter: ask mode (default action for unmatched requests)

Run in another terminal to approve/deny requests:
  devsandbox proxy monitor

Requests without response within 30s will be rejected.

Step 2: Open another terminal (in the same project directory) and run the monitor:

devsandbox proxy monitor

The socket path is auto-detected from the current directory's sandbox. You can also specify it explicitly:

devsandbox proxy monitor /path/to/ask.sock

The monitor displays incoming requests:

┌──────────────────────────────────────────────────────────────────┐
│  Request #1                                                      │
├──────────────────────────────────────────────────────────────────┤
│  Method: GET                                                     │
│  Host:   api.example.com                                         │
│  Path:   /v1/users                                               │
├──────────────────────────────────────────────────────────────────┤
│  [A]llow    [B]lock    Allow [S]ession    Block [N]ever         │
└──────────────────────────────────────────────────────────────────┘
Decision:

Keys (instant response, no Enter needed):

  • a - Allow this request
  • b - Block this request
  • s - Allow and remember for session
  • n - Block and remember for session

Timeout: Requests that don't receive a response within 30 seconds are automatically rejected and logged to internal logs as unanswered.

Generate Filter Rules from Logs

Analyze existing proxy logs to generate filter configuration:

# Generate whitelist rules from current project's logs (default: block unmatched)
devsandbox proxy filter generate

# Generate from specific log directory
devsandbox proxy filter generate --from-logs ~/.local/share/devsandbox/myproject/logs/proxy/

# Generate blacklist rules (allow unmatched)
devsandbox proxy filter generate --default-action allow

# Save to file
devsandbox proxy filter generate -o filter-rules.toml

# Only include domains with 5+ requests
devsandbox proxy filter generate --min-requests 5

Show Current Configuration

devsandbox proxy filter show

Filter Logs

Filter decisions are logged with requests:

{
  "ts": "2024-01-15T10:30:05Z",
  "method": "GET",
  "url": "https://blocked.example.com/",
  "status": 403,
  "filter_action": "block",
  "filter_reason": "matched rule: *.blocked.com"
}

Credential Injection

The proxy can inject authentication credentials into requests for specific domains, keeping tokens completely out of the sandbox environment. The sandboxed process never sees the token - it stays on the host side.

How It Works

  1. Intercept - The proxy intercepts outgoing requests from the sandbox.
  2. Match - For each configured credential injector, it checks whether the request host matches the injector's host (exact match or glob).
  3. Inject - If matched, the injector sets the configured header to the rendered value_format with {token} substituted from the resolved source. The existing header is preserved unless overwrite = true.
  4. Isolate - The sandbox process never sees the token. It is read from the host environment and added transparently.
sequenceDiagram
    autonumber
    participant App as Sandboxed app
    participant Proxy as devsandbox proxy
    participant Source as Host token source
    participant Upstream as Upstream API

    App->>Proxy: HTTPS request, no Authorization
    Note over Proxy: MITM decrypt, match host<br/>against injector globs
    alt host matches an injector
        Proxy->>Source: Resolve credential<br/>from env, file, or value
        Source-->>Proxy: token
        Note over Proxy: Render value_format,<br/>set header unless already set<br/>or overwrite = true
        Proxy->>Upstream: HTTPS request + Authorization
        Upstream-->>Proxy: response
        Proxy-->>App: response, token never returns
    else no injector matches
        Proxy->>Upstream: HTTPS request, unchanged
        Upstream-->>Proxy: response
        Proxy-->>App: response
    end

Universal Schema

Every injector is defined by the same set of fields under [proxy.credentials.<name>]:

Field Purpose
enabled Master switch. Injector is inert unless enabled = true.
host Hostname to match. Exact (api.github.com) or glob (*.example.com).
header HTTP header to set on matching requests. Canonicalized at load (authorizationAuthorization).
value_format Template for the header value. {token} is replaced with the resolved source value. Defaults to "{token}".
overwrite When true, replaces any existing value for the configured header. Default false.
preset Optional name of a built-in preset whose defaults are used as the base for this injector.
[...source] sub-table Where the token comes from: env, file, or value.

A custom non-GitHub injector - no Go code required:

[proxy.credentials.gitlab]
enabled = true
host = "gitlab.com"
header = "PRIVATE-TOKEN"
value_format = "{token}"

  [proxy.credentials.gitlab.source]
  env = "GITLAB_TOKEN"

Built-in Presets

Built-in preset names are reserved - using one as the section name (e.g. [proxy.credentials.github]) automatically applies the preset's defaults. User fields override preset defaults; [...source] overrides the preset's default source.

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)

Minimal GitHub configuration - the preset supplies everything else:

[proxy.credentials.github]
enabled = true

Source Types

Field Description Example
env Read from an environment variable env = "DEVSANDBOX_GITHUB_TOKEN"
file Read from a file (supports ~ expansion, whitespace trimmed) file = "~/.config/devsandbox/github-token"
value Static value in config value = "github_pat_..."

When multiple fields are set, priority is: value > env > file. Set exactly one for clarity.

Specificity Ordering

When more than one configured injector could match the same request host, the most-specific one wins:

  1. Exact host beats any glob.
  2. Among globs, the longer literal portion (len(host) - count('*')) wins.
  3. Ties are broken by injector name in alphabetical order.

So an exact api.github.com injector wins over a *.github.com injector for api.github.com, and the proxy injects exactly one credential per request.

A glob that doesn't match any actual request is not an error - host coverage is enforced lazily at request time, not at config load.

Overwriting Existing Authorization Headers

By default the injector never replaces an existing value for its configured header - the sandboxed tool wins. That's safe, but breaks the pattern where a CLI inside the sandbox needs a token set in its environment to start (e.g. gh CLI refuses to run without GH_TOKEN).

To handle this, set overwrite = true and inject a placeholder env var into the sandbox so the CLI starts:

[sandbox.environment.GH_TOKEN]
value = "placeholder"

[proxy.credentials.github]
enabled = true
overwrite = true

[proxy.credentials.github.source]
env = "GH_RO_TOKEN"   # real read-only token on the host

Export GH_RO_TOKEN on the host only. The sandbox sees GH_TOKEN=placeholder; gh adds Authorization: Bearer placeholder to its requests; the proxy replaces the header with the real token from GH_RO_TOKEN before forwarding to api.github.com.

Security trade-off: the sandbox sees a non-functional placeholder, not the real token - leaking the placeholder is harmless. This preserves the core guarantee: the real credential never enters the sandbox.

AI agent workflow: Credential injection is particularly useful for AI coding assistants like Claude Code that need GitHub API access. The token stays on the host - the AI agent never sees it, but its API requests to github.com are automatically authenticated.

Notes:

  • Credential injection requires proxy mode (--proxy) with MITM enabled (the default).
  • Injectors are only active when explicitly enabled = true and the credential source resolves to a non-empty value. An empty source silently disables the injector - it is not a config error.
  • By default the injector never overwrites an existing value for its configured header. Set overwrite = true to change this.
  • Invalid configuration fails fast at load time: unknown preset, missing host/header when enabled = true, invalid glob, or unreadable source file.

See Configuration: Proxy Credentials for the complete TOML reference.

Content Redaction

Content redaction scans outgoing requests for secrets and blocks or rewrites them before they leave your machine. It checks request bodies, headers, and URLs against configured rules.

Redaction only sees requests that actually reach the proxy, so its coverage is bounded by the same per-backend limits as everything else the proxy does - see Coverage below before relying on it.

Actions

Action What happens
Block Request rejected with HTTP 403. Secret never leaves your machine.
Redact Secret replaced with [REDACTED:<rule-name>] in body, headers, and URL. Modified request forwarded to destination.
Log Request forwarded unmodified. Match recorded in proxy logs as a warning.

Redaction Coverage

Redaction runs inside the proxy's request handler, so a request is only scanned if it reaches the proxy and the proxy can read it. Three things bound that:

  • The request has to reach the proxy. Coverage inherits the enforcement strength of the backend in use - see Backend-Specific Behavior. Under the docker backend the sandbox is only pointed at the proxy through HTTP_PROXY/HTTPS_PROXY, so a process that ignores those variables is never scanned. bwrap and krun both fail closed: the deny-by-default lockdown leaves no path out that does not pass through the proxy port, so a request that skips the proxy does not go anywhere rather than going unscanned.
  • HTTPS needs MITM. With --no-mitm, CONNECT requests are tunneled without interception, so HTTPS bodies, headers, and URLs are never inspected and redaction applies to plain HTTP only. devsandbox prints a warning at startup when redaction is enabled with MITM off.
  • Only requests, only the configured rules. Responses are not scanned, and a secret that matches no rule passes through untouched.

Treat redaction as a strong guard against accidental leaks, not as a barrier against code deliberately trying to exfiltrate a secret. For that, use an enforced backend - bwrap or krun - with MITM enabled.

Quick Start

# Block requests containing your API key
devsandbox --proxy
# ~/.config/devsandbox/config.toml or .devsandbox.toml
[proxy.redaction]
enabled = true
default_action = "block"

[[proxy.redaction.rules]]
name = "api-key"
[proxy.redaction.rules.source]
env = "API_SECRET_KEY"

Any scanned request containing the value of $API_SECRET_KEY is blocked with HTTP 403.

Source Types

Rules detect secrets using either a source (exact value lookup) or a pattern (regex match).

Field Description Example
env Environment variable on the host env = "API_SECRET_KEY"
file File path (supports ~, whitespace trimmed) file = "~/.secrets/token"
env_file_key Key in project .env file env_file_key = "DB_PASSWORD"
value Static value in config value = "literal-secret"

Choosing an action:

  • Block when the secret must never leave your machine (most secure, may break the tool's request)
  • Redact when the request should proceed but without the secret (destination sees [REDACTED:rule-name])
  • Log when you want visibility without enforcement (monitoring only)

Log Entries

Redaction events appear in proxy logs with additional fields:

{
  "ts": "2026-02-23T10:30:05Z",
  "method": "POST",
  "url": "https://api.example.com/v1/chat",
  "status": 403,
  "redaction_action": "block",
  "redaction_matches": ["api-key"]
}

For the redact action, the logged URL and body contain the replacement placeholders - the original secret never appears in logs.

View redaction events:

devsandbox logs proxy --json | jq 'select(.redaction_action != null)'

Important Behavior

  • Content redaction requires proxy mode (--proxy), and MITM for anything beyond plain HTTP. What it does and does not cover is spelled out under Redaction Coverage.
  • All source values must resolve at startup. If an environment variable is missing or a file is unreadable, devsandbox exits with an error (fail-closed).
  • Log entries for blocked and redacted requests have secrets replaced - secrets never appear in proxy logs.
  • Redaction rules are always additive when merging configs. The default action uses most-restrictive-wins.
  • Redaction rules must not match values used by credential injectors. If a redaction rule (source or pattern) would match an injected credential, devsandbox exits with an error at startup. This prevents the confusing situation where credential injection adds a token and redaction immediately blocks it.
  • When multiple rules match, the most severe action wins: block > redact > log.

Configuration Reference

See Configuration: Content Redaction for the full TOML reference, pattern rules, and merge behavior.

Skipping Log Entries

Log-skip rules drop matching requests from the proxy log entirely. This is for noise reduction, not for security: matched requests still pass through (filtering, redaction, and credential injection still apply); they simply never appear in logs/proxy/requests.jsonl and are never forwarded to remote log dispatchers (syslog/OTLP).

The intended use case is endpoints you'd otherwise see hundreds of times per session and don't care about - for example, telemetry traffic that an agent sends to your own observability infrastructure, where the data is already captured upstream.

How It Works

Each rule has a pattern and an optional scope (default host) and type (default glob, regex auto-detected on metacharacters). Matching reuses the same engine as filter rules: host matches the request hostname (port stripped), path matches the URL path, url matches the full URL string. Rules are evaluated in order, first match wins. Skip is absolute - a matched entry is never logged, even if the request errored, was blocked by the security filter, or triggered a redaction rule.

Configuration

[[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"

[[proxy.log_skip.rules]]
pattern = "/v1/metrics"
scope = "path"
type = "exact"

Interaction With Other Features

  • Filter (allow/block): independent. A request blocked by the filter is still skipped from logs if it also matches a log_skip rule. If you want to keep a record of blocked attempts, do not log-skip the same hosts you block.
  • Redaction: independent. Redaction still scans outbound requests in flight (see Redaction Coverage - responses are not scanned); log-skip just decides whether the (already-redacted) entry gets persisted.
  • Credential injection: independent. Tokens are still injected on outbound requests; log-skip only affects the local log artifact.

Configuration Reference

See Configuration: Log Skip for the full TOML reference.

Troubleshooting

"proxy mode requires pasta"

devsandbox includes an embedded pasta binary. If you see this error, extraction failed and no system package is installed:

# Check doctor for details (shows embedded vs system source)
devsandbox doctor

# Install system package as fallback (see Requirements above)

"proxy mode needs an enforceable egress lockdown"

The pre-flight refused the launch before anything was started. The message names which piece is missing - iproute2, nft/iptables, the throwaway namespace, or a rule the kernel refused:

devsandbox doctor              # the `proxy: firewall` row reports the same check
sudo modprobe nf_tables nf_conntrack

The most common cause is nftables installed with nf_conntrack unloaded: every binary is present, and the rule set still cannot apply because it matches on ct state.

"egress lockdown" aborts the launch

The launch prints a devsandbox: egress lockdown: diagnostic and exits instead of starting. This is the same failure seen from inside the sandbox network namespace rather than from the pre-flight - most often an undiscoverable default route device, or a rule the kernel refused only in that namespace. Proxy mode fails closed, so the workload never ran:

devsandbox doctor              # the `proxy: firewall` row names which half failed
sudo modprobe nf_tables nf_conntrack

There is no setting that degrades back to unenforced egress. Run without --proxy if you do not need the proxy.

Requests timing out

  1. Check if the target allows proxy connections
  2. Some services block known proxy IPs
  3. Try accessing the URL directly to verify it's reachable
  4. Under bwrap or krun, only the proxy port on 10.0.2.2 is reachable - a tool that opens a direct socket instead of honoring HTTP_PROXY/HTTPS_PROXY will hang until it times out. Direct DNS behaves the same way (see below)

Direct DNS does not resolve

Only the proxy port on the gateway is permitted, so a query sent straight to a nameserver has no path out. The proxy resolves hostnames itself, so anything honoring HTTP_PROXY/HTTPS_PROXY is unaffected; a tool that resolves names on its own is not. This is deliberate - permitting :53 to the gateway would re-open a DNS-tunnel exfiltration channel.

The sandbox sees the host's /etc/resolv.conf (bound read-only), which changes nothing here: on a systemd-resolved host it names 127.0.0.53, which inside the namespace is the sandbox's own loopback with nothing listening on it, so direct DNS did not work there before the lockdown either.

Certificate errors

  1. Ensure the CA environment variables are set correctly
  2. Some tools require manual CA configuration
  3. Certificate pinning may prevent interception

No logs appearing

  1. Verify proxy mode is enabled: devsandbox --proxy --info
  2. Check the log directory exists
  3. Make HTTP requests (not just TCP connections)

See Also

Back to docs index | Back to README