Hyperlang runs untrusted Erlang inside an Elixir host, on the same BEAM, with no ambient authority. A guest is ordinary Erlang source. Hyperlang compiles it through its verifier into a BEAM module that can call only its own functions, a frozen stdlib allowlist and the host bindings you pass in. Every run is metered for reductions, heap and wall time. Host-owned scopes admit and prioritise the work.

The Erlang/OTP secure-coding guide says there is "no built-in sand-boxing mechanism for running untrusted Erlang code", and the EEF advises an embedded language such as Lua. Teams therefore use Luerl, WebAssembly, a restricted evaluator, or a separate node or OS process. Hyperlang guests still share schedulers, memory allocators, the atom table and the code server. Hyperlang adds three properties:

  • Guest code never becomes trusted loaded code.
  • Every atom a guest can cause comes from a fixed set bounded by host limits.
  • The generated module cannot express calls outside the declared set.
Hyperlang against wasmex, Luerl and Dune, with erl_eval, peer nodes, OS processes and Docker as unsandboxed references. fib(25): Hyperlang 0.35 ms, wasmex 0.68 ms, interpreters 85 to 275 ms. Idle actor memory: Hyperlang 23 KiB, others 71 to 109 KiB. Noisy-neighbour p99 slowdown: Hyperlang 1.37x, Luerl 3.0x, Dune 35.7x. Tenant start from a compiled program: Hyperlang 0.05 ms, Luerl 0.03 ms, child VM about 115 ms.

Measurements are from one shared 16-core host (BEAM with 4 schedulers, load average 9 to 32). Method and raw data are in doc/COMPARISON.md. Among the BEAM sandboxes (wasmex, Luerl, Dune), Hyperlang is fastest, smallest and least disturbed by noisy neighbours:

  • Compute: guest code is compiled BEAM code. A guest fib(25) took 0.35 ms. Native Erlang in a peer node or OS process took 0.33 to 0.38 ms, wasmex 0.68 ms, and Luerl, erl_eval and Dune 85 to 275 ms.
  • Memory: an idle guest actor added 23 KiB of resident memory. A Rust wasmex instance added 89 KiB, a Luerl state 71 KiB, a Dune session 109 KiB, and a peer node or OS erl process 74 to 76 MiB.
  • Noisy neighbours: next to 16 CPU-bound tenants, a victim's p99 rose 1.4x with Hyperlang.Scope admission and priority classes, 3.0x in Luerl, 7.4x in erl_eval and 36x in Dune. A victim in its own peer node rose 1.1x.
  • Tenant start: a new tenant of a compiled program answered in 0.05 ms. Luerl took 0.03 ms, wasmex 0.08 ms and Dune 0.41 ms. A peer node or OS process took 111 to 123 ms to boot. Compiling a small program from source is a separate one-time step of about 17 ms.

Each call into a guest costs about 21 µs for a run/4 in the caller and 29 µs for an actor call. For many tiny calls, wasmex and Luerl in the caller are faster.

Code you do not fully trust, inside the VM

Agent systems run partly trusted code: code the agent wrote, tools and plugins, per-tenant logic, and agent steps that untrusted input (prompt injection, poisoned tool output) can steer. None of it should hold ambient authority. A bad step must not reach the host, other tenants, or capabilities it was not given. Agents are long-lived, stateful, concurrent and supervised, which BEAM processes and OTP provide. Current agent sandboxes run outside the VM: E2B uses Firecracker microVMs, AutoGen uses Docker containers, and Cloudflare loads V8 isolates (sources). Hyperlang runs these steps inside the VM as capability-scoped, metered guest actors written in ordinary Erlang. Running a generated tool shows an example.

Why the guest language is Erlang

Compiling Elixir runs Elixir. Module bodies, attributes, macros, use and @before_compile hooks execute during compilation, with the host's authority and no time bound. A sandbox that checks Elixir source from outside must either forbid those constructs or run untrusted code while compiling. Erlang source runs no user code at compile time. Hyperlang runs no preprocessor, rejects -include, -define, -compile and -on_load, and meters its own compile with a reduction budget. The sound path to sandboxed Elixir is to run the Elixir compiler itself as a guest (design). If you want to work on that, see CONTRIBUTING.md.

How it compares

