The Capability Model: From ACLs to Object Capabilities

Traditional operating systems use Access Control Lists. A process has a user ID, and the kernel checks that user ID against file permissions. The problem is ambient authority, a process can attempt to access any resource, and the kernel decides yes or no after the fact. This means a compromised process inherits all the permissions of its user.

Capability-based security inverts this. Instead of checking "who are you and what are you allowed to do?", the runtime says "here are handles to exactly the resources you can use, and nothing else exists." If you don't have a handle to the filesystem, you can't even attempt to open a file. There's no open("/etc/passwd") that returns "permission denied", the function open simply doesn't exist in your universe.

WASI Preview 2 implements this through the Component Model and WIT (WebAssembly Interface Types):

// This is a WIT world definition, it defines what a plugin can do
world data-transformer {
 // What the plugin receives from the host
 import wasi:io/streams@0.2.1;
 import wasi:filesystem/types@0.2.1;
 import wasi:filesystem/preopens@0.2.1;
 import wasi:logging/logging@0.1.0;

 // What the plugin must implement
 export transform: func(input: list<u8>) -> result<list<u8>, string>;
}

A component compiled against this world literally cannot make HTTP requests, because wasi:http isn't in its imports. It can't access the clock, can't spawn threads, can't open sockets. The Wasm runtime enforces this at link time, if a component tries to import an interface that isn't in its world, instantiation fails before a single instruction executes.

Filesystem Sandboxing: Preopens and Directory Handles

WASI's filesystem model deserves special attention because it's where most plugin systems fail. In WASI Preview 2, a component doesn't get access to a filesystem, it gets handles to specific directories that the host "preopens":

// Host-side: configuring what the plugin can see
use wasmtime::component::*;
use wasmtime_wasi::{WasiCtxBuilder, DirPerms, FilePerms};

let mut wasi_ctx = WasiCtxBuilder::new();

// Plugin gets read-only access to /data/input
wasi_ctx.preopened_dir(
 "/data/input",
 "input",
 DirPerms::READ,
 FilePerms::READ,
)?;

// Plugin gets read-write access to /data/output
wasi_ctx.preopened_dir(
 "/data/output",
 "output",
 DirPerms::all(),
 FilePerms::all(),
)?;

// That's it. No other filesystem access exists.

Inside the plugin, the filesystem looks like this:

// Plugin-side: this is the entire visible filesystem
// "input/" → maps to /data/input on the host (read-only)
// "output/" → maps to /data/output on the host (read-write)

fn transform(data: Vec<u8>) -> Result<Vec<u8>, String> {
 // This works, "input" is a preopened directory
 let config = std::fs::read_to_string("input/config.json")
 .map_err(|e| e.to_string())?;

 // This works, "output" is preopened with write access
 std::fs::write("output/result.csv", &processed_data)
 .map_err(|e| e.to_string())?;

 // This would fail at runtime, no preopen for "/"
 // std::fs::read_to_string("/etc/passwd") // ERROR: no such preopen

 // This would fail, no preopen for ".."
 // std::fs::read_to_string("../../../etc/shadow") // ERROR: path escapes preopen

 Ok(processed_data)
}

Path traversal attacks (../../../etc/passwd) are structurally impossible because the runtime resolves paths relative to preopens and rejects anything that escapes. This isn't a string filter on paths, it's enforced at the directory handle level.

Network Capabilities: wasi:http

For plugins that need to make outbound HTTP requests (fetching exchange rates, calling APIs, etc.), WASI Preview 2 provides wasi:http/outgoing-handler. The critical design decision is that the host controls what the plugin can connect to:

// Host-side: creating an HTTP capability with domain allowlist
use wasmtime_wasi_http::WasiHttpCtx;

struct PluginHttpHandler {
 allowed_domains: HashSet<String>,
}

impl PluginHttpHandler {
 fn handle_request(&self, request: OutgoingRequest) -> Result<IncomingResponse> {
 let authority = request.authority()
 .ok_or_else(|| anyhow!("missing authority"))?;

 // Enforce domain allowlist
 if !self.allowed_domains.contains(&authority) {
 return Err(anyhow!(
 "plugin attempted to connect to unauthorized domain: {}",
 authority
 ));
 }

 // Forward the request to the actual HTTP client
 self.inner_client.send(request)
 }
}

// Plugin only gets access to specific domains
let http_handler = PluginHttpHandler {
 allowed_domains: HashSet::from([
 "api.exchangerate.host".into(),
 "data.provider.com".into(),
 ]),
};

From the plugin's perspective, it just makes HTTP requests. It doesn't know about the allowlist, a request to an unauthorized domain simply fails:

// Plugin-side: making HTTP requests
use wasi::http::outgoing_handler;

fn fetch_exchange_rate(currency: &str) -> Result<f64, String> {
 let request = OutgoingRequest::new(
 Method::Get,
 Some("api.exchangerate.host"),
 Some(&format!("/latest?base={}", currency)),
 Some(Scheme::Https),
 );

 let response = outgoing_handler::handle(request, None)
 .map_err(|e| format!("HTTP request failed: {:?}", e))?;

 // Parse response...
 Ok(rate)
}

Building a Secure Plugin Runtime

