Article
Un bac à sable JavaScript pour agents : QuickJS et WebAssembly
Comment arcate exécute le JavaScript d'un agent sans risque : QuickJS compilé en WebAssembly, runtime Chicory pur-JVM, modèle deny-all, une seule porte d'I/O.
Décider qu'un agent doit écrire du code plutôt qu'appeler des outils est une chose. Exécuter ce code — produit par un modèle, donc imprévisible — sans ouvrir une faille en est une autre. Voici comment nous l'avons résolu, et les briques que nous avons assemblées.
La doctrine est posée ailleurs : un agent agit mieux en écrivant du code qu'en appelant une myriade d'outils sur mesure (voir Outiller un agent : du code, pas des outils sur mesure). Mais cette doctrine a un prix d'ingénierie : il faut un endroit où faire tourner du JavaScript arbitraire, généré par un LLM, sans qu'il puisse lire un secret, taper une base ou contacter n'importe quel serveur. Ce texte est un retour d'expérience sur cet endroit — notre bac à sable JavaScript.
La contrainte : isoler sans payer un conteneur par appel
La solution évidente — lancer chaque script dans un conteneur ou un processus jetable — fonctionne, mais coûte cher : démarrage, ordonnancement, nettoyage, à chaque appel. Or un agent peut exécuter du code des dizaines de fois dans une seule conversation. Nous voulions une isolation forte in-process, dans la JVM, sans sous-processus ni dépendance native, et avec une latence d'amorçage proche de zéro.
Les briques : QuickJS, WebAssembly, Chicory
L'assemblage retenu enchaîne trois pièces :
- QuickJS — un petit moteur JavaScript embarquable, complet et léger, plutôt qu'un moteur lourd ou un Node embarqué.
- WebAssembly — QuickJS est compilé en Wasm, et c'est la clé du modèle de sécurité : un module Wasm est deny-all par construction. Le code à l'intérieur calcule librement, mais ne touche le monde extérieur que par les fonctions hôtes qu'on lui tend explicitement.
- Chicory — un runtime WebAssembly écrit entièrement en Java. Pas de binaire natif, pas de JNI, rien à compiler pour la cible : le sandbox tourne partout où tourne la JVM. C'est lui qui exécute le QuickJS-en-Wasm, in-process.
Le tout est relié par la bibliothèque quickjs4j, qui expose QuickJS au code Java et permet d'enregistrer les fonctions hôtes. Concrètement, le chemin est : JavaScript de l'agent → QuickJS (en Wasm) → Chicory (pur JVM) → notre processus, sans jamais en sortir.
Le modèle de sécurité : trois portes, pas une de plus
Puisque Wasm interdit tout par défaut, la surface d'exposition se résume aux fonctions hôtes que l'on choisit d'ouvrir. Il y en a exactement trois :
- fetch — la seule porte d'entrée/sortie, soumise à une liste blanche de domaines : un script n'appelle que l'API de la plateforme et les hôtes explicitement autorisés (garde anti-SSRF, redirections jamais suivies). Surtout, le jeton d'authentification n'entre jamais dans le JavaScript : il est posé côté Java au moment de l'appel. Idem pour les clés d'API tierces — résolues dans le compte appelant et injectées dans l'en-tête côté serveur, invisibles du script.
- localStorage — un magasin clé-valeur pour passer de l'état entre étapes, dont le backend est tenu côté serveur.
- crypto — entropie, hachage et HMAC calculés en Java (jamais un Math.random faiblard).
Au-dessus de ces trois fonctions, un prélude injecté avant chaque script reconstruit les API web que le modèle attend — fetch à la forme standard, Headers, URL, crypto, localStorage, atob/btoa — pour que l'agent écrive du JavaScript ordinaire, pas un dialecte maison.
Les bornes
Un code généré peut boucler ou gonfler à l'infini. Deux garde-fous : un budget de temps d'exécution (compilation comprise), et un plafond de mémoire linéaire Wasm (64 Mio par défaut) qui transforme une allocation galopante en erreur propre plutôt qu'en fuite.
Les bibliothèques que le modèle connaît déjà
Le sandbox embarque une poignée de bibliothèques JavaScript répandues — decimal.js pour l'arithmétique exacte, js-yaml, dayjs, papaparse, marked, fast-xml-parser, diff, jsonpath-plus — chargées paresseusement : une lib n'est injectée que si le script mentionne son nom, pour ne payer son coût de compilation que lorsqu'elle sert. L'idée rejoint la doctrine : on expose au modèle l'API exacte, inchangée, qu'il a déjà vue mille fois à l'entraînement, plutôt qu'un wrapper maison à réapprendre.
Ce que ce n'est pas
Honnêteté d'ingénieur : ce bac à sable est une frontière applicative, pas une frontière système. Le code tourne dans la JVM ; Wasm le prive d'accès, mais ce n'est ni un conteneur ni un hyperviseur. Pour ce qui exige une isolation forte ou de vraies capacités système — système de fichiers, accès à des bibliothèques natives, à une base — nous avons un second étage : un exécuteur Ruby dans un conteneur dédié, derrière une unique entrée HTTP. Deux outils, deux niveaux d'isolement, selon ce que le code doit pouvoir faire : le JavaScript in-process pour la grande majorité des cas — manipuler des données, appeler l'API — ; le conteneur Ruby quand il faut vraiment sortir de la JVM.
La doctrine derrière ce choix : Outiller un agent : du code, pas des outils sur mesure.
Laisser un commentaire
Vous devez être connecté pour pouvoir laisser un commentaire.