HyperlangDuneLuerlerl_eval + handlerwasmexPeer node / OS process
Guest languageErlangElixir subsetLuaErlangWasmAny
Ambient authorityNoneAST allowlist + run-time checkLua globals removedos:cmd/1 ranDeclared imports onlyFull
VM atoms added by guest0 after warm-up+1,314, then 0+13 for 50,000 keys+2,000 per programNoneOwn table
80 MB heap cap heldYes (39 ms)No: +270 MB, 1.3 sIf the host sets itIf the host builds itYesOS limits only
Guest outlives its callerNoNoYes, past max_timeYes, unlinked spawnUntil call timeoutn/a
fib(25)0.35 ms275 ms85 ms106 ms0.68 ms¹0.33–0.38 ms¹
Memory per idle tenant23 KiB109 KiB71 KiB23 KiB89 KiB (Rust guest)74–76 MiB
Tenant start, compiled program0.05 ms0.41 ms0.03 ms0.007 ms0.08 ms111–123 ms

¹ Runs on its own threads or VM, so compare with care. Evidence for every cell (probes, measurements, sources) is in doc/COMPARISON.md. Guests share the VM's schedulers, allocators, garbage collector and code server; the threat model states what that leaves.

Running a generated tool

An agent wrote this tool at run time. Its only authority is the host-declared search:query/1 binding. A step that calls the operating system fails to compile. A step that never ends stops at its reduction budget.

defmodule Readme.Search do
  @behaviour Hyperlang.Binding

  @impl true
  def exports, do: [query: 1]

  @impl true
  def call(%{docs: docs}, :query, [term]), do: Enum.filter(docs, &String.contains?(&1, term))
end

manifest = %{bindings: %{"search" => Readme.Search}, terms: %{public: :erlang}}

spec = %{
  bindings: %{"search" => {Readme.Search, %{docs: ["BEAM schedulers", "BEAM atoms are not collected"]}}},
  policy: %{max_reductions: 1_000_000, wall_ms: 100, heap_words: 100_000}
}

tool = ~S"""
-module(tool).
-export([result/1]).
result(Term) -> [D || D <- search:query(Term), byte_size(D) < 20].
"""

{:ok, program} = Hyperlang.program(%{entry: "tool.erl", files: %{"tool.erl" => tool}, manifest: manifest})
{:ok, ["BEAM schedulers"], _meta} = Hyperlang.run(spec, program, :entry, ["BEAM"])

shell = "-module(shell).\n-export([result/1]).\nresult(Cmd) -> os:cmd(Cmd).\n"
{:error, [%{category: :verify} | _]} =
  Hyperlang.program(%{entry: "shell.erl", files: %{"shell.erl" => shell}, manifest: manifest})

loop = "-module(loop).\n-export([result/1]).\nresult(X) -> result(X).\n"
{:ok, looping} = Hyperlang.program(%{entry: "loop.erl", files: %{"loop.erl" => loop}, manifest: manifest})
{:error, %{category: :reduction_watchdog}} = Hyperlang.run(spec, looping, :entry, [0])

Hyperlang 101

The model maps onto Cloudflare Workers: a program is a Worker script, a run is a request, bindings are env, and a callback actor is a Durable Object. This guide pairs each Workers concept with its Hyperlang equivalent. The JavaScript blocks are Cloudflare reference code. Every Elixir block runs as written; test/readme_examples_test.exs extracts and executes them.

The security contract is doc/SECURITY_SPECIFICATION.md. To report a vulnerability, see SECURITY.md. The companion project hyperbob_os is an OS-scale host on Hyperlang: a BEAM port of the Inferno OS that runs its userland as Hyperlang guests.

Install

Add {:hyperlang, "~> 0.1.0"} to deps in mix.exs and run mix deps.get.

Hyperlang needs Erlang/OTP 29 and Elixir 1.20 or later. The stdlib allowlist and the vendored OTP sources are pinned to OTP 29. Later OTP releases are untested.

Threat model

Hyperlang enforces authority isolation for untrusted guest source inside one VM. A guest cannot reach any function, process, port, file, atom or code path that its program and run do not grant. The compiler lowers and checks every call before the module exists. Each run is metered for reductions, heap and wall time. Every term that crosses the door is sanitized.

