An open API service indexing awesome lists of open source software.

https://github.com/nick22985/sbx

Sandbox Everything
https://github.com/nick22985/sbx

ai autoclaude claude development profile sandbox shai-hulud

Last synced: 7 days ago
JSON representation

Sandbox Everything

Awesome Lists containing this project

README

          

# sbx

Sandboxed Docker dev environments. Pick a flavor (npm, bun, rust, java,
claude, ...), `sbx init` a project, then `sbx shell` (or `sbx run`) to drop
into a container with your repo bind-mounted at the same path it lives at
on the host. Single Rust binary; dynamic shell completions via
`clap_complete`.

sbx demo: cd into a project, sbx init bun, sbx config port add 3000, sbx run, dev server up. One command to a working sandbox.

## Why

Modern dev environments are full of code you didn't write and don't audit:
transitive npm/pip/cargo dependencies, postinstall scripts, AI coding agents
running shell commands, language servers, build plugins. Any one of them
runs with your full user privileges by default, meaning access to your
SSH keys, browser cookies, cloud credentials, shell history, and every
other project on disk.

That's the blast radius supply-chain worms keep exploiting. The
**Shai-Hulud** npm worm (Sept 2025) propagated through a single
compromised package and exfiltrated GitHub tokens, npm tokens, and cloud
credentials from anyone who installed it, turning developer machines into
the spreading mechanism. It wasn't the first and won't be the last:
similar credential-stealing payloads keep shipping through compromised
packages and prompt-injected agents.

`sbx` shrinks that blast radius. Each project runs in its own container
with only the files it needs: the repo, declared mounts, scoped caches.
No host home directory, no SSH agent unless you opt in, no docker socket
unless you opt in, no host network unless you opt in. If something inside
the sandbox tries to read `~/.aws/credentials` or `~/.ssh/id_ed25519`,
there's nothing there to read. Outbound network access can be gated
through an allow-listed `host-proxy`, public exposure goes through a
separately-managed Cloudflare tunnel, and TLS termination happens in a
sidecar so per-project secrets stay per-project.

It's not a security boundary as strong as a VM, but it's a meaningful
default-deny for the day-to-day "I just ran `npm install`" risks.

## Quick start

```sh
./install.sh # one-time, places `sbx` on $PATH
sbx build base # build the base image
sbx build npm # …and one flavor
cd ~/code/some-project
sbx init npm # tag the project; writes .sbx/config.toml
sbx config port add 3000 # publish 3000 to the host
sbx run # spin up & run config.toml's `start` (or shell in)
```

That's the 90% case. Everything else (HTTPS, VPN, public URLs, multi-service
sidecars, claude profiles…) is covered below.

## Install

```sh
./install.sh
```

Then for completions, add to your shell rc:

```sh
# Bash
source <(COMPLETE=bash sbx)
# Zsh
source <(COMPLETE=zsh sbx)
# Fish
COMPLETE=fish sbx | source
```

`sbx completions ` also prints a static completion script if you'd
rather check one in.

Flavors live under `~/.config/sbx/flavors//Dockerfile`. The repo
ships a working set in [`examples/config/flavors/`](examples/config/flavors/),
`base`, `npm`, `bun`, `rust`, `java`, and `claude`. Copy the ones you
want into `~/.config/sbx/flavors/`, then `sbx build base` and
`sbx build ` to bring them up.

tree ~/.config/sbx/flavors/ showing one directory per flavor (base, npm, bun, rust, java, claude), each containing a Dockerfile.

## Project lifecycle

```
sbx init [-p] Mark cwd as and build the image
-p stores the marker in $SBX_PRIVATE_DIR instead of ./.sbx
sbx Print top-level help
sbx shell [cmd...] Enter the project's container (or run `cmd` in it)
sbx shell -f [cmd...]
Override the flavor (ad-hoc), with optional command
sbx Ad-hoc transient shell of in cwd
sbx run Run `start` from .sbx/config.toml in a fresh container
sbx sessions List running sbx containers (alias: ps)
sbx stop Stop containers, services, and network sidecars
sbx list List available flavors
```

### Shadowing host tools (npm, bun, cargo, …)

`sbx shell -f [cmd...]` lets you run a one-shot command inside
the matching flavor container from *anywhere* on the host, without
`sbx init`-ing the directory first. Pair it with shell aliases to
transparently route risky tools through their sandbox:

