Skip to main content

Build an AI Sandbox from Scratch: Hello World in Rust with wash

Walkthroughs 1 and 2 used the app. This one doesn't. You'll build the same kind of sandboxed workload by hand—a Rust HTTP component, from an empty directory to a running server—using nothing but wash, the wasmCloud shell.

The point isn't the hello world. It's that halfway through, you'll run one command that prints the component's complete capability surface, and then watch that surface change when you add three words of code. That list is what an AI sandbox is actually made of. Everything the app does on top—the review step, the deny-by-default egress, the digest pinning—is policy layered over this one property.

What you'll need

  • wash 2.6.1 or later. Install it from the wasmCloud installation guide; check with wash --version.

  • A Rust toolchain, plus the WebAssembly target:

    rustup target add wasm32-wasip2
  • Cosmonic Desktop, for the last step only. Steps 1–7 are pure wash.

wash is the CNCF wasmCloud project's CLI. It's independent of Cosmonic Desktop—the components it builds are plain WebAssembly components, which is exactly why the boundary you'll see here is the same one Desktop enforces.

1. Install wash and the Rust wasm target

Confirm both are in place before you start:

wash --version
rustup target list --installed | grep wasm32-wasip2

2. Scaffold the project

wash new clones a template out of a git repository. The wasmCloud repo ships the HTTP hello world:

wash new https://github.com/wasmCloud/wasmCloud.git \
  --name hello-world \
  --subfolder templates/http-hello-world
cd hello-world

You get a small, complete project:

hello-world/
├── .wash/
│   └── config.yaml          # how wash builds this project
├── manifests/
│   └── workloaddeployment.yaml
├── src/
│   └── lib.rs               # the handler
├── wit/
│   └── world.wit            # the component's interface contract
├── Cargo.toml
├── Cargo.lock
└── wkg.lock                 # pinned WIT dependencies

3. Read the code

Three files matter. Start with src/lib.rs:

use wstd::http::{Body, Request, Response, StatusCode};

#[wstd::http_server]
async fn main(req: Request<Body>) -> Result<Response<Body>, wstd::http::Error> {
    match req.uri().path() {
        "/" => home(req).await,
        _ => not_found(req).await,
    }
}

async fn home(_req: Request<Body>) -> Result<Response<Body>, wstd::http::Error> {
    Ok(Response::new("Hello from wasmCloud!\n".into()))
}

Ordinary async Rust. No runtime to configure, no server to bind. The #[wstd::http_server] macro exports wasi:http/incoming-handler, which is the interface that makes this thing callable over HTTP.

wit/world.wit is the interface contract, and it's nearly empty:

package wasmcloud:hello;

world hello {
}

Empty because the macro handles the export. When you need a capability the macro doesn't provide—key-value storage, logging, outbound HTTP—you declare it as an import here, and that declaration is what the runtime grants against.

.wash/config.yaml is the whole build configuration:

build:
  command: cargo build --target wasm32-wasip2 --release
  component_path: target/wasm32-wasip2/release/hello_world.wasm

wash shells out to cargo. There's no hidden build system.

4. Build the component

wash build
    Finished `release` profile [optimized] target(s) in 5.64s
Successfully built component at: …/target/wasm32-wasip2/release/hello_world.wasm

That .wasm file is around 350 KB and it is the entire deployable artifact—no base image, no interpreter, no runtime bundled alongside it. It's also the unit the sandbox is enforced against, which is what makes the next step possible.

5. Inspect the sandbox boundary

This is the step the whole walkthrough exists for:

wash inspect target/wasm32-wasip2/release/hello_world.wasm
package root:component;

world root {
  import wasi:io/poll@0.2.9;
  import wasi:clocks/monotonic-clock@0.2.9;
  import wasi:io/error@0.2.9;
  import wasi:io/streams@0.2.9;
  import wasi:cli/stdout@0.2.9;
  import wasi:cli/stderr@0.2.9;
  import wasi:cli/stdin@0.2.9;
  import wasi:http/types@0.2.9;
  import wasi:cli/environment@0.2.9;
  import wasi:cli/exit@0.2.9;
  import wasi:cli/terminal-input@0.2.9;
  import wasi:cli/terminal-output@0.2.9;

  import wasi:random/insecure-seed@0.2.9;

  export wasi:http/incoming-handler@0.2.9;
}

Read what is not there.

There is no wasi:filesystem. There is no wasi:sockets. This component cannot open a file or a socket—not because a policy forbids it, not because a seccomp filter intercepts the syscall, but because the machinery to express the request doesn't exist inside the binary. A WebAssembly component has no ambient authority: it can only call functions its host explicitly supplied, and it can only receive functions it declared as imports.

That's the difference from a container. A container starts with a filesystem, a network stack, and a process tree, and hardening means subtracting from that set—which is why a missed --cap-drop or a permissive seccomp profile is a real vulnerability class. A component starts with an empty set and every element you see above was added deliberately. There is no equivalent of "forgot to drop a capability," because nothing was granted to begin with.

The one-line version: the import list is the sandbox. And you didn't write it—the compiler derived it from the code.

6. Watch the boundary change

Don't take the previous step's word for it. Make the code reach for something and watch the manifest move.

Edit src/lib.rs so home touches the filesystem:

async fn home(_req: Request<Body>) -> Result<Response<Body>, wstd::http::Error> {
    let who = std::fs::read_to_string("/etc/hostname").unwrap_or_else(|_| "nobody".into());
    Ok(Response::new(format!("Hello from {who}\n").into()))
}

Rebuild and inspect again:

wash build && wash inspect target/wasm32-wasip2/release/hello_world.wasm

Two imports you didn't have before:

  import wasi:filesystem/types@0.2.9;
  import wasi:filesystem/preopens@0.2.9;

Nothing was declared. No manifest was edited. The capability appeared in the artifact the moment the code needed it, because std::fs on wasm32-wasip2 compiles down to WASI calls that must be imported by name.

This is why the capability list on a workload spec is worth trusting in a way a hand-written permissions file isn't. It can't drift from the code, and it can't be understated by an author—or by an agent—who'd rather not mention what their component does. A reviewer reading that list is reading the binary, not a claim about it.

Try it with egress

Swap the filesystem read for an outbound HTTP call and wasi:http/outgoing-handler appears the same way. That import is precisely what Cosmonic Desktop's deny-by-default allowedHosts list governs—a component without it cannot make an outbound request at all, and a component with it can only reach the hosts you named.

Revert the change before continuing:

git checkout src/lib.rs   # or undo the edit by hand

7. Run it

wash dev builds the component, starts a host, serves it, and rebuilds on every file change:

wash dev
INFO HTTP server listening addr=0.0.0.0:8000 protocol="HTTP"
INFO building component path="…/hello-world"
INFO listening for HTTP requests address=http://127.0.0.1:8000

From another terminal:

curl localhost:8000
Hello from wasmCloud!

Note what the host logs on startup: it lists the interfaces it provides, including wasi:filesystem and wasi:sockets. The host offers them; your component never asked for them, so it never gets them. Supply and demand are negotiated per component, which is what lets a single host run mutually untrusting workloads side by side.

Edit src/lib.rs while wash dev is running and it rebuilds in about a second. Stop it with Ctrl+C.

8. Promote it to a real sandbox

wash dev is a development loop—it stops when you close the terminal. To get a durable, digest-pinned workload with reviewable policy, hand the component to Cosmonic Desktop.

Push it to any OCI registry you can write to:

wash oci push ghcr.io/<your-org>/hello-world:0.1.0 \
  target/wasm32-wasip2/release/hello_world.wasm

Then take it through the same flow as walkthrough 1: choose Start workload in Desktop, paste the reference, and review the draft. Desktop resolves the tag to an immutable digest, infers the capabilities from the very import list you inspected in step 5, and starts allowedHosts empty.

For a fully offline path, wash can write an OCI image-layout directory that cosmonicd oci import <path> loads straight into the local content-addressed cache—no registry involved. See the daemon's oci commands.

The manifests/workloaddeployment.yaml in the template is the Kubernetes-flavored sibling of that spec: the same runtime.wasmcloud.dev/v1alpha1 API Cosmonic Control serves on a cluster. One artifact, one capability boundary, three places to run it.

What to take away

  • A WebAssembly component's imports are its capability surface, and the compiler produces them from the code. Sandboxing is a property of the artifact, not a configuration you apply to it.
  • Because that surface is derived rather than declared, it's honest about code you didn't write—whether it came from a dependency, a registry, or a language model.
  • The platform's job isn't to create the boundary. It's to pin the artifact, make the boundary reviewable before the code starts, and enforce the policy you attach to it.

Frequently asked questions

How do you build a sandbox for AI-generated code from scratch?

Compile the code to a WebAssembly component and run it on a component-model runtime. The component can only call functions it declared as imports, so the compiler produces an exhaustive list of what the code can reach — visible with wash inspect. You then grant capabilities against that list. There is no ambient filesystem, network, or process access to take away, because none was granted.

What is wash?

wash is the wasmCloud shell, the CLI for the CNCF wasmCloud project. It scaffolds, builds, inspects, runs, and publishes WebAssembly components. Version 2.6.1 is the current release. It is independent of Cosmonic Desktop: components built with wash run anywhere the component model runs.

Why is WebAssembly a better sandbox than a container for untrusted code?

Default posture and granularity. A container starts with a filesystem, network namespace, and process tree, and is hardened by removing privileges — so a missed capability drop is a vulnerability. A WebAssembly component starts with nothing and receives only the interfaces it imported, which the compiler derives from the code. Components also start in milliseconds and idle near zero, so a per-request or per-task sandbox is practical in a way a per-request container is not.

How big is a WebAssembly component compared with a container image?

The Rust HTTP hello world in this walkthrough compiles to roughly 350 KB — the complete deployable artifact, with no base image, interpreter, or bundled runtime. An equivalent container image is typically tens to hundreds of megabytes because it carries a userland the component does not need.

Can I use a language other than Rust?

Yes. The wasmCloud repository ships HTTP starter templates for Go (TinyGo, wasip2) and TypeScript (jco componentize) alongside the Rust one, and Cosmonic Desktop scaffolds from the same three. The capability boundary works identically in every language, because it is a property of the compiled component rather than of the source language.

Does wash dev sandbox my component?

Yes, in the same structural sense: wash dev runs the component on a wasmCloud host and it can still only use the interfaces it imported. What wash dev does not give you is the policy layer — digest pinning, a deny-by-default allowedHosts list, signature verification, and a reviewable spec. That is what deploying the component as a Cosmonic Desktop workload adds on top.

Next steps