Skip to content

Sandboxing and limits

A wazy guest starts with nothing of its own: no files, no network, no stdio. Every capability below is something you hand it. The two ABIs are configured through different surfaces — WASI 0.1 through ModuleConfig methods on a core module, WASI 0.2 through the wasip2.Config struct you pass to wasip2.With — and the defaults are not the same, so every row names its ABI.

Capability ABI Default Granted by
Filesystem 0.1 nothing visible WithFSConfig mounts
Filesystem 0.2 nothing visible Config.FS mounts
stdout, stderr, stdin 0.1 discarded / empty WithStdout, WithStderr, WithStdin
stdout, stderr, stdin 0.2 discarded / empty Config.Stdout, Config.Stderr, Config.Stdin
Environment 0.1 empty WithEnv
Environment 0.2 empty Config.Env
Arguments 0.1 empty, no argv[0] WithArgs
Arguments 0.2 argv[0] only Config.Args appends after it
Wall clock 0.1 a fake clock, frozen WithSysWalltime
Wall clock 0.2 the real time.Now already on; Config.WallClock replaces it
Randomness 0.1 deterministic WithRandSource
Randomness 0.2 real crypto/rand already on; not configurable
TCP / UDP 0.2 unregistered Config.AllowTCP / Config.AllowUDP
Outgoing HTTP 0.2 unregistered Config.EnableHTTP

Cyan marks a capability that is already on before you ask; grey marks one that is not there until you hand it over.

Arguments are the row that differs most between the two. WASI 0.1 defaults to no arguments at all, argv[0] included — WithArgs sets the whole vector, and the runtime synthesizes nothing. WASI 0.2 always prepends a synthetic argv[0] of "wazy", because wasi:cli/environment.get-arguments returns the full argv and guests following the Unix convention skip the first element; Config.Args holds only what comes after it.

The frozen clock is the WASI 0.1 default, and it is the one that surprises people. It is deliberate: a guest that needs the real time has to be given it, and a guest that reads the clock for fingerprinting gets nothing useful.

The component path does not inherit that default, or the deterministic random source. wasip2.With registers wasi:clocks and wasi:random unconditionally, backed by time.Now and crypto/rand: WallClock is the only clock you can replace, and wasip2.Config has no randomness field at all. The way to deny a component those interfaces is not to register WASI on it — every WASI interface a component imports that you did not register is an unregistered trap stub that fails loud, naming the interface and function, the first time the guest calls it.

Mounts are explicit, and read-only is a first-class option:

fs := wazy.NewFSConfig().
WithReadOnlyDirMount("./assets", "/assets"). // guest sees /assets, cannot write
WithDirMount("./scratch", "/tmp"). // read-write
WithFSMount(embeddedFS, "/lib") // an fs.FS, e.g. go:embed

WithFSMount takes any fs.FS, so an embed.FS works and nothing touches the real filesystem. WithSysFSMount is the escape hatch for a custom sys.FS implementation.

There is no .. escape. Both the core and the component path resolve a guest path with the same path.Clean + fs.ValidPath rule and reject anything that leaves the descriptor it is resolved against — ../../.. and rooted paths alike — before the path reaches the filesystem. There is no way to turn that off: to give a guest a second directory, add a second mount.

r := wazy.NewRuntimeWithConfig(ctx, wazy.NewRuntimeConfig().
WithMemoryLimitPages(256)) // 256 * 64 KiB = 16 MiB, hard cap

A module whose declared minimum exceeds the limit is rejected at compile time. A declared maximum over it is clamped to the limit instead, so the module still loads and simply cannot grow as far as it asked; a memory.grow past the limit fails at run time, returning -1, which is a value the guest must handle.

This bounds a memory declared with an i64 index type as well — otherwise a module could reach past your only general memory ceiling by doing nothing more than declaring i64, which is what clang --target=wasm64 and Rust’s memory64 target emit. WithMemory64LimitPages sets that ceiling independently, above or below this one, and wins whichever order the two are configured in; it is the only way to allow a 64-bit memory past four gibibytes, since WithMemoryLimitPages itself tops out at 65536 pages.

WithMemoryCapacityReservePages trades address space for speed: reserving capacity up front makes in-capacity memory.grow a 109 ns operation instead of a reallocation. Out-of-capacity, shared and imported memories keep the safe path.

A context deadline kills even a tight compiled loop, if you ask for it:

r := wazy.NewRuntimeWithConfig(ctx, wazy.NewRuntimeConfig().
WithCloseOnContextDone(true))
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
_, err := fn.Call(ctx) // returns once the deadline passes, even mid-loop

The compiler decrements a reserved register and takes a predicted-not-taken branch at every function entry and every loop back-edge, rather than calling back into Go. When the counter runs out, one trip through Go does the authoritative check and yields to the scheduler. Real compute pays about +18% with the option on, where wazero pays +1057%.

The worst case is a near-empty compute kernel (a bare spin loop), which has no body to dilute the per-back-edge decrement against and pays +44%. A host-call-dense loop, which used to be the expensive shape, now pays +2%. There is no knob; if your workload is a tight compute kernel and you do not need Kill() to interrupt a stuck guest, leave the option off.

  • No fuel metering. There is no instruction budget. Wall time is the lever; if you need kernel-enforced CPU quotas per tenant, you want a container or a microVM.
  • No wasi-threads spawn. Shared memory and atomics are available via experimental.CoreFeaturesThreads, but a guest cannot spawn OS threads.
  • No side-channel hardening. Sandboxing here is memory-safety and capability isolation, not protection against a co-resident guest measuring timing.

A guest trap — out-of-bounds access, an unreachable, a failed memory.grow the guest did not handle — surfaces as an error from Call, with the guest stack in the message. It does not take your process down. A host function that panics becomes a trap the same way.

Closing the runtime closes everything it created:

r := wazy.NewRuntime(ctx)
defer r.Close(ctx)