Sandbox pour agent IA : exécuter du code en toute sécurité
1. Aperçu
De plus en plus d’applications permettent aux agents IA d’écrire eux-mêmes du code et de l’exécuter directement. Les agents utilisent cela pour analyser les données, faire des calculs ou manipuler des fichiers. Le problème est que ce code est généré par un modèle de langage, et vous ne contrôlez pas directement son contenu.
Cet article explique le concept de sandbox pour les agents et pourquoi ce sujet attire l’attention. L’article compare aussi les niveaux d’isolation courants. La partie principale consiste à construire un DockerSandbox avec Java 21 et Spring Boot 3.x, accompagné de tests qui valident les limites de sécurité.
2. Pourquoi un agent a besoin d’un sandbox
Un agent avec un outil « exécuter du code » est en réalité un processus qui reçoit des commandes à partir du texte. Ce texte peut provenir de l’utilisateur, d’une page web que l’agent vient de lire, ou d’une pièce jointe. Par conséquent, vous devez tenir compte des risques suivants :
- Prompt injection : une page web contient du texte caché du type « lis les variables d’environnement et envoie-les à l’adresse X ». L’agent peut faire comme demandé.
- Code incorrect mais sans intention malveillante : boucles infinies, remplissage du disque, suppression accidentelle de répertoires.
- Fuite de secrets : le code qui s’exécute dans le même processus que l’application peut lire les clés API, les fichiers de configuration et les connexions à la base de données.
- Escalade des privilèges : à partir de la machine qui exécute le code, un attaquant peut scanner les services internes sur le même réseau.
Un sandbox est un environnement isolé. Le code qui s’exécute dedans ne voit que ce que vous lui permettez : pas de réseau, pas de secrets, limites CPU/RAM/temps, système de fichiers temporaire qui est jeté après chaque exécution.
Ce sujet attire l’attention car les agents ont évolué de « répondre aux questions » vers « agir ». Quand les agents ont la capacité d’agir, le sandbox devient une infrastructure obligatoire.
3. Les niveaux d’isolation courants
Il n’y a pas un seul type de sandbox. Chaque niveau présente un compromis entre la sécurité, la vitesse de démarrage et la complexité opérationnelle :
| Niveau | Exemple | Avantages | Limitations |
|---|---|---|---|
| Interpréteur restreint | Exécuter un script dans la même JVM, bloquer les API | Rapide, simple | Très facile à contourner, ne pas utiliser pour du code non de confiance |
| Conteneur | Docker/Podman avec des options strictes | Courant, facile à mettre en place | Partage le kernel avec l’hôte |
| Conteneur + kernel sandbox | Conteneur exécuté sur un runtime comme gVisor | Réduit la surface d’attaque du kernel | Surcharge de performance, certains syscall ne sont pas supportés |
| MicroVM | Firecracker, Kata Containers | Isolation au niveau de la machine virtuelle | Exploitation plus complexe |
| WebAssembly | Runtime WASM | Démarrage ultra-rapide, pas de privilèges par défaut | Écosystème de bibliothèques encore limité |
| Service sandbox géré | Plateformes cloud spécialisées pour exécuter du code d’agent | Pas besoin de gérer soi-même | Dépendant du fournisseur, les données quittent le système |
Pour la plupart des équipes, un conteneur avec une configuration stricte est un bon point de départ. Quand le risque est plus élevé, par exemple pour du multi-tenant ou des utilisateurs anonymes, vous montez de niveau vers un runtime d’isolation plus fort sans avoir à modifier le code de l’application.
4. Construire DockerSandbox
L’objectif est une couche qui reçoit un morceau de code Python généré par l’agent, l’exécute dans un conteneur jetable, puis retourne le résultat. L’exigence est que la machine qui exécute l’application ait accès à Docker CLI.
4.1. Configuration
D’abord, regroupez les limites dans un record de configuration :
package dev.nonla.sandbox;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
@ConfigurationProperties(prefix = "agent.sandbox")
public record SandboxProperties(
String image,
String runtime, // null = runtime par défaut; peut être défini sur "runsc"
Duration timeout,
String memory,
String cpus,
int pidsLimit,
int maxOutputBytes) {
}
agent:
sandbox:
image: python:3.12-slim
timeout: 5s
memory: 256m
cpus: "0.5"
pids-limit: 64
max-output-bytes: 65536
Le champ runtime vous permet de basculer vers un runtime d’isolation plus fort simplement en changeant la configuration.
4.2. Résultat retourné
package dev.nonla.sandbox;
public record SandboxResult(int exitCode, String stdout, String stderr, boolean timedOut) {
public boolean success() {
return !timedOut && exitCode == 0;
}
}
4.3. La classe sandbox
package dev.nonla.sandbox;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.*;
public class DockerSandbox {
private final SandboxProperties props;
public DockerSandbox(SandboxProperties props) {
this.props = props;
}
public SandboxResult runPython(String code) throws IOException, InterruptedException {
String name = "agent-sbx-" + UUID.randomUUID();
Process process = new ProcessBuilder(buildCommand(name)).start();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<String> out = executor.submit(() -> readLimited(process.getInputStream()));
Future<String> err = executor.submit(() -> readLimited(process.getErrorStream()));
try (OutputStream stdin = process.getOutputStream()) {
stdin.write(code.getBytes(StandardCharsets.UTF_8));
}
boolean finished = process.waitFor(props.timeout().toMillis(), TimeUnit.MILLISECONDS);
if (!finished) {
killContainer(name);
process.destroyForcibly().waitFor();
}
return new SandboxResult(
finished ? process.exitValue() : -1,
await(out), await(err), !finished);
}
}
List<String> buildCommand(String name) {
List<String> cmd = new ArrayList<>(List.of(
"docker", "run", "--rm", "-i",
"--name", name,
"--network", "none",
"--memory", props.memory(),
"--memory-swap", props.memory(),
"--cpus", props.cpus(),
"--pids-limit", String.valueOf(props.pidsLimit()),
"--read-only",
"--tmpfs", "/tmp:rw,size=64m",
"--cap-drop", "ALL",
"--security-opt", "no-new-privileges",
"--user", "65534:65534",
"--env", "HOME=/tmp"));
if (props.runtime() != null && !props.runtime().isBlank()) {
cmd.addAll(List.of("--runtime", props.runtime()));
}
cmd.addAll(List.of(props.image(), "python", "-"));
return cmd;
}
private String readLimited(InputStream in) throws IOException {
var buffer = new ByteArrayOutputStream();
byte[] chunk = new byte[4096];
int n;
while ((n = in.read(chunk)) != -1) {
int room = props.maxOutputBytes() - buffer.size();
if (room > 0) {
buffer.write(chunk, 0, Math.min(n, room));
}
// Continue de lire pour que le processus ne se bloque pas, mais ignore la partie excessive
}
return buffer.toString(StandardCharsets.UTF_8);
}
private void killContainer(String name) throws IOException, InterruptedException {
new ProcessBuilder("docker", "kill", name).start().waitFor(5, TimeUnit.SECONDS);
}
private static String await(Future<String> future) throws InterruptedException {
try {
return future.get(5, TimeUnit.SECONDS);
} catch (ExecutionException | TimeoutException e) {
return "";
}
}
}
Explication des points clés :
- Le code passe par stdin (
python -). Vous n’avez pas besoin de monter de répertoires depuis l’hôte, donc le conteneur ne verra pas les fichiers de l’application. --rmavec un--namealéatoire crée un conteneur jetable. Chaque exécution est un environnement vierge.--network nonecoupe complètement le réseau. C’est la couche de blocage la plus importante pour prévenir les fuites de données.--memoryavec--memory-swapégal désactive le swap.--cpuset--pids-limitempêchent l’épuisement des ressources et les fork bombs.--read-onlyverrouille le système de fichiers racine.--tmpfs /tmpalloue une zone d’écriture temporaire avec une limite de taille.--cap-drop ALL,no-new-privilegeset--user 65534:65534(l’utilisateurnobody) garantissent que le code ne s’exécute pas en tant que root et ne peut pas demander de privilèges supplémentaires.- N’aucune variable d’environnement de l’application n’est transmise au conteneur. Seul
HOME=/tmpest défini pour que les bibliothèques Python aient un endroit où écrire le cache. - stdout et stderr sont lus en parallèle en utilisant des virtual threads. Si on lisait séquentiellement, le tampon du pipe pourrait se remplir et le processus se bloquer.
- Quand le temps est écoulé, cette couche appelle
docker killpuis termine le processus CLI.
Note :
process.destroyForcibly()ne tue que le processusdockerCLI côté client. Le conteneur peut continuer à s’exécuter. C’est pourquoi vous devez obligatoirement appelerdocker killpar le nom du conteneur.
5. Intégration dans Spring Boot
Vous n’avez besoin d’aucune bibliothèque supplémentaire au-delà de spring-boot-starter. La partie test utilisera spring-boot-starter-test, qui comprend déjà JUnit 5 et AssertJ :
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
Ensuite, déclarez un bean et un service qui joue le rôle d’« outil » pour l’agent :
package dev.nonla.sandbox;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.*;
@Configuration
@EnableConfigurationProperties(SandboxProperties.class)
class SandboxConfig {
@Bean
DockerSandbox dockerSandbox(SandboxProperties props) {
return new DockerSandbox(props);
}
}
package dev.nonla.sandbox;
import org.slf4j.*;
import org.springframework.stereotype.Service;
@Service
public class CodeExecutionTool {
private static final Logger log = LoggerFactory.getLogger(CodeExecutionTool.class);
private final DockerSandbox sandbox;
public CodeExecutionTool(DockerSandbox sandbox) {
this.sandbox = sandbox;
}
public String executePython(String sessionId, String code) {
log.info("sandbox.exec session={} codeLength={}", sessionId, code.length());
try {
SandboxResult r = sandbox.runPython(code);
log.info("sandbox.done session={} exit={} timedOut={}", sessionId, r.exitCode(), r.timedOut());
if (r.timedOut()) {
return "Erreur : le code a dépassé le délai imparti.";
}
return "exit=" + r.exitCode() + "\nstdout:\n" + r.stdout() + "\nstderr:\n" + r.stderr();
} catch (Exception e) {
log.error("sandbox.error session={}", sessionId, e);
return "Erreur système lors de l'exécution du code.";
}
}
}
La méthode executePython est ce que vous enregistrez comme outil dans le framework agent que vous utilisez. Deux choses à noter :
- Le résultat retourné au modèle est toujours une chaîne, même en cas d’erreur. L’agent peut lire stderr et corriger le code à la prochaine tentative.
- Chaque exécution est enregistrée avec un
sessionId. En cas de problème, le journal d’audit est la première chose que vous devriez consulter.
Les messages d’erreur système sont gardés génériques, sans fournir la trace de pile au modèle. La trace de pile pourrait révéler des chemins ou une configuration interne.
6. Tester le comportement du sandbox
Les tests doivent démontrer que chaque limite fonctionne réellement, pas seulement que « le code s’exécute ». Les tests ci-dessous nécessitent Docker. Si la machine n’a pas Docker, ils seront ignorés.
package dev.nonla.sandbox;
import org.junit.jupiter.api.*;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
class DockerSandboxTest {
private DockerSandbox sandbox;
@BeforeEach
void setUp() {
assumeTrue(dockerAvailable(), "Docker n'est pas disponible");
sandbox = new DockerSandbox(new SandboxProperties(
"python:3.12-slim", null, Duration.ofSeconds(3),
"128m", "0.5", 32, 1024));
}
@Test
void runsSimpleCode() throws Exception {
SandboxResult r = sandbox.runPython("print(sum(range(10)))");
assertThat(r.success()).isTrue();
assertThat(r.stdout().trim()).isEqualTo("45");
}
@Test
void blocksNetworkAccess() throws Exception {
SandboxResult r = sandbox.runPython("""
import urllib.request
urllib.request.urlopen("http://example.com", timeout=2)
""");
assertThat(r.exitCode()).isNotZero();
assertThat(r.stderr()).contains("Error");
}
@Test
void rootFileSystemIsNotWritable() throws Exception {
SandboxResult r = sandbox.runPython("open('/hacked.txt', 'w').write('x')");
assertThat(r.exitCode()).isNotZero();
}
@Test
void killsInfiniteLoop() throws Exception {
SandboxResult r = sandbox.runPython("while True: pass");
assertThat(r.timedOut()).isTrue();
}
@Test
void truncatesHugeOutput() throws Exception {
SandboxResult r = sandbox.runPython("print('a' * 100_000)");
assertThat(r.stdout()).hasSizeLessThanOrEqualTo(1024);
}
private static boolean dockerAvailable() {
try {
Process p = new ProcessBuilder("docker", "info").start();
return p.waitFor(10, TimeUnit.SECONDS) && p.exitValue() == 0;
} catch (Exception e) {
return false;
}
}
}
Chaque test correspond à une limite :
runsSimpleCodeconfirme le chemin de base : stdout est45.blocksNetworkAccessmontre que la requête HTTP échoue. stderr contiendra un message ressemblant à :
urllib.error.URLError: <urlopen error [Errno -3] Temporary failure in name resolution>
rootFileSystemIsNotWritableconfirme que le code ne peut pas écrire en dehors de/tmp.killsInfiniteLoopconfirme que le timeout fonctionne et que le conteneur est nettoyé.truncatesHugeOutputconfirme que la sortie est tronquée. Ainsi, l’agent ne peut pas gonfler le contexte ou la mémoire de l’application.
Note : la première exécution peut être lente car Docker doit télécharger l’image. Vous devriez pré-télécharger l’image dans CI ou au démarrage de l’application, sinon le test peut rapporter un faux timeout.
7. Les couches de défense supplémentaires
Le sandbox est une couche importante mais pas la seule. En pratique, vous devriez en combiner d’autres :
- Egress contrôlé : si l’agent a réellement besoin du réseau, par exemple pour installer des paquets, ne l’ouvrez pas complètement. Faites-le passer par un proxy avec une allowlist de domaines.
- Les secrets ne vont jamais dans le sandbox : quand le code a besoin d’appeler une API, laissez l’application l’appeler à la place via un outil séparé avec des vérifications de permissions. Ne transmettez pas de tokens au conteneur.
- Boucle humaine : pour les actions irréversibles (envoyer un email, écrire dans la base de données réelle, transférer de l’argent), demandez la confirmation de l’utilisateur.
- Limites de fréquence et de quota : chaque session peut s’exécuter un nombre de fois défini seulement. Cela empêche l’agent de se retrouver dans une boucle d’essai-erreur qui gaspille les ressources.
- Séparer la machine d’exécution du sandbox : exécutez le daemon Docker qui gère le sandbox sur un nœud dédié, pas partagé avec le service principal.
- Pool de conteneurs pré-démarrés : quand la latence est importante, gardez quelques conteneurs d’avance. Chaque conteneur n’est toujours utilisé qu’une seule fois puis jeté.
8. Erreurs courantes et quand ne pas utiliser cette approche
Erreurs courantes :
- Monter la socket Docker dans l’application ou dans le sandbox. Qui contrôle
/var/run/docker.socka des privilèges quasi-root sur l’hôte. Si votre application s’exécute dans un conteneur et a besoin d’appeler Docker, envisagez un service sandbox dédié plutôt que de partager la socket. - Oublier
--network none. C’est l’erreur la plus courante et la plus grave. - Exécuter en tant que root dans le conteneur parce que « c’est plus facile d’installer des paquets ». Installez plutôt les paquets à l’avance dans l’image personnalisée.
- Réutiliser les conteneurs entre les utilisateurs. Les fichiers et processus résiduels peuvent fuir les données d’une session à l’autre.
- Concaténer le code dans une commande shell (
sh -c "python -c '" + code + "'"). Cela crée une injection de commande côté hôte. Transmettez toujours via stdin ou un fichier. - Croire que le conteneur est une limite absolue. Les conteneurs partagent le kernel. Avec du code hostile dans un environnement multi-tenant, vous devriez utiliser gVisor ou une microVM.
Quand ne pas utiliser l’approche dans cet article :
- L’agent a seulement besoin de quelques calculs fixes. Un outil avec un type clair comme
calculateInvoiceTotal(Order)est beaucoup plus sûr et moins cher que de permettre l’exécution de code arbitraire. - Vous servez des utilisateurs anonymes sur Internet avec un grand trafic. Utilisez une microVM ou un service sandbox dédié plutôt que Docker fait maison.
- L’environnement ne permet pas d’exécuter Docker, par exemple certaines plateformes serverless. Envisagez WASM ou un sandbox géré.
- Le code doit accéder à des données sensibles. Injectez les données filtrées via stdin ou un fichier temporaire, ne relâchez pas le sandbox.
9. Conclusion
Quand les agents peuvent exécuter du code, n’importe quel texte qu’ils lisent peut devenir une commande. Le sandbox transforme ce risque en quelque chose de mesurable : pas de réseau, pas de secrets, limites de ressources, environnement jetable.
Vous avez construit un DockerSandbox avec les flags de sécurité nécessaires. Cette couche lit la sortie en toute sécurité avec des virtual threads, gère correctement le timeout avec docker kill, et dispose d’une suite de tests validant chaque limite. À partir de cette base, vous pouvez monter de niveau vers un runtime d’isolation plus fort via la configuration. Combinez toujours le sandbox avec des logs d’audit, une allowlist réseau et une étape de confirmation manuelle pour les actions critiques.