zygo_sdk - isolated, fast sandboxes for other people's Python/JS code

Hello,

I’ve been building Zygo, a Linux sandbox runtime for running code other people wrote: customer plugins, workflow steps, an LLM’s tool calls. zygo_sdk is its Elixir client.

Why a BEAM app might want it

If your app has to run a user’s Python or JavaScript, the BEAM can’t sandbox it for you. A Port or System.cmd runs it as your app’s own user, with your files and your network. Zygo gives every call its own process, cgroup, deadline and secrets, inside a sandbox (namespaces, seccomp, Landlock, a read-only root from an OCI image, network off unless you allow names), and throws it away afterwards. For Python it forks a warm, pre-imported interpreter, so a call costs about 1.4 ms through the API on a 2 vCPU Linux VM. Node can’t be forked safely, so each JS call gets a pre-loaded worker instead, at about 25 ms of CPU.

What it looks like

client = Zygo.connect()

Zygo.serve_runtime!(client, "py312", %{"image" => "python:3.12-slim", "agent" => "python"})

script = Zygo.put_script!(client, """
def handler(event):
    words = event["text"].split()
    return {"words": len(words), "longest": max(words, key=len)}
""")

Zygo.run_script!(client, "py312", script.sha256, %{"text" => "the quick brown fox"}).result
#=> %{"words" => 4, "longest" => "quick"}

A script that misbehaves stays in its sandbox:

{:error, %Zygo.Error{kind: :handler, stderr: stderr}} =
  Zygo.run_script(client, "py312", "def handler(e):\n    open('/etc/shadow').read()\n")

The client

  • Every API call returns {:ok, value} or {:error, %Zygo.Error{}} and has a ! twin. One error struct with a kind (:busy, :handler, :timeout, …) instead of a module per error.
  • Pooled keep-alive connections over TCP or a unix socket, with a named pool you start under your own supervisor.
  • :busy and :unavailable mean the request never ran, so they can be retried for you, honouring Retry-After.
  • A call’s output can be streamed as a lazy Stream.
  • Two dependencies, Mint and NimblePool. Elixir 1.18+, for the standard library’s JSON.

Before you try it

The package is only a client. zygo itself is a static binary for Linux; on macOS it runs in a VM it manages. The README’s “Before you start” section has the four commands to get the API running.

Limits

The wall is the host kernel, so it’s meant for semi-trusted code, your customers’ rather than anonymous attackers’. The escape suite attempts 21 vectors with 0 escapes, but there has been no external audit. It’s Linux only and one machine. v0.1.5, Apache-2.0, one maintainer, no production users yet.

Feedback I’d especially like

  • Is one error struct with a kind the right shape for Elixir, or would you rather match on exception modules? ADR 0007 explains why I chose it.
  • How would you supervise this in a real app: one named pool per API, or one per tenant?
  • Anything that feels unidiomatic.

Links:

I built it with heavy use of Claude Code.

2 Likes