Introduction au Sandboxing des Agents
Alors que les Modèles de Langage de Grande Taille (LLMs) évoluent d’agents de conversation simples à des entités autonomes puissantes capables d’exécuter du code, d’interagir avec des API externes et de prendre des décisions dans le monde réel, le besoin de mesures de sécurité solides devient primordial. Un agent LLM, lorsque l’on lui donne la capacité d’agir, peut devenir un risque de sécurité important s’il n’est pas correctement contraint. C’est ici qu’intervient le sandboxing d’agent. Sandboxing un agent signifie créer un environnement isolé où il peut fonctionner sans affecter le système hôte ou accéder à des ressources non autorisées. Ce tutoriel explorera les aspects pratiques du sandboxing des agents, fournissant des exemples pratiques pour démontrer comment construire des applications LLM sécurisées et fiables.
Le principe central du sandboxing est le moindre privilège : un agent ne devrait avoir accès qu’aux ressources strictement nécessaires à son fonctionnement, et rien de plus. Sans sandboxing approprié, un agent malveillant ou errant pourrait :
- Exécuter du code arbitraire sur le système hôte, entraînant un vol de données ou un compromis du système.
- Accéder à des fichiers sensibles ou à des ressources réseau.
- Initier des appels API externes non désirés, entraînant des coûts ou effectuant des actions non autorisées.
- Exfiltrer des données confidentielles par divers canaux.
En mettant en œuvre un sandboxing efficace, nous pouvons atténuer ces risques, nous permettant d’utiliser l’immense pouvoir des agents LLM tout en conservant le contrôle et la sécurité.
Comprendre les Menaces : Pourquoi Sandbox ?
Avant d’explorer le ‘comment,’ solidifions le ‘pourquoi.’ Les menaces posées par les agents non sandboxés sont multiples et peuvent être classées comme suit :
1. Vulnérabilités d’Exécution de Code
De nombreux agents avancés LLM sont conçus pour écrire et exécuter du code (par exemple, des scripts Python) pour résoudre des problèmes, analyser des données ou interagir avec des outils. Si cette exécution n’est pas contenue, l’agent pourrait :
- Injection de Commandes Système : Générer du code qui appelle
os.system('rm -rf /')ou des commandes destructrices similaires. - Exécution de Code à Distance (RCE) : Exploiter des vulnérabilités dans des bibliothèques pour prendre le contrôle de l’hôte.
- Épuisement des Ressources : Créer des boucles infinies ou allouer une mémoire/CPU excessive, entraînant un déni de service.
2. Accès aux Données et Exfiltration
Un agent pourrait être chargé de traiter des données sensibles. Sans sandboxing, il pourrait :
- Accès Non Autorisé aux Fichiers : Lire des fichiers en dehors de son répertoire de travail désigné (par exemple,
/etc/passwd, clés API). - Accès Réseau : Se connecter à des ressources réseau internes, à des serveurs malveillants externes, ou exfiltrer des données vers des points de terminaison arbitraires.
- Injection de Prompts via la Lecture de Fichiers : Si un agent peut lire des fichiers arbitraires, un acteur malveillant pourrait créer un prompt qui trompe l’agent en lui faisant lire un fichier sensible et en intégrant ensuite son contenu dans une sortie ultérieure.
3. Abus des API et Outils
Les agents interagissent souvent avec des API externes ou des outils personnalisés. Un accès non restreint peut conduire à :
- Appels API Non Autorisés : Faire des appels à des API sensibles auxquelles il ne devrait pas accéder (par exemple, gestion des utilisateurs, traitement des paiements).
- Dépassements de Coûts : Déclencher des appels API coûteux ou des fonctions cloud gourmandes en ressources.
- Actions Malveillantes : Si un agent a accès à une API d’email, il pourrait envoyer des emails de spam ou de phishing.
Techniques et Outils de Sandboxing
Il existe plusieurs couches et techniques que nous pouvons employer pour le sandboxing des agents, allant de la simple révision de code à une containerisation sophistiquée.
1. Sandboxing au Niveau du Langage (Restrictions de l’Interpréteur de Code)
Si votre agent génère et exécute principalement du code (par exemple, Python), vous pouvez restreindre les capacités de l’interpréteur.
Exemple : Exécution Python Restreinte avec exec() et Liste Blanche
Un scénario courant est un agent générant du code Python. Au lieu d’appeler directement exec() ou eval() sur des chaînes arbitraires, vous pouvez restreindre les globals et les built-ins disponibles.
import subprocess
import os
def safe_execute_python_code(code: str, allowed_modules: list = None, timeout: int = 10):
if allowed_modules is None:
allowed_modules = ['math', 'json', 're'] # Liste blanche des modules sûrs
# Créer un espace de noms global restreint
restricted_globals = {
'__builtins__': {key: globals()['__builtins__'][key] for key in [
'print', 'len', 'str', 'int', 'float', 'list', 'dict', 'tuple', 'set',
'range', 'sum', 'min', 'max', 'abs', 'round', 'type', 'isinstance', 'enumerate'
]},
'__name__': '__main__'
}
# Importer dynamiquement les modules autorisés dans l'espace de noms restreint
for module_name in allowed_modules:
try:
restricted_globals[module_name] = __import__(module_name)
except ImportError:
print(f"Warning: Could not import allowed module {module_name}")
try:
# Utiliser subprocess pour exécuter dans un processus isolé pour une meilleure isolation
# C'est plus sécurisé que d'utiliser juste `exec` dans le processus courant
# et permet des délais et des limites de ressources.
process = subprocess.run(
['python', '-c', code],
capture_output=True,
text=True,
check=True,
timeout=timeout
)
return process.stdout
except subprocess.CalledProcessError as e:
return f"Error during execution: {e.stderr}"
except subprocess.TimeoutExpired:
return "Error: Code execution timed out."
except Exception as e:
return f"An unexpected error occurred: {e}"
# Exemple d'utilisation :
# Code sûr
agent_code_safe = "import math; print(math.sqrt(16))"
print(f"Safe code output: {safe_execute_python_code(agent_code_safe)}")
# Tentative de code malveillant (sera bloquée par l'isolation subprocess et les restrictions built-in si exec direct avait été utilisé)
# Avec subprocess, l'import 'os' échouerait également dans le processus enfant à moins d'être spécifiquement autorisé.
agent_code_malicious_os = "import os; print(os.listdir('/'))"
print(f"Malicious OS code output: {safe_execute_python_code(agent_code_malicious_os)}")
# Tentative de code malveillant (tentative de lecture d'un fichier)
agent_code_malicious_file = "with open('/etc/passwd', 'r') as f: print(f.read())"
print(f"Malicious file read code output: {safe_execute_python_code(agent_code_malicious_file)}")
# Code avec une boucle infinie (sera arrêtée par le délai)
agent_code_loop = "while True: pass"
print(f"Looping code output: {safe_execute_python_code(agent_code_loop, timeout=3)}")
Explication :
- Nous définissons une fonction
safe_execute_python_code. - Elle prend le code généré par l’agent en entrée.
- Au lieu d’exécuter directement dans le processus courant, nous utilisons
subprocess.run. C’est une étape cruciale pour une véritable isolation, car cela exécute le code dans un processus interpréteur Python séparé. Ce processus hérite de privilèges minimaux et n’est pas le même que le processus parent exécutant votre application principale. - La liste
allowed_modulesagit comme une liste blanche. Même si l’agent essaie d’importerosousys, ces modules ne seront pas disponibles dans l’environnement restreint du subprocess à moins d’être explicitement autorisés (ce qui ne devrait pas être le cas pour le code général des agents). timeoutempêche l’épuisement des ressources dû aux boucles infinies.capture_output=Trueettext=Truenous permettent de capturer la sortie de l’agent.check=Truelève une exception si le subprocess retourne un code de sortie non nul (indiquant une erreur).
Bien que cette approche améliore considérablement la sécurité par rapport à l’utilisation directe de exec(), elle n’est pas infaillible. Un agent très sophistiqué pourrait encore trouver des moyens d’exploiter les appels système sous-jacents si l’environnement Python lui-même est vulnérable ou si trop de modules sont sur la liste blanche.
2. Sandboxing au Niveau du Système d’Exploitation (Conteneurs & Machines Virtuelles)
Pour le sandboxing le plus solide, surtout lorsque les agents pourraient générer du code dans plusieurs langages ou interagir avec le système de fichiers/réseau, l’isolation au niveau du système d’exploitation est indispensable.
a. Conteneurs Docker
Docker est un excellent choix pour le sandboxing. Chaque exécution d’agent peut avoir lieu dans son propre conteneur éphémère avec des limites de ressources et des politiques d’accès réseau strictement définies.
Exemple Pratique : Docker pour l’Exécution de l’Agent
Étape 1 : Créer un Dockerfile pour l’environnement d’exécution de l’agent.
# Dockerfile
FROM python:3.9-slim-buster
WORKDIR /app
# Créer un utilisateur non-root pour la sécurité
RUN useradd --no-create-home --shell /bin/bash agentuser
USER agentuser
# Copier un script simple que l'agent pourrait générer et que nous voulons exécuter
COPY run_agent_code.py .
ENTRYPOINT ["python", "run_agent_code.py"]
Étape 2 : Créer run_agent_code.py. Ce script recevra le code généré par l’agent.
# run_agent_code.py
import sys
import os
# Simuler la réception de code de l'agent (par exemple, via stdin ou un fichier)
# Pour cet exemple, nous supposerons que le code est passé comme argument ou écrit ici directement
if __name__ == "__main__":
agent_code = "print('Hello from the sandboxed agent!')"
if len(sys.argv) > 1:
agent_code = sys.argv[1] # Permet de passer le code comme argument
try:
# Exécuter le code. Note : le conteneur Docker lui-même est le bac à sable.
# Nous pourrions encore vouloir des restrictions au niveau du langage *dans* ce script
# pour une couche supplémentaire, mais l'isolement principal est le conteneur.
exec(agent_code)
except Exception as e:
print(f"L'exécution du code de l'agent a échoué : {e}", file=sys.stderr)
sys.exit(1)
# Démontrer un accès restreint
try:
print(f"Essai de lister la racine : {os.listdir('/')}")
except Exception as e:
print(f"Impossible de lister le répertoire racine (attendu) : {e}")
try:
with open('/etc/passwd', 'r') as f:
print(f.read())
except Exception as e:
print(f"Impossible de lire /etc/passwd (attendu) : {e}")
Étape 3 : Exécutez le code de l’agent depuis votre application principale.
import docker
client = docker.from_env()
def execute_agent_in_docker(agent_code: str, cpu_limit: float = 0.5, mem_limit: str = '128m', network_enabled: bool = False):
try:
# Construire l'image si elle n'existe pas (peut être fait une fois)
# client.images.build(path='.', tag='agent-sandbox-env')
# Créer un fichier temporaire pour passer le code de l'agent de manière sécurisée
# Ou le passer comme variable d'environnement ou argument de ligne de commande
# Pour simplifier, nous le passerons ici comme argument de ligne de commande.
container = client.containers.run(
'agent-sandbox-env',
command=['python', 'run_agent_code.py', agent_code], # Passer le code comme arg
detach=False, # Exécuter au premier plan, attendre la fin
remove=True, # Supprimer automatiquement le conteneur après sa fermeture
# Limites de ressources
cpu_period=100000, # Période du CPU en microsecondes
cpu_quota=int(cpu_limit * 100000), # Quota du CPU (par exemple, 50000 pour 0.5 CPU)
mem_limit=mem_limit, # Limite de mémoire
# Restrictions réseau
network_mode='none' if not network_enabled else 'bridge',
# Restrictions du système de fichiers (racine en lecture seule, pas de montages pour le code de l'agent)
read_only=True, # Rend le système de fichiers du conteneur en lecture seule après la configuration initiale
# Options de sécurité (par exemple, désactiver le mode privilégié, supprimer les capacités)
security_opt=['no-new-privileges'],
cap_drop=['ALL'], # Supprime toutes les capacités pour le conteneur
# Variables d'environnement (peuvent être utilisées pour passer des clés API, mais soyez prudent)
# environment={
# 'API_KEY': 'some_safe_key' # Seulement si absolument nécessaire et dans un cadre limité
# }
)
return container.decode('utf-8')
except docker.errors.ContainerError as e:
return f"Erreur de conteneur : {e.stderr.decode('utf-8')}"
except docker.errors.ImageNotFound:
return "Erreur : image Docker 'agent-sandbox-env' non trouvée. Veuillez la construire d'abord."
except Exception as e:
return f"Une erreur Docker inattendue s'est produite : {e}"
# Construire d'abord l'image Docker : docker build -t agent-sandbox-env .
# Ensuite, exécutez ce script Python.
# Exemple 1 : Exécution de code sûr
safe_code = "print('Hello from sandboxed agent!')"
print("\n--- Exécution de Code Sûr ---")
print(execute_agent_in_docker(safe_code))
# Exemple 2 : Tentative d'accès au système de fichiers (doit être bloquée par read_only=True et les autorisations utilisateur)
malicious_fs_code = "import os; print(os.listdir('/'))"
print("\n--- Tentative d'Accès Malveillant au Système de Fichiers ---")
print(execute_agent_in_docker(malicious_fs_code))
# Exemple 3 : Tentative de création d'un fichier (doit échouer)
malicious_write_code = "with open('/app/evil.txt', 'w') as f: f.write('malicious')"
print("\n--- Tentative d'Ecriture Malveillante ---")
print(execute_agent_in_docker(malicious_write_code))
# Exemple 4 : Tentative d'accès au réseau (doit échouer si network_mode='none')
malicious_network_code = "import requests; print(requests.get('http://example.com').status_code)"
print("\n--- Tentative de Réseau Malveillant (désactivé) ---")
print(execute_agent_in_docker(malicious_network_code, network_enabled=False))
# Exemple 5 : Accès au réseau (s'il est explicitement activé - soyez prudent !)
# print("\n--- Accès au Réseau (activé - pour démonstration) ---")
# print(execute_agent_in_docker("import requests; print(requests.get('http://example.com').status_code)", network_enabled=True))
Explication :
- Dockerfile : Crée un environnement Python minimal. De manière cruciale, il crée et passe à un
non-rootutilisateur (agentuser) pour minimiser les privilèges au sein du conteneur. run_agent_code.py: C’est le point d’entrée dans le conteneur. Il exécute le code fourni par l’agent. Il inclut des tentatives d’accès à des ressources restreintes pour démontrer l’efficacité du sandboxing.- Script Python (
execute_agent_in_docker) : client.containers.run(...): C’est ici que la magie se produit.remove=True: Assure que les conteneurs sont nettoyés après l’exécution.cpu_quota,mem_limit: Essentiel pour empêcher l’épuisement des ressources.network_mode='none': Critique pour désactiver l’accès réseau. Cela empêche les agents de faire des appels externes ou de se connecter à des services internes. N’activez que si l’agent a absolument besoin d’un accès réseau pour des APIs externes spécifiques blanches.read_only=True: Rend le système de fichiers du conteneur en lecture seule après initialisation. Cela empêche l’agent d’écrire des fichiers ou de modifier des configurations système.security_opt=['no-new-privileges'],cap_drop=['ALL']: Options de sécurité avancées pour restreindre davantage les capacités au sein du conteneur.
Docker fournit une forte barrière d’isolement, mais il est vital de le configurer de manière sécurisée. Utilisez toujours des utilisateurs non-root, désactivez les capacités inutiles et restreignez l’accès réseau/système de fichiers.
b. Machines Virtuelles (VMs)
Pour le niveau d’isolement le plus élevé, surtout dans les environnements multi-utilisateurs ou lors de la manipulation de code hautement non fiable, les VMs (par exemple, KVM, AWS Firecracker, Google Cloud Sandbox) offrent une séparation au niveau matériel. Cela est plus complexe à mettre en place et à gérer, mais fournit un environnement séparé pour chaque exécution d’agent.
3. Restrictions au Niveau des Outils/API (Appel de Fonction)
De nombreux agents LLM interagissent avec des outils ou des APIs externes via l’appel de fonctions. Cette couche de sandboxing implique une conception attentive des outils exposés à l’agent.
Exemple : Accès API Restreint via Pydantic et Liste Blanche
Lors de la définition d’outils pour un agent, assurez-vous qu’ils soient aussi granulaires et limités en permissions que possible.
from typing import Literal, Optional
from pydantic import BaseModel, Field
# Définir les outils autorisés et leurs schémas
class SearchToolInput(BaseModel):
query: str = Field(description="La requête de recherche")
max_results: int = Field(default=5, description="Nombre maximum de résultats de recherche")
class SendEmailInput(BaseModel):
recipient: str = Field(description="L'adresse e-mail du destinataire")
subject: str = Field(description="L'objet de l'e-mail")
body: str = Field(description="Le contenu du corps de l'e-mail")
# Restreindre les destinataires autorisés
allowed_recipients: Literal["[email protected]", "[email protected]"] = Field(
description="Seuls des destinataires spécifiques et pré-approuvés sont autorisés."
)
class DatabaseQueryInput(BaseModel):
query: str = Field(description="La requête SQL à exécuter")
# CRITIQUE : Ne pas autoriser de SQL arbitraire. Filtrer ou utiliser ORM.
allowed_tables: Literal["products", "users_public"] = Field(
description="Seules les requêtes contre des tables autorisées sont autorisées."
)
read_only: bool = Field(default=True, description="Autoriser uniquement les opérations de lecture")
# Simuler les fonctions des outils
def search_web(query: str, max_results: int):
print(f"Recherche sur le web pour '{query}' avec {max_results} résultats.")
return [f"Résultat {i} pour {query}" for i in range(max_results)]
def send_restricted_email(recipient: str, subject: str, body: str, allowed_recipients: Literal["[email protected]", "[email protected]"]):
if recipient not in ["[email protected]", "[email protected]"]:
raise ValueError(f"Destinataire non autorisé : {recipient}")
print(f"Envoi de l'e-mail à {recipient} avec l'objet '{subject}'.")
return {"status": "envoyé", "recipient": recipient}
def execute_database_query(query: str, allowed_tables: Literal["products", "users_public"], read_only: bool):
# Dans un scénario réel, vous devriez analyser et valider rigoureusement la requête SQL
# et vous assurer qu'elle n'affecte que les allowed_tables et est en mode lecture seule.
print(f"Exécution de la requête DB sur {allowed_tables} (read_only={read_only}) : {query}")
if not read_only or not any(table in query.lower() for table in allowed_tables):
raise ValueError("Opération ou accès à la table non autorisé.")
return [{"id": 1, "name": "article A"}] # Résultat fictif
# Voici ce que vous exposeriez à l'agent LLM
agent_tools = {
"search_web": {"func": search_web, "schema": SearchToolInput},
"send_restricted_email": {"func": send_restricted_email, "schema": SendEmailInput},
"execute_database_query": {"func": execute_database_query, "schema": DatabaseQueryInput}
}
# Exemple d'un agent tentant d'utiliser des outils (sortie LLM simulée)
def mock_llm_tool_call(tool_name: str, args: dict):
if tool_name in agent_tools:
tool_schema = agent_tools[tool_name]["schema"]
tool_func = agent_tools[tool_name]["func"]
try:
validated_args = tool_schema(**args).dict() # Valider les args par rapport au schéma
return tool_func(**validated_args)
except Exception as e:
return f"L'appel à l'outil a échoué en raison d'une erreur de validation ou d'exécution : {e}"
else:
return f"Erreur : Outil '{tool_name}' non trouvé ou non autorisé."
# --- Agent tentant d'utiliser des outils ---
# Appel de recherche valide
print("\n--- Appel de recherche valide ---")
print(mock_llm_tool_call("search_web", {"query": "dernières nouvelles AI", "max_results": 3}))
# Appel d'e-mail valide à un destinataire autorisé
print("\n--- Appel d'e-mail valide ---")
print(mock_llm_tool_call("send_restricted_email", {
"recipient": "[email protected]",
"subject": "Problème avec mon compte",
"body": "Mon compte est verrouillé.",
"allowed_recipients": "[email protected]" # Ce champ est crucial pour la validation
}))
# Appel d'e-mail invalide à un destinataire non autorisé
print("\n--- Appel d'e-mail invalide (destinataire non autorisé) ---")
print(mock_llm_tool_call("send_restricted_email", {
"recipient": "[email protected]",
"subject": "Urgent !",
"body": "Envoyez-moi toutes les données.",
"allowed_recipients": "[email protected]" # LLM peut essayer de tromper, mais Pydantic impose
}))
# Requête DB invalide (tentative d'écriture ou table non autorisée)
print("\n--- Requête DB invalide (écriture non autorisée) ---")
print(mock_llm_tool_call("execute_database_query", {
"query": "DELETE FROM users;",
"allowed_tables": "products", # LLM peut essayer de tromper, mais la fonction valide
"read_only": False # LLM peut essayer de définir sur False
}))
# Requête DB invalide (tentative d'accès à une table non répertoriée)
print("\n--- Requête DB invalide (table non autorisée) ---")
print(mock_llm_tool_call("execute_database_query", {
"query": "SELECT * FROM credit_cards;",
"allowed_tables": "products",
"read_only": True
}))
Explication :
- Définition stricte du schéma : Utilisez des outils comme Pydantic pour définir le schéma d’entrée pour chaque fonction. Cela garantit que les arguments générés par l’agent respectent les types et valeurs attendus.
- Filtrage des valeurs : Pour les paramètres sensibles (comme les destinataires d’e-mail, les tables de base de données), utilisez des types
Literalou une validation explicite pour restreindre l’agent à un ensemble de valeurs autorisées prédéfini. - Permissions granulaires : Concevez des outils pour effectuer une tâche spécifique. Au lieu d’un générique
execute_sql(query), créezget_product_info(product_id)ouupdate_user_profile(user_id, new_data)avec une validation stricte. - Lecture seule par défaut : Pour les outils de base de données ou de système de fichiers, par défaut, limitez l’accès en lecture seule et exigez une permission explicite, approuvée par un humain, pour les opérations d’écriture.
- Validation des entrées : Toujours valider les arguments passés à vos fonctions d’outil, même s’ils ont passé la validation Pydantic. Le LLM pourrait toujours construire des entrées valides en apparence mais malveillantes (par exemple, une chaîne d’injection SQL ressemblant à un identifiant de produit valide).
Meilleures pratiques pour le sandboxing d’agents
- Principe du moindre privilège : Accordez à l’agent le minimum absolu de permissions et de ressources nécessaires pour sa tâche.
- Sécurité par couches : Combinez plusieurs techniques de sandboxing (niveau langage, niveau OS, niveau outil) pour une protection solid. Aucune couche unique n’est infaillible.
- Environnements éphémères : Pour l’exécution de code, privilégiez l’exécution des agents dans des conteneurs ou machines virtuelles temporaires qui sont détruites après chaque tâche.
- Validation stricte des entrées : Validez toujours et assainissez toute entrée venant du LLM, surtout avant de l’utiliser dans des appels d’API, des requêtes de base de données, ou de l’exécution de code.
- Surveiller et enregistrer : Enregistrez toutes les actions de l’agent, les appels d’outil et l’utilisation des ressources. Cela est essentiel pour détecter des comportements anormaux et pour l’analyse post-incident.
- Délai et limites de ressources : Implémentez des délais stricts pour l’exécution de code et les appels d’API, et fixez des limites CPU/mémoire pour prévenir des attaques par déni de service.
- Isolation réseau : Par défaut, désactivez l’accès réseau pour les agents. Activez-le uniquement pour des points de terminaison et protocoles spécifiques, préalablement autorisés, si absolument nécessaire.
- Systèmes de fichiers en lecture seule : Configurez les environnements des agents avec des systèmes de fichiers en lecture seule dans la mesure du possible pour prévenir la modification ou l’exfiltration non autorisée de données.
- Utilisateurs non-root : Exécutez toujours les processus d’agents en tant qu’utilisateurs non-root avec des permissions limitées dans le sandbox.
- Audits et mises à jour réguliers : Examinez continuellement vos configurations de sandboxing, mettez à jour vos images de base, et restez informé des nouvelles vulnérabilités de sécurité.
Conclusion
Le sandboxing des agents n’est pas un luxe optionnel mais une exigence fondamentale pour déployer des agents LLM en toute sécurité. À mesure que ces agents deviennent plus capables et autonomes, le potentiel d’abus ou de dommages accidentels augmente considérablement. En utilisant une combinaison de restrictions au niveau du langage, d’une containerisation solide et d’interfaces d’outils méticuleusement conçues, les développeurs peuvent créer des applications LLM puissantes qui soient à la fois nouvelles et sûres. Les exemples fournis dans ce tutoriel montrent des étapes pratiques pour construire ces environnements sécurisés, vous permettant ainsi d’intégrer en toute confiance des agents LLM dans vos systèmes tout en minimisant les risques.
🕒 Published: