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 :

CoucheCe qu'elle empêcheConcrètement
Allowlist ferméeUn nom de tool inventé ou détourné déclenche du code arbitraireDispatch via un tableau fermé, jamais $nom(...) ni call_user_func($nom, ...)
Validation du schémaDes arguments malformés ou hors périmètre atteignent le handlerVérifier type et forme avant d'exécuter, rejeter sinon — jamais de confiance implicite
Isolation d'exécutionUn tool compromis ou buggé affecte le process principalproc_open restreint : répertoire dédié, environnement vide, timeout
La première couche coûte presque rien et bloque déjà la majorité des cas. La troisième ne se justifie que pour les tools qui touchent au système de fichiers, au réseau, ou exécutent du code externe.

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.