Aller au contenu principal

Sandboxing et isolation

Mis à jour le 28 juillet 2026

Pourquoi isoler les agents IA ?

Un agent IA qui exécute du code ou appelle des outils est fondamentalement différent d’un chatbot simple. Le chatbot compromis produit au pire une réponse embarrassante ; l’agent compromis, lui, agit — il peut exécuter des commandes système, lire des fichiers sensibles ou communiquer avec des services externes. Toutes les défenses vues jusqu’ici cherchaient à empêcher la compromission. Le sandboxing change de posture : il part du principe qu’elle finira par arriver et s’attache à borner ce qu’elle permettra. C’est une différence de nature, et c’est pourquoi il ne remplace aucune des couches précédentes.

Niveau 1 : borner l’accès aux ressources

Le premier niveau applique le moindre privilège aux fichiers et aux commandes. Le point technique à retenir est l’usage de resolve() avant comparaison : sans lui, une chaîne comme /data/app/uploads/../../etc/passwd commence bien par le dossier autorisé et passerait une vérification naïve par préfixe. La résolution du chemin réel déjoue cette traversée, qui reste l’une des attaques les plus courantes contre les agents manipulant des fichiers.

import os
from pathlib import Path

class SandboxPermissions:
    """Contrôle les permissions d'accès aux ressources."""

    def __init__(self, dossier_autorise: str, commandes_autorisees: list[str]):
        self.dossier_autorise = Path(dossier_autorise).resolve()
        self.commandes_autorisees = set(commandes_autorisees)

    def verifier_chemin(self, chemin: str) -> bool:
        """Vérifie qu'un chemin est dans le dossier autorisé."""
        chemin_resolu = Path(chemin).resolve()
        try:
            chemin_resolu.relative_to(self.dossier_autorise)
            return True
        except ValueError:
            return False

    def verifier_commande(self, commande: str) -> bool:
        """Vérifie qu'une commande est dans la liste blanche."""
        cmd_base = commande.split()[0] if commande else ""
        return cmd_base in self.commandes_autorisees

    def lire_fichier(self, chemin: str) -> str:
        """Lecture sécurisée avec vérification de chemin."""
        if not self.verifier_chemin(chemin):
            raise PermissionError(
                f"Accès refusé : {chemin} est hors du dossier autorisé"
            )
        return Path(chemin).read_text()

# Utilisation
sandbox = SandboxPermissions(
    dossier_autorise="/data/app/uploads",
    commandes_autorisees=["ls", "cat", "wc"],
)

# OK : fichier dans le dossier autorisé
try:
    contenu = sandbox.lire_fichier("/data/app/uploads/rapport.txt")
except PermissionError as e:
    print(f"Bloqué : {e}")

# Bloqué : tentative de traversée de chemin
try:
    contenu = sandbox.lire_fichier("/data/app/uploads/../../etc/passwd")
except PermissionError as e:
    print(f"Bloqué : {e}")

Les commandes suivent la même logique, avec une liste blanche plutôt qu’une liste noire. L’asymétrie est décisive : une liste noire vous oblige à imaginer toutes les commandes dangereuses, exercice que vous perdrez, alors qu’une liste blanche vous demande seulement d’énumérer celles dont l’agent a réellement besoin — souvent trois ou quatre.

Niveau 2 : contrôler ce qui sort

L’isolation réseau répond au scénario d’exfiltration vu précédemment. Tant que l’agent peut émettre une requête HTTP vers n’importe quel domaine, toute donnée qu’il touche peut quitter votre périmètre, et la sortie ressemblera à du trafic applicatif ordinaire. Restreindre les destinations à une liste explicite transforme une tentative d’exfiltration en exception loggée.

import subprocess

class SandboxReseau:
    """Contrôle les communications réseau de l'agent."""

    def __init__(self, domaines_autorises: list[str]):
        self.domaines_autorises = set(domaines_autorises)

    def requete_autorisee(self, url: str) -> bool:
        """Vérifie qu'une URL est dans la liste blanche."""
        from urllib.parse import urlparse
        domaine = urlparse(url).hostname
        return domaine in self.domaines_autorises

    def fetch_securise(self, url: str) -> str:
        """Requête HTTP avec vérification de domaine."""
        if not self.requete_autorisee(url):
            raise PermissionError(f"Domaine non autorisé : {url}")
        import urllib.request
        with urllib.request.urlopen(url, timeout=10) as resp:
            return resp.read().decode()

sandbox_net = SandboxReseau(
    domaines_autorises=["api.openai.com", "api.anthropic.com"]
)

# OK
try:
    sandbox_net.fetch_securise("https://api.openai.com/v1/models")
except PermissionError as e:
    print(e)

# Bloqué : exfiltration vers un domaine externe
try:
    sandbox_net.fetch_securise("https://attaquant.com/collect")
except PermissionError as e:
    print(f"Bloqué : {e}")

Niveau 3 : exécuter du code sans lui confier la machine

Faire tourner du code écrit par un modèle est le cas le plus délicat. L’implémentation ci-dessous combine trois protections : un contrôle des imports avant exécution, des limites de mémoire et de temps CPU posées dans le script lui-même, et un environnement réduit au strict minimum au lancement du sous-processus.

Un avertissement s’impose toutefois sur la vérification des imports. Elle repose sur une recherche textuelle, et un attaquant un peu attentif la contournera — __import__("os") ne contient pas la chaîne recherchée. Traitez cette vérification comme un garde-fou contre les erreurs et les tentatives naïves, jamais comme une frontière de sécurité. La frontière réelle, c’est le processus isolé avec ses limites de ressources.

import subprocess
import tempfile
import os

class SandboxExecution:
    """Exécute du code généré par le modèle dans un environnement isolé."""

    def __init__(self, timeout: int = 10, max_memoire_mb: int = 256):
        self.timeout = timeout
        self.max_memoire = max_memoire_mb

    def executer_python(self, code: str) -> dict:
        """Exécute du code Python dans un processus isolé."""
        # Vérifications de sécurité avant exécution
        imports_dangereux = ["os", "subprocess", "shutil", "socket", "requests"]
        for imp in imports_dangereux:
            if f"import {imp}" in code or f"from {imp}" in code:
                return {
                    "succes": False,
                    "erreur": f"Import interdit : {imp}",
                    "sortie": "",
                }

        # Créer un fichier temporaire
        with tempfile.NamedTemporaryFile(
            mode="w", suffix=".py", delete=False
        ) as f:
            # Ajouter des restrictions au début du script
            wrapper = f"""
import resource
import sys

# Limiter la mémoire
resource.setrlimit(resource.RLIMIT_AS, ({self.max_memoire * 1024 * 1024}, {self.max_memoire * 1024 * 1024}))

# Limiter le temps CPU
resource.setrlimit(resource.RLIMIT_CPU, ({self.timeout}, {self.timeout}))

# Code de l'utilisateur
{code}
"""
            f.write(wrapper)
            fichier_temp = f.name

        try:
            result = subprocess.run(
                ["python3", fichier_temp],
                capture_output=True,
                text=True,
                timeout=self.timeout,
                env={"PATH": "/usr/bin", "HOME": "/tmp"},
            )
            return {
                "succes": result.returncode == 0,
                "sortie": result.stdout[:5000],
                "erreur": result.stderr[:2000] if result.returncode != 0 else "",
            }
        except subprocess.TimeoutExpired:
            return {"succes": False, "erreur": "Timeout dépassé", "sortie": ""}
        finally:
            os.unlink(fichier_temp)

# Utilisation
sandbox = SandboxExecution(timeout=5, max_memoire_mb=128)

# OK : calcul simple
resultat = sandbox.executer_python("print(sum(range(100)))")
print(resultat)  # {"succes": True, "sortie": "4950\n", "erreur": ""}

# Bloqué : tentative d'accès système
resultat = sandbox.executer_python("import os; os.listdir('/')")
print(resultat)  # {"succes": False, "erreur": "Import interdit : os"}

Pour les cas de production, Docker fournit une isolation plus robuste parce qu’elle ne repose plus sur la coopération du code exécuté. Chaque option de la commande ferme une porte précise : --network=none supprime toute possibilité d’exfiltration réseau, --read-only empêche la persistance, --memory et --cpus bornent la consommation, --tmpfs accorde un espace de travail volatile et plafonné, et --security-opt=no-new-privileges interdit l’escalade en cours d’exécution. Le --rm détruit le conteneur à la fin, ce qui garantit qu’aucun état ne survit d’une exécution à la suivante.

class SandboxDocker:
    """Exécute du code dans un conteneur Docker éphémère."""

    def __init__(self, image: str = "python:3.12-slim"):
        self.image = image

    def executer(self, code: str, timeout: int = 10) -> dict:
        """Exécute du code dans un conteneur isolé."""
        cmd = [
            "docker", "run", "--rm",
            "--network=none",          # Pas de réseau
            "--memory=256m",           # Limite mémoire
            "--cpus=0.5",              # Limite CPU
            "--read-only",             # Système de fichiers en lecture seule
            "--tmpfs=/tmp:size=50m",   # Espace temporaire limité
            "--security-opt=no-new-privileges",
            self.image,
            "python3", "-c", code,
        ]
        try:
            result = subprocess.run(
                cmd, capture_output=True, text=True, timeout=timeout
            )
            return {
                "succes": result.returncode == 0,
                "sortie": result.stdout[:5000],
                "erreur": result.stderr[:2000],
            }
        except subprocess.TimeoutExpired:
            return {"succes": False, "erreur": "Timeout", "sortie": ""}

Choisir le bon niveau

Empiler les trois niveaux sur un simple chatbot de FAQ coûte cher pour rien, tandis que se contenter du premier sur un agent qui exécute du code revient à ne rien protéger. Le tableau ci-dessous donne le point de départ ; ajustez-le selon la sensibilité des données accessibles, qui compte souvent davantage que la sophistication de l’agent.

Scénario Niveau recommandé
Chatbot Q&A simple Permissions (niveau 1)
Agent avec accès API Permissions + Réseau (niveaux 1-2)
Agent qui exécute du code Docker complet (niveau 3)
Agent multi-utilisateurs Docker + isolation par session

Quel que soit le niveau retenu, testez-le comme vous testeriez une fonctionnalité. Un sandbox qu’on installe puis qu’on oublie se dégrade silencieusement au fil des évolutions — une dépendance ajoutée, un volume monté « temporairement », un domaine ouvert pour dépanner. Reprenez les tests de traversée de chemin et de domaine interdit à chaque évolution de l’agent : un bypass de sandbox n’est pas une anomalie mineure, c’est un incident critique.

Points clés à retenir

  • Le sandboxing est indispensable dès qu’un agent a accès à des outils ou exécute du code
  • Trois niveaux d’isolation : permissions, réseau, conteneur — combinez selon le risque
  • Le principe de moindre privilège s’applique à chaque ressource : fichiers, réseau, commandes
  • Docker avec --network=none et --read-only fournit l’isolation la plus robuste
  • L’isolation doit être testée régulièrement — un bypass de sandbox est un incident critique