by Martin Monperrus

TLDR: a coding agent that only speaks WebAssembly has strong sandboxing and traceability features.

Prototype: https://github.com/ASSERT-KTH/cacc/blob/main/wasm_agent.py

The Concept

Every coding agent on the market executes code through a shell. The shell is the workhorse of tooling — and also the single scariest tool in the box: it is a general-purpose machine with full access to the filesystem, the network, the environment, and the ability to exfiltrate all three. Sandboxing a shell is an arms race of namespaces, seccomp filters, and bubblewrap profiles.

wasm_agent.py asks a different question: what if the only thing the agent could execute was WebAssembly modules it compiled itself?

The tool surface is deliberately asymmetric. The agent gets the boring file-related tools unchanged: read_file, write_file, str_replace.

…and then exactly two execution tools:

…plus two crates.io tools that never execute anything:

No shell. No dynamic languages like Python. No bash -c. If the agent wants to observe anything about its own code — print a value, run a test, time a loop, read its arguments — it must write Rust, compile it to a wasm module, and execute it in wasmtime. The sandbox is the architecture itself, not a policy bolted on top the harness.

Why this is a real sandbox

Wasmtime gives capability-based security by construction:

Compare that with a standard agent: instead of preventing the general-purpose machine from doing bad things, we hand over a machine that cannot do them.

Bounded instructions, spelled out

A wall-clock timeout bounds waiting, not work. The two come apart badly in practice:

Wasmtime’s fuel metering bounds the work itself. wasm_agent.py runs every module as wasmtime run -W fuel=N (default N = 20 billion, one unit per wasm instruction executed). When the counter hits zero the module traps — the same clean trap as any other fault, at an instruction boundary, with nothing half-applied and nothing running afterwards. Here is an infinite loop in main, written to the disk by an agent, meeting its budget:

$ wasmtime run -W fuel=1000000000 spin.wasm
   ...
   2: wasm trap: all fuel consumed by WebAssembly

The budget is deterministic (same module, same fuel, same trap point), machine-load-independent, and counts only the guest’s own instructions — the harness pays nothing for compilation or for its own bookkeeping.

Sandbox and Traceability

When the module needs files, wasmtime_exec takes a dirs argument: each entry is a host directory to preopen, optionally remapped (HOST::GUEST), forwarded to wasmtime run --dir. The Rust program then reads and writes through guest paths like /work:

let input = fs::read_to_string("/work/input.txt").expect("read failed");
fs::write("/work/output.txt", sorted).expect("write failed");

A live run put this to work: create input.txt with three fruit, write a Rust program that sorts the lines in reverse and performs the file I/O itself, compile, run with the folder preopened, then read the result back to verify:

▶ write_file(wasi_agent_run/input.txt, "apple\nbanana\ncherry\n")
▶ write_file(wasi_agent_run/main.rs, …)      // fs::read_to_string("/work/input.txt") …
▶ compile_rust_to_wasm(→ sort_lines.wasm)    // 1.79 MB, target wasm32-wasip1
▶ wasmtime_exec(sort_lines.wasm, dirs='wasi_agent_run::/work')
  wrote 3 lines: cherry banana apple
▶ read_file(wasi_agent_run/output.txt)
  cherry banana apple

The remarkable property is that this buys traceability of side effects for free, not just containment. With a shell agent, the blast radius of a command is unknowable in principle: bash -c can touch an unbounded set of files, spawn children, open sockets, and the transcript records only the string that was executed. Here the same journal entry that grants the capability also delimits it:

{"type": "tool_call", "name": "wasmtime_exec",
 "arguments": {"path": "wasi_agent_run/sort_lines.wasm",
               "dirs": "wasi_agent_run::/work"}}

Read any trace, any time, and the answer to “what could this run have modified?” is a parseable field,. The capability and its audit record are one value — the model cannot obtain filesystem access without writing that access into the log, because the only channel to the host is the dirs argument it had to state out loud. Prompts can’t launder it (prompt injection can at worst get code inside the sandbox), and a supervisor can enforce a static policy on it (reject any dirs outside the task’s workspace) without understanding Rust.

The same closure applies one level up. The agent’s own file tools (write_file, str_replace) are the only other way bytes reach the disk, and their arguments are equally explicit in the journal. Combine the two and the full set of an agent turn’s possible side effects is exactly: the files named in write_file/str_replace calls, plus the directories named in dirs= — a finite, machine-readable list. For shell agents the equivalent statement doesn’t exist at any granularity.

That is the deepest argument for this architecture. Sandboxing usually trades away introspection — you can’t see inside a bubblewrap namespace, and the sandbox config drifts from what the agent actually did. Capability-based execution makes the sandbox self-describing: the grant is data, so the security boundary and the execution trace are the same artifact.

Third-party crates without cargo

The crates are just files. So the second iteration splits the two roles cargo conflates:

How does the crate get into the binary without cargo build? The same way anything does here: rustc compiles the whole thing. The program mounts the vendored modules directly:

#[path = "../deps/tinyjson/src/generator.rs"] mod generator;
#[path = "../deps/tinyjson/src/json_value.rs"] mod json_value;
#[path = "../deps/tinyjson/src/parser.rs"]     mod parser;

pub use json_value::JsonValue;

The #[path] attributes inline the crate’s source files as modules of the program, and the pub use re-exports keep the crate’s internal crate::… references resolving. One rustc invocation, one binary, no package manager in sight.

The live run is worth reading in full, because the model turned dependency selection into tool-driven reasoning:

▶ cargo_search(query='json parser minimal', limit=10)
  rjson 0.3.1 — A minimal json parser …
▶ cargo_install(crate='rjson')
  OK: vendored rjson 0.3.1 into deps/rjson/ (1 files)
▶ read_file(path='deps/rjson/src/lib.rs')
  #![no_std] | #![feature(alloc)] | #![feature(core_intrinsics)] …
▶ cargo_search(limit=5, query='tinyjson') → tinyjson 2.5.1 — zero deps, safe
▶ cargo_install(crate='tinyjson')
  OK: vendored tinyjson 2.5.1 into deps/tinyjson/ (4 files)

The agent installed rjson, read its source, saw #![feature(core_intrinsics)] (nightly-only), and went back to the index for a crate that compiles on stable — picking tinyjson for its zero-dependency, safe-Rust pitch. That is dependency vetting as a tool loop, not a Cargo.toml line the model copied from training data. It then read all four vendored files, wired them up with #[path], hit one borrow-checker error (cannot move out of index of JsonValue), fixed it with a .clone(), recompiled, and ran:

▶ compile_rust_to_wasm(deps='deps/tinyjson', path='crates_run/main.rs', …)
▶ wasmtime_exec(path='crates_run/main.wasm')
  name = wasm
  n (before) = 42
  serialized = {"n":43,"name":"wasm"}
  re-parsed n = 43

Parse, mutate, serialize — with a third-party crate, zero shells, and a journal in which “which crates did this run use” is literally the list of cargo_install calls.

The honest caveat: this works smoothly for small, dependency-free crates (tinyjson: 4 files, no deps). Anything with a build script, serde_derive proc-macros, or a transitive dependency tree still needs a real package manager, and the current answer is “pick a crate that doesn’t.” The system prompt nudges exactly that way. But the boundary it draws is the interesting part: the agent can acquire foreign code without gaining a single new execution capability — fetching is files, integration is #[path], and everything that runs still runs as wasm under fuel.

The Trace

The whole run is seven tool calls, textbook in shape:

1. write_file — the primes program

fn main() {
    let mut primes: Vec<u32> = Vec::new();
    let mut n: u32 = 2;
    while primes.len() < 10 {
        if primes.iter().all(|&p| n % p != 0) {
            primes.push(n);
        }
        n += 1;
    }
    for p in &primes {
        println!("{}", p);
    }
}

OK: wrote 275 bytes to wasmtest_agent/primes.rs · 135 completion tokens

2. compile_rust_to_wasm

OK: compiled wasmtest_agent/primes.rs -> wasmtest_agent/primes.wasm
   (1,753,265 bytes, target wasm32-wasip1)

3. wasmtime_exec

2 3 5 7 11 13 17 19 23 29

Note what the model didn’t do: no cargo new, no ls, no checking whether the target was installed. The system prompt says “write Rust, compile, run,” and glm-5.3 took the direct path. A 1.75 MB module for printing ten numbers is the price of std — fine for an agent loop, and -O keeps compile times short (both compiles came back in seconds).

4–7. Edit, recompile, rerun

str_replace appends the sum line:

    println!("sum: {}", primes.iter().sum::<u32>());

then compile → run → sum: 129. The model verified its own edit by executing the changed artifact, which is exactly the discipline this tool surface is designed to force: there is no cat to eyeball the file, only observable behaviour.

Final answer: Done. wasmtest_agent/primes.rs computes the first 10 primes (2..29), and after the edit also prints their sum (129). Both wasm builds ran successfully under wasmtime.