Hyperlang.Scope adds resource isolation between tenants. Each scope has its own admission limits, quotas and priority class. Node-wide admission counts only runnable guest work, so blocked guests hold no capacity. Latency scopes keep a reserved slot and wake first. An isolation verification of this release measured the effect on a victim tenant:

Victim p99 latency next to 0, 1, 4 and 16 noisy tenants. With neighbours that have no scope policy it rises to 1.8x under CPU load, 9.4x under heap pressure and 1.4x under compile load. With the neighbours in background scopes with quotas it stays at 1.1x, 1.1x and 1.0x.

Each point is the p99 of 1,000 requests from one latency-class tenant on a node with 4 schedulers, divided by that tenant's p99 with no neighbours, and is the median of three runs. Each noisy tenant repeatedly spins a CPU-bound loop, grows its heap until its heap limit kills the run, or compiles a new guest module. The victim keeps its reserved latency slot in both series. The neighbours either have no scope policy or each run in their own :background scope with a CPU, heap-churn or compile quota.

Noisy tenants01416
CPU, no neighbour policy1.0x1.0x1.8x1.8x
CPU, background scopes1.0x1.3x1.1x1.1x
Heap, no neighbour policy1.0x1.0x4.3x9.4x
Heap, background scopes1.0x0.9x1.0x1.1x
Compile, no neighbour policy1.0x1.0x1.3x1.4x
Compile, background scopes1.0x1.0x1.0x1.0x

Resource control guarantees:

  • Each run has fresh reduction accounting. A spawned run's limits group is destroyed when the run ends: 10,000 runs leave 0 groups, processes, ports, ETS tables or file descriptors behind. An actor's callbacks, and one host's in-caller runs, share one limits group per process. Its usage restarts with every run, and the process's exit destroys it.
  • Guest children owned by a run are killed when the run ends.
  • Timer quotas and handles belong to a host-selected scope. One scope cannot read or cancel another scope's timers.
  • Channels, buffers and broker heaps count against scope quotas (1,024 channels per scope by default).
  • OTP actor status and crash reports never show binding state or policy secrets, whatever format_status the guest defines.
  • Internal fun application rejects host funs.

Guests share the VM's schedulers, allocators, garbage collector and code server, and a few native calls cannot be interrupted. Hosts that need hard latency or memory bounds against hostile tenants add OS or node isolation. The security specification lists the full contract and its known limitations.

Workers and Hyperlang at a glance

Left: Cloudflare Workers code. Right: the equivalent Hyperlang guest in plain Erlang. Authority that Workers get through env arrives as static calls to named bindings (kv:get/1, storage:put/2). The host declares the bindings once and supplies them at run time. The sections below show and run the host side of each pair. Each Erlang snippet is copied from one of those examples, and the test suite checks that the copies match.

Cloudflare Workers (JavaScript)Hyperlang guest (Erlang)
Entrypoint: fetch becomes result/1
```js export default { async fetch(request, env, ctx) { const { name } = await request.json(); return Response.json({ greeting: `hello ${name}` }); }, }; ``` ```erlang -module(hello). -export([result/1]). result(#{<<"name">> := Name}) -> #{<<"greeting">> => <<"hello ", Name/binary>>}. ```
RPC method: a WorkerEntrypoint method becomes a manifest-declared export
```js export class Calc extends WorkerEntrypoint { add(a, b) { return a + b; } } ``` ```erlang -module(calc). -export([add/2]). add(A, B) -> A + B. ```
KV binding: env.KV becomes the kv binding
```js export default { async fetch(request, env) { await env.KV.put("greeting", "hi"); return new Response(await env.KV.get("greeting")); }, }; ``` ```erlang -module(greet). -export([result/1]). result(Key) -> ok = kv:put(Key, <<"hi">>), kv:get(Key). ```
Secret: env.API_TOKEN stays inside the binding's host state, and the guest never sees it
```js export default { async fetch(request, env) { return new Response(await sign(env.API_TOKEN, "payload")); }, }; ``` ```erlang -module(signed). -export([result/1]). result(P) -> signer:sign(P). ```
Logging: console.log becomes io:format, returned to the host in meta.stdout
```js export default { async fetch(request) { console.log("pricing", "A1"); return new Response("42"); }, }; ``` ```erlang -module(price). -export([result/1]). result(Sku) -> io:format("pricing ~s~n", [Sku]), 42. ```
Durable Object: a class with ctx.storage becomes a gen_server callback module with a storage binding
```js export class Counter extends DurableObject { constructor(ctx, env) { super(ctx, env); ctx.blockConcurrencyWhile(async () => { this.n = (await ctx.storage.get("n")) ?? 0; }); } async inc() { this.n += 1; await this.ctx.storage.put("n", this.n); return this.n; } } ``` ```erlang -module(counter). -behaviour(gen_server). -export([init/1, handle_call/3]). init(_Id) -> {ok, storage:get(<<"n">>, 0)}. handle_call(<<"inc">>, _From, N) -> ok = storage:put(<<"n">>, N + 1), {reply, N + 1, N + 1}. ```