```sh
# ~/.bashrc / ~/.zshrc
alias npm='sbx shell -f npm npm'
alias npx='sbx shell -f npm npx'
alias bun='sbx shell -f bun bun'
alias bunx='sbx shell -f bun bunx'
alias cargo='sbx shell -f rust cargo'
alias rustc='sbx shell -f rust rustc'
```

Now `npm install` in any directory runs `npm install` inside the `npm`
flavor's container with cwd bind-mounted at the same path. No host
`node_modules` postinstall scripts, no host `cargo build.rs` running with
your credentials. If the project has its own `.sbx/config.toml`, drop the
`-f` and the project's flavor is used automatically (`sbx shell npm install`).

Flavor name vs. binary: the `-f` arg picks the **image**, the rest is
the **command**. Useful when they differ, e.g. `sbx shell -f rust cargo
build` (the `rust` flavor ships `cargo`, not `rust`). `--flavor` and
`--flavour` (British spelling) are both accepted as long forms of `-f`.

## Images

```
sbx build [flavor|all] Rebuild image(s)
sbx rebuild [flavor|all] Rebuild with --no-cache
sbx clean [flavor] Remove cache volumes
sbx purge [flavor] Remove caches + images (prompts)
sbx scan [fs|image] Full trivy scan
```

## Per-project config

All per-project state lives under `sbx config` (aliases: `cfg`, `conf`).

A project's `config.toml` is resolved from up to three locations and
**merged**: a private copy under `$SBX_PRIVATE_DIR` (defaults to
`~/dotfiles/env/.config/.nickInstall/install/configs/private/sbx/`),
the git common dir's `.sbx/`, and the working tree's `.sbx/`. Scalar
fields (`flavor`, `start`, `name`, `port-offset`) follow local-wins
precedence; list fields (`mounts`, `caches`, `ports`, `[[tunnel]]`) are
concatenated and deduped; boolean flags (`ssh`, `docker`, `gui`) are
OR'd; map fields (`hostname`, `public`) merge with the local key
overriding. `sbx config ...` writes to the local file only.

```
sbx config port [list|add N|rm N]
sbx config mount [list|add SPEC|rm SPEC] [-g] SPEC: host[:container][:ro]; -g targets the global config
sbx config hostname [list|add HOST PORT|rm HOST] Map HOST.sbx.localhost via the proxy sidecar
sbx config tunnel [list|add DIR L R|rm DIR L] Forward TCP between host, sandbox, and remote (DIR: out/in/via/via-host)
sbx config env [list|set K=V|unset K] Manages ~/.config/sbx/env
sbx config start [show|set |clear]
sbx config service [list|add NAME|rm NAME] Built-ins: redis, postgres, mongo, mysql, mailpit
sbx config ssh [on|off|status] Mount $SSH_AUTH_SOCK on next start
sbx config docker [on|off|status] Forward /var/run/docker.sock into the sandbox
sbx config gui [on|off|status] Forward host X11 / Wayland sockets so GUI apps can render on the host
```

### Mounts

Extra host paths can be made visible inside every sbx session (opt-in,
off by default). Three layered sources, plus claude's `-m` flag:

- `mounts = [...]` in `$XDG_CONFIG_HOME/sbx/flavors//config.toml`,
bound only when that flavor is active. Good for editor configs and
other host paths a single flavor needs (e.g. `~/.config/nvim` for the
`nvim` flavor).
- `mounts = [...]` in `$XDG_CONFIG_HOME/sbx/config.toml`, global,
applied to **every** sbx session regardless of flavor. Good for
caches/tooling you always want (e.g. `~/.m2`, `~/.gradle`,
`~/.cache/pip`).
- `mounts = [...]` in `./.sbx/config.toml`, per-project, layered on top
of the global file.

Entry syntax (missing host paths are silently skipped):

```
"host" # same path on both sides
"host:container" # explicit container path
"host:container:ro" # read-only
"host::ro" # same-path bind, read-only
```

`~/` on the host side expands to your host `$HOME`; `~/` on the
container side expands to the flavor's container home (e.g.
`/home/dev`, or `~/` for `sbx claude` which mirrors the host home).

**Mounting on top of a named volume.** Some flavors (e.g. `java`) bind a
named docker volume over a container path for cache reuse. To inject
your own config without losing that cache, mount the single file *on
top* of the volume:

```
~/.m2/settings.xml:~/.m2/settings.xml:ro
```

The container still gets the cache volume at `~/.m2/`, but Maven now
picks up your host `settings.xml` (auth, mirrors, etc.).

### Caches

Flavor authors declare caches in `caches = [...]` inside
`~/.config/sbx/flavors//config.toml`, alongside the `Dockerfile`. Each
entry names a host path or named docker volume that survives between
runs (e.g. `~/.npm`, `~/.cargo`, `@sbx-maven-cache:/home/dev/.m2`). Two
extra layers let you add to or override those without editing the
flavor's config:

- `caches = [...]` in `$XDG_CONFIG_HOME/sbx/config.toml`, global,
applied to **every** sbx session regardless of flavor. Good for
caches you always want shared with the host (e.g. `.cargo/registry`,
`.npm`).
- `caches = [...]` in `./.sbx/config.toml`, per-project, layered on top
of the global file.

All three layers use the same per-entry syntax:

```
.cache/pip # host bind: ~/.cache/pip -> /home/dev/.cache/pip
.m2:/home/dev/.m2 # host bind, explicit container path
@sbx-maven-mine:/home/dev/.m2 # named docker volume
```

Override semantics: entries are merged by **container path**, with the
project config winning over the global config winning over the flavor
config. So if the `java` flavor ships `@sbx-maven-cache:/home/dev/.m2`
and you'd rather use your host's `~/.m2`, add `".m2:/home/dev/.m2"` to
`caches` in `~/.config/sbx/config.toml` (or just the project's
`.sbx/config.toml` for one project) and the host bind replaces the
named volume. Missing host paths are auto-created on first run.

User-defined volumes are user-owned: `sbx clean` / `sbx purge` only
remove volumes declared in the flavor's own `caches` list, so renaming
the active volume via an override won't trigger surprise deletions of
your data.

### GUI forwarding (opt-in)

`sbx config gui on` sets `gui = true` in `./.sbx/config.toml`; the next
container start mounts the host's Wayland and X11 sockets and forwards
`DISPLAY`, `WAYLAND_DISPLAY`, and `XDG_RUNTIME_DIR` so GUI apps inside the
sandbox render on the host (Electron apps, browsers, IDEs launched from a
flavor shell, etc.).

```sh
sbx config gui on # sets gui = true in ./.sbx/config.toml
sbx config gui status # shows whether forwarding is on + detected host sockets
sbx config gui off # sets gui = false
```

`sbx config gui status` prints whether forwarding is enabled and which
of `WAYLAND_DISPLAY` / `DISPLAY` were detected on the host, useful when
an app silently fails to open a window.

## Networking


sbx network architecture: one shared netns (vpn / tailscale / first service / tunnel sidecar / sbx-container, first present owns it); a separate sbx-proxy-net bridge hosting sbx-proxy (Traefik) and sbx-public (cloudflared); sbx-host-proxy reached via host.docker.internal.

```
sbx net vpn [status|use SPEC|auth|inline|off]
sbx net tailscale [on [name]|off|status|auth [name]|list|rm name]
sbx proxy [status|routes|logs [-f]|stop]
sbx tunnel [status|logs [-f]|stop]
sbx public [list|add HOST PORT|rm HOST|login|status|logs [-f]|stop]
sbx host-proxy [on|off|status|list|allow HOST|disallow HOST|reload|logs [-f]|stop]
```

`sbx proxy` controls the shared Traefik sidecar that publishes
`*.sbx.localhost` routes from `sbx config hostname` and from any container labels.
The Traefik dashboard is at http://traefik.sbx.localhost/dashboard/ whenever
the sidecar is up.

### Exposing a project (four flavors)

