← Retour à tous les articles
Publié le · par Renaud Deraison

Les politiques OpenShell dans Bromure, et le raisonnement derrière chaque connexion

OpenShell, de NVIDIA, décrit dans un seul fichier de politique ce qu'un agent de codage a le droit de toucher. Bromure Agentic Coding 5.4 applique ces politiques dans la VM de l'agent, avec Landlock et seccomp à l'intérieur et les règles réseau sur votre Mac, et surveille le noyau invité avec un module sentinelle. Il relie ensuite chaque connexion réseau aux processus qui l'ont ouverte, à l'appel d'outil qui les a lancés et au raisonnement que l'agent a donné pour cet appel.

Un agent de codage a besoin d'un vrai accès pour être utile, et c'est la politique qui en fixe l'étendue. Elle fonctionne mieux sous la forme d'un seul document que l'on peut lire, vérifier et appliquer, plutôt que de réglages éparpillés entre un pare-feu, des options de montage et la configuration de l'agent lui-même.

OpenShell, de NVIDIA

OpenShell est le runtime open source de NVIDIA pour les agents, publié sous licence Apache 2.0. Nous nous sommes appuyés sur son format de politique, où un seul document YAML couvre quatre domaines :

  • filesystem_policy et landlock : les chemins que l'agent peut lire et écrire, appliqués par le module de sécurité Landlock du noyau Linux.
  • process : l'utilisateur et le groupe sous lesquels l'agent s'exécute.
  • network_policies : les destinations que chaque programme peut joindre, et les requêtes qu'il peut y envoyer, jusqu'à la méthode et au chemin HTTP. Tout ce qui n'est pas autorisé est refusé : « OpenShell refuse toute connexion sortante d'un bac à sable, sauf si une règle de network_policies l'autorise. »

OpenShell fournit aussi un prouveur qui utilise un solveur SMT pour vérifier si une politique autorise quoi que ce soit au-delà d'une autre. Une organisation peut fixer une limite et obtenir un oui ou un non à chaque modification, au lieu de lire des diffs.

C'est une conception soignée, due à des gens qui ont manifestement étudié la façon dont les agents dérapent, et elle est ouverte : nous sommes donc partis de là.

OpenShell dans Bromure

Dans Bromure Agentic Coding 5.4, un espace de travail peut utiliser une politique OpenShell à la place des règles de pare-feu propres à Bromure. Nous avons repris le format de politique et sa sémantique, pas le code : le moteur est le nôtre, sur le Mac, avec nos propres composants dans l'invité Linux. Pour nous assurer que les deux concordent, nous avons fait tourner notre moteur et le moteur de référence d'OpenShell sur 125 666 cas générés, couvrant les règles réseau, les règles HTTP, MCP, GraphQL, WebSocket et les vérifications d'adresse de destination. Ils n'ont divergé sur aucun. Ces tests ont fait apparaître des astuces de chemin comme ..%3b et les barres obliques doublement encodées (%252F), que nous traitons désormais comme OpenShell.

Bromure se distingue d'OpenShell par l'endroit où la politique est appliquée. OpenShell lance ses bacs à sable via une passerelle et un runtime de conteneurs. Bromure exécute chaque espace de travail dans sa propre VM Linux jetable sur votre Mac, la politique est donc répartie entre les deux côtés :

  • Dans la VM : Landlock pour le système de fichiers, seccomp, no_new_privs et le passage à l'utilisateur de la politique. La section suivante explique comment.
  • Sur le Mac : les règles réseau. Chaque connexion quitte la VM par le commutateur virtuel de Bromure, qui vérifie la destination et le port, et par son proxy TLS, qui vérifie les méthodes et chemins HTTP ainsi que les messages MCP, GraphQL et WebSocket. Le Mac résout et épingle les adresses, si bien que le DNS ne peut pas servir à contourner une règle allowed_ips. Root dans la VM ne peut pas modifier une règle qui vit en dehors d'elle.