The door

The public API is four functions:

FunctionRole
Hyperlang.program/1Compile a source value into an immutable %Hyperlang.Program{}.
Hyperlang.program/2Return a program cached under your key; compile on a miss.
Hyperlang.run/4Run one export of a program with the authority in a spec.
Hyperlang.module/1Return the program's confined OTP callback module.

Hyperlang.Binding is the behaviour for host capabilities, and Hyperlang.Test is the test kit.

A Worker entrypoint is result/1 plus run/4

A Worker exports fetch. A Hyperlang program exports result/1. The host calls it with Hyperlang.run(spec, program, :entry, [input]).

# Hyperlang: guest Erlang + host Elixir
{:ok, program} =
  Hyperlang.program(%{
    entry: "hello.erl",
    files: %{
      "hello.erl" => ~S"""
      -module(hello).
      -export([result/1]).

      result(#{<<"name">> := Name}) ->
          #{<<"greeting">> => <<"hello ", Name/binary>>}.
      """
    },
    manifest: %{terms: %{public: :erlang}}
  })

{:ok, %{<<"greeting">> => "hello Ada"}, meta} =
  Hyperlang.run(%{}, program, :entry, [%{<<"name">> => "Ada"}])

%{stdout: [], reductions: _, wall_us: _} = meta

The value comes back as a plain term. Inputs and outputs cross a sanitizer. Binaries, numbers, lists, tuples, maps and allowlisted atoms pass. Pids, ports, references and funs are rejected. terms: %{public: :erlang} selects the Erlang term profile used in this guide. The default profile projects terms to a JSON-safe shape (binaries become %{"$binary" => base64}) for hosts that serialize results.

Named exports correspond to RPC methods on a WorkerEntrypoint. Only exports the manifest declares are callable. A source -export attribute alone grants nothing.

{:ok, program} =
  Hyperlang.program(%{
    entry: "calc.erl",
    files: %{"calc.erl" => "-module(calc).\n-export([add/2]).\nadd(A, B) -> A + B.\n"},
    manifest: %{
      terms: %{public: :erlang},
      exports: %{functions: %{"add" => %{function: "add", arity: 2}}}
    }
  })

{:ok, 3, _meta} = Hyperlang.run(%{}, program, "add", [1, 2])

env bindings and wrangler config are Hyperlang.Binding and manifest bindings

In Workers, wrangler.jsonc declares a binding by name and the code calls env.KV.get(key). In Hyperlang, a binding is a trusted host module. The manifest names it once. Guest code calls it as a static module call, kv:get(Key).

// wrangler.jsonc: { "kv_namespaces": [{ "binding": "KV", "id": "..." }] }
export default {
  async fetch(request, env) {
    return new Response(await env.KV.get("greeting"));
  },
};
defmodule Readme.KV do
  @behaviour Hyperlang.Binding

  @impl true
  def exports, do: [get: 1, put: 2]

  @impl true
  def call(%{table: table}, :get, [key]) do
    case :ets.lookup(table, key) do
      [{^key, value}] -> value
      [] -> :undefined
    end
  end

  def call(%{table: table}, :put, [key, value]) do
    true = :ets.insert(table, {key, value})
    :ok
  end
end

{:ok, program} =
  Hyperlang.program(%{
    entry: "greet.erl",
    files: %{
      "greet.erl" => """
      -module(greet).
      -export([result/1]).
      result(Key) -> ok = kv:put(Key, <<"hi">>), kv:get(Key).
      """
    },
    manifest: %{
      bindings: %{"kv" => Readme.KV},
      terms: %{public: :erlang, allowed_atoms: [:ok, :undefined]}
    }
  })

