zandGitHub 760573b9b1 Explicit Memory64 detection and safe sandbox/AOT separation (#1)
* Detect imported and defined Memory64 modules; name unsupported sandbox backend

* Fail explicitly on Memory64 guests in 32-bit wazero sandbox

* Test imported/defined Memory64 classification and explicit runtime rejection

* Document explicit Memory64 sandbox contract and migration requirements

* Clarify Memory64 capability and native-versus-sandbox boundary

* CI: verify formatting, race safety and Memory64 rejection tests

* Format Memory64 sandbox test for Go CI

* Scope CI to self-contained sandbox tests; compiler fixtures require external binaries
2026-10-08 19:21:59 -07:00

wasm

Memory64 support: this repo is a sandboxed wazero wrapper and currently supports 32-bit-indexed guest memory only. A module declaring imported or defined Memory64 returns ErrMemory64Unsupported. Hanzo's separate wasm2go compiler supports native AOT Memory64; native code is not a sandbox replacement. See MEMORY64.md.

WebAssembly that runs in the calling process. A thin wrapper over wazero — not a fork of it.

e, _ := wasm.New(ctx, wasm.Limits{})   // once, at startup — WASI already granted
defer e.Close(ctx)

mod, _ := e.Compile(ctx, src)          // ~833 µs — the slow half, done once
in, _ := mod.Start(ctx)                // ~249 µs — a fresh sandbox
defer in.Close(ctx)

out, _ := in.Call(ctx, "add", 2, 40)   // ~89 ns

Why this is not a container

A wasm module is already a sandbox: it reaches no memory it was not given, and holds no syscalls, no descriptors and no network unless a host hands them over. Putting one inside a container buys a second boundary the first already provides, and the bill is four orders of magnitude:

path cost relative
warm call, in process 89 ns 1×
fresh sandbox, in process 249 µs 2,800×
pooled container 37.5 ms ~151× the sandbox
spawn a wasm CLI 228 ms ~917× the sandbox

Measured on one machine, one pass, with the commands in bench/. Read them as orders of magnitude rather than constants.

What belongs here, and what does not

Here: pure computation on data you hand it — transforms, scoring, policy, untrusted snippets, anything expressible as a function.

Not here: work that must run somebody else's binary. WASI has no process spawn, so a guest cannot exec pytest or cargo build. Work that genuinely needs a kernel runs under Visor (runtimeClassName: visor).

But "needs a kernel" is a smaller set than it first looks, and Store is why.

Reaching the outside: Store

A guest that shells out to git needs exec and cannot run here. A guest handed an object store does not — a repository IS objects, a read is Get, a write is Put, and the network hop happens on the HOST side where the credential already lives:

e.Bind(ctx, s3)   // hanzoai/s3 over ZAP in the fleet, a map in a test

The guest imports one module, hanzo, with get/put/list. It names keys, never hosts, and holds no credential it could leak. Nothing is spawned because nothing needs to be — which is what makes S3-backed agentic work possible at sandbox prices rather than container prices.

The wire is a key as (ptr,len) and a result written into guest-allocated memory. No JSON crosses the boundary: bytes and two integers is the cheapest thing that can cross, and the only shape that cannot disagree about a schema. A guest that wants results exports alloc(i32) i32; one that does not traps, and its Call returns ErrNoAlloc.

get answers -1 for a key that is absent and -2 when the store failed. A missing object is an answer; a failed fetch is worth asking again. An address outside the guest's memory is not a question, so it traps instead of being answered.

Limits

The zero value is bounded, not unlimited: 256 pages (16 MiB) and a 5s ceiling on one call, or on the start function Start runs. An unset bound is the case most callers ship, so it is the one that has to be safe.

A guest loop is not interruptible from outside except by cancelling its context, which is what Run does, and what the caller's own cancellation does sooner. A call stopped that way closes its instance, and the error matches context.DeadlineExceeded or context.Canceled. For the cancellation to reach a running guest, wazero checks for it on every loop iteration: ~22 ns each on an M1 Max, so a loop that does little else runs ~65× slower. The gitoxide timings below predate that check.

Loops are the only place wazero checks. A guest that runs long through recursion alone — f(n) = f(n-1) + f(n-1), no loop anywhere — is not stopped by Run, and it holds the next garbage collection, and so the process, until it returns.

Naming

wasm is the package; wazero is the engine it wraps. This repo exists so that "the Hanzo wasm runtime" has one obvious home — not to diverge from upstream. If it ever needs engine changes, that is the moment to reconsider, and not before.

It runs real Rust

gitoxide — pure-Rust git — compiled for wasm32-wasip1 and parsing loose objects inside the sandbox, measured through this package:

compile gitoxide module   28.3 ms   (once)
instantiate sandbox      315.7 µs
blob     -> kind 1        11.2 µs
commit   -> kind 3        28.8 µs
garbage  -> rejected       2.0 µs

That is the whole argument for S3-backed agentic work: a repository is objects, gix-object reads them, the bytes arrive through Store, and nothing is spawned. No git subprocess, so nothing needs exec, so WASI having no process spawn stops being the objection it looks like.

Note which crates that needs: the object layer (gix-object, gix-hash) and none of the transport ones. gitoxide is modular, and the half that wants sockets is the half you leave out — the network hop belongs on the host, where the credential already is.

WASI is on by default

Because a Rust cdylib built for wasm32-wasip1 imports wasi_snapshot_preview1 through std whether the code calls it or not. Left to opt in, every such guest fails with module[wasi_snapshot_preview1] not instantiated — an error that names the host's omission while sounding like the guest's fault, and sends people reading their own code.

It grants nothing much: wazero starts WASI with nothing mounted, so the fd calls exist and reach nothing. No filesystem, no network. Limits{NoWASI: true} declines it for a guest that genuinely imports nothing.

It runs real compilers: compile

wasm runs a guest. compile is the host for the case where the guest is a compiler — one registry, two roles, one diagnostic shape:

c, _ := compile.NewTSGo(ctx, compile.Config{
    Blob:  compile.Blob{Path: "tsgo.wasm", Sum: "b17624…"},
    Cache: "/var/cache/hanzo/wazero",
})
compile.Register(c)

diags, _ := c.Check(ctx, compile.Project{
    Root:  "/project",
    Entry: []string{"entry.ts"},
    Files: compile.Map{"entry.ts": src, "math.ts": more},
}, compile.Options{})
// entry.ts:2:14 error TS2322 Type 'number' is not assignable to type 'string'.

A checker is registered, not endpointed. Supporting Rust is a row rather than a route, and Checkers() is how an agent learns what can be checked here instead of hardcoding a list.

Checker and Bundler are separate interfaces because most checkers do not bundle. esbuild bundles const wrong: string = add(1,2) and exits 0, so it registers as a Bundler and only that — asking for CheckerNamed("esbuild") gets nothing, which is the registry refusing to claim source was verified when it was not.

A project is files, not a path

Project.Files is a host callback: ReadFile, FileExists, DirExists, Entries, Realpath. There is no disk path in it. The repository lives in s3, the host answers reads out of its own cache, and the guest reaches exactly what the host answers for and nothing else. Map is the in-memory implementation a test uses and the shape a warm cache takes.

Every string that becomes one of those calls is cleaned first and refused if it leaves the root — a guest's import, a caller's entry, the host's own Realpath answer. import "/project/../outside" is not a file: it is a name the project does not contain, and the guest is told so at the line that asked for it. A path in an esbuild on-load request is no different — only a name some on-resolve already answered for is read, because a field in a request is not authority. So the obvious host, os.ReadFile(filepath.Join(base, name)), is a safe one.

esbuild needs no filesystem at all: its plugin protocol carries every resolve and load, so the module runs with zero preopened directories. tsgo issues ordinary WASI reads — porting a compiler off a filesystem would mean forking it — so its mount is an fs.FS backed by the same callbacks. Either way the answer comes from a function, and a project with no tsconfig.json gets one that exists only in the guest's view of the tree.

One diagnostic shape

{file, line, column, severity, code, message, checker}, lines and columns 1-based because every compiler in the set already reports that way. A column counts UTF-16 code units, which is what a compiler counts; esbuild counts bytes from 0, so on import { nope as ééé } from "./gone"; it puts the opening quote at 32 where tsgo puts it at 29. Both say 29 here, and that translation happens once rather than in every caller.

A diagnostic about no position in a file has line and column 0 — the only thing 0 means — and still names its file when the message does: tsc reports File '/project/ghost.ts' not found. with no location at all, and an agent routes by file. An agent that reads a TS2322 reads an E0308 with no new code.

What it costs

tsgo is a command module: _start runs main() and exits, so every check re-parses the standard library, and that parse is the bill. Measured on evo (x86_64, 32 cores, 2026-09-17) over a two-file project:

wall memory
default libs, one check 1.7 s 795 MB
lib: ["es2022"], types: [] 273 ms 430 MB
eight at once, GOMEMLIMIT=256MiB 541 ms 895 MB total

So es2022 with no ambient types is the default here, GOMEMLIMIT is set per check, and one CompiledModule is instantiated many times — eight concurrent checks at 68 ms each. Compiling the module itself costs ~12 s, which is why Config.Cache is a directory: reading 49 MB of machine code back is ~300 ms. go test -bench . prints cold, warm and eight-at-once for the host it runs on.

Above that fleet size the numbers turn: sixteen instances of the default-lib module cost 5.0 s and 4.07 GB. ~290 MB per instance is the budget, and the structural fix is a reactor — a live instance holding a parsed library that answers per-edit checks — not more instances.

The modules are not in this repository

tsgo is 49 MB and esbuild 20 MB. Blob names one by path or URL and its sha256; a blob with no digest is refused rather than trusted. The tests want TSGO_WASM and ESBUILD_WASM pointing at local builds, with TSGO_SHA256 and ESBUILD_SHA256 to pin them. Without those they fail and name the one that is missing. They do not skip: a run of this package that ran neither compiler has verified nothing, and a skip prints ok for it.

S
Description
WebAssembly in the calling process — a thin wrapper over wazero
Readme
181 KiB
Languages
Go 100%