Skip to the content.

RFC 0035: Per-project isolation inside the shared Deno runtime

Status: Draft (implemented in the PR that adds this file; not deployed)

Problem

The shared Deno runtime is one container running one deno run --allow-all router that import()s every project’s handler. Each project has its own dstack proxy at /run/broker/<project>/dstack.sock (dstack_proxy.DstackProxyManager). The proxy builds the GetKey path from the project that owns the socket and ignores the caller’s path, so the socket is the identity: whoever can connect to /run/broker/relay-broker/dstack.sock gets relay-broker’s keys.

Before this change the shared runtime mounted the whole broker parent at /run/broker (_shared_broker_binds, “co-trust by construction”). Every co-resident could open every other project’s socket. On Nov 5 2026 relay-broker-layover derived relay-broker’s payments key 0xd3DD…03E7 this way (souk-co/layover#49). Nobody had to try: relay-broker’s key.ts defaults the socket to /run/broker/relay-broker/dstack.sock, so a second deployment of the same code reaches the first one’s socket.

The socket was not the only thing shared. Inside that one process a co-resident could also:

And the node/python shared runtimes got the same whole-parent mount, so they could reach the sockets of deno projects, and of isolation: container projects too. Moving a victim to isolation: container did not protect it.

What the shared runtime should guarantee

Required: a project can connect only to its own /run/broker/<project>/ sockets, so it can derive only its own keys and quotes.

Achievable with the same mechanism, so included: a project reads only its own files, reads and writes only its own data dir, has its own V8 heap and process env, and cannot spawn processes or load native code.

Not provided here:

Options, most minimal first

(a1) One Worker per project with deno.permissions. Smallest memory cost, but on Deno 2.9.8 per-worker permissions still require --unstable-worker-options (checked: without the flag, new Worker(..., {deno: {permissions}}) throws “Unstable API”). The worker lives in the --allow-all process, so the boundary is an unstable API inside one address space. The router would still need to proxy requests into the worker.

(a2) One deno run subprocess per project with scoped flags. Uses the stable CLI flags, and the same recipe isolation: container already runs in production (start_isolated). Separate process, separate heap. Costs about 39 MB RSS per idle child (measured: a hello-world child), plus whatever the app itself uses, which it used inside the shared process before anyway. Chosen.

(b) Per-project socket token. The proxy would demand a token that only the owning project holds. In the old shared runtime the token travels through ctx.env and the shared heap, which co-residents can read, so it adds nothing there. Once (a) exists, it is redundant for dstack.sock. (creds.sock already works this way; BROKER_TOKEN is not injected in the shared runtime today.)

(c) Per-project containers. Already exists as isolation: container. It is heavier (one container and one Docker network per app) and, without the bind change below, it did not protect against the shared runtime anyway.

Design

The router stays trusted daemon code with --allow-all, but never imports app code. For each project it:

  1. resolves files = realpath(/projects/<name>/files) and data = realpath(/daemon-data/<name>);
  2. walks both and refuses to start the project if any symlink is dangling or resolves outside files/data, or any file in data has more than one hard link (see below);
  3. spawns

    deno run --no-prompt --no-check
      --allow-read=<files>,<data>,/run/broker/<name>,/tmp/tee-rt/<name>
      --allow-write=<data>,/run/broker/<name>,/tmp/tee-rt/<name>
      --allow-net --allow-import --allow-env --allow-sys --deny-run --deny-ffi
      /tmp/tee-rt/_child.ts <entry-url> <data> /tmp/tee-rt/<name>/http.sock   (env JSON on stdin)
    

    The child shim reads env from stdin, imports the handler, runs the existing /_warmup call, and serves handler(req, {env, dataDir}) on its unix socket. A child that is not listening within 120 s is killed. A child that exits for any reason is respawned with backoff (1 s doubling to 60 s, reset once a run lasts a minute); while it is down its path answers 503, and the other apps keep serving;

  4. proxies /<name>/... to that socket with fetch and a unix createHttpClient, keeping the original origin, so req.url in the handler is what it was before.

Invariant: _child.ts must stay the child’s main module, and the entry specifier must come from Deno.args. Deno loads a dynamic import whose specifier is a string literal in the main module as part of the module graph, without a permission check, and once loaded any module in the process can import it again. Checked on 2.9.8: with --allow-read=/x/a, a literal import("file:///x/b/s.ts") in the main module succeeded, and the same literal inside the app (imported via Deno.args) then succeeded too; with no literal in the main module, the app’s import failed. The shim imports nothing by literal.

The handler contract, ctx.env, ctx.dataDir, warmup, the 404 project list, logs on the container’s stdout, and the SIGTERM drain are unchanged. proxy/test_deno_drain.py passes against the new router without edits beyond the image constant.

Facts the design relies on, checked on denoland/deno:2.9.8:

Flag choices that differ from isolation: container:

Broker mounts: _shared_broker_binds(projects) now binds one subdir per co-resident project, <BROKER_HOST_PATH>/<name>:/run/broker/<name>:ro, instead of the parent. The path apps use does not change, and projects in other containers (node/python runtimes, isolation: container, image apps) are no longer visible in this one. Node and python shared runtimes are still co-trust among their own residents; this RFC does not change them.

The Deno image is pinned to denoland/deno:2.9.8 (it was :latest, re-pulled on every refresh; today’s :latest has the same digest). The boundary now rests on Deno’s permission semantics, so a Deno upgrade should be a reviewed change with proxy/test_deno_isolation.py re-run, not a silent re-pull.

What changes for apps

Residual risks

Tests

proxy/test_deno_isolation.py runs the real router in denoland/deno:2.9.8 with three projects, and real DstackProxyManager sockets in front of a fake dstack that returns sha256(path). Project a gets its own key; a’s attempts on b’s socket, b’s project.json, b’s data, b’s code (import()), b’s router socket, and Deno.Command all fail with NotCapable; a’s ctx.env, ctx.dataDir, req.url, POST bodies and gzip pass through; b still derives its own key and was warmed up; project c, whose files contain leak -> /run/broker/b/dstack.sock, and project d, whose data dir holds a hard link into another data dir, are refused while a and b serve. Project h, whose module never finishes loading, is killed at the (test-shortened) startup timeout and answers 503 while the others serve. No /proc/*/cmdline in the container contains a’s env value. After a calls Deno.exit(1) it answers 503, then is respawned and serves the same ctx.env. Against the old router the same test fails at the first cross-project check: a gets b’s key.