Les agents IA modernes écrivent du code, le compilent et l’exécutent — c’est ce qui leur donne leur autonomie. C’est aussi ce qui en fait la surface d’attaque la plus dangereuse d’une infrastructure : un prompt mal contrôlé, une exfiltration de clé d’API, un script qui balayent /home, et l’opérateur découvre un incident après coup. Le modèle de capabilités de WebAssembly + WASI 0.2 corrige cela structurellement : un composant exécuté dans Wasmtime ne dispose par défaut d’aucun fichier, d’aucun socket, d’aucune horloge, et l’on accorde explicitement ce qui est nécessaire.
Ce tutoriel construit pas-à-pas un runner Rust qui exécute du code utilisateur dans une cellule Wasmtime avec capabilités strictes : système de fichiers en lecture-écriture limité à un dossier de scratch, pas de réseau, horloge fixée, limite mémoire dure, timeout via epoch interruption. Le résultat est un sous-système réutilisable, déployable en production, qui transforme l’exécution de code généré en opération contrôlable. Pour le contexte plus large, on se référera au guide opérationnel sur la sandbox d’agent IA et au guide principal sur WebAssembly en production.
Étape 1 — Définir le modèle de menace
Avant d’écrire la moindre ligne, on cartographie ce que la sandbox doit empêcher. Quatre familles de risques dominent quand on exécute du code provenant d’un LLM ou d’un utilisateur tiers.
Évasion vers le système hôte. Le code peut tenter de lire /etc/passwd, ~/.ssh/id_rsa, des variables d’environnement contenant des tokens. WebAssembly empêche structurellement les syscalls non câblés : il n’y a pas d’API open qui contourne l’hôte.
Exfiltration réseau. Le code peut tenter de POSTer des données vers un serveur attaquant. Sans wasi-sockets ou wasi-http outgoing accordé, aucune connexion sortante n’est possible.
Déni de service. Le code peut allouer toute la mémoire ou boucler à l’infini. Les limites de mémoire et l’interruption par epoch tranchent cela.
Persistance et chaînage. Le code peut écrire un fichier qu’un autre script lira plus tard pour s’auto-déclencher. On compense en utilisant des préopens en lecture seule ou des dossiers de scratch jetés après chaque exécution.
Étape 2 — Préparer le projet hôte Rust
On crée un binaire Rust qui embarque Wasmtime et charge un composant utilisateur. Les versions sont alignées : wasmtime et wasmtime-wasi doivent avoir la même version mineure pour rester compatibles.
cargo new --bin agent-sandbox
cd agent-sandbox
cargo add wasmtime@44 wasmtime-wasi@44 anyhow uuid sha2 hex
cargo add tokio --features rt-multi-thread,macros
cargo add uuid --features v4
On édite Cargo.toml pour activer le profil release optimisé, ce qui réduit la consommation mémoire de l’hôte lui-même.
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
Étape 3 — Activer epoch interruption dans l’engine
L’epoch interruption est la voie recommandée par Wasmtime pour interrompre du code utilisateur : elle ajoute environ 10 % de surcoût d’exécution et reste 2 à 3 fois plus rapide que le fuel sur les charges réelles (le fuel impose un compteur incrémenté à chaque instruction WebAssembly, l’epoch n’observe qu’un compteur global rarement modifié). On active la fonctionnalité au niveau de l’Engine.
// src/main.rs
use anyhow::Result;
use wasmtime::component::{Component, Linker, ResourceTable};
use wasmtime::{Config, Engine, Store};
use wasmtime_wasi::p2::{DirPerms, FilePerms, WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
struct AppState { ctx: WasiCtx, table: ResourceTable, limits: StoreLimits }
impl WasiView for AppState {
fn ctx(&mut self) -> WasiCtxView<'_> {
WasiCtxView { ctx: &mut self.ctx, table: &mut self.table }
}
}
fn make_engine() -> Result<Engine> {
let mut cfg = Config::new();
cfg.wasm_component_model(true)
.epoch_interruption(true)
.consume_fuel(false);
Ok(Engine::new(&cfg)?)
}
L’engine porte le compteur global d’epoch. Un thread d’arrière-plan l’incrémente à intervalle régulier, et chaque Store définit une deadline relative à ce compteur. Quand la deadline est atteinte, le code utilisateur trap proprement, sans tuer l’hôte.
Étape 4 — Configurer le contexte WASI avec capabilités strictes
Le coeur de la sandbox tient dans la construction du WasiCtx. On y déclare exhaustivement ce que le composant peut faire — chaque omission est une interdiction de fait.
use wasmtime_wasi::p2::pipe::MemoryOutputPipe;
fn make_wasi(scratch_dir: &str) -> Result<(WasiCtx, MemoryOutputPipe, MemoryOutputPipe)> {
let dir = std::fs::canonicalize(scratch_dir)?;
std::fs::create_dir_all(&dir)?;
let stdout = MemoryOutputPipe::new(64 * 1024);
let stderr = MemoryOutputPipe::new(64 * 1024);
let ctx = WasiCtxBuilder::new()
.preopened_dir(&dir, "/scratch", DirPerms::all(), FilePerms::all())?
.env("RUN_ID", &uuid::Uuid::new_v4().to_string())
// Pas d'inherit_env() : aucune variable de l'hôte n'est exposée.
// Pas d'inherit_network() : aucun socket sortant.
.stdout(stdout.clone())
.stderr(stderr.clone())
.build();
Ok((ctx, stdout, stderr))
}
Le préopen /scratch est l’unique surface filesystem visible côté composant. DirPerms::all() autorise lecture, écriture et liste à l’intérieur ; FilePerms::all() couvre lecture-écriture des fichiers. On peut restreindre à DirPerms::READ pour un dossier de configuration en lecture seule.
L’absence de inherit_env() est essentielle : le composant ne voit aucune variable de l’hôte. Si l’opérateur lance le binaire avec AWS_SECRET_ACCESS_KEY dans l’environnement, le code agent n’a aucun moyen de la lire — il n’existe pas pour lui.
Étape 5 — Limites de mémoire et fuel comme deuxième ceinture
Même avec un dossier scratch et pas de réseau, un script peut tenter d’allouer 8 Go ou de boucler. On configure deux limites quantitatives.
use wasmtime::{StoreLimits, StoreLimitsBuilder};
fn make_store(engine: &Engine, ctx: WasiCtx, max_memory_mb: usize) -> Store<AppState> {
let limits = StoreLimitsBuilder::new()
.memory_size(max_memory_mb * 1024 * 1024)
.instances(1)
.tables(1)
.build();
let state = AppState { ctx, table: ResourceTable::new(), limits };
let mut store = Store::new(engine, state);
store.limiter(|s| &mut s.limits);
store.set_epoch_deadline(1); // trap au prochain tick d'epoch dépassé
store
}
La limite mémoire plafonne la linear memory du composant : toute tentative d’allocation au-delà retourne une erreur dans le code utilisateur (qui peut paniquer ou s’arrêter proprement). La limite instances empêche un composant malveillant d’instancier des centaines de sous-modules.
Étape 6 — Lancer un tick d’epoch en arrière-plan
Pour que la deadline ait du sens, on incrémente l’epoch régulièrement. Un thread dédié suffit ; on choisit une fréquence raisonnable (typiquement 100 ms à 1 seconde) selon la granularité d’interruption voulue.
use std::sync::Arc;
use std::time::Duration;
fn spawn_epoch_ticker(engine: Arc<Engine>, tick: Duration) {
std::thread::spawn(move || loop {
std::thread::sleep(tick);
engine.increment_epoch();
});
}
Avec un tick de 100 ms et set_epoch_deadline(50), le composant a 5 secondes de temps d’exécution maximum (50 ticks × 100 ms). Au-delà, Wasmtime trap proprement et la fonction call_run retourne une erreur que l’hôte capture.
Étape 7 — Exécuter une tâche utilisateur et capturer la sortie
On enchaîne tous les morceaux : moteur, ticker, store par tâche, capture des flux. Pour chaque exécution, on instancie un Store neuf — c’est l’unité d’isolation. Aucun état n’est partagé entre deux exécutions différentes.
use std::sync::Arc;
use wasmtime_wasi::p2::pipe::MemoryOutputPipe;
async fn run_user_task(engine: Arc<Engine>, wasm_path: &str, scratch: &str)
-> Result<(String, String)>
{
let component = Component::from_file(&engine, wasm_path)?;
let mut linker = Linker::<AppState>::new(&engine);
wasmtime_wasi::p2::add_to_linker_sync(&mut linker)?;
let (ctx, stdout, stderr) = make_wasi(scratch)?;
let mut store = make_store(&engine, ctx, /*max_memory_mb=*/64);
let cmd = wasmtime_wasi::p2::bindings::sync::Command::instantiate(
&mut store, &component, &linker)?;
let outcome = cmd.wasi_cli_run().call_run(&mut store);
let out = String::from_utf8_lossy(&stdout.contents()).to_string();
let err = String::from_utf8_lossy(&stderr.contents()).to_string();
match outcome {
Ok(Ok(())) => Ok((out, err)),
Ok(Err(())) => Err(anyhow::anyhow!("task exited with error: {}", err)),
Err(e) => Err(anyhow::anyhow!("trap: {} | stderr: {}", e, err)),
}
}
Quand le composant termine normalement, on récupère stdout et stderr pour les remonter au système qui a planifié la tâche (par exemple un MCP server qui orchestre l’agent). En cas de trap (timeout, mémoire excédée, panic du composant), la fonction renvoie une erreur structurée — pas de crash de l’hôte.
Étape 8 — Audit log immuable des exécutions
Pour pouvoir investiguer un incident a posteriori, chaque exécution est journalisée avec un identifiant unique et l’empreinte SHA-256 du module exécuté. On écrit le log dans un append-only file ou un service externe (Loki, Cloudflare Logs).
use sha2::{Digest, Sha256};
fn module_fingerprint(path: &str) -> Result<String> {
let bytes = std::fs::read(path)?;
let mut hasher = Sha256::new();
hasher.update(&bytes);
Ok(hex::encode(hasher.finalize()))
}
Le log structuré contient : timestamp, run_id, hash du module, taille mémoire pic, durée, code de sortie, premières lignes de stdout/stderr. Cela suffit à reconstruire la chaîne d’exécution si un comportement suspect est détecté par une supervision en aval.
Étape 9 — Surface de capabilités élargies, avec parcimonie
Le scénario minimal (filesystem + epoch + mémoire) couvre la majorité des cas. Pour des agents qui doivent appeler une API externe, on étend prudemment.
Outgoing HTTP via wasi-http. On peut câbler un HttpClient qui ne route que vers une whitelist de domaines. La crate wasmtime-wasi-http expose l’interface ; côté hôte, on intercepte chaque requête et l’on bloque celles hors liste.
Variables d’environnement explicites. Si l’agent doit voir une clé d’API, on la lui passe via env() de manière nominative — pas par inherit_env(). Le contenu reste opaque dans les logs hôte si l’on prend soin de filtrer.
Multiples préopens en lecture seule. Un dossier /config en DirPerms::READ et un dossier /output en écriture séparent la configuration de l’écriture, ce qui empêche le composant de réécrire sa propre config.
Pour comparer cette approche à d’autres stratégies d’isolation (containers, gVisor, microVMs), le guide opérationnel Sandboxer un agent IA en production et la lecture Cybersécurité agentique : SOC IA et triage SIEM donnent l’arbitrage architectural.
Étape 9bis — Signer et vérifier les modules avant exécution
Une sandbox bien configurée n’empêche pas de charger un mauvais composant. Si l’attaquant prend la main sur le pipeline de build, il peut substituer le binaire avant qu’il n’arrive sur l’hôte. La parade consiste à signer les modules à la sortie du build et à vérifier la signature avant l’instanciation.
La voie standard est Sigstore (cosign), qui signe avec une clé éphémère générée à la volée et publie l’empreinte dans un journal transparent (Rekor). Côté hôte Rust, on vérifie la signature avant Component::from_file. La vérification ajoute quelques millisecondes mais bloque toute injection de binaire non autorisé en amont du chargement.
// pseudo-flux
let expected = "sha256:abc123..."; // empreinte attendue, distribuée par canal séparé
let got = module_fingerprint(wasm_path)?;
if got != expected { return Err(anyhow::anyhow!("module hash mismatch")); }
let component = Component::from_file(&engine, wasm_path)?;
Pour un déploiement multi-tenants, on associe chaque tenant à une empreinte ou à une clé publique distincte. Toute exécution est traçable au tenant qui a fourni le binaire, ce qui complique les attaques par substitution.
Étape 9ter — Limiter le nombre d’exécutions concurrentes
Sur un hôte qui sert plusieurs agents, on plafonne aussi le parallélisme. Un sémaphore Tokio (tokio::sync::Semaphore) à N permits limite les tâches concurrentes ; les agents au-delà attendent dans la file. Cette discipline empêche un agent gourmand de bloquer les autres.
On accompagne le tout d’un dashboard simple : nombre de tâches en cours, durée médiane, p95, taux de trap pour timeout vs mémoire. Ce sont les indicateurs qui révèlent un changement de comportement avant qu’il ne devienne incident. Pour des architectures plus avancées (file de tâches durable, retries, scheduling à priorité), on s’appuie sur un orchestrateur dédié — Temporal, Inngest ou un MCP server custom — qui pilote la sandbox plutôt que de l’invoquer en direct.
Étape 10 — Erreurs fréquentes
| Symptôme | Cause | Résolution |
|---|---|---|
| Composant trap immédiatement | set_epoch_deadline non appelé |
Toujours appeler avant call_run |
| Pas d’interruption malgré timeout | Ticker absent ou trop lent | Vérifier spawn_epoch_ticker actif |
| Composant lit hors scratch | Préopen surdimensionné | Vérifier le chemin de preopened_dir |
| Capture stdout vide | inherit_stdio() activé par erreur |
Remplacer par MemoryOutputPipe |
| Erreur réseau côté composant | Aucun outbound câblé (normal) | Ajouter HttpClient whitelist si besoin |
| Mémoire excédée non détectée | store.limiter non posé |
Appliquer StoreLimitsBuilder |
L’opérateur qui pilote la sandbox a une responsabilité opérationnelle : trier les alertes générées par la supervision en aval, garder à jour la version de Wasmtime utilisée (les advisories d’avril 2026 sur les LTS rappellent que le runtime est lui-même audité comme un binaire critique), et tester périodiquement les capabilités en jouant un mini scénario d’attaque (un composant qui tente d’écrire hors scratch, de boucler à l’infini, d’allouer 8 Go) pour vérifier que chaque limite déclenche bien le trap attendu.
Aller plus loin
Cette sandbox isole un composant compilé. Pour exécuter du code Python ou JavaScript généré par l’agent, on compile l’interpréteur lui-même en composant (componentize-py pour CPython, ComponentizeJS qui embarque le moteur StarlingMonkey pour JavaScript) puis on le charge dans la même cellule. Le tutoriel WASI et serveurs WebAssembly avec Wasmtime couvre la chaîne de build composant. Pour distribuer la sandbox à l’edge, le tutoriel Déployer un module WebAssembly Rust sur Cloudflare Workers permet de combiner exécution distribuée et capabilités strictes. Le guide principal sur WebAssembly en production récapitule les choix d’architecture.
Ressources officielles
- docs.wasmtime.dev — Interrupting Execution — epoch et fuel
- docs.wasmtime.dev — Config — options de l’engine
- docs.wasmtime.dev — wasmtime_wasi — WasiCtx et capabilités
- github.com/bytecodealliance/wasmtime — crates/wasi — code source de référence
- component-model.bytecodealliance.org — modèle de composants WebAssembly
- github.com/bytecodealliance/componentize-py — exécuter du Python dans un composant
- wasi.dev/interfaces — interfaces WASI normalisées
WebAssembly transforme l’exécution de code utilisateur ou généré d’un problème de confiance en un problème de configuration explicite. Les huit étapes ci-dessus suffisent à mettre en place une cellule qui isole structurellement le filesystem, le réseau, la mémoire et le temps CPU. C’est l’investissement le plus rentable que l’on puisse faire quand on déploie des agents IA en production.