Un LLM ne fait jamais qu'une chose avec un tool : proposer de l'appeler, avec un nom et des arguments. Rien ne l'empêche de halluciner un nom de tool qui n'existe pas, de forger des arguments hors de tout schéma, ou — pire — de le faire sur instruction d'un contenu piégé qu'il vient de lire. Le sandboxing, ce n'est pas empêcher le modèle de proposer n'importe quoi : c'est s'assurer que ton code ne peut pas exécuter n'importe quoi.
Une autre échelle que le serveur MCP
Sécuriser un serveur MCP traite la question au niveau du protocole : scoper les droits d'un serveur, auditer les descriptions d'outils, éviter le confused deputy. Ici, l'échelle est plus locale — celle de n'importe quel agent qui exécute des tools dans son propre process, MCP ou pas. Entre la décision du LLM et l'exécution réelle, il y a une frontière de code, et c'est cette frontière qu'on sandboxe.
Trois couches, dans l'ordre
Aucune des trois ne remplace les autres. Elles interviennent en série, chacune bloquant une classe de problème différente :
| Couche | Ce qu'elle empêche | Concrètement |
|---|---|---|
| Allowlist fermée | Un nom de tool inventé ou détourné déclenche du code arbitraire | Dispatch via un tableau fermé, jamais $nom(...) ni call_user_func($nom, ...) |
| Validation du schéma | Des arguments malformés ou hors périmètre atteignent le handler | Vérifier type et forme avant d'exécuter, rejeter sinon — jamais de confiance implicite |
| Isolation d'exécution | Un tool compromis ou buggé affecte le process principal | proc_open restreint : répertoire dédié, environnement vide, timeout |
Le dispatch : un tableau fermé, pas une fonction dynamique
PHP permet d'appeler une fonction dont le nom est dans une variable. C'est exactement le réflexe à éviter ici, pour la même raison qu'eval() est à proscrire dans un tool de calcul en Python : le nom vient in fine d'une sortie de modèle, potentiellement influencée par un contenu externe. La liste des tools valides doit être un tableau fermé, jamais une résolution dynamique :
final class Tool
{
public function __construct(
public readonly string $name,
public readonly array $schema,
public readonly \Closure $handler,
) {
}
}
$tools = [
'get_weather' => new Tool(
name: 'get_weather',
schema: ['city' => 'string'],
handler: static fn (string $city): string => weather_lookup($city),
),
'run_readonly_query' => new Tool(
name: 'run_readonly_query',
schema: ['sql' => 'string', 'params' => 'array'],
handler: static fn (string $sql, array $params): array => run_readonly_query($sql, $params),
),
];
function dispatch(array $tools, string $name, array $arguments): mixed
{
if (!isset($tools[$name])) {
// le nom vient du modèle : ne jamais supposer qu'il désigne un tool existant
throw new \RuntimeException("tool inconnu : {$name}");
}
$tool = $tools[$name];
validateArguments($arguments, $tool->schema);
return ($tool->handler)(...$arguments);
}
Rien dans dispatch() ne peut faire exécuter autre chose que ce qui est déjà dans $tools. Ajouter un tool, c'est ajouter une ligne dans ce tableau — jamais une chaîne qui traverse le code jusqu'à un point d'exécution.
Valider les arguments avant d'exécuter
Un nom de tool correct ne garantit rien sur ses arguments. Le modèle peut envoyer un city de 50 Ko, un params qui n'est pas un tableau, ou un champ requis absent. La validation doit être déterministe et se faire avant tout appel au handler, jamais dans le handler lui-même :
function validateArguments(array $arguments, array $schema): void
{
foreach ($schema as $field => $type) {
if (!array_key_exists($field, $arguments)) {
throw new \InvalidArgumentException("argument manquant : {$field}");
}
$actual = get_debug_type($arguments[$field]);
if ($actual !== $type) {
throw new \InvalidArgumentException(
"type invalide pour {$field} : attendu {$type}, reçu {$actual}"
);
}
}
}
C'est volontairement minimal — un vrai JSON Schema avec bornes de longueur et formats couvrirait plus de cas. Le principe compte plus que l'implémentation : le handler ne doit jamais recevoir un argument qui n'a pas déjà été vérifié.
Isoler l'exécution avec proc_open
Pour un tool en lecture seule sur des données déjà maîtrisées, le dispatch fermé et la validation suffisent. Pour un tool qui touche au système de fichiers, lance une commande, ou exécute du code fourni par le modèle, une troisième couche s'impose : faire tourner ce tool dans un process séparé, avec le minimum de droits.
function runIsolated(string $command, array $args, string $cwd, int $timeoutSeconds = 5): string
{
$process = proc_open(
[$command, ...$args],
[1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
$pipes,
$cwd,
[], // environnement vide : pas de clé API ni de PATH hérité du process parent
['bypass_shell' => true] // exécution directe, pas de /bin/sh à injecter
);
if (!\is_resource($process)) {
throw new \RuntimeException('impossible de lancer le tool isolé');
}
stream_set_blocking($pipes[1], false);
$start = microtime(true);
$output = '';
while (proc_get_status($process)['running']) {
$output .= stream_get_contents($pipes[1]);
if (microtime(true) - $start > $timeoutSeconds) {
proc_terminate($process, 9);
throw new \RuntimeException('tool arrêté : dépassement du délai');
}
usleep(50_000);
}
$output .= stream_get_contents($pipes[1]);
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
return $output;
}
Trois choses font le travail ici : un cwd dédié qui borne les accès fichier relatifs, un environnement vide qui empêche le tool de récupérer une clé API ou une variable sensible du process principal, et un timeout explicite — sans lui, un tool qui boucle bloque la boucle agent tout entière, pas seulement lui-même.
Ce que ça coûte
proc_open a un coût réel (latence de lancement du process, complexité de gestion des pipes) qui ne se justifie pas pour un tool qui fait une addition ou une lecture en base avec une requête préparée. La règle pratique : dispatch fermé et validation partout, isolation par processus seulement pour les tools dont l'échec ou le détournement aurait un effet en dehors du périmètre attendu — fichiers, réseau, commandes système.
Ce sandboxing côté exécution ferme la boucle d'un parcours qui a commencé par la question amont — que faire du contenu non maîtrisé qu'un agent lit — puis par la question aval — que faire de ce qu'un agent affiche. Ici, c'est ce qui se passe entre les deux : ce qu'un agent a le droit de faire une fois qu'il a décidé d'agir.
FAQ
Faut-il sandboxer tous les tools d'un agent ?
Le dispatch fermé et la validation des arguments, oui, systématiquement — leur coût est négligeable. L'isolation par processus ne se justifie que pour les tools à effet de bord réel (fichiers, réseau, commandes) ; un tool de lecture pure sur des données déjà maîtrisées n'en a pas besoin.
Un timeout suffit-il à sécuriser un tool isolé ?
Non, seul mais nécessaire. Sans lui, un tool qui boucle bloque le process entier. Il doit se combiner avec un environnement restreint (pas de secrets hérités) et un périmètre de fichiers limité — le timeout couvre la disponibilité, pas la confidentialité ni l'intégrité.
Pourquoi ne pas valider les arguments dans le handler du tool lui-même ?
Parce que ça duplique la validation dans chaque handler, avec le risque qu'un nouveau tool oublie une vérification. La centraliser avant le dispatch garantit qu'aucun handler ne peut être atteint avec un argument non conforme, quel que soit qui l'a écrit.