Le bac à sable des commandes
« Lancer les tests » paraît anodin. Mais la suite de tests est du code, et l'agent peut l'avoir modifiée : un conftest.py ajouté, un test qui démarre un serveur et ne s'arrête jamais, une fixture qui lit os.environ et envoie votre clé d'API à une URL glissée dans un fichier README piégé. Le jour où cela arrive, la question n'est pas de savoir si le modèle « voulait » le faire. C'est de savoir ce que le processus pouvait faire.
Le mécanisme
Le bac à sable de ce projet empile quatre protections, chacune contre une défaillance précise.
| Protection | Défaillance visée | Mise en œuvre |
|---|---|---|
| Pas de shell | chaînage pytest; curl …, redirections | subprocess.Popen(argv), jamais shell=True |
| Délai + groupe de processus | test bloqué, serveur orphelin | start_new_session=True, os.killpg(…, SIGKILL) |
| Environnement vidé | fuite de clé ou de jeton cloud | seules PATH, LANG, LC_ALL, HOME, TMPDIR passent |
| Réseau coupé | exfiltration, téléchargement de code | unshare -rn : espace de noms réseau vide |
| Répertoire de travail | commande lancée ailleurs | cwd= racine du dépôt |
Un espace de noms réseau (network namespace) est une pile réseau isolée du noyau Linux. unshare -rn en crée un vide, sans autre interface que la boucle locale, en se faisant passer pour root dans un espace de noms utilisateur (-r), donc sans privilège réel.
flowchart LR
A["run_command('python -m pytest -q')"] --> B["shlex.split : argv"]
B --> C["python → interpréteur du harness"]
C --> D{"unshare -rn disponible ?"}
D -->|"oui"| E["unshare -rn argv"]
D -->|"non"| F["argv + avertissement journalisé"]
E --> G["Popen : env minimal, cwd = dépôt, nouvelle session"]
F --> G
G --> H{"fini avant le délai ?"}
H -->|"oui"| I["code de sortie + sortie"]
H -->|"non"| J["killpg SIGKILL + « arrêté après N s »"]
La mise en œuvre
SAFE_ENV = ("PATH", "LANG", "LC_ALL", "HOME", "TMPDIR")
def run(argv: list[str], cwd: Path, timeout_s: int, isolate_network: bool = True) -> Result:
if argv and argv[0] in ("python", "python3"):
argv = [sys.executable, *argv[1:]]
isolated = isolate_network and network_isolation_available()
full = (["unshare", "-rn"] if isolated else []) + argv
env = {k: os.environ[k] for k in SAFE_ENV if k in os.environ}
proc = subprocess.Popen(full, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL, text=True, start_new_session=True)
try:
out, _ = proc.communicate(timeout=timeout_s)
return Result(proc.returncode, out, False, isolated)
except subprocess.TimeoutExpired:
os.killpg(proc.pid, signal.SIGKILL) # tue aussi les petits-enfants
out, _ = proc.communicate()
return Result(-9, (out or "") + f"\n[arrêté après {timeout_s} s]", True, isolated)network_isolation_available() lance une fois unshare -rn true et met le résultat en cache. Si l'isolation manque (macOS, conteneur sans espaces de noms utilisateur, Ubuntu 24.04 avec la restriction AppArmor active), la commande tourne quand même, mais la sortie renvoyée au modèle porte la mention « réseau non isolé » et un avertissement part dans les journaux. Le choix est délibéré pour un poste de développement ; en CI ou sur un serveur partagé, inversez-le et refusez de lancer.
stdin=subprocess.DEVNULL compte aussi : sans lui, un test qui appelle input() attend indéfiniment une saisie qui ne viendra pas, et seul le délai le débloque.
Résultat obtenu
$ .venv/bin/python -m pytest -v tests/test_sandbox.py # 2026-10-15, Linux 6.8
tests/test_sandbox.py::test_delai_tue_le_processus PASSED
tests/test_sandbox.py::test_aucune_cle_transmise PASSED
tests/test_sandbox.py::test_repertoire_de_travail PASSED
tests/test_sandbox.py::test_reseau_coupe PASSED
tests/test_sandbox.py::test_isolation_exigee_mais_absente PASSED
5 passed in 1.12sUne requête HTTPS lancée dans le bac à sable échoue dès la résolution DNS : URLError: [Errno -3] Temporary failure in name resolution.
Ce que ce bac à sable ne fait pas
Soyez précis avec votre équipe de sécurité : ce bac à sable ne confine pas le système de fichiers. Un test peut lire ~/.ssh/id_ed25519 ou écrire dans ~/.bashrc, avec vos droits. Le réseau coupé empêche l'exfiltration directe, pas la persistance. Pour confiner aussi les fichiers sur Linux, bubblewrap (bwrap) monte la racine en lecture seule, masque le répertoire personnel et ne rend inscriptible que le dépôt : c'est l'outil qu'utilisent le bac à sable de Claude Code et celui de Codex sur Linux. L'exercice le branche.
Ce qui casse en production
- Processus orphelins. Un test lance
uvicornen sous-processus ; le délai tuepytestmais pas le serveur, qui garde le port 8000. Les runs suivants échouent avec « address already in use ». Parade :start_new_session=Trueetkillpg, jamaisproc.kill()seul. - Isolation silencieusement absente. Sur Ubuntu 24.04 et suivantes, y compris des images de CI, la politique AppArmor par défaut peut interdire les espaces de noms utilisateur non privilégiés :
unshare -rnéchoue, le test réseau est ignoré, et personne ne lit l'avertissement. Parade : en CI,ISOLATION_REQUIRED=1; le harness refuse alors d'exécuter (code 126) au lieu de tourner avec le réseau. - Dépendances qui téléchargent. Une suite qui installe un paquet au premier lancement échoue en réseau coupé, et l'agent tente alors de « corriger » le code. Parade : préparer l'environnement hors de la boucle (
make setup), et dire au modèle, dans la sortie, que le réseau est coupé. - Sortie gigantesque. Un test en boucle d'affichage produit 200 Mo avant le délai. Parade côté harness : troncature avant d'entrer dans le contexte (leçon 5) ; côté bac à sable, lire la sortie par blocs avec un plafond, si vos suites sont bavardes.
La règle de décision
Sur un poste de développeur, unshare -rn plus un environnement vidé couvre la fuite de clés et l'exfiltration réseau, en moins de 60 lignes. Dès que l'agent tourne sans humain (CI, file de tickets) ou sur un dépôt dont vous ne maîtrisez pas le contenu, passez à bwrap ou à un conteneur jetable par session, comme OpenHands. Surveillez dans le journal la proportion de commandes exécutées avec « réseau non isolé » : elle doit être nulle là où vous l'exigez.
Pratique
Après un run d'agent, le port 5432 de votre poste reste occupé et les tests suivants échouent. Le journal montre un run_command arrêté par le délai. Quelle correction appliquez-vous ?
Le délai a tué le processus parent, pas ses descendants : la base lancée par la fixture survit. Seul le groupe de processus les atteint tous. Allonger le délai déplace le problème. terminate() ne vise toujours que le parent. Interdire les tests d'intégration retire de la valeur sans corriger la cause.
Votre RSSI demande si le bac à sable empêche un test malveillant de voler la clé SSH du développeur. Quelle réponse est exacte pour ce projet ?
cwd ne confine rien, c'est un point de départ. Vider l'environnement protège les clés en variables, pas les fichiers. Le réseau coupé bloque l'envoi direct, mais une copie de la clé écrite dans le dépôt peut partir au prochain git push. D'où bwrap ou un conteneur quand la question est posée.
Exercice. Ajoutez une variante bwrap : racine en lecture seule, répertoire personnel masqué, .venv en lecture seule, dépôt inscriptible, /tmp vide, réseau coupé.
Voir la solution
def bwrap_prefix(repo: Path, venv: Path) -> list[str]:
home = str(Path.home())
return ["bwrap", "--ro-bind", "/", "/", "--tmpfs", home,
"--ro-bind", str(venv), str(venv), # remonté après le masquage de home
"--bind", str(repo), str(repo),
"--tmpfs", "/tmp", "--dev", "/dev", "--proc", "/proc",
"--unshare-net", "--die-with-parent", "--chdir", str(repo)]L'ordre compte : --tmpfs $HOME masque tout le répertoire personnel, puis les --bind suivants y remontent seulement le dépôt et l'environnement virtuel. Testé le 2026-10-15 : lire ~/.bashrc donne FileNotFoundError, écrire dans /usr donne OSError, écrire dans le dépôt réussit. Utilisez un chemin absolu vers l'interpréteur : sous bwrap, le chemin relatif .venv/bin/python n'est pas résolu depuis le bon répertoire.
- Je lance chaque commande sans shell, avec délai et destruction du groupe de processus
- Je transmets un environnement minimal, sans aucune clé
- Je coupe le réseau et je sais détecter quand l'isolation manque
- Je sais dire précisément ce que mon bac à sable ne protège pas