Aller au contenu principal

Sandboxing et isolation

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. S’il est compromis par une injection, il peut exécuter des commandes système, lire des fichiers sensibles ou communiquer avec des services externes. Le sandboxing consiste à enfermer l’agent dans un environnement restreint pour limiter les dégâts en cas de compromission.

Niveaux d’isolation

Niveau 1 : Isolation des permissions (moindre privilège)

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}")

Niveau 2 : Isolation réseau

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écution de code en sandbox

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"}

Isolation avec Docker

Pour les cas de production, Docker fournit une isolation plus robuste :

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": ""}

Matrice de décision : quel niveau d’isolation ?

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

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