Security
The isolate is the security boundary. Nimbus adds Unix permissions enforced at one filesystem seam, per-session isolation, and bearer-token access.
The isolation boundary
Section titled “The isolation boundary”Each process runs in its own V8 isolate, spawned by Cloudflare’s Worker Loader, the same isolation Cloudflare uses for untrusted code. Nimbus adds no second sandbox underneath, so the isolate is the boundary.
One session is one supervisor Durable Object plus the facet isolates it spawns. A facet receives one capability when it starts: an RPC stub pointing at its own session’s supervisor. Generated facet code cannot import supervisor modules, cannot reach another session’s DO, and cannot mint stubs it was not given.
No runtime code generation
Section titled “No runtime code generation”workerd’s content security policy forbids eval and new Function at
request time, and forbids compiling WASM from raw bytes at request time.
Nimbus runs everything, including compilers and interpreters, inside
that constraint. WASM modules enter a facet pre-compiled through the
module map, and nothing in a session can generate and execute new
machine code.
The node and bun REPLs therefore cannot keep declarations between
submits. Evaluating a REPL line is runtime code generation, and the CSP
blocks it. The REPL banners note it.
Unix permissions, enforced at one seam
Section titled “Unix permissions, enforced at one seam”Every inode stores mode, uid, and gid in the session’s SQLite disk,
so chmod and chown are real mutations that survive hibernation. Every
process carries an immutable credential (uid, gid, groups, umask),
assigned at spawn and inherited from its parent. Every filesystem
operation is bound to that credential before it reaches the VFS. The VFS
exposes no uncredentialed mutation surface, and path resolution checks
traverse permission on every component.
Denials return standard errno: EACCES for access, EPERM for ownership
rules, in every runtime. Root (uid 0) bypasses permission bits as Unix
root does. The terminal below runs as uid 1000
against a history file owned by root:
Live sandbox — connecting…
Sessions don’t share
Section titled “Sessions don’t share”Loopback never leaves the session. The DNS shim resolves localhost and
nothing else. 127.0.0.1:<port> is a lookup in the session’s own port
registry, a map inside the supervisor DO. It is not a network hop. Two
sessions both listening on port 3000 are isolated. Neither can reach the
other’s port.
The only thing sessions share is a read-only, content-addressed cache. It
holds npm tarballs keyed by name@version (immutable on the registry
since 2018) and runtime blobs, stored in R2. Sessions reach that cache through
narrow supervisor RPC methods, and the R2 bindings live on the Worker
rather than in any session or facet. So no session can write another
session’s view of a package. There is no cross-session filesystem, no
shared scratch space, and no DO that serves two sessions.
Who can attach
Section titled “Who can attach”Session access is bearer tokens: HS256, verified with a constant-time
comparison against a fixed algorithm. The verifier never reads the
algorithm from the token header. Claims carry a tenant, a subject,
optional scopes (session:create, session:attach, session:destroy,
sandbox:use, …), and an optional sid pin that locks the token to one
session. Attaching to a terminal requires session:attach and a matching
pin. Server-minted bootstrap tokens are single-use, consumed atomically
in the DO, so a replayed bootstrap gets a 401. Secrets rotate with a
previous-secret grace window.
The hosted demo’s anonymous sandboxes use the same machinery with tighter limits. The attach token is sid-pinned and expires in two minutes. A global cap bounds concurrent anonymous sessions, a per-address rate limit applies, and every session ends after ten minutes.
The preview origin
Section titled “The preview origin”A session serves each exposed port on a hostname of its own,
<port>--<id>.<suffix>, where the suffix is the deployment’s
NIMBUS_PREVIEW_HOST_SUFFIX binding. The app mounts at that host’s root
and owns the whole path space, so the origin is the trust boundary.
Everything served there is untrusted user code.
The router matches the preview hostname before anything else. Embedder
routes, /new, /s/<id>/, the agent OAuth callback, the SDK’s remote API,
and the static-asset fallthrough all run after it. So
https://3000--<id>.<suffix>/login reaches the app’s own /login. No
Nimbus endpoint is same-origin with code the app served.
Nimbus strips its own credentials before the request crosses into the app.
The port proxy removes Authorization, Proxy-Authorization, every
X-Nimbus-* header, and every cookie in the platform namespace: names
starting with nimbus_, __Host-nimbus, or __Secure-nimbus. Matching the
namespace rather than a list also covers the agent OAuth cookie and an
embedder’s own cookies.
Cookies the app itself set are preserved. A token arriving as
?nimbus_token= is deleted from the query at the same edge, so it reaches
neither the app nor the session’s logs. The session cookie is
__Host-nimbus_token. Browsers reject that prefix on any cookie carrying
a Domain attribute or a Path other than /. Preview code cannot set a
shadowing cookie for the parent domain.
A preview URL is a link. It lands in history, referrers, and chat logs.
Under enforced auth, GET /s/<id>/api/preview-url?port=<n> mints it a
token scoped session:preview. The token is pinned to that session, good
for 90 seconds, and carries a jti the session DO consumes set-if-absent.
The preview host
trades that token once for the sid-pinned session:attach cookie and
redirects to the requested path. A replayed link gets 401. The same token
sent as a bearer to /s/<id>/ gets 403, because session:preview grants
that one exchange and nothing else. The preview exchange refuses reusable
embedder attach tokens, and accepts only the single-use mint.
The hostname carries the session id, and every request on it is checked
against the token’s sid pin. A credential minted for one session is
rejected on another session’s preview host.
A deployment with no suffix bound serves previews at /s/<id>/port/<n>/
instead. Credentials are stripped there the same way, and the app shares an
origin with the session shell.
Fault isolation
Section titled “Fault isolation”The isolate is also the fault boundary. A process that exhausts its 128 MiB heap produces a catchable RPC error. The shell reports it, the session keeps running, and the disk is untouched because facets never own files. The supervisor holds a self-imposed 64 MiB heap ceiling, half the platform cap. A supervisor OOM resets the session; a facet OOM does not.
Limits
Section titled “Limits”- Permission enforcement is new. The durable
uid/gid/modeschema and the credentialed seam shipped in July 2026. It is probed live, but its coverage is young; treat it as a boundary still being hardened. - Some subsystems run as uid 0. Package installation, runtime staging, and control-plane file operations act as root, the way a system package manager does. Root-owned files in a fresh session come from that, not from a leak.
- Outbound network is open by default. npm and git need it. Per-session egress policy is designed as an embedder option but not shipped. Do not put a sandbox in front of secrets it should not be able to exfiltrate.
- A known upstream availability issue. Certain user-space activity can crash the shared workerd process, which resets the sessions running on it. The impact is availability only. Sessions reconnect and disks survive, and it is not a path to another tenant’s data. It is reproduced, reported, and being escalated with Cloudflare.
The research page tracks all of these. The hosted demo is for evaluation only. It has no SLA, and it is not a place for secrets.