Skip to content

Security

The isolate is the security boundary. Nimbus adds Unix permissions enforced at one filesystem seam, per-session isolation, and bearer-token access.

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.

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.

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:

nimbus — permissions

Live sandbox — connecting…

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.

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.

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.

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.

  • Permission enforcement is new. The durable uid/gid/mode schema 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.