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:
compile_rust_to_wasm(path, out, deps)—rustc --target wasm32-wasip1 -O, returns compiler diagnostics on failurewasmtime_exec(path, args, dirs)— runs the.wasmunder the wasmtime CLI.
…plus two crates.io tools that never execute anything:
cargo_search(query)— read-only HTTPS query against the crates.io indexcargo_install(crate)— downloads the.cratetarball and vendors its source intodeps/<crate>/; no build, no~/.cargocache
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:
- No ambient authority. A wasm module gets exactly
the imports it’s granted. Our binaries get std I/O (so
println!works) and, when the agent asks for it, explicitly preopened directories — no sockets, nothing else. - Time and fuel boundable. Every execution runs under a wasmtime fuel budget — a hard, deterministic cap on executed wasm instructions. See below; this is the capability a shell harness cannot have.
- The compiler is the gate.
rustcreading a.rsfile and emitting.wasmnever runs the agent’s code.
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:
- The timeout fires too late or too early, never exactly. A busy machine makes a healthy build miss the deadline; an idle one lets a pathological loop spin for the full budget. The same program passes on an empty laptop and fails under load.
- It races against side effects. When the wall-clock fires, the kill lands wherever the code happens to be — mid-write, between two file updates, halfway through a loop over the workspace. The timeout cannot be made atomic, so it converts “slow” into “slow and possibly corrupt.”
- It measures the wrong thing. The dangerous property of agent-generated code is unbounded work — infinite loops, accidental exponential searches. Wall-clock time is a noisy proxy for that.
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:
cargo_search(query)asks the crates.io index API over HTTPS and returns name, version, description. Read-only by construction: nothing is downloaded, nothing runs.cargo_install(crate)downloads the.cratetarball (a plain gzipped tar), extracts onlysrc/andCargo.toml, and vendors it underdeps/<crate>/in the working directory. No build, no~/.cargo, no global state — the “install” is a file operation, visible toread_fileand bounded by the same journal as every other side effect.
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.rscomputes the first 10 primes (2..29), and after the edit also prints their sum (129). Both wasm builds ran successfully under wasmtime.