Skip to content

Repository files navigation

microsandbox

A from-scratch, learning-oriented code sandbox modeled on E2B. The goal is not to ship a product, but to understand how an AI code sandbox is actually built.

Every Sandbox is a Firecracker microVM: its own guest Linux kernel behind the KVM hardware-virtualization boundary (the strongest isolation), with the data path carried over a per-sandbox TAP/netns NIC (TCP; Stage 12 retired the original vsock channel) and a stateful Jupyter kernel running inside the VM (variables persist across run_code, like E2B). The sandbox is inbound-reachable but outbound-denied by default (DNAT, no MASQUERADE) — so you can expose its ports, yet it can't phone home. It is a learning implementation, not security-audited, never safe to expose to untrusted input.

History. This project was built up in stages — host subprocess → Docker container → resident in-container agent → microVM — as a way to understand each isolation technique. Those earlier backends were learning scaffolding; now that the microVM works they have been removed, leaving only the Firecracker path. The full staged journey is preserved in the git history (tag archive/stages-0-3).

Quick start

One-time setup (see docs/MICROVM_DESIGN.md §7 for details):

pip install -e ".[dev]"

# 1) Let your user open /dev/kvm without sudo, then restart WSL to apply the group.
sudo usermod -aG kvm "$USER"        # then: wsl --shutdown (from Windows), reopen the terminal

# 2) Put the firecracker binary + a guest kernel with virtio-net under vendor/
#    (vendor/firecracker, vendor/vmlinux — see the design doc for sources).

# 3) Build the VM rootfs (docker is only a one-time build tool here) and a warm snapshot.
docker build -t microsandbox-agent .   # the agent image the rootfs is exported from
scripts/build-rootfs.sh                # export an ext4 rootfs from that image (no root needed)
scripts/build-snapshot.sh              # optional: a warm snapshot for millisecond restore

# 4) Build and start the Go host services (Stages 8-9): the orchestrator owns the microVM
#    fleet (gRPC SandboxService), client-proxy is the edge data proxy, the api is the REST
#    front the SDK talks to. dev-up builds + runs all three; base_url is
#    http://127.0.0.1:8080. Leave it running.
scripts/dev-up.sh &

python examples/quickstart.py

Usage feels like E2B:

from microsandbox import Sandbox

# (Start the services first: scripts/dev-up.sh -- orchestrator (VM lifecycle) + client-proxy (data path) + api (REST front).)
# Cold start a microVM (~0.94s: firecracker + guest kernel boot + daemon on TCP over its NIC).
with Sandbox() as sandbox:
    ex = sandbox.run_code("print('hello from the microVM')")
    print(ex.stdout)        # hello from the microVM
    print(ex.success)       # True

    # The in-VM kernel is stateful: variables persist across run_code.
    sandbox.run_code("x = 41")
    print(sandbox.run_code("print(x + 1)").stdout)   # 42

    # Stream output as it arrives.
    sandbox.run_code(
        "for i in range(3): print(i)",
        on_stdout=lambda chunk: print("live:", chunk.strip()),
    )

    # File / shell API (modeled on E2B's sandbox.files / sandbox.commands).
    sandbox.files.write("/tmp/data.txt", "42")    # the VM root is read-only; only /tmp is writable
    print(sandbox.files.read("/tmp/data.txt"))    # 42
    print(sandbox.commands.run("ls /tmp").stdout) # data.txt

# Restore from a warm snapshot instead: ready in ~30ms (skips kernel boot + kernel cold start).
with Sandbox(from_snapshot=True) as sandbox:
    print(sandbox.run_code("print(6 * 7)").stdout)    # 42

# Boot a custom image (Stage 6 templates): build a template once, then select it by name.
#   scripts/build-template.sh example   # templates/example/Dockerfile -> vendor/templates/example/
# (swap the marker line in that Dockerfile for `RUN pip install ...` to make a real env.)
with Sandbox(template="example") as sandbox:
    print(sandbox.files.read("/etc/microsandbox-template"))   # hello from the example template

# Build a template through the API (Stage 10): submit a Dockerfile recipe, the orchestrator
# builds it asynchronously, build_template polls until it is ready, then boot it by name.
from microsandbox import build_template
build_template("myenv", "FROM microsandbox-agent\nRUN pip install cowsay", with_snapshot=False)
with Sandbox(template="myenv") as sandbox:
    print(sandbox.run_code("import cowsay; cowsay.cow('hi')").stdout)

Project structure