The following architecture demonstrates a production-grade approach. The key insight is that each plugin invocation gets a fresh component instance with a tailored capability set:

use wasmtime::*;
use wasmtime::component::*;
use wasmtime_wasi::*;

struct PluginRuntime {
 engine: Engine,
 linker: Linker<PluginState>,
 plugin_components: HashMap<String, Component>,
}

impl PluginRuntime {
 fn execute_plugin(
 &self,
 plugin_id: &str,
 tenant_id: &str,
 input: &[u8],
 ) -> Result<Vec<u8>> {
 let component = self.plugin_components.get(plugin_id)
 .ok_or_else(|| anyhow!("unknown plugin"))?;

 // Each invocation gets isolated state
 let mut store = Store::new(&self.engine, PluginState {
 wasi: self.build_wasi_ctx(tenant_id)?,
 fuel_remaining: 1_000_000_000, // CPU budget
 memory_limit: 256 * 1024 * 1024, // 256MB
 });

 // Fuel metering prevents infinite loops
 store.set_fuel(1_000_000_000)?;

 let instance = self.linker.instantiate(&mut store, component)?;

 // Call the exported transform function
 let transform = instance.get_typed_func::<(Vec<u8>,), (Result<Vec<u8>, String>,)>(
 &mut store, "transform"
 )?;

 let (result,) = transform.call(&mut store, (input.to_vec(),))?;
 result.map_err(|e| anyhow!("plugin error: {}", e))
 }

 fn build_wasi_ctx(&self, tenant_id: &str) -> Result<WasiCtx> {
 let mut builder = WasiCtxBuilder::new();

 // Tenant-isolated filesystem
 builder.preopened_dir(
 &format!("/data/tenants/{}/input", tenant_id),
 "input",
 DirPerms::READ,
 FilePerms::READ,
 )?;

 builder.preopened_dir(
 &format!("/data/tenants/{}/output", tenant_id),
 "output",
 DirPerms::all(),
 FilePerms::all(),
 )?;

 // No network, no clock, no env vars, no args
 // stdout/stderr captured for logging
 builder.stdout(LogCapture::new(tenant_id));
 builder.stderr(LogCapture::new(tenant_id));

 Ok(builder.build())
 }
}

Component Composition: Virtualizing Capabilities

One of Preview 2's most powerful features is component composition. You can interpose a component between the plugin and the host to transform or restrict capabilities without modifying either:

// A logging virtualization component that redacts sensitive data
world logging-filter {
 import wasi:logging/logging@0.1.0;
 export wasi:logging/logging@0.1.0;
}
// The filter component intercepts all log calls
impl wasi::logging::Logging for LogFilter {
 fn log(level: Level, context: &str, message: &str) {
 // Redact anything that looks like a credit card number
 let redacted = CREDIT_CARD_REGEX.replace_all(message, "[REDACTED]");
 // Forward to the real logging implementation
 wasi::logging::log(level, context, &redacted);
 }
}

You compose these at link time:

# Compose plugin with logging filter
wasm-tools compose plugin.wasm \
 --definitions logging-filter.wasm \
 -o composed-plugin.wasm

The resulting component has identical behavior from the plugin's perspective, but all logging goes through the filter. The plugin can't bypass it because the composition is enforced structurally.

Performance Reality Check

Overhead is a common concern. Here are real numbers from a production system:

Plugin instantiation: ~50μs (component model, Wasmtime 28)
Simple transform (1KB input): ~120μs total execution
Complex transform (1MB input): ~8ms total execution
Memory overhead per instance: ~2MB baseline + plugin allocation

For comparison:
Docker container cold start: ~200ms-2s
V8 isolate creation: ~5ms
Lua state creation: ~100μs (but no memory safety)

The 50-microsecond instantiation means a fresh, fully-isolated plugin instance can be created per request without batching. That eliminates an entire class of state-leakage bugs between requests.

Lessons From Production Deployments

Several lessons emerge from extended production usage:

Fuel metering is essential. Without it, a plugin with an accidental infinite loop blocks a worker thread forever. Setting fuel limits based on the expected complexity of each plugin type and killing instances that exhaust their budget is critical.

Log capture prevents information leakage. Plugins write to stdout/stderr thinking they're debugging, but in a multi-tenant system, those logs need to go to the right tenant's log stream. Capturing stdout via the WASI context handles this cleanly.

Pre-compile components. Wasmtime supports ahead-of-time compilation with wasmtime compile. A pre-compiled component loads in ~5μs instead of ~50μs. For hot-path plugins, this matters.

# Pre-compile for production deployment
wasmtime compile --target x86_64-unknown-linux plugin.wasm -o plugin.cwasm

Version WIT interfaces. This lesson becomes apparent when a WIT interface update breaks plugins that were compiled against the old version. The component model's version matching prevents silent breakage, but you still need to maintain backward-compatible interfaces.

The capability-based security model in WASI Preview 2 is not merely a theoretical property, it is a practical engineering tool that eliminates entire categories of security bugs. When a plugin system literally cannot access resources it was not granted, the security posture improves dramatically. Compared to building sandboxes from cgroups and seccomp filters alone, the reduction in attack surface is substantial.