plimsoll trainer · 08 / calling your API from the sandbox

The API broker

Code you do not trust needs to read from your API, and your API needs a key. Everything on this page follows from one rule: the key never enters the room with the code, the room being the sandbox the code runs in. The code asks for a route; the broker, the part of plimsoll that runs outside the sandbox on the host machine, checks the route against a short list, attaches the key itself, and sends the call on to your API. Even fully hostile code in the room holds nothing worth stealing.

The line the key never crosses

The room has no network. One private line leads out of it to the broker, which holds the run's grant, its permission to call a listed set of routes on one API, and a credential minted for this run, meaning created fresh for it alone. The room changes with the provider, the backend that runs the code; the broker does not. Pick one below: a docker container; a JavaScript engine compiled to WebAssembly, a portable bytecode format, running inside the plimsoll process (the wasm provider); or a small virtual machine on E2B, a hosted service that starts one per run (e2b).

THE ROOM · a docker container --network none agent code host.get("/v1/items/42") knows: the relative route it wants never sees: the origin, the key, the list a per-run Unix socket only bytes cross HOST SIDE · plimsolld the broker 1 route on the list? 2 within budget? 3 attach the key, forward 4 record the template 🔑 minted per run held in Go your API the list, the origin and the key live here and are never handed across the line

The room

Inside a session, a grant needs the profile's permission

A session keeps one sandbox open for many calls. A cell, code the session runs in an interpreter it keeps alive between calls, carries no grant at all: the interpreter outlives the call, and its code with it. A snippet or project call in a docker session can carry one, with one difference from a single run. A running container cannot gain a mount, so the session mounts the broker's socket once, when it opens. The socket serves a call's grant only while that call runs, and answers 503 between calls and to a call without a grant. While it serves a grant, it serves anything in the container, so code an earlier call left running (a timer inside an interpreter, a process it started between calls) can use a later call's grant. On openshell the relay a granted call starts inside the sandbox can be reached the same way. So a session refuses a granted call before anything runs unless its profile sets allow_in_sessions; turn that on only for an API whose access may be shared with every call of the session.

The route check, exactly

The broker approves a path only if it is, byte for byte, the path that will be sent. Type a path, or tap one of the attempts, and read the verdict the broker would give against the list of allowed routes below. The page applies the daemon's own rules, in the daemon's order.

This grant's Allow listGET /v1/warehouses
GET /v1/items/*
GET /v1/items/*/stock
POST /v1/orders

Why the path checked has to be the path sent. A guest, the code running in the sandbox, could write /v1/items/%2e%2e/admin: the broker would see a harmless-looking segment, and your API's server would decode it to .., the parent directory. Or it could write /v1/items/42?x=/stock: the list would match it against the longer template /v1/items/*/stock (a route pattern, where * stands for one segment), while the request actually reaches the shorter route, because Go's net/http strips everything after the ?. So any path not already in its final form (decoded, clean, and with nothing an HTTP client would re-encode) is refused before matching, and the path approved is the path sent. The call trace, the broker's log of the run's calls, records the template that matched, never the path itself.

The eight words

If a word on this page loses you, it is one of these.

sandboxa room where code we do not trust runs.The provider decides how thick its walls are: in-process, a container, a container under gVisor (a layer that answers the container's requests to the kernel itself), or a microVM (a small virtual machine created for one run, on E2B or on Docker Cloud Sandboxes, Docker's hosted sandbox service).
granta per-run permission slip: this one run may call these routes on this one API.Off by default, twice: no grant means no network, and an empty route list reaches nothing.
profilea named grant held by the daemon.The API's origin (scheme, host and port), the routes, the allowed callers, the scopes (permissions the credential carries), and how the credential is minted. A caller selects one by name and can change nothing in it.
brokerthe checkpoint on the host side of the line.Checks the route, counts the call against the budget, attaches the key, forwards the call, records the template.
credentialwhat the broker attaches.A fresh short-lived JWT (a signed token) per run, naming the caller as its subject, or one static bearer token kept on the server. Either way it stays in the daemon's Go process.
the linethe one way out of the room.A Unix socket (docker), a function the daemon gives the WebAssembly code (wasm), or one allowed network exit (e2b). Only request and response bytes cross it.
egresstraffic leaving the room.Denied. The line is not egress: it goes to plimsoll, not to the internet.
forward proxythe industry's name for this kind of checkpoint.A mandatory exit that every outbound call must pass through, and that holds the rules. Service meshes such as Istio, which route the traffic between a cluster's services, do this with an egress gateway.

Four numbers the broker counts

Each limit is generous for honest code and stops a loop that has run away; telling those two apart is all a per-run cap can usefully do.

256 callsper run, by defaultA profile whose workload is a loop by design can raise max_calls, never past 100,000.
1 MiBrequest bodyRead from the guest and counted before any route matching.
4 MiBresponse bodyA larger answer from your API is not delivered: its row in the call trace stays, marked undelivered, so a 200 cut off at the cap never counts as a success.
256 rowscall traceWhatever the call budget is. Calls past 256 rows are counted as dropped, and an advisor finding then says "at least".

E2B: the room is on someone else's servers

With docker the room is on the same machine as plimsoll, so a socket can reach under the door. An E2B microVM runs on E2B's servers, so there is no hallway to run a line down. The rule does not change, so the plumbing has to.

  1. All egress denied. The VM's network is off, and at startup the smoke test (one real throwaway run) creates a VM with no grant and proves it live. That is the starting point: deny-all, every connection blocked unless a rule allows it.
  2. One allowed exit. It points at plimsoll's guard, an HTTPS address served by the plimsoll daemon and set in E2B_GUARD_URL. The guard hands each call to the same broker every other provider uses.
  3. The guest cannot forge the exit. E2B's per-host header transform, a rule that adds a header to requests bound for one host, attaches the run's guard token to requests on their way out, outside the VM, so the code inside never sees the token and cannot make its own. The guard checks the token, and accepts or refuses the request, before reading its body.
  4. No guard, no grant. With E2B_GUARD_URL unset, an E2B grant is refused outright rather than downgraded to something weaker.
  5. The exception is proved separately. The startup smoke test proves deny-all, not "denied except for the guard". make e2b-guard-live proves the path through the guard, and it fails rather than skips when its settings are missing, so a pass can only come from having actually used that path.

Docker Cloud Sandboxes reaches the guard too. The other VM provider, dockercloud, runs each sandbox on Docker-managed compute with its network policy at deny-all, read back and checked on every run. With SANDBOX_DOCKERCLOUD_GUARD_URL set, a run with a grant gets exactly one network rule, the guard’s host:443, and the provider reads it back before any code runs. Two things differ from E2B. The rule is applied through a Docker call outside its published API (the one its own sbx CLI makes). And Docker’s proxy only injects credentials for its own fixed list of services, so the guest holds its own run’s guard credential: short-lived, good only at the guard, only for this run’s routes and budget, dead when the run ends. The API key still never enters the VM.

OpenShell, NVIDIA’s agent sandbox runtime, keeps its sandbox at no network rules at all, grant or not. The openshell provider reaches in instead of letting the guest reach out: a small relay program in the sandbox listens on a loopback port and on the Unix socket the injected host client talks to, the same client as under docker. For each connection the guest opens, plimsoll dials into that port through ForwardTcp, a call on the OpenShell gateway (the server that creates the sandboxes). The broker, the minted credential and the route check stay in plimsoll’s process; the network policy read back before the run is the no-grant policy, unchanged.

The mainstream pattern, with one rule made stricter

Controlling the outbound calls of code you do not fully trust is a solved problem. plimsoll uses two standard designs, and refuses a third, the one cloud providers use to hand credentials to their own VMs.

Forced-egress forward proxy

The network blocks everything by default, and every outbound call must pass one proxy that holds the rules. Egress gateways in Envoy, Istio and Linkerd are this design. plimsoll's guard is exactly this.

Short-lived scoped tokens

A broker hands out a narrow, expiring credential and attaches it at the proxy. SPIFFE and SPIRE (a standard and a server for short-lived workload identities) and OAuth client credentials work this way. plimsoll's JWT profile is this, with the calling principal, the authenticated caller, as the token's subject.

Metadata server: refused

An address that only the VM itself can reach (a link-local endpoint) hands the credential into the VM, the way AWS's instance metadata service (IMDS) and the GCP metadata server do, which is why IMDSv2 had to add defences against code inside the VM asking for it. For hostile code the key must never enter the room, so this design is out.

Keep reading