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
nftoriptablesfor 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:
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:
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¶
Configuration File¶
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 = falseor extraction fails - plusiproute2andnftoriptableswithnf_tables/nf_conntrackloaded
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_ADMINover 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 unloadednf_tables/nf_conntrack, an undiscoverable default route device, or any failing rule aborts the launch with adevsandbox: 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 noCAP_NET_ADMINover a netns owned by pasta's user namespace and cannot flush the table. Unsharing its own netns yields a namespace with onlyloand 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
nftoriptables
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)¶
- Network Isolation - pasta creates a new network namespace with its own network stack
- Gateway Setup - Traffic is routed through a virtual gateway (10.0.2.2)
- Proxy Server - A local HTTP/HTTPS proxy runs on the host
- 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_PROXYhas no path to an external address. Any step failing aborts the launch - see bwrap backend - 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:
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
CONNECTline 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. requestline but no matchingresponseline within the client timeout - the upstream (or the proxy-to-upstream connection) never returned headers. The stall is upstream, not in the proxy.responseline with a largetime_to_headers- the upstream itself was slow to respond. A smalltime_to_headersbut a client that still times out points to the client or the connection after headers, not the proxy.streamingreports only whether the response advertised a streamingContent-Type(e.g.text/event-stream); it is informational. Bodies stream through regardless ofContent-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:
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:
The socket path is auto-detected from the current directory's sandbox. You can also specify it explicitly:
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 requestb- Block this requests- Allow and remember for sessionn- 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¶
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¶
- Intercept - The proxy intercepts outgoing requests from the sandbox.
- Match - For each configured credential injector, it checks whether the request host matches the injector's
host(exact match or glob). - Inject - If matched, the injector sets the configured
headerto the renderedvalue_formatwith{token}substituted from the resolvedsource. The existing header is preserved unlessoverwrite = true. - 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 (authorization → Authorization). |
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:
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:
- Exact host beats any glob.
- Among globs, the longer literal portion (
len(host) - count('*')) wins. - 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 = trueand 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 = trueto change this. - Invalid configuration fails fast at load time: unknown
preset, missinghost/headerwhenenabled = true, invalid glob, or unreadable sourcefile.
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¶
# ~/.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:
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_skiprule. 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¶
- Check if the target allows proxy connections
- Some services block known proxy IPs
- Try accessing the URL directly to verify it's reachable
- Under bwrap or krun, only the proxy port on
10.0.2.2is reachable - a tool that opens a direct socket instead of honoringHTTP_PROXY/HTTPS_PROXYwill 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¶
- Ensure the CA environment variables are set correctly
- Some tools require manual CA configuration
- Certificate pinning may prevent interception
No logs appearing¶
- Verify proxy mode is enabled:
devsandbox --proxy --info - Check the log directory exists
- Make HTTP requests (not just TCP connections)
See Also¶
- Sandboxing - filesystem and process isolation, security model
- Configuration: Remote Logging - send proxy logs to syslog or OTLP
- Configuration: Credential Injection - inject tokens into requests without exposing them to the sandbox
- Configuration: Port Forwarding - forward ports between host and sandbox (requires proxy mode)
- Use Cases: Security Monitoring - real-time monitoring and post-session audit scripts