| Scope | Setup | URL |
|---|---|---|
| **Local plain HTTP** | `sbx config hostname add app.sbx.localhost 8080` | `http://app.sbx.localhost/` |
| **Local HTTPS** (mkcert) | `sbx proxy mkcert` (once, needs host `mkcert`), then `sbx config hostname add app.sbx.localhost 8080` | `https://app.sbx.localhost/` |
| **Local HTTPS** (Let's Encrypt + Cloudflare DNS-01) | `sbx config hostname add app.local.example.com 8080`, plus `CLOUDFLARE_DNS_API_TOKEN` and `SBX_ACME_EMAIL` in `~/.config/sbx/env` | `https://app.local.example.com/` |
| **Public** (Cloudflare Tunnel) | `sbx public login` (once), then `sbx public add app.example.com 8080` | `https://app.example.com/` |

All four share the same internal Traefik proxy on `sbx-proxy-net`. You can mix
them in one project, e.g. `app.sbx.localhost` for fast local dev and
`app.example.com` for a public preview link to share with a teammate.


How a request reaches the sandbox, by scope: (1) HTTP: browser to 127.0.0.1:80, Traefik routes to sandbox; (2) HTTPS: browser to 127.0.0.1:443, Traefik terminates TLS (mkcert or Let's Encrypt) and forwards plain HTTP to sandbox; (3) Public: browser to Cloudflare edge, cloudflared sidecar dials out over QUIC, then HTTP through Traefik to sandbox.

VPN/Tailscale settings are stored per-project under `[network]` in
`.sbx/config.toml` and applied on the next `sbx` shell start. Tailscale
supports multiple named profiles - each maps to its own
`SBX_TAILSCALE_AUTHKEY[_]` env var.

### Tunnels

`sbx config tunnel` forwards raw TCP between the host, the sandbox, and remote
services reachable via Tailscale/VPN. Four directions, written as `[[tunnel]]`
tables in `.sbx/config.toml`:

```toml
[[tunnel]] # sandbox :3000 -> host 127.0.0.1:3000
dir = "out"
left = 3000
right = 3000

[[tunnel]] # host :5432 -> sandbox localhost:5432
dir = "in"
left = 5432
right = 5432

[[tunnel]] # host :5432 -> remote :5432 through the sandbox netns
dir = "via"
left = 5432
right = "db.staging.tail-net.ts.net:5432"

[[tunnel]] # sandbox -> host.docker.internal:27017 -> remote (uses host's netns)
dir = "via-host"
left = 27017
right = "192.168.1.67:27017"
```

`via:` is most useful with Tailscale/VPN on: the sandbox netns has tailnet routes
and MagicDNS, so host tools (TablePlus, psql, etc.) can reach tailnet-only services
without running Tailscale themselves. `in:` and `via:` spin up a small `alpine/socat`
sidecar joined to the session's netns; `out:` is published via `-p` on the netns
owner.

`via-host:` is the inverse: the *sandbox* needs to reach a destination the **host**
can route to but the sandbox's own netns can't, most commonly a LAN/RFC1918
service when VPN is on (which has stolen the sandbox's default route). A separate
`sbx-via-host-` sidecar runs on `--network host` (so it has the host's
full LAN/VPN routing) and listens on the docker bridge gateway at `LEFT`, forwarding
to `RIGHT`. The sandbox connects to `host.docker.internal:LEFT`; this traffic
exits the VPN netns via the bridge interface because gluetun's
`FIREWALL_OUTBOUND_SUBNETS` already exempts the docker bridge subnet. The listener
binds to the bridge IP specifically, so LAN-side machines can't reach the forward.

`sbx tunnel status` shows the configured tunnels and the state of the per-project
socat sidecars; `sbx tunnel logs [-f]` tails them; `sbx tunnel stop` tears them
down.

### Host proxy (HTTPS pass-through via host)

`sbx host-proxy` lets the sandbox reuse the **host's** outbound network for
HTTPS, typically to reach a service that is only routable via the host's
Tailscale/VPN when you can't (or don't want to) run Tailscale inside the
container. A shared `sbx-host-proxy` sidecar runs `tinyproxy` on the host
network and the sandbox is given `https_proxy=http://host.docker.internal:8118`
so any tool that honours `https_proxy` (curl, maven, npm, pip, go, …) goes
through it. TLS is end-to-end, tinyproxy uses HTTP `CONNECT`, never
terminates the TLS.

```sh
sbx host-proxy on # sets [host_proxy] enabled = true
sbx host-proxy allow repo.internal.example.com # add a host to the allowlist
sbx host-proxy allow '*.maven.org' # wildcard (subdomains)
sbx host-proxy list # show this project's allowlist
sbx host-proxy status # marker + sidecar + merged allowlist
sbx run # sidecar auto-starts on first session
```

The `[host_proxy]` table in `.sbx/config.toml` carries `enabled` (the
on/off marker) and `allow` (the per-project allowlist). An **empty
`allow`** with `enabled = true` means "unrestricted for this project".
A non-empty list restricts proxied traffic to only those hosts (matched
by a tinyproxy `Filter` with `FilterDefaultDeny Yes`). Wildcards:
`foo.com` matches `foo.com` exactly; `*.foo.com` matches any subdomain.

Changes to the allowlist hot-reload, `sbx host-proxy allow|disallow`
rewrites the filter file and sends `SIGHUP` to the running sidecar. No
container restart, no session disruption.

When to use it instead of `via:` tunnels: `via:` is best for raw TCP to a
single known `host:port`; `host-proxy` is best when the sandbox already
talks HTTPS by URL (e.g. Maven repos behind a private Nexus) and you'd
rather not enumerate every endpoint.

**Shared-sidecar caveat.** A single `sbx-host-proxy` container serves
every project, so the active allowlist is the **union of every active
project's entries**. If project A allows only `repo.example.com` and
project B's `allow` is empty (meaning "unrestricted for B"),
the sidecar is still restricted to `[repo.example.com]` because A
demanded restrictions, B effectively inherits A's allowlist for the
duration. To run a truly unrestricted host-proxy, none of the active
projects can have a non-empty allowlist. The same goes the other way:
adding an entry anywhere strictens the proxy for everyone.

Tinyproxy listens on `0.0.0.0:8118` of the host network and only accepts
RFC1918 + loopback clients (`Allow` rules in the generated config). TLS
stays end-to-end (`CONNECT` tunnel, the proxy never terminates TLS).
The sidecar is reference-counted: it auto-starts when a session with
the marker spins up, and `stop_sidecar_if_idle` tears it down once no
sandbox container still has `https_proxy` set in its env.

### Public URLs (Cloudflare Tunnel)

`sbx public` exposes a project on the public internet through a Cloudflare
Tunnel, no inbound ports, no DNS records to manage by hand. One-time setup:

```sh
sbx public login # browser flow; writes ~/.config/sbx/cloudflared/cert.pem
sbx public add app.example.com 8080 # in your project dir, adds to [public] in .sbx/config.toml
sbx run
```

First start creates a single shared `sbx-public` Cloudflare tunnel, registers
the CNAME (`HOST → sbx-public.cfargotunnel.com`), spins up a global
`sbx-public` cloudflared sidecar on the proxy network, and routes traffic
through the existing Traefik proxy, so `sbx config hostname` and
`sbx public` share the same internal HTTP plane (CF terminates TLS at the
edge). Multiple projects can register their own hostnames; the sidecar's
`config.yml` is merged from per-project fragments.

`sbx public status` shows sidecar / login / tunnel state and merged hostnames
across all active sessions. `sbx public logs [-f]` tails cloudflared;
`sbx public stop` force-stops it. Hostnames added under `[public]` in
`./.sbx/config.toml` are registered on the next `sbx run` and
unregistered on session exit.

The `cert.pem` produced by `sbx public login` is the Cloudflare API
credential (account-scoped); the per-tunnel `credentials.json` next to it
is what cloudflared actually uses at runtime. CF's dashboard makes you pick
a zone during login, but the resulting cert works for every zone in your
account.

## sbx claude

`sbx claude` is just an [agent](#agents-sbx-opencode--sbx-copilot--your-own)
like the rest, sharing the exact same CLI surface. Its `[agent]` config
opts into two extra features: `--dangerously-skip-permissions` autonomy and
`remote-control = true`.

```
sbx claude [-m PATH]... [-p PROFILE] [-s] [--rc] [--docker] [args...]
sbx claude shell Drop to bash inside the sandbox
sbx claude build|rebuild Build/rebuild the claude image
sbx claude profile [list|add NAME|rm NAME|current]
```

Flags (`-m`, `-p`, `-s`, `--rc`, `--docker`) come *before* the
subcommand: `sbx claude -m ~/projects/foo -p work shell`.

`sbx claude` is independent of the project's flavor, you can launch it
on an npm/bun/rust/uninitialised project. It bind-mounts cwd at the same
path it lives at on the host and the host's `~/.claude` rw (so auth,
config, and history are shared). The image bundles node + bun + rust +
python and the Claude Code CLI.

Because the container is already a sandbox, `sbx claude` auto-passes
`--dangerously-skip-permissions` to `claude` so prompts don't get in the
way. Pass `-s` / `--safe` to opt out for a single invocation, or pass
`--dangerously-skip-permissions` yourself and it won't be duplicated.

In addition to the global / per-project [mount files](#mounts),
`sbx claude` accepts `-m / --mount ` repeated per invocation for
ad-hoc mounts: `sbx claude -m ~/projects/foo -m ~/.m2/settings.xml:~/.m2/settings.xml:ro`.

`sbx claude` can opt a session into [Remote Control] so it's reachable
from `claude.ai/code` and the Claude mobile app. When enabled, sbx
appends `--remote-control "-"` to the inner `claude`
invocation. It's **off by default**, opt in with `--rc` for a single
run, set `SBX_REMOTE_CONTROL=1` in `~/.config/sbx/env` to default-enable
it persistently, or pass your own `--remote-control` / `--rc` flag (sbx
won't double up).

[Remote Control]: https://code.claude.com/docs/en/remote-control

### Profiles

`sbx claude profile add work` creates an isolated `~/.claude` clone under
`$XDG_CONFIG_HOME/sbx/claude-profiles/work/` seeded from your host
`.claude.json`. Use it with `sbx claude -p work`, or pin a project to a
profile by setting `[claude] profile = "..."` in `./.sbx/config.toml`. Useful for
separating personal/work logins or for keeping different MCP setups apart.

### Docker socket forwarding (opt-in)

`sbx config docker on` sets `docker = true` in `./.sbx/config.toml`; every
container start for that project then bind-mounts `/var/run/docker.sock`
from the host and `--group-add`s the host docker GID so the unprivileged
in-container user can talk to it. The base image ships the docker client
binary.

`sbx claude` intentionally does *not* follow the project `docker` flag,
opt in per-session with `--docker`, or globally with `SBX_DOCKER=1` in
`~/.config/sbx/env`.

**Security:** mounting the docker socket is effectively root on the host -
anything inside the container can `docker run --privileged -v /:/host ...` and
escape the sandbox. Only enable this when you trust what's running inside.

## Agents (sbx opencode / sbx copilot / your own)

An **agent** is any flavor whose `config.toml` declares an `[agent]` block.
Such a flavor launches its CLI directly, with no per-agent Rust code, just a
flavor directory:

```
sbx opencode [args...] # opencode
sbx copilot [args...] # GitHub Copilot CLI
sbx [-m PATH]... [-p PROFILE] [-s|--safe] [--docker] [args...]
sbx shell | bash # drop to bash inside the sandbox
sbx build | rebuild # build/rebuild the agent image
sbx profile [list|add NAME|rm NAME|current]
```

sbx's own flags (`-m`/`--mount`, `-p`/`--profile`, `-s`/`--safe`, `--docker`,
`--shell`) come *before* any passthrough args, which are forwarded verbatim to
the inner CLI. The reserved verbs above (`shell`/`bash`, `build`, `rebuild`,
`profile`) are claimed by sbx when they appear first, so they shadow any
same-named subcommand of the inner CLI. Agents are independent of the project's
flavor and bind-mount cwd at its host path. `sbx claude` is one of these agents
(it just enables a couple of extra features in its config, see
[above](#sbx-claude)).

### Defining an agent

Add `[agent]` to a flavor's `config.toml`:

```toml
[agent]
bin = "opencode" # binary to exec; defaults to the flavor name
persist = [".config/opencode", ".local/share/opencode"] # host dirs bind-mounted for auth/config
autonomy = ["--allow-all"] # flags injected unless --safe / already present
autonomy-detect = ["--yolo"] # extra flags that count as "already autonomous"
forward-env = ["GH_TOKEN"] # host env vars forwarded when set
profiles = true # enable named logins (sbx profile ...)
```

Pair it with a `Dockerfile` that installs the CLI and `sbx ` just works.

### Autonomy

Since the container is already a sandbox, agents auto-inject their `autonomy`
flags so prompts don't get in the way. `sbx copilot` injects `--allow-all`;
use `--safe` to opt out for one invocation, or pass your own
`--allow-all` / `--yolo` and it won't be duplicated. opencode declares no
autonomy flag; it's autonomous by default and its prompting is configured via
the `permission` key in `opencode.json` (shared from the host through the
mounted `~/.config/opencode`).

### Auth and config

The `persist` dirs are bind-mounted from the host, so logins are shared with
the host install:

- **opencode** persists `~/.config/opencode` and `~/.local/share/opencode`
(provider creds in `auth.json`, sessions). Log in once on the host with
`opencode auth login`, or do it inside the sandbox (`sbx opencode auth
login`); it persists either way.
- **copilot** persists `~/.copilot` (auth + state) and forwards
`COPILOT_GITHUB_TOKEN` / `GH_TOKEN` / `GITHUB_TOKEN` from the host env when
set, so token auth works without an interactive `/login`.

Agents honor the global / per-project [mount files](#mounts) plus ad-hoc
`-m / --mount `, and the opt-in docker socket via `--docker` /
`SBX_DOCKER=1` (same caveats as `sbx claude`).

### Profiles

Agents with `profiles = true` support multiple named logins, so you can keep
e.g. a work and a personal account side by side:

```
sbx opencode profile add work # create an empty profile
sbx opencode profile list # list profiles (* marks the active one)
sbx opencode profile current # print the active profile
sbx opencode profile rm work # delete a profile
sbx opencode -p work [args...] # run using that profile
```

Each profile lives at `$XDG_CONFIG_HOME/sbx/-profiles//` and holds
its own copy of the agent's `persist` dirs. When a profile is active those dirs
bind from the profile instead of your host home, so logins are fully isolated.
A freshly added profile starts logged out, so sign in once inside it (`sbx
opencode -p work auth login`) and it persists there. With no profile selected,
the agent binds from your host home and shares the host login.

Pin a project to a profile with a `[profiles]` entry in `./.sbx/config.toml`:

```toml
[profiles]
opencode = "work"
copilot = "personal"
```

A `-p NAME` flag overrides the pin for one invocation. (`sbx claude` has the
same feature via its own `[claude] profile` key, see [above](#profiles).)

## Files

Three TOML scopes, one schema per scope, layered project > global > flavor:

- `./.sbx/config.toml` - per-project: every project knob
(`flavor`, `ports`, `mounts`, `caches`, `ssh`, `docker`, `gui`, `start`,
`[network]`, `[hostname]`, `[public]`, `[[tunnel]]`, `[services]`,
`[host_proxy]`, `[claude]`, `[profiles]`, plus `name` / `port-offset`
worktree overrides).
- `$XDG_CONFIG_HOME/sbx/config.toml` - global: `mounts` and `caches`
applied to every sbx session.
- `$XDG_CONFIG_HOME/sbx/flavors//config.toml` - flavor: `mounts` and
`caches` shipped with the flavor's Dockerfile.

Plus:

- `./.sbx/Dockerfile` - optional, layers on top of the flavor image.
- `$XDG_CONFIG_HOME/sbx/flavors//Dockerfile` - base image source per flavor.
- `$XDG_CONFIG_HOME/sbx/env` - persistent env (KEY=value, chmod 600).
- `$XDG_CONFIG_HOME/sbx/claude-profiles//` - alternate `~/.claude` per profile.
- `$XDG_CONFIG_HOME/sbx/-profiles//` - isolated `persist` dirs per agent profile.

See [`examples/sbx/`](examples/sbx/) for an annotated project `config.toml`
and [`examples/config/`](examples/config/) for the global + per-flavor layout.

Coming from an older sbx layout with separate files for `flavor`,
`hostname`, `ports`, `mounts`, `caches`, etc., or with flavors at the
top level of `~/.config/sbx/`? Run `sbx migrate` once; it folds project
files into `./.sbx/config.toml`, global `~/.config/sbx/{mounts,caches}`
into `~/.config/sbx/config.toml`, relocates each top-level flavor dir
into `~/.config/sbx/flavors//`, and consolidates each flavor's
`mounts` and `caches` into a per-flavor `config.toml`. Project legacy originals land
in `./.sbx/legacy/` and global ones in `~/.config/sbx/legacy/`.

In a git worktree, `.sbx/config.toml` is looked up in the worktree first,
then the shared bare/primary repo, then the private overlay
(`$SBX_PRIVATE_DIR//.sbx/`).

### Worktrees

When `sbx` runs inside a linked git worktree it auto-derives a suffix from
the checked-out branch and applies it everywhere that needs to be unique
across worktrees:

- `project_name` becomes `-`, distinct containers, proxy
routes, sidecars, etc., so two worktrees of the same repo can run side
by side.
- Hostnames under `[hostname]` and `[public]` in `.sbx/config.toml` are
auto-prefixed with `-`. So a single shared
`"app.sbx.localhost" = 3000` under `[hostname]` yields
`https://app.sbx.localhost/` in the main checkout and
`https://server-live-app.sbx.localhost/` in a worktree on branch
`server-live`. The prefix is flat (dash, not dot) so the URL stays at the
same DNS depth as the original, existing wildcard certs
(`*.sbx.localhost`, `*.example.com`) keep working.
- Published ports get a stable hash-derived offset in `[1, 9]` so two
worktrees of the same repo don't collide on the host (`master` / `main`
/ non-worktree always use 0). Pin a specific offset by setting
`port-offset = N` in `.sbx/config.toml`.

The branch name is sanitized (`feature/foo` -> `feature-foo`). To use
something other than the branch name, set `name = "exp1"` in the worktree's
`.sbx/config.toml`; its value replaces the suffix (so
`name = "exp1"` -> `exp1-app.sbx.localhost` and project_name `-exp1`).
Project images (`sbx--`) are unaffected, they're shared
across worktrees.

## Environment

| Var | Meaning |
|-----|---------|
| `SBX_PORTS=3000,8080` | Extra ports to publish |
| `CLOUDFLARE_DNS_API_TOKEN=…` | CF API token used by Traefik for ACME DNS-01 (real-domain local HTTPS) |
| `SBX_ACME_EMAIL=…` | Contact email for Let's Encrypt registration. Required with `CLOUDFLARE_DNS_API_TOKEN` |
| `SOCKET_CLI_API_TOKEN=…` | socket.dev API token (forwarded into containers that ship `socket`) |
| `SOCKET_ORG_SLUG=…` | Default org for `socket scan create` |
| `SBX_VPN_DIR=…` | Directory for bare VPN names |
| `SBX_PRIVATE_DIR=…` | Read-only overlay for `.sbx` configs; also where `sbx init -p` writes |
| `SBX_PROJECT_DIR=…` | Override the detected project root (set inside the sandbox; usually auto) |
| `SBX_PROJECT` | Set *inside* sandboxes, full project name (e.g. `myapp-master`) |
| `SBX_PROJECT_BASE` | Set inside sandboxes, repo base name without worktree suffix (e.g. `myapp`) |
| `SBX_WORKTREE` | Set inside sandboxes, worktree suffix, empty in main checkout (e.g. `master`) |
| `SBX_HOSTNAME` / `SBX_HOSTNAMES` | Set inside sandboxes, primary / all hostnames (public preferred over local, useful for OAuth/SAML callbacks) |
| `SBX_LOCAL_HOSTNAME` / `SBX_LOCAL_HOSTNAMES` | Set inside sandboxes, first / all local hostnames from `[hostname]` (already prefixed) |
| `SBX_PUBLIC_HOSTNAME` / `SBX_PUBLIC_HOSTNAMES` | Set inside sandboxes, first / all public hostnames from `[public]` |
| `SBX_PORT` | Set inside sandboxes, primary published port (first entry in `ports`) |
| `SBX_DOCKER=1` | Default `sbx claude` to mount the host docker socket |
| `SBX_REMOTE_CONTROL=1` | Default `sbx claude` to enable `--remote-control` (off by default) |
| `SBX_TAILSCALE_AUTHKEY[_]=…` | Auth key for the default / named tailscale profile |
| `SBX_TAILSCALE_EXTRA_ARGS=…` | Extra args appended to `tailscale up` |
| `SBX_BUILDX_BUILDER=default` | Buildx builder to use for sbx's own builds (default: `default`; set empty to inherit `docker buildx use`) |

Persist these in `~/.config/sbx/env` (KEY=value lines, chmod 600). Host env
wins over the file.

**Anything you put in `~/.config/sbx/env` is also forwarded into every sbx
container.** So `sbx config env set MY_API_KEY=…` (or editing the file
directly) is enough to make `MY_API_KEY` available to your app inside the
sandbox, no allowlist or prefix required. The sbx-internal vars above
(`CLOUDFLARE_DNS_API_TOKEN`, `SBX_TAILSCALE_AUTHKEY*`, etc.) are
forwarded too, treat the file as the single source of truth for "env I
want in my sandboxes" and keep host-only secrets out of it.