table = :ets.new(:readme_kv, [:public])
spec = %{bindings: %{"kv" => {Readme.KV, %{table: table}}}}

{:ok, "hi", _meta} = Hyperlang.run(spec, program, :entry, ["greeting"])

Hyperlang.program/1 reads exports/0 once and pins the MFA set (kv:get/1, kv:put/2) into the program and its hash. A call to any other kv: function is a compile error. At run time, spec.bindings must name exactly the declared bindings with the same modules, or the run fails before the guest starts.

Export options:

  • {:get, 1, limits: %{max_binary_bytes: 4096}} caps argument and return sizes.
  • {:now_ms, 0, nondeterministic: true} marks a call that the manifest must allow with capabilities: %{nondeterminism: true}.

Secrets and vars live in binding state

A Worker reads secrets from env.API_TOKEN. In Hyperlang, secrets go in the binding's state. State goes to call/3 and never reaches the guest.

defmodule Readme.Signer do
  @behaviour Hyperlang.Binding

  @impl true
  def exports, do: [sign: 1]

  @impl true
  def call(%{token: token}, :sign, [payload]),
    do: :crypto.mac(:hmac, :sha256, token, payload) |> Base.encode16(case: :lower)
end

{:ok, program} =
  Hyperlang.program(%{
    entry: "signed.erl",
    files: %{"signed.erl" => "-module(signed).\n-export([result/1]).\nresult(P) -> signer:sign(P).\n"},
    manifest: %{bindings: %{"signer" => Readme.Signer}, terms: %{public: :erlang}}
  })

spec = %{bindings: %{"signer" => {Readme.Signer, %{token: "s3cret"}}}}
{:ok, signature, _meta} = Hyperlang.run(spec, program, :entry, ["payload"])
64 = byte_size(signature)

console.log and Tail Workers are io:format and meta.stdout

Guest io:format output is captured, sanitized, bounded and returned with the result. A host can forward it to its own logger, as a Tail Worker does.

{:ok, program} =
  Hyperlang.program(%{
    entry: "price.erl",
    files: %{
      "price.erl" => ~S"""
      -module(price).
      -export([result/1]).
      result(Sku) -> io:format("pricing ~s~n", [Sku]), 42.
      """
    },
    manifest: %{terms: %{public: :erlang}}
  })

{:ok, 42, %{stdout: ["pricing A1\n"], reductions: reductions}} =
  Hyperlang.run(%{}, program, :entry, ["A1"])

true = reductions > 0

CPU and memory limits are reductions, heap, and wall time

Workers cap CPU time and memory per request. Hyperlang caps reductions (max_reductions), heap words (heap_words) and wall time (wall_ms) per run, set in spec.policy. A run that crosses a limit returns a sanitized error with a fixed category.

{:ok, program} =
  Hyperlang.program(%{
    entry: "work.erl",
    files: %{
      "work.erl" => """
      -module(work).
      -export([result/1]).
      result(<<"spin">>) -> spin();
      result(N) -> N.
      spin() -> spin().
      """
    }
  })

{:error, %{category: :reduction_watchdog}} =
  Hyperlang.run(%{policy: %{max_reductions: 100_000}}, program, :entry, ["spin"])

{:error, %{category: :timeout}} =
  Hyperlang.run(%{policy: %{max_reductions: nil, wall_ms: 50}}, program, :entry, ["spin"])

{:ok, 7, _meta} = Hyperlang.run(%{policy: %{heap_words: 200_000}}, program, :entry, [7])

Limits count reductions, so the same program and input hit the same limit on every run.

Compilation is metered the same way. The manifest limit max_compile_reductions caps the reductions of the compile worker (parse, verification, code generation and the OTP compiler). max_compile_ms is a wall-clock backstop for work that does not execute reductions. A source is admitted or rejected the same way on an idle host and on a loaded host.

Worker Loader LOADER.get is Hyperlang.program(key, loader)

The Worker Loader API fetches a dynamic Worker by id and calls a factory only on a miss. Hyperlang.program/2 does the same with a caller-owned key.

const worker = env.LOADER.get("tenant-acme-v3", async () => ({
  mainModule: "main.js",
  modules: { "main.js": await fetchSource("acme") },
}));
loader = fn ->
  %{entry: "acme.erl", files: %{"acme.erl" => "-module(acme).\n-export([result/1]).\nresult(N) -> N * 2.\n"}}
