wasm_run โ the constrained execution lane¶
Looking for end-to-end examples?
wasm-walkthroughs.mdwalks through five real-world workflows: markdown frontmatter validation, JSON Schema validation with caching, AI-agent safe execution via MCP, polyglot pipelines, and CI hot loops. This page is the reference โ every flag, every capability declaration, full spec.perch has two execution lanes.
shellis the universal escape hatch โ flexible, fast to write, best-effort to constrain.wasm_runis the constrained lane โ your code (or a third party's) runs with exactly the capabilities you declared, enforced by the WASM runtime by construction.Mix freely. Use
wasm_runfor the parts where it matters;shellfor everything else.
TL;DR¶
wasm_run "./hello.wasm"
wasm_arg "alice"
wasm_arg "bob"
wasm_env "GREETING,HOME"
wasm_mount_read "./src"
wasm_mount_write "./bin"
end
- The module sees
argv = ["hello.wasm", "alice", "bob"] - Only
GREETINGandHOMEenv vars are visible inside the module โ anything else on the host is invisible, includingPATH. ./src(host) is mounted read-only at/ro/srcinside the module../bin(host) is mounted read-write at/rw/bin.- The module cannot see anything else on the host filesystem.
- No network. No subprocesses. No syscalls beyond what WASI Preview 1 declares.
Why wasm_run is a different kind of safety¶
Compared to perch's existing capability flags:
Today (shell + --no-* flags) |
wasm_run |
|---|---|
--no-shell blocks the op kind |
The module cannot syscall โ nothing to block |
--allow-bin docker matches argv[0] string at runtime |
The module's WASI imports are enumerated at instantiation; unknown imports fail at load |
--allow-host api.x.com is a runtime DNS check |
The module gets no sockets unless they're imported (sockets aren't in v1 โ see Roadmap) |
firejail / sandbox-exec / AppContainer for genuinely adversarial input |
WASM has memory isolation in the spec; cross-platform by one wazero binary |
| Best-effort enforcement on top of a permissive model | Enforcement by construction โ nothing not declared exists in the module's environment |
This isn't an incremental security improvement; it's a different category
of guarantee. For agent-driven workflows (perch-mcp), it's the
difference between "the agent can't easily escape" and "the agent
cannot, by construction, do anything we didn't declare."
Building a module¶
Any language that targets wasm32-wasi (Preview 1) works. Stock Go 1.21+
does it without any extra toolchain:
// hello.go
package main
import (
"fmt"
"os"
)
func main() {
fmt.Println("argv:", os.Args)
if v, ok := os.LookupEnv("GREETING"); ok {
fmt.Println("greeting:", v)
}
}
TinyGo, Rust (cargo build --target wasm32-wasi), Zig, AssemblyScript,
and C++ via wasi-sdk all target the same ABI. perch doesn't care which
toolchain produced the .wasm โ it just loads and runs it under WASI.
Embedded modules โ declare once with as NAME, reference by name¶
wasm_run accepts two argument shapes, distinguished by syntax:
wasm_run "./mod.wasm" # string literal โ load from disk
wasm_run policy_wasm # bare identifier โ resolve via bundle alias
Aliases come from the file-level bundle ... end section:
name "myapp"
version "1.0.0"
bundle
include "./policy.wasm" as policy_wasm
include "./schema.wasm" as schema
include "./policies/rules.json"
end
command run_plugin
do
# `policy_wasm` resolves to in-memory bytes from the embedded
# archive. Zero disk reads at runtime; works identically on
# every OS.
wasm_run policy_wasm
wasm_arg "/ro/deploy"
wasm_mount_read "./deploy"
end
end
end
Build + ship:
perch --build -f myapp.perch -o myapp # bundle declared in-file; no --include
./myapp run_plugin # .wasm bytes are inside ./myapp
Three pieces, one clean story:
| Piece | Role |
|---|---|
bundle ... end (file-level) |
Declares what gets embedded into the binary at --build time. The .perch is the source of truth โ no CLI flag required. as NAME registers an alias usable as a bare identifier downstream. |
wasm_run NAME (bare ident) |
At runtime, look up NAME in the bundle's alias table and run the matching entry's bytes under WASI. No disk write. |
wasm_run "PATH" (string) |
Unchanged: loads a .wasm file from disk. Use during development before deciding what goes in the bundle. |
CLI --include PATH still works at --build time and is additive on top of the declared set โ useful for CI steps injecting a generated config:
perch --build -f myapp.perch --include ./build-stamp.txt -o myapp
# embeds ./policy.wasm + ./schema.wasm + ./policies/rules.json (declared)
# + ./build-stamp.txt (CLI)
The compiled wazero module is cached internally keyed by archive hash + entry, so repeated wasm_run calls (e.g. inside a parallel block) compile once and reuse the same CompiledModule โ same caching benefits as the on-disk path, with no disk involvement.
Why this matters. Recipients of your binary get one executable with every plugin already inside. No tar -xzf, no chmod +x, no "where did the .wasm files go." Combined with wasm_run's capability gates this is the practical shape of distributing a sandboxed plugin host as one artifact.
The string form (wasm_run "./foo.wasm") still works alongside the alias form โ useful during development before you've decided what goes in the bundle.
The capability vocabulary¶
Inside a wasm_run block, five declarations control what the module sees:
| Declaration | Effect |
|---|---|
wasm_arg "VALUE" |
Append VALUE to the module's argv. May appear multiple times. |
wasm_env "K1,K2,โฆ" |
Comma-joined env var names. Only listed names pass through. Anything else is (not set) from inside the module. |
wasm_mount_read "PATH" |
Mount host PATH (a directory) as read-only at /ro/<basename> inside the module. |
wasm_mount_write "PATH" |
Same, but read-write, at /rw/<basename>. |
wasm_allow_host "HOST" |
Permit the module to dial HOST via the host-provided HTTP imports (see below). May appear multiple times. Composes AND-wise with --allow-host. |
Anything not declared does not exist in the module's environment. There is no escape hatch from inside.
HTTP from inside a module (wasm_allow_host)¶
WASI Preview 1 has no sockets. Rather than wait for Preview 2, perch exposes a small set of host imports under the module name perch: http_get, http_status, http_body_len, http_read_body, http_close. Modules call them through a tiny SDK (Go: wasm-sdk/perchhttp; equivalents for Rust / Zig are straight-forward).
command zen
do
wasm_run "${script_dir}/fetch.wasm"
wasm_allow_host "api.github.com" # โ required for any HTTP
end
end
end
// inside fetch.go โ compiled with GOOS=wasip1 GOARCH=wasm
import "github.com/olivierdevelops/perch/wasm-sdk/perchhttp"
body, status, err := perchhttp.Get("https://api.github.com/zen")
What's enforced:
- No
wasm_allow_hostdeclarations โ every HTTP call returns -1. The module can callhttp_get(the import always resolves) but it never reaches the network. Same fail-closed shape as the rest of perch. wasm_allow_host "api.github.com"and the module dialsevil.comโ refused. The allowlist is checked at the host-function entry; the module never sees the request leave the host.- Outer
--allow-hostpolicy still applies. If perch was launched with--allow-host api.github.com, a module declaringwasm_allow_host "example.com"gets the intersection (empty โ no network). - SSRF guard still active. A module dialing
http://169.254.169.254/latest/meta-data(AWS metadata) is refused even when the host is on the allowlist, because the SSRF guard runs on every request and every redirect hop. - Redirect policy still active. A 302 to a host outside the allowlist is refused at the redirect-check boundary.
What's NOT yet supported (honest):
- Only
GET(no POST/PUT/DELETE) and no custom headers. The minimum surface that gives modules an "HTTP read" capability without exploding scope. - No streaming โ bodies are buffered (32 MB cap).
- No sockets, no DNS, no UDP, no raw TCP.
- No mTLS / cert pinning yet.
Roadmap: perchhttp.Post(url, body) is next; custom headers after that; sockets only if WASI Preview 2 reaches stable in wazero.
The module is invoked via WASI's _start (no return value, exit code
indicates outcome). The standard streams (stdin / stdout / stderr) are
wired to perch's normal sinks, so --audit, --trace, --report all
see the module's output as if it were a regular op.
Composition with everything else¶
wasm_run is a block op. It composes with every other primitive:
parallel
wasm_run "validate-darwin.wasm"
wasm_arg "darwin"
wasm_mount_read "./manifests"
end
wasm_run "validate-linux.wasm"
wasm_arg "linux"
wasm_mount_read "./manifests"
end
end
retry 3
wasm_run "fetch-and-process.wasm"
wasm_env "API_TOKEN"
wasm_mount_write "./out"
end
end
cache "build-${target}-${sha256_file('go.sum')}" "24h"
wasm_run "build.wasm"
wasm_arg "--target=${target}"
wasm_mount_read "./src"
wasm_mount_write "./bin"
end
end
sandbox "no_shell,no_network"
wasm_run "third-party-plugin.wasm"
wasm_arg "${input}"
end
end
The sandbox block above is belt-and-braces โ the WASM module already
has no shell or network access by construction, so wrapping it adds no
extra guarantee. But the sandbox block IS useful for the perch ops
around the wasm_run, like a bare command invocation that might itself
shell out.
Compose with --trace and --report¶
WASM execution is just another op in the span tree:
$ perch --trace -f release.perch deploy
โธ sandbox flags="no_network"
โธ wasm_run "./validate.wasm"
โ manifest valid
โ (124ms)
โธ shell "kubectl apply -f manifest.yaml"
deployment.apps/api configured
โ (1.20s)
โ (1.32s)
$ perch --report deploy
โโ perch trace โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ deploy (1.32s)
โโ โ sandbox "no_network" (1.32s)
โโ โ wasm_run "./validate.wasm" (124ms)
โโ โ exec kubectl apply ... (1.20s)
--audit FILE.ndjson records wasm_run events with the module's hash
in args โ auditors can verify exactly which .wasm blob ran.
Implementation details¶
- Runtime: wazero v1.11.0. Pure Go, no CGO. Adds ~3 MB to the perch binary.
- WASI level: Preview 1. Broadest tooling support โ Go's stdlib, TinyGo, Rust+wasm32-wasi, Zig, wasi-sdk all produce Preview 1 modules.
- Module cache: compiled bytecode is keyed by SHA-256 of the
module file. Re-running the same module skips parse/compile/validate.
Cache lives in-process; survives across
wasm_runcalls within a single perch invocation. - Deadline integration: if a
timeoutblock or--max-runtimeflag is active, wazero's context honors it. Module execution cancels at the same point any other op would. - Path mounts: read-only mounts land at
/ro/<basename>, read-write at/rw/<basename>inside the module. Convention; not user-configurable in v1 (see Roadmap).
Status โ what's in the v1 / what's coming¶
โ Shipped (v1)¶
- WASI Preview 1 modules via
_start - argv via
wasm_arg - env allowlist via
wasm_env - fs mounts via
wasm_mount_read/wasm_mount_write - deadline integration with
timeoutblock +--max-runtime - module bytecode cache (in-process, sha256-keyed)
- composes with all execution contexts (
parallel,retry,cache,sandbox, โฆ) - integrates with
--audit/--trace/--report
๐ง Roadmap (not yet โ fail loudly if you try)¶
- Network / sockets. WASI Preview 1 has no socket API. Preview 2
introduces them properly. Until then, modules cannot make outbound
network calls โ period. If you need network access in your module,
call out to perch's
http_getop around thewasm_runblock and pass results in via stdin or a mounted file. - URL-loaded modules. Today
wasm_runtakes a local path only. Loading from HTTPS with a--accept-wasm-hash SHA256pin is on the roadmap. For now:perch download "URL" "PATH" && perch -f ... cmd. - Named-export typed calls. Today only
_startruns (the WASI convention). Callingwasm_run "X.wasm" func:"build" arg1:42with typed integer/float parameters is a v2 feature โ needs Component Model support. - Configurable mount paths. Today read mounts land at
/ro/<basename>and writes at/rw/<basename>. Awasm_mount_read "host" at:"/data"form is on the roadmap. - WASI Preview 2 / Component Model. Coming, but the ecosystem is still settling. v2 will likely be additive (the existing Preview 1 path stays as a "compatibility lane").
- Module signature verification. Cosign / sigstore-style verification before loading. Currently you can verify a module's sha256 externally but perch doesn't enforce a signature policy.
- Persistent on-disk cache (
~/.cache/perch/wasm/<sha>.cwasm). Today the cache is in-process only โ every fresh perch invocation re-compiles. Wazero supports the persistent format; just not wired yet.
If you reach for any of these and find them missing, that's a known gap โ please open an issue or PR; the design space is documented and the implementation path is clear.
When to reach for wasm_run vs shell¶
Reach for shell when |
Reach for wasm_run when |
|---|---|
| Wrapping docker / kubectl / git / aws / brew | Running validation/transformation logic on your own machine |
| Glue work that orchestrates real tools | Letting an AI agent execute arbitrary computation safely |
| The "we just need this to work" lane | Loading third-party / community plugins safely |
| Migrating from existing bash scripts | Anything where determinism + portability matters more than convenience |
Most real .perch files will mix both. The recipes folder uses shell
exclusively today; future recipes (e.g. content validators, format
converters, security scanners) are likely to be wasm_run-based.
See also¶
demos/wasm-hello/โ the worked example (Go source + pre-built.wasm+commands.perch)execution-contexts.mdโ the block-op shape thatwasm_runplugs intosandbox.mdโ the broader capability modelideas/07-hermetic-vs-capability.mdโ the design discussion that led to this feature