microsandbox/
├── CLAUDE.md                  # Claude Code project memory (conventions, pointers)
├── README.md
├── pyproject.toml
├── Dockerfile                 # the agent image (Jupyter kernel runtime) the rootfs is exported from
├── docs/
│   ├── ARCHITECTURE.md        # the three-layer design (client / protocol / daemon+backend)
│   ├── MICROVM_DESIGN.md      # the microVM design (Firecracker, networking, snapshots)
│   ├── STAGE4_DESIGN.md       # Stage 4: extracting the Go control plane
│   ├── STAGE5_DESIGN.md       # Stage 5: the warm pool
│   ├── STAGE6_DESIGN.md       # Stage 6: named templates (custom images)
│   ├── STAGE7_DESIGN.md       # Stage 7: the Go in-VM daemon (envd)
│   ├── STAGE8_DESIGN.md       # Stage 8: split the control plane into api + orchestrator (gRPC)
│   ├── STAGE9_DESIGN.md       # Stage 9: client-proxy + routing catalog (data path off the api)
│   ├── STAGE10_DESIGN.md      # Stage 10: TemplateService (async template builds) + pkg/storage
│   ├── STAGE11_DESIGN.md      # Stage 11: envd -> ConnectRPC (Process/Filesystem) + a separate code-interpreter
│   ├── STAGE12_DESIGN.md      # Stage 12: per-sandbox TAP/netns networking; data path vsock -> TCP; user ports; vsock retired
│   └── E2B_ALIGNMENT_ROADMAP.md  # the post-Stage-7 roadmap toward E2B's component architecture
├── src/microsandbox/
│   ├── connect.py             # hand-rolled ConnectRPC client (Stage 11): unary + server-streaming over urllib
│   ├── protocol.py            # SDK result types (Execution/OutputEvent) + reference for the retired SSE wire
│   ├── client.py              # SDK: Sandbox / run_code -- pure-HTTP lifecycle, ConnectRPC data path
│   ├── server.py              # the retired Python in-VM daemon (Stage 7 replaced it; kept as reference)
│   └── backend.py             # the retired Python kernel backend (reference)
├── daemon/                    # the Go in-VM daemon (E2B's envd): ConnectRPC envd + code-interpreter on two TCP ports (:49983/:49999, Stage 12); proto/ + genpb/
├── services/                  # the Go host control plane (Stages 8-12, E2B's "infra"), module microsandbox/services
│   ├── cmd/api/               #   lifecycle-only REST front + SQLite store + the /templates build API; calls the orchestrator over gRPC
│   ├── cmd/client-proxy/      #   edge data proxy (Stage 9): routes the data path by the <port>-<id> Host header via the catalog
│   ├── cmd/orchestrator/      #   microVM fleet + warm pool (SandboxService) + per-sandbox network slot + TCP data proxy + the template builder (TemplateService)
│   ├── pkg/                   #   fc / pool / network / proxy / template / store / catalog / storage / build, + grpc/ (generated stubs)
│   └── proto/                 #   the gRPC contracts (orchestrator.proto, template-manager.proto)
├── scripts/
│   ├── build-rootfs.sh        # export an ext4 rootfs from the agent image (no root needed)
│   ├── build-snapshot.sh      # build a warm Firecracker snapshot for millisecond restore
│   ├── build-template.sh      # build a named custom image (Stage 6): Dockerfile -> rootfs (+ snapshot)
│   ├── build-services.sh      # build the Go host services (api + client-proxy + orchestrator) to vendor/
│   ├── gen-proto.sh           # regenerate the gRPC stubs from services/proto (needs protoc)
│   └── dev-up.sh              # build + run orchestrator + client-proxy + api locally (SDK base_url = http://127.0.0.1:8080)
├── templates/                 # template recipes (Stage 6): templates/<name>/Dockerfile (built artifacts -> vendor/templates/)
├── examples/quickstart.py
└── tests/                     # end-to-end / stateful / snapshot / metadata / ports tests on real VMs (host-side unit tests are in services/)

How it works (one paragraph)

The SDK (client.py) asks the api (services/cmd/api, Go) for a sandbox over HTTP; the api calls the orchestrator (services/cmd/orchestrator) over gRPC, which writes a declarative Firecracker config and starts the firecracker process — a microVM with its own guest kernel and an ext4 rootfs — and records the sandbox in the api's SQLite metadata store, and registers the sandbox's data route in client-proxy's catalog. The orchestrator also gives the VM a per-sandbox network slot (pkg/network): a virtio-net NIC backed by a host TAP in its own netns, reachable at a routable host address via DNAT (inbound only — no MASQUERADE, so the sandbox can't phone home). Inside the VM, PID 1 (/init) execs the Go daemon (daemon/, E2B's envd), which listens over the VM's NIC as two ConnectRPC services on two TCP portsenvd (Filesystem + Process) on :49983 and a separate code-interpreter (the kernel) on :49999. The SDK sends the data path to client-proxy (the data URL the api returned) with a <port>-<id> Host header, speaking ConnectRPC: run_code is the code-interpreter's server-streaming Execute; files/commands are envd's unary RPCs. client-proxy parses the Host header, looks the sandbox up in its routing catalog, and reverse-proxies to the orchestrator's data proxy, which dials <slot-ip>:<port> over TCP (the port in the hostname picks the service, or any user port — sandbox.get_host(port)), streaming the response straight back; the code-interpreter drives a long-lived Python Jupyter kernel via a Jupyter Kernel Gateway. Through Stage 10 the client↔daemon wire (protocol.py) was byte-stable as the isolation evolved (subprocess → microVM → control-plane split → Go daemon → api/orchestrator gRPC → client-proxy/catalog); Stage 11 deliberately moved it to ConnectRPC, so the e2e suite is now a behavioral parity oracle, and Stage 12 flipped the transport from vsock to TCP over each sandbox's NIC (real <port>-<id> hostnames + user-port exposure). See docs/ARCHITECTURE.md, docs/E2B_ALIGNMENT_ROADMAP.md and the stage design docs (docs/STAGE4STAGE12_DESIGN.md).

⚠️ Safety note

The microVM gives each sandbox its own guest kernel behind a KVM boundary — the first isolation in the project strong enough to seriously discuss untrusted code — but this is a learning implementation, not security-audited. Since Stage 12 each sandbox also has a NIC: it is inbound-reachable (ports can be exposed) and outbound-denied by default (DNAT, no MASQUERADE). This narrows the isolation the earlier "fully offline" design had, so the warning matters more, not less. Real defense-in-depth (jailer, seccomp-bpf, network policy, rate limiting, escape monitoring) is out of scope. This project is for local learning only; do not expose it as a service or feed it arbitrary external input.

About

A from-scratch, learning-oriented code sandbox approaching E2B — evolving from a host subprocess to a Firecracker microVM with vsock transport and millisecond snapshot restore.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages