⚠ Early release - single-tenant only

boxy

Kubernetes-native sandbox runtime

Run isolated shell commands inside ephemeral Linux environments via a clean HTTP API or MCP - no VMs, no hypervisors, no hardware dependencies. Each sandbox is an nsjail process jail backed by a shared read-only Ubuntu 24.04 rootfs.

Go 1.26+ nsjail isolation MCP / AI-native Helm OCI chart MIT
Install with Helm (OCI)
helm upgrade --install boxy oci://ghcr.io/niradler/charts/boxy \
--version 0.0.3 --namespace boxy --create-namespace \
--set router.auth.staticToken="$(openssl rand -hex 16)"

Architecture

Three tiers, each with a single responsibility. The router is the only public-facing component. All sandbox state lives in Kubernetes CRs and controller node storage - the router and operator are fully stateless.

Clients
🌐 REST Client
πŸ€– AI / MCP
Bearer ↓
boxy-router Deployment Γ— N
1Auth - SA token via TokenReview (or static dev bypass)
2Validate body, parse request fields
3Create / read / delete Sandbox CRs
4Proxy exec to controller via mTLS
5MCP server - exposes bash tool
CRD ↕
boxy-operator leader-elected
ABin-pack β†’ assign pod
BAuto-scale StatefulSet
CTTL expiry + GC
mTLS (mutual cert auth)
↓
boxy-controller StatefulSet (auto-scaled by operator)
boxy-ctrl-0
sandbox-A
/workspace
sandbox-B
/workspace
boxy-ctrl-1
sandbox-C
/workspace
-
+
scale on demand
nsjail sandbox isolation layers
πŸ—‚οΈ
Mount Namespace
R/O Ubuntu 24.04 rootfs
R/W /workspace
tmpfs /tmp per-exec
πŸ”Œ
Network Namespace
No internet by default
opt-in: allowInternetAccess
MACVLAN / pasta modes
πŸ”¬
PID + UTS Namespace
Isolated process tree
Custom hostname
SIGKILL on timeout
⚑
cgroup + rlimits
memoryMb via cgroup
cpu / nofile / nproc
wall-clock timeout
πŸͺ
Lifecycle Hooks
setupScript on create
teardownScript on delete
per-sandbox scriptEnv

Sandbox lifecycle

Pending
assign pod→
Creating
ctrl POST /v1/sandboxes→
Running
TTL / delete→
Deleting
GC after N s→
Terminated

TTL is a sliding window - refreshed on every exec. A sandbox pinned to a controller pod; if that pod restarts, the router detects the stale route, resets the CR to Pending, and the operator reassigns it.

Dual-track output design

One sandbox runtime, two client models. Developers talk REST; AI agents talk MCP. The same nsjail execution engine serves both - no separate code paths, no duplication.

🌐
REST API
For developers, services, CI pipelines
  • βœ“JSON over HTTP - works with any HTTP client
  • βœ“Full lifecycle: create, exec, delete sandboxes
  • βœ“SA token auth via Kubernetes TokenReview
  • βœ“Structured error codes with clear semantics
{"exitCode": 0, "stdout": "hello from nsjail\n",
 "stderr": "", "timedOut": false}
πŸ€–
MCP Server
For AI agents - Claude, Codex, any MCP client
  • βœ“Model Context Protocol (JSON-RPC 2.0)
  • βœ“Streamable HTTP transport - stateless
  • βœ“bash tool agents call like a function
  • βœ“Optional default sandbox for stateless clients
{"content": [{"type": "text",
  "text": "hello from nsjail\n"}]}

Use cases

πŸ€–

AI agent code execution

Give Claude, Codex, or any LLM a real bash shell backed by an isolated Ubuntu environment. Low-latency exec, stateful workspace per session, native MCP support.

πŸ—οΈ

CI step isolation

Isolate build steps from each other without spinning up full containers. File isolation between jobs, reproducible Ubuntu 24.04 rootfs, no image pull on every run.

πŸ§‘β€πŸ’»

Interactive shells

Provision on-demand shell environments for users. Strong cross-sandbox isolation, TTL enforcement, per-session persistent workspace that survives multiple exec calls.

πŸ”

Script evaluation API

Evaluate user-supplied scripts safely. Output capture, timeout enforcement, cgroup memory caps, and blocked network namespaces prevent abuse.

πŸ”’

Secure code execution API

Build a service that runs untrusted code. Validated inputs, read-only base OS, isolated network namespace, and rlimits bound every execution.

βš™οΈ

Sandboxed tool execution

Bind-mount specific binaries (curl, python3, jq) into sandboxes via allowedBinaries - whitelist exactly what each sandbox can run.

Request flows

+ Create sandbox

Client
POST /v1/sandboxes {sandboxId, ttlSeconds, …}
↓
boxy-router
Validates auth + body Β· creates Sandbox CR (phase: Pending)
↓
boxy-operator
Watches CR Β· bin-packs to a controller pod Β· sets phase: Creating
↓
boxy-controller
POST /v1/sandboxes Β· mkdir /workspace Β· runs setupScript (if set) Β· nsjail ready
↓
boxy-operator
Sets phase: Running
↓
Client
← 201 Created {sandboxId, phase: "Running", ready: true}

β–Ά Execute command

Client
POST /v1/sessions/exec {sandboxId, sessionId, command, args, timeoutSeconds}
↓
boxy-router
Reads Session CR Β· resolves controllerAddress
↓
boxy-router
POST controller /v1/exec over mTLS β†’ boxy-controller
↓
boxy-controller
Builds NsjailConfig proto Β· writes to temp file Β· spawns nsjail process
↓
nsjail sandbox
Executes command Β· captures stdout/stderr Β· exits (or SIGKILLed on timeout)
↓
Client
← 200 {exitCode, stdout, stderr, timedOut} + X-Boxy-Session-Id

Router also updates lastExecAt on the CR to slide the TTL window.

Quick start

Requires a Kubernetes cluster (kind works), kubectl, helm β‰₯ 3.x

1 Β· Install

helm upgrade --install boxy \
  oci://ghcr.io/niradler/charts/boxy \
  --version 0.0.3 \
  --namespace boxy --create-namespace \
  --set router.auth.staticToken="$(openssl rand -hex 16)"

2 Β· Get a token

# Kubernetes ServiceAccount token (production)
export TOKEN=$(kubectl create token  \
  -n  --duration=3600s)

# Or use the static token set during install (dev only)
export TOKEN=
export BASE=http://<router-service>:8080

3 Β· Create a sandbox

curl -sS \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"sandboxId":"demo-1","ttlSeconds":3600}' \
  $BASE/v1/sandboxes | jq .

4 Β· Create and exec

curl -sS \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"sessionId":"demo","sandboxId":"demo-1",
       "owner":"you"}' \
  $BASE/v1/sessions | jq .

curl -sS \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"sessionId":"demo","sandboxId":"demo-1",
       "command":"sh","args":["-c","echo hello"],
       "timeoutSeconds":30}' \
  $BASE/v1/sessions/exec | jq .

# Response:
# { "exitCode": 0, "stdout": "hello\n",
#   "stderr": "", "timedOut": false }

API reference

Method Path Description
GET /healthz Liveness check - returns 200 ok
POST /v1/sandboxes Create a sandbox config. Returns 201
GET /v1/sandboxes List all sandbox configs
GET /v1/sandboxes/{sandboxId} Get sandbox config metadata and active session count
PUT /v1/sandboxes/{sandboxId} Update sandbox config (same body as POST)
DELETE /v1/sandboxes/{sandboxId} Delete sandbox config
POST /v1/sandboxes/{sandboxId}/evict Evict all active sessions for a sandbox
GET /v1/sandboxes/{sandboxId}/sessions List sessions belonging to a sandbox
POST /v1/sessions Create a running session from a sandbox config. Returns 201
GET /v1/sessions List sessions. Query params: sandboxId, owner, phase
GET /v1/sessions/{sessionId} Get session status
DELETE /v1/sessions/{sessionId} Delete (terminate) a session
POST /v1/sessions/exec Execute a command in a session (creates session if needed)
POST /mcp MCP endpoint - JSON-RPC 2.0 over Streamable HTTP

POST /v1/sandboxes - request body

Field Type Notes
sandboxId*stringRequired. Unique ID
ttlSecondsint0 = no expiry. Sliding window on sessions
envmapMax 64 keys. KUBERNETES_* / BOXY_* blocked
allowedBinariesstring[]Whitelist of binaries to bind-mount
vmobjectmemoryMb, vcpus, workdir, shell, hostname, rlimits, scripts…
networkobjectallowInternetAccess, macvlan, usePasta
volumesobject[]{guestPath, type, hostPath, sizeMb, readonly}
patchesobject[]{type, path, content, bytes, hostPath, mode, replace}
setupScriptstringPath to executable on controller. Runs after provisioning
teardownScriptstringPath to executable on controller. Runs before cleanup
scriptEnvmapCustom env vars injected into setup/teardown scripts

POST /v1/sessions - request body

Field Type Notes
sandboxId*stringRequired. Sandbox config to instantiate
sessionIdstringAuto-generated if omitted
ownerstringCaller identity for tracking

POST /v1/sessions/exec - request body

Field Type Notes
sandboxId*stringRequired
sessionId*stringRequired
command*stringRequired. Resolved against rootfs PATH
timeoutSeconds*intRequired. 1–3600. Hard SIGKILL
argsstring[]Max 256 args
envmapPer-exec env overrides

HTTP status codes

200/201Success
400Bad request - missing field, blocked env prefix, body >6 MB
401Missing, expired, or invalid bearer token
404Sandbox not found
409Sandbox already exists
429Concurrency limit hit (semaphore full)
502/503Controller pod unreachable or not ready

MCP server

POST /mcp - JSON-RPC 2.0 over Streamable HTTP. Stateless, no session management. Built with the official Go MCP SDK.

Required headers

Authorization: Bearer <BOXY_ROUTER_TOKEN>
Content-Type: application/json
Accept: application/json, text/event-stream

Sandbox routing

Set X-Sandbox-Id header to target a specific sandbox. Omit to use the default sandbox (requires BOXY_DEFAULT_SANDBOX_ENABLED=true).

Available tools

bash
Runs a shell command in the target sandbox. Parameters: command (string, required), timeoutSeconds (int, default 60).

Example calls

# Initialize MCP session
curl -sS \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize",
       "params":{"protocolVersion":"2025-03-26",
                 "clientInfo":{"name":"cli","version":"1.0"},
                 "capabilities":{}}}' \
  $BASE/mcp | jq .

# Call bash tool
curl -sS \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "X-Sandbox-Id: demo-1" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
       "params":{"name":"bash",
                 "arguments":{"command":"echo hello"}}}' \
  $BASE/mcp | jq .

Configuration

All configuration is via environment variables. The Helm chart injects sensible defaults; override only what you need.

boxy-router

Variable Default
BOXY_ROUTER_TOKEN-
BOXY_SANDBOX_NAMESPACEdefault
BOXY_LISTEN_ADDR:8080
BOXY_MAX_CONCURRENCY100
BOXY_MAX_TIMEOUT_SECONDS3600
BOXY_MAX_BODY_BYTES6 MB
BOXY_MAX_OUTPUT_BYTES6 MB
BOXY_MTLS_DISABLEDfalse
BOXY_DEFAULT_SANDBOX_ENABLEDfalse

boxy-operator

Variable Default
BOXY_NAMESPACErequired
BOXY_MAX_SANDBOXES_PER_CONTROLLER20
BOXY_MAX_CONTROLLER_REPLICAS50
BOXY_MIN_CONTROLLER_REPLICAS1
BOXY_SCALE_DOWN_COOLDOWN_SECONDS300
BOXY_TERMINATED_RETENTION_SECONDS3600
BOXY_CONTROLLER_PORT8080
BOXY_MTLS_DISABLEDfalse

boxy-controller

Variable Default
BOXY_MAX_SANDBOXES20
BOXY_MAX_EXEC_CONCURRENCY50
BOXY_MAX_OUTPUT_BYTES6 MB
BOXY_CONTROLLER_TOKENauto (Helm)
BOXY_NSJAIL_PATH/usr/sbin/nsjail
BOXY_NSJAIL_ROOTFS/rootfs/ubuntu-24.04
BOXY_NSJAIL_SANDBOX_ROOT/var/lib/boxy/sandboxes
BOXY_MTLS_DISABLEDfalse

Custom Resource Definitions

boxy ships three CRDs in the boxy.dev group. All state lives in these CRs; the router and operator are fully stateless. Short names work with kubectl (kubectl get sbx, kubectl get sess, kubectl get cp).

S
Sandbox sandboxes.boxy.dev / v1alpha1
group: boxy.dev Β· scope: Namespaced Β· shortName: sbx

spec fields

Field Type Notes
sandboxId*stringRequired. 1–63 chars
ttlSecondsinteger0–604800. Sliding TTL window
envmap<string>BOXY_* / KUBERNETES_* reserved
allowedBinariesstring[]Bind-mount whitelist
vm.memoryMbinteger64–65536 MB cgroup cap
vm.vcpusinteger1–64
vm.workdirstringWorking directory inside the sandbox
vm.shellstringShell binary to use
vm.hostnamestringUTS hostname for the sandbox
vm.userstringUser to run as inside the sandbox
vm.maxDurationSecintegerHard nsjail time_limit
vm.idleTimeoutSecintegerIdle kill timeout
vm.rlimitsobject[]{resource, soft, hard}
vm.scriptsobject[]{name, content} init scripts
vm.seccompStringstringCustom seccomp profile JSON string
vm.cloneNewTimeboolClone time namespace
network.enabledboolEnable network namespace
network.allowInternetAccessboolDisable network NS isolation
network.macvlanobject{interface, ip, netmask, gateway, mac} MACVLAN config
network.usePastaboolUse pasta instead of MACVLAN for networking
volumesobject[]{guestPath, type, hostPath, sizeMb, readonly}
patchesobject[]{type, path, content, bytes, hostPath, mode, replace}
setupScriptstringController-side executable. Runs after provisioning
teardownScriptstringController-side executable. Runs before cleanup
scriptEnvmap<string>Custom env vars for setup/teardown scripts

example manifest

apiVersion: boxy.dev/v1alpha1
kind: Sandbox
metadata:
  name: demo-1
spec:
  sandboxId: demo-1
  ttlSeconds: 3600
  vm:
    memoryMb: 512
    vcpus: 2
  network:
    allowInternetAccess: true
  setupScript: /opt/boxy/scripts/setup.sh
  teardownScript: /opt/boxy/scripts/teardown.sh
  scriptEnv:
    CUSTOMER_TIER: premium
Se
Session sessions.boxy.dev / v1alpha1
group: boxy.dev Β· scope: Namespaced Β· shortName: sess

spec fields

Field Type Notes
sessionId*stringRequired. 1–63 chars
sandboxId*stringRequired. References a Sandbox config
ownerstringOptional. Caller identity for tracking

status fields

Field Type Notes
phasestringPending Β· Creating Β· Running Β· Deleting Β· Terminated
controllerPoolstringAssigned ControllerPool name
controllerPodstringAssigned controller pod name
controllerAddressstringmTLS dial address for the router
portint32Listening port on controller
createdAtdate-timeWhen session was created
expiresAtdate-timeComputed TTL expiry (from sandbox ttlSeconds)
terminatedAtdate-timeWhen session terminated
lastExecAtdate-timeUpdated on every exec (slides TTL)
messagestringHuman-readable status detail

example manifest

apiVersion: boxy.dev/v1alpha1
kind: Session
metadata:
  name: my-session
spec:
  sessionId: my-session
  sandboxId: demo-1
  owner: alice
CP
ControllerPool controllerpools.boxy.dev / v1alpha1
group: boxy.dev Β· scope: Namespaced Β· shortName: cp

spec fields

Field Type Notes
maxSandboxes*integerRequired. Min 1. Max per controller pod
maxReplicas*int32Required. Min 1. StatefulSet upper bound
minReplicas*int32Required. Min 1. Always-warm floor
imagestringController container image override
preinstalledBinariesstring[]Binaries available in all sandboxes on this pool

status fields

Field Type Notes
readyReplicasint32Controller pods ready
activeSandboxCountint32Running sandboxes across the pool
lastScaleTimedate-timeLast autoscale event
conditionsobject[]{type, status, reason, message, lastTransitionTime}

example manifest

apiVersion: boxy.dev/v1alpha1
kind: ControllerPool
metadata:
  name: default
spec:
  maxSandboxes: 20
  minReplicas: 1
  maxReplicas: 10

Security model

Authentication layers

1
Client β†’ Router: Bearer token
SA token validated via Kubernetes TokenReview API (production) or static bypass token (dev/e2e). All authenticated callers have equal access - no per-caller RBAC.
2
Router/Operator β†’ Controller: mTLS
Mutual TLS with a Helm-generated CA. Identity is CA membership. Disable with BOXY_MTLS_DISABLED=true for local dev only.

Controller pod capabilities

+ SYS_ADMIN(namespace setup)
+ SETUID, SETGID, SETPCAP
+ NET_ADMIN(network namespaces)
+ SYS_CHROOT, MKNOD
βˆ’ ALL others dropped
βˆ’ allowPrivilegeEscalation: false

Threat model

Threat Mitigation
Unauthorized API access Bearer token on every route - SA token via TokenReview (prod) or static dev bypass
Router/operator impersonation toward controller mTLS with shared CA - both sides verify cert against same CA
Sandbox escaping to host filesystem nsjail mount namespace + R/O rootfs; only /workspace (R/W) and /tmp (tmpfs) are writable
Sandbox reaching other sandboxes over network Isolated network namespace per sandbox by default
Sandbox exhausting host memory --cgroup_mem_max enforced by nsjail when vm.memoryMb is set
Sandbox running forever --time_limit (nsjail SIGKILLs) + TTL sliding window (operator GCs the CR)
Malicious env var injection (KUBERNETES_*, BOXY_*) Blocked prefixes at the router; max 64 keys, values ≀ 16 KB
⚠ No per-caller tenant isolation All authenticated callers share equal access - single-tenant only in this release
⚠ Sandbox processes run as uid 0 Linux user namespace uid remapping is disabled (incompatible with most container runtimes + CAP_SYS_ADMIN). Security boundary is mount/PID/network NS isolation