Multi-tenant credential-isolation sandbox. Each user gets their own
container with an R2-backed /workspace, OAuth tokens stored in OpenBao
and stamped onto outbound API calls by an MITM proxy, and a shared CLI
toolbox the operator manages.
┌────────────┐ ┌───────────────────────────────────────┐
│ sbox │ HTTPS │ sboxd (daemon) │
│ CLI │◄───────►│ Bao auth │ FUSE mount │ TLS proxy │
└────────────┘ │ per tenant: docker container │
│ /workspace ← R2 (per-tenant) │
│ /opt/sbox ← R2 (operator-shared) │
└───────────────────────────────────────┘
▲
│ docker exec
▼
┌─────────────────────────┐
│ agent (per-tenant) │
│ shells out to gh, │
│ linear, gws, ... │
└─────────────────────────┘
What you get:
- Per-tenant isolation — each tenant has its own docker container and its own R2 prefix. No tenant can see another's files via path traversal, the FUSE mount, or the HTTP API.
- No credential exposure — OAuth tokens never leave the daemon. The
proxy injects
Authorizationheaders on outbound calls based on the tenant identity attached to the proxy listener. - Dynamic everything — providers (OAuth dialect), skills (markdown docs), and CLI binaries are all pushed live without touching the daemon code or restarting containers.
| Package | What it does |
|---|---|
cmd/sbox |
the sbox CLI binary (cobra command tree) |
cmd/agent |
the per-tenant agent that runs in each container |
internal/daemon |
HTTP API: auth, OAuth, files, skills, admin tools, sandbox lifecycle |
internal/sboxfs |
abstract object-storage FS, R2 backend, Prefixed scoper, FUSE adapter |
internal/skills |
catalog at _catalog/, per-tenant install state |
internal/provider |
YAML provider definitions (OAuth + injection rules) |
internal/proxy |
MITM TLS proxy that stamps creds onto outbound requests |
internal/vault |
OpenBao client (auth, KV, policy) |
internal/cli |
CLI command implementations |
The daemon (sboxd) needs:
- OpenBao running and unsealed (token in
BAO_TOKEN, addr inBAO_ADDR) - R2 bucket + access key (
R2_ACCOUNT_ID,R2_ACCESS_KEY_ID,R2_SECRET_ACCESS_KEY,R2_BUCKET, optionalR2_ENDPOINT) - fuse3 installed and
user_allow_otherin/etc/fuse.conf - docker for spawning per-tenant containers
- The
sbox-agent:latestdocker image built (./examples/agent/build.sh)
Drop a systemd unit (see deploy/sboxd.service) and start with:
systemctl start sboxdReach the daemon over a Cloudflare Tunnel or any public HTTPS:
SBOX_PUBLIC_URL=https://sbox.example.com sboxd ...sbox auth login --remote https://sbox.example.com --user alice
# password prompt → token saved to ~/.config/sbox/auth.json
sbox whoami/workspace inside the container is the same R2 prefix the CLI
manipulates here:
sbox files put notes/today.md ./notes.md
sbox files ls
sbox files get notes/today.md
sbox files rm notes/today.mdCatalog of markdown docs the agent reads to learn capabilities:
sbox skills list # operator's catalog + your install state
sbox skills install gmail # copy from catalog into your /workspace/skills/
sbox skills sync # pull all catalog updates + new skills
sbox skills install my-thing ./local-dir # custom upload (immune to sync)
sbox skills uninstall gmailsbox login google
sbox login github
sbox login slack
sbox login notion
# whatever YAML the operator dropped in providers.d/ — just workssbox chat "list my unread emails"
sbox chat -i # interactive REPL
sbox chat --history # dump conversation
sbox chat --reset # clear historyThree artifacts. Three commands. No daemon code, no container restart.
mything/
├── mything.yaml # OAuth provider (auth/inject config)
├── bin/mything # binary: drops into /opt/sbox/bin/<name>
└── skills/mything/SKILL.md # markdown the agent reads on demand
# 1. provider — daemon hot-loads it
sbox provider add ./mything/mything.yaml
# 2. binary — visible to every tenant container at /opt/sbox/bin/mything
sbox admin tools put bin/mything ./mything/bin/mything
# 3. skill — replicated into each tenant on `sbox skills sync`
sbox admin skills push ./mything/skillsTenant side:
sbox skills sync # pulls the new skill into /workspace/skills/
sbox login mything # OAuth dance against the third partyThe agent's system prompt is regenerated on every message — the skill + binary appear automatically the next time the agent sends a message. No daemon restart, no agent restart, no docker pull.
name: <provider-name> # also the URL path (sbox login <name>)
kind: oauth2 # or "static" for API-key providers
auth:
flow: loopback
auth_url: https://example.com/oauth/authorize
token_url: https://example.com/oauth/token
client_id: "..."
client_secret: "..."
default_scopes: [scope_a, scope_b]
extra_auth_params: # provider quirks (Google offline, Slack user_scope, ...)
access_type: offline
prompt: consent
inject:
destinations: ["api.example.com", "*.example.com"]
mode: header # or "env"
header: Authorization
value: "Bearer {access_token}"That's the only knob the operator needs to add a new OAuth provider — no
Go code, no daemon restart. Hot-load via sbox provider add.
sbox admin tools put bin/<name> ./<name-linux> # operator-shared binary
sbox admin tools ls
sbox admin tools rm bin/<name>
sbox admin skills push ./skills/ # tarball of skills/<name>/
sbox admin skills default a,b,c # auto-install set for new tenants- Auth — every HTTP call hits OpenBao
lookup-self(cheap, ~1ms). Identity is attached to request context. - Tenant prefix — all storage paths are derived server-side from
id.TenantName(). Clients can't name themselves. - Path traversal —
Prefixed.full()rejects any key containing..or starting with/before any backend call. - FUSE per tenant — each tenant's container has its own
/workspacemount; no inode resolves outside the prefix. - Container isolation — separate docker container per tenant; only this tenant's FUSE mount + the read-only operator-shared mount are bind-mounted in.
- Proxy injection —
InjectionsFor(sandboxName, host)looks up the cred for this tenant's sandbox before stamping headers.
The shared /opt/sbox mount carries only operator-pushed bytes that
contain no tenant data.
This is a working prototype. Not production-hardened. Things known to work: per-tenant FUSE, R2 backend, generic OAuth (google/slack/github tested), shared admin tools, dynamic system-prompt discovery, skill catalog with versioned sync.
Things deliberately punted: per-tenant R2 keys (one bucket key for the whole daemon — compromise it and you have everyone's data), quotas, audit logs, anything resembling fine-grained authorization beyond "is operator?".