La politique peut aussi désigner le programme autorisé à établir une connexion. Avec le mode strict activé, Bromure vérifie quel binaire a ouvert la connexion, par son hash : curl peut joindre api.github.com, un script Python ne le peut pas. Combiné à une règle HTTP comme GET uniquement sur api.github.com/repos/**, un DELETE est refusé au niveau du proxy. Landlock ne peut rien faire de tout cela : il ne confine que les fichiers.

Quand une organisation fixe une limite depuis bromure.io, Bromure vérifie chaque politique d'espace de travail par rapport à elle avec l'openshell-prover de NVIDIA s'il est installé, et avec une vérification intégrée plus stricte sinon. Une politique qui sort de la limite ne peut pas être enregistrée.

Nous avons fait un changement délibéré : chaque section ne s'applique que si elle est présente. Une politique sans filesystem_policy laisse Landlock désactivé, et une politique sans process ne change pas d'utilisateur. OpenShell remplit des valeurs par défaut parce que ses pilotes envoient toujours un document complet. Dans Bromure, adopter OpenShell ne casse jamais un espace de travail en silence. Cela donne trois niveaux :

La politique contientAppliqué dans la VMCe qui est garanti
Uniquement des règles réseauRien ; l'agent garde sudoLa politique réseau, et l'observation par la sentinelle noyau
filesystem_policyLandlock, appliqué tant qu'on est encore rootUn confinement du système de fichiers qui tient même face à root
Une section process, ou le mode strictLandlock, seccomp, no_new_privs, un utilisateur non root ; sudo et docker retirésTout ce qui précède, aucun chemin vers root, et aucun moyen d'arrêter les processus de Bromure

Quelques champs sont acceptés sans être appliqués. Les principaux sont les champs de réécriture et de signature des identifiants : Bromure garde déjà les vrais identifiants sur le Mac et les substitue à la sortie, si bien que l'agent ne détient jamais que des substituts.

Dans la VM : Landlock, seccomp et un abandon de privilèges

La partie invitée est une réimplémentation du code d'application de la crate sandbox-linux d'OpenShell. Elle est écrite en Python avec ctypes, parce qu'elle doit s'exécuter dans la VM au démarrage, où il n'y a pas de chaîne d'outils Rust. Elle fait les mêmes appels système que les crates Rust landlock et seccompiler, et un banc de test différentiel compile les vraies crates d'OpenShell et compare les deux chemin par chemin et appel système par appel système.

Un superviseur root, bromure-sandboxd, lance chaque session d'agent. Il place la session dans son propre cgroup, le cgroup du bac à sable, puis la confine en trois étapes.

Landlock. Landlock permet à un processus non privilégié de se restreindre lui-même, ainsi que tous les processus qu'il lance, à une liste de chemins. Le superviseur :

  1. appelle landlock_create_ruleset avec les droits que régit le jeu de règles. Sur le noyau 6.8 de l'invité (ABI Landlock 4), ce sont l'exécution, la lecture et l'écriture de fichiers, le listage des répertoires, la création et la suppression de chaque type de fichier, le renommage d'un répertoire à l'autre (REFER) et la troncature ;
  2. ouvre chaque chemin de read_only et read_write avec O_PATH et l'ajoute avec landlock_add_rule, sous forme de règle PATH_BENEATH portant les droits de lecture ou la totalité des droits ;
  3. appelle landlock_restrict_self.

Dès lors, tout accès que régit le jeu de règles et qu'aucune règle n'accorde échoue avec EACCES, quel que soit le demandeur. Landlock vérifie la tâche, pas l'utilisateur, donc sudo ne l'élargit pas. Un processus peut empiler d'autres jeux de règles, mais jamais en retirer un, et c'est pourquoi un changement sur le système de fichiers exige un redémarrage de la VM alors que les règles réseau s'appliquent à chaud. landlock.compatibility décide de ce qui se passe sur un noyau qui n'a pas l'ABI : best_effort continue avec ce que le noyau propose, et hard_requirement refuse de lancer la session.

seccomp. En mode strict, ou quand la politique a une section process, le superviseur active no_new_privs et installe les trois filtres seccomp d'OpenShell :

  • Le filtre principal autorise par défaut et bloque ce qui permet de contourner le bac à sable. Cela inclut ptrace, bpf, io_uring_setup, userfaultfd, perf_event_open, process_vm_readv et process_vm_writev, toute la famille des montages, et setns. Il bloque memfd_create et execveat avec AT_EMPTY_PATH, parce qu'exécuter du code depuis un fichier anonyme contourne Landlock. Il bloque unshare et clone avec CLONE_NEWUSER, parce qu'un nouvel espace de noms utilisateur distribue des capacités. Il bloque enfin les sockets des familles packet, Bluetooth et vsock, ainsi que netlink hors routage.
  • Un filtre de compatibilité répond à clone3 par ENOSYS. Un filtre seccomp peut lire la valeur des registres, mais pas suivre des pointeurs, et clone3 passe ses drapeaux par un pointeur. ENOSYS pousse la glibc à se rabattre sur clone, dont le filtre principal peut vérifier les drapeaux.
  • Un filtre de durcissement empêche la session d'atteindre son superviseur, qui s'exécute sous le même utilisateur : pas de ptrace, process_vm_*, pidfd_getfd ni kcmp, et aucun signal vers le processus ou le groupe du superviseur.

Nous nous écartons d'OpenShell à deux endroits, et uniquement sur ce qui est signalé ou sur la cible des signaux. Nos filtres portent SECCOMP_FILTER_FLAG_LOG, si bien qu'un appel système refusé atteint le point d'audit du noyau, où la sentinelle le voit. Et la règle sur les signaux est resserrée pour que le contrôle des tâches du shell (kill %1) fonctionne toujours dans un terminal interactif.

L'abandon de privilèges. run_as_user fait passer la session sous l'utilisateur de la politique, sans aucun des groupes de l'utilisateur par défaut : donc ni sudo ni docker. Le répertoire personnel de l'utilisateur est monté dans la session via un montage idmappé, si bien que les fichiers créés par la session appartiennent toujours à l'utilisateur par défaut sur le disque, et que la configuration de l'agent s'applique toujours.

Une sentinelle dans le noyau invité

Pour enregistrer ce qu'un agent a réellement fait, Bromure charge dans la VM son propre module noyau, la sentinelle. Sa conception part du principe que l'agent peut devenir root dans la VM : rien de ce que root pourrait arrêter ne compte comme preuve, et la capture, la mise en mémoire tampon et le transport s'exécutent tous dans le noyau.

Capture. La sentinelle enregistre des kprobes depuis l'espace noyau :

  • les lancements de programmes, sur security_bprm_committed_creds, avec la ligne de commande et l'heure de démarrage du processus ;
  • les changements d'identité : les familles setuid et setgid, capset et commit_creds ;
  • ptrace, les chargements et déchargements de modules, bpf, kexec_load, mount, unshare et setns ;
  • les nouveaux flux réseau : TCP à l'envoi du premier SYN (tcp_connect), UDP au premier envoi vers une destination, ainsi que ICMP et les sockets bruts, chacun avec la chaîne d'au plus huit processus qui en est à l'origine ;
  • les appels système refusés, sur audit_seccomp ;
  • les opérations sur fichier refusées, via des kretprobes sur les points de sécurité fichier du noyau : security_file_open, et les points security_path_* pour mkdir, rmdir, unlink, symlink, link, rename et truncate. Quand l'un d'eux renvoie EACCES ou EPERM pour un processus du cgroup du bac à sable, c'est un refus. L'exécution d'un fichier que Landlock n'autorise pas apparaît comme une ouverture refusée avec FMODE_EXEC.

Le superviseur donne une seule fois à la sentinelle l'identifiant du cgroup du bac à sable, par un paramètre de module que l'on peut définir mais jamais modifier. Root ne peut pas le faire pointer ailleurs pour que l'activité de l'agent soit étiquetée comme celle de Bromure.

Transport. Un thread noyau ouvre un socket AF_VSOCK vers le Mac avec sock_create_kern. Le socket n'a pas de descripteur de fichier : il n'apparaît sous aucun processus dans /proc et rien dans l'espace utilisateur ne peut le fermer. Les événements passent par un anneau de 256 emplacements, et chaque type est limité à 64 par seconde, avec des rafales jusqu'à 256. Chaque trame est une longueur sur 4 octets suivie de JSON.

Rester chargée. Le module n'a pas de fonction de sortie, donc le noyau refuse de le décharger, et il détient en plus une référence sur lui-même. Juste après son chargement, le chargeur relève le verrouillage du noyau à integrity, ou à confidentiality quand l'espace de travail exige la sentinelle. Dès lors, root ne peut plus charger de module non signé, écrire dans la mémoire du noyau via /dev/mem ni faire un kexec vers un noyau sans la sentinelle.

Prouver qu'elle est en vie. Chaque seconde, la sentinelle envoie un battement de cœur avec un numéro de séquence, le nombre d'événements perdus et limités, et le nombre de sondes armées. Ce nombre seul peut être trompé : désactiver toutes les kprobes d'un coup via debugfs laisse intacts les drapeaux propres à chaque sonde, et le compte paraît sain alors que plus rien ne se déclenche. Pour le détecter, la sentinelle garde une kprobe sur une fonction vide qui lui appartient et l'appelle une fois par battement de cœur. Si le compteur de déclenchements ne bouge pas, les sondes ne se déclenchent pas, quelle qu'en soit la raison, et le battement de cœur le signale.

Prouver qu'elle est la sentinelle. Chaque chargement génère un secret de 32 octets. Le premier message de salutation le transporte, et le Mac l'épingle pour ce démarrage. Une reconnexion ne répète jamais le secret. Elle envoie à la place sha256(secret, boot_id, connection number), si bien qu'un processus qui aurait lu le secret en mémoire après la première salutation ne peut toujours pas se faire passer pour le module. Une deuxième sentinelle, un mauvais secret, un numéro de connexion réutilisé, un trou dans la séquence ou 15 secondes de silence comptent tous comme une altération. Il ne reste à root que la possibilité de faire planter ou d'éteindre la VM, ce que le Mac voit comme un silence.

Distribution. Les modules ne sont pas compilés dans la VM. La CI en construit un pour chacune des 20 versions du noyau Ubuntu 24.04, deux fois, et les deux builds doivent être identiques à l'octet près. Chaque module et le catalogue qui les liste sont signés avec une clé ed25519, et l'application refuse un module dont la signature ou le hash ne correspond pas.

Une leçon tirée de sa construction : pendant un temps, la sonde des refus sur fichier comparait une valeur de retour non signée à -EACCES et ne s'est jamais déclenchée une seule fois. Les tests vérifiaient seulement que la sonde était enregistrée, ce qu'elle était toujours. Ils vérifient désormais qu'un refus produit un événement.

Juger les événements. Sur le Mac, les événements sont jugés sur ce qu'ils signifient, pas sur l'appel système qui s'est déclenché. Chacun ajoute un poids à un score, et 20 en 60 secondes déclenchent l'alarme :

ÉvénementPoids
Une altération de la sentinelle, ou root obtenu depuis un bac à sable strict20
Un module non signé, un chargement BPF, un kexec, une tentative d'abaisser le verrouillage ; un même programme refusé 20 fois en une minute10
Un ptrace, ou un appel système refusé par seccomp3
Un montage ou un changement d'espace de noms ; des événements perdus2
Une opération sur fichier refusée par le bac à sable1
Un sudo dans un espace de travail qui autorise sudo0, affiché dans la chronologie

Une alarme qui se déclenche dès la première minute apprend aux gens à ignorer les vraies. Les tâches de maintenance de Bromure s'exécutent donc hors du bac à sable et n'apparaissent jamais comme des refus, et les événements liés à l'assemblage de la VM par Bromure au démarrage sont comptés, sans déclencher d'alarme, pendant une fenêtre de démarrage fixe. Quand l'alarme se déclenche, Bromure peut mettre l'espace de travail en quarantaine : il coupe le réseau de la VM et la met en pause.

La lignée : le raisonnement derrière chaque connexion

Un journal réseau montre que quelque chose dans la VM s'est connecté à un hôte, mais pas pourquoi. Quand c'est un agent qui fait le travail, c'est le pourquoi qu'il vous faut connaître.

Bromure peut vous le dire, parce qu'il voit les deux bouts. Son proxy IA se place entre l'agent et le fournisseur du modèle et lit chaque réponse avant que l'agent n'agisse : il connaît donc chaque appel d'outil, la commande qu'il va exécuter et ce que le modèle a écrit juste avant. La sentinelle signale chaque programme lancé dans la VM, avec sa ligne de commande, et chaque nouvelle connexion, avec la chaîne de processus qui en est à l'origine. Bromure relie les deux.

Imaginons que vous demandiez à l'agent d'ajouter un contrôle de santé qui relance les requêtes vers votre API de paiement. Il décide qu'il lui faut un client HTTP et exécute npm install axios. npm récupère le paquet sur registry.npmjs.org, ce que la politique autorise. Puis un script postinstall contenu dans le téléchargement démarre et tente de joindre telemetry.example.net, ce qu'aucune règle n'autorise. La connexion est refusée au niveau du proxy sur le Mac, et la lignée montre comment c'est arrivé :

Vue de lignée de Bromure pour une connexion bloquée vers telemetry.example.net : le prompt, le raisonnement de l'agent et son intention déclarée, l'appel d'outil Bash npm install axios marqué comme correspondance exacte, cinq processus de claude à node exécutant scripts/postinstall.js en passant par bash, npm et sh, le flux TCP, et la décision Bloqué par la politique de requêtes, avec en dessous le flux autorisé vers registry.npmjs.org
La lignée d'une connexion bloquée : la demande, le raisonnement, l'appel d'outil, les cinq processus jusqu'au script postinstall, le flux et la décision. Le téléchargement autorisé depuis registry.npmjs.org figure sous le même appel d'outil.

L'agent n'a jamais tapé de commande qui ait joint telemetry.example.net. Un simple journal réseau montrerait un processus Node parlant à un hôte inconnu ; la lignée montre qu'il s'agissait du script d'installation d'une dépendance, sous le npm install que l'agent a lancé pour une raison qu'il a donnée.

Le rattachement se fait en trois étapes :

  1. Appel d'outil. Le proxy IA conserve pendant dix minutes chaque appel d'outil de la réponse du modèle : son identifiant (tool_use_id, ou le call_id d'OpenAI), la commande, et jusqu'à 2 000 caractères de ce que le modèle a écrit avant lui dans le même tour.
  2. Shell. À partir des lancements de programmes signalés par la sentinelle, le Mac tient une table des processus pour chaque VM, indexée par pid et heure de démarrage, si bien qu'un pid réutilisé ne peut pas hériter de l'historique d'un autre. Un shell lancé sous l'agent dans les 30 secondes qui suivent la réponse est rattaché à l'appel d'outil quand la commande apparaît dans sa ligne de commande, une fois défait l'échappement propre à l'agent. Claude Code, par exemple, exécute les commandes sous la forme bash -c '… eval '"'"'<command>'"'"' …'. C'est une correspondance exacte. Quand l'agent a réécrit la commande, Bromure se rabat sur le premier mot, la chronologie et l'ascendance, et marque la correspondance comme approximative.
  3. Décision. Un flux est associé à l'autorisation ou au refus du commutateur d'après son protocole, sa destination, son port et son port source, dans une fenêtre de 5 secondes, et à la décision du proxy quand celui-ci a inspecté la requête.

Chaque connexion sous le shell hérite du lien. Cela inclut le trafic qui passe par le pont proxy de Bromure, qui autrement ressemblerait entièrement à des connexions vers localhost. Le pont commence chaque flux vers le Mac par une ligne indiquant le port source du client, ce qui permet au Mac de faire correspondre la connexion locale à la vraie destination.

Dans la fenêtre Sécurité, Show the Whole Chain sur une ligne réseau ouvre cette vue.

Certains modèles passent directement à l'appel d'outil sans rien écrire avant. La vue montre alors le prompt et l'intention déclarée de l'appel. Les services d'arrière-plan et tout ce qui a été lancé en dehors d'un appel d'outil sont marqués comme non rattachés à un agent.

La lignée change aussi ce qui se passe après un blocage. Un agent qui ne voit qu'une connexion réinitialisée ou une erreur de permission y voit une panne et essaie un autre hôte, un autre port ou un autre outil, ce qui, vu de l'extérieur, ressemble à un agent qui part en vrille. Bromure dit à l'agent, dans sa propre session, que la politique a refusé la requête et pourquoi, et qu'il doit vous demander plutôt que de la contourner. Le tiroir Sandbox d'un espace de travail en cours d'exécution liste ce qui a été bloqué, avec un bouton Autoriser pour chaque entrée, et le conseiller de politique rédige des règles à partir des refus, que vous approuvez ou non.

Le tiroir Sandbox d'un espace de travail en cours d'exécution utilisant OpenShell + Sentry, listant quatre destinations bloquées avec le nombre de tentatives et la date de la dernière, un bouton Autoriser pour chacune, et la mention que les changements réseau s'appliquent à chaud
Le tiroir Sandbox liste les destinations bloquées. Les changements réseau s'appliquent à chaud.

Le raisonnement de l'agent est toujours affiché dans votre propre Chronologie de sécurité. Il n'est envoyé à la console d'une organisation sur bromure.io que si cette organisation l'active, ce qui n'est pas le cas par défaut, et jamais pour les profils privés. Les jetons présents dans les lignes de commande sont masqués avant que quoi que ce soit ne quitte le Mac.

Une boucle de 24 secondes sur un espace de travail de démonstration. La Chronologie de sécurité enregistre ce que la sentinelle et Landlock ont refusé : un processus Node qui cherche à atteindre les clés SSH et AWS, et un ptrace. Puis la connexion bloquée se déplie en sa lignée, lue du paquet jusqu'au raisonnement de l'agent.

L'activer

Les politiques OpenShell, la sentinelle noyau et la lignée arrivent dans Bromure Agentic Coding 5.4. Elles sont désactivées par défaut. Pour les activer sur un espace de travail, ouvrez les Réglages, puis la Politique du bac à sable de l'espace de travail, et réglez Sandbox engine sur OpenShell + Sentry. Vous pouvez y écrire la politique, ou importer un fichier YAML OpenShell existant.

Réglages d'un espace de travail Bromure, panneau Politique du bac à sable, avec le moteur de bac à sable réglé sur OpenShell + Sentry et une politique OpenShell autorisant github.com, un accès en lecture seule à registry.npmjs.org et un accès en lecture seule à pypi.org
Un espace de travail sous OpenShell + Sentry. La politique de départ autorise GitHub, et un accès en lecture seule à npm et PyPI : les installations fonctionnent, la publication est bloquée.
  • Les règles réseau s'appliquent à chaud. Les changements sur le système de fichiers prennent effet au prochain démarrage de la VM, parce que Landlock ne peut que restreindre ce qu'un processus en cours d'exécution a le droit de faire.
  • Par défaut, la sentinelle fonctionne en mode « au mieux ». Réglez-la sur Required et Bromure relève le verrouillage du noyau à son niveau le plus strict, et traite une sentinelle qui ne se connecte jamais comme une altération.
  • Avec la sentinelle obligatoire, le shell d'un espace de travail est prêt en 4 secondes quand le module de son noyau est en cache, et en 6 secondes quand Bromure doit le télécharger.

Pour la voir à l'œuvre, regardez les trois épisodes de Claudia : elle écrit une politique, l'ajuste pendant qu'un agent travaille, et remonte une attaque de la chaîne d'approvisionnement survenue à 2 heures du matin jusqu'au raisonnement de l'agent.

Merci à l'équipe OpenShell de NVIDIA d'avoir publié en open source le format de politique, le moteur de référence et le prouveur. Nous nous sommes appuyés sur les trois.