end

{:ok, program} = Hyperlang.program({:tenant, "acme", 3}, loader)
{:ok, ^program} = Hyperlang.program({:tenant, "acme", 3}, fn -> raise "not called on a hit" end)
{:ok, 10, _meta} = Hyperlang.run(%{}, program, :entry, [5])

Programs are content-addressed. The same source and manifest always produce an equal program value.

A Durable Object is a callback actor plus a storage binding

A Durable Object is a single-threaded object with durable storage. Hyperlang builds the same shape from existing parts:

  • Input gate: Hyperlang.module(program) is a confined GenServer callback module. The actor runs one callback at a time.
  • Output gate: storage is an ordinary binding passed in the spec, for example {MyHost.Storage, %{ns: actor_id}}. Binding calls are synchronous inside the callback, so a write-through storage binding returns only after the write is durable.
  • blockConcurrencyWhile: in-memory state is a cache that init/1 reloads from storage. An actor that stops or crashes restarts with the state its storage holds.
  • Limits: spec.policy applies to every callback with no host wiring: wall_ms and max_reductions per callback, rate and ceiling for the actor's lifetime.
defmodule Readme.Storage do
  @behaviour Hyperlang.Binding

  @impl true
  def exports, do: [get: 2, put: 2]

  @impl true
  def call(%{table: table, ns: ns}, :get, [key, default]) do
    case :ets.lookup(table, {ns, key}) do
      [{_key, value}] -> value
      [] -> default
    end
  end

  def call(%{table: table, ns: ns}, :put, [key, value]) do
    true = :ets.insert(table, {{ns, key}, value})
    :ok
  end
end

{:ok, program} =
  Hyperlang.program(%{
    entry: "counter.erl",
    files: %{
      "counter.erl" => ~S"""
      -module(counter).
      -behaviour(gen_server).
      -export([init/1, handle_call/3]).

      init(_Id) -> {ok, storage:get(<<"n">>, 0)}.

      handle_call(<<"inc">>, _From, N) ->
          ok = storage:put(<<"n">>, N + 1),
          {reply, N + 1, N + 1}.
      """
    },
    manifest: %{
      result: %{name: "init", arity: 1},
      terms: %{public: :erlang, allowed_atoms: [:ok]},
      bindings: %{"storage" => Readme.Storage}
    }
  })

table = :ets.new(:readme_storage, [:public])
spec = %{bindings: %{"storage" => {Readme.Storage, %{table: table, ns: "counter-1"}}}}

{:ok, pid} = GenServer.start(Hyperlang.module(program), {spec, "counter-1"})
1 = GenServer.call(pid, "inc")
2 = GenServer.call(pid, "inc")
:ok = GenServer.stop(pid)

# A new actor for the same id reloads from storage in init/1.
{:ok, pid} = GenServer.start(Hyperlang.module(program), {spec, "counter-1"})
3 = GenServer.call(pid, "inc")
:ok = GenServer.stop(pid)

The host owns the id-to-process registry (idFromName), when actors start and stop, and where storage lives. An alarm is another binding. The guest asks the host to record one, and the host delivers it later as a message. The default guest profile has no timers.

vitest-pool-workers is Hyperlang.Test

Hyperlang.Test compiles guests, fakes bindings, runs exports and starts actors through the public door only. A fake binding is a map of functions, which the kit backs with a generated Hyperlang.Binding module.

// vitest.config.ts declares bindings; tests import from "cloudflare:test"
import { env, SELF } from "cloudflare:test";
it("prices an item", async () => {
  const res = await SELF.fetch("https://example.com/?sku=A1");
  expect(await res.text()).toBe("42");
});
import Hyperlang.Test

source = "-module(shop).\n-export([result/1]).\nresult(Sku) -> io:format(\"sku~n\"), catalog:price(Sku).\n"
catalog = %{"catalog" => %{price: fn "A1" -> 42 end}}
manifest = %{terms: %{public: :erlang}}

42 = run!(source, :entry, ["A1"], bindings: catalog, manifest: manifest)

{:ok, 42, meta} = run(source, :entry, ["A1"], bindings: catalog, manifest: manifest)
assert_stdout(meta, "sku\n")

program = program!(source, Map.put(manifest, :bindings, catalog))
{:ok, 42, _meta} = run(program, :entry, ["A1"], bindings: catalog)

start_actor!/3 starts a callback actor with bindings: and policy:.

Where Hyperlang deliberately differs

  • No async/await, no ctx.waitUntil. Binding calls block inside the metered call, so nothing interleaves and no input-gate machinery is needed. Background work belongs to the host: record a durable intent, or have the actor cast to itself.
  • No ambient fetch or outbound network. A guest has network access only through a binding, which does its own checks host-side.
  • No env value in the guest. Bindings are static calls such as kv:get(K). The guest cannot store, pass or dynamically call them. The verifier rejects dynamic M:F(...) calls.
  • No persisted live capability stubs. A guest cannot save a binding and redeem it later. The host re-supplies authority on every run.
  • Reductions instead of CPU milliseconds. Reduction limits are deterministic enough to test exactly.
  • Allowlisted atoms. The program declares a bounded atom set (terms.allowed_atoms). A run's policy.atoms may narrow it and never widen it. Workers have no atom table to protect; the BEAM does.
  • No WebSocket hibernation. BEAM processes are cheap. An idle actor can be stopped and restarted from storage.
  • Child processes and programs go through host hooks. A guest's spawn, spawn_link and spawn_monitor work only when the host installs a spawn hook. The child runs the same program's code, is metered as part of the run, and dies with it unless the hook takes over metering. A guest starts another program only through an exec hook or binding the host grants, as hyperbob_os does for exec. The host chooses the program and its authority. Only host code loads programs.

Standard library calls

Hyperlang admits a frozen allowlist of Erlang/OTP stdlib functions. Allowed calls run the real OTP functions behind argument and result sanitization.

{:ok, program} =
  Hyperlang.program(%{
    entry: "std.erl",
    files: %{
      "std.erl" => ~S"""
      -module(std).
      -export([result/1]).
      result(_) ->
          {lists:sum([1, 2, 3]),
           maps:get(<<"x">>, #{<<"x">> => 7}),
           re:replace(<<"banana">>, <<"a">>, <<"x">>, [global, {return, binary}])}.
      """
    },
    manifest: %{terms: %{public: :erlang}}
  })

{:ok, {6, 7, "bxnxnx"}, _meta} = Hyperlang.run(%{}, program, :entry, [nil])

Live clocks and entropy require capabilities: %{nondeterminism: true}: erlang:system_time/0,1, erlang:monotonic_time/0,1, calendar:universal_time/0, rand:mwc59_seed/0, sys:millisec/0 and local-time-zone conversions. The compiler rejects ambient authority: os:*, raw file:*, ports, ETS, code loading, dynamic apply and NIF loading.

io:format/*, io_lib:format/* and float formatting run vendored, pinned OTP formatting code behind Hyperlang wrappers. Output stays bounded, cannot mint atoms and never renders a fun. Raw OTP JSON encoders are rejected. A host that needs JSON provides it as a binding.

Filesystem capability

A manifest can enable a mediated virtual filesystem. Guest code uses standard file:* and filelib:* calls on virtual paths. No filesystem module is visible to the guest. The test suite runs these examples against a manifest with a readable /data mount and a writable /work mount:

result(_) ->
    {ok, Bin} = file:read_file("/data/input.txt"),
    ok = file:write_file("/work/output.txt", Bin),
    #{<<"copied">> => Bin}.
result(_) ->
    {ok, Names} = file:list_dir("/data"),
    lists:sort(Names).

The supported file:* subset is sequential and uses virtual paths:

  • read_file/1, write_file/2,3, list_dir/1, make_dir/1, del_dir/1, delete/1, rename/2, read_file_info/1, read_link_info/1.
  • With handle compatibility enabled: open/2, read/2, write/2, close/1. Handles are opaque guest terms.

Every call enforces mount permissions, byte limits and entry limits. A host can instead supply a Plan 9 style syscall facade through sys:* with spec.syscall.

Existing symlinks in host-backed mounts follow host semantics. Guests cannot create links. Do not mount a host directory whose symlinks point to paths the guest must not read. Use memory or snapshot mounts for strict fixtures.

Run the tests

mix test

CONTRIBUTING.md describes the opt-in isolation stress probes.

License

Apache License, Version 2.0. See LICENSE and NOTICE, which credits the vendored Erlang/OTP sources.