Keyboard shortcuts

Press ← or → to navigate between chapters

Press ? to show this help

Press Esc to hide this help


id: ADR-014 title: Les instances de plugins n’accèdent au réseau que via une unique autorité de sortie gérée par l’hôte date: 2026-08-20 status: proposé relates-to:

  • ADR-006
  • ADR-009
  • https://github.com/zeroclaw-labs/zeroclaw/issues/9395
  • https://github.com/zeroclaw-labs/zeroclaw/issues/8850
  • https://github.com/zeroclaw-labs/zeroclaw/issues/8398
  • https://github.com/zeroclaw-labs/zeroclaw/pull/9580
  • https://github.com/zeroclaw-labs/zeroclaw/pull/9137
  • https://github.com/zeroclaw-labs/zeroclaw/pull/9582
  • https://github.com/zeroclaw-labs/zeroclaw/pull/9584
  • https://github.com/zeroclaw-labs/zeroclaw/pull/11098
  • https://github.com/zeroclaw-labs/zeroclaw/issues/10996
  • https://github.com/zeroclaw-labs/zeroclaw/pull/9126
  • crates/zeroclaw-infra/src/net_guard.rs
  • crates/zeroclaw-plugins/src/egress.rs
  • crates/zeroclaw-plugins/src/component.rs
  • crates/zeroclaw-config/src/schema.rs
  • crates/zeroclaw-tools/src/helpers/domain_guard.rs

ADR-014 : Les instances de plugin n’atteignent le réseau que par une seule autorité de sortie détenue par l’hôte

Contexte

Avant la livraison de l’application des règles dans #9582, ZeroClaw liait l’import wasi:http à un store de plugin lorsque le manifeste du plugin demandait la permission http_client. Ce store installait les hooks de requête par défaut de wasmtime (crates/zeroclaw-plugins/src/component.rs), de sorte que rien côté ZeroClaw ne décidait vers où le composant invité pouvait se connecter : aucune liste d’autorisation de destinations, aucune protection par classe d’adresse, aucune configuration accessible à l’opérateur. Les permissions du manifeste étaient des demandes, et l’hôte accordait chaque demande telle quelle (crates/zeroclaw-plugins/src/instance.rs), tandis que plugins.security.signature_mode avait pour valeur par défaut disabled. Un composant non signé pouvait donc déclarer http_client, l’obtenir, et devenir un pivot non filtré vers les services de bouclage, la passerelle locale ou les adresses de métadonnées cloud. Le bug #9395 consigne cette conséquence historique ; l’application livrée applique désormais la politique de destination détenue par l’hôte.

Le volet côté hôte refuse déjà par défaut (fail closed). L’outil http_request refuse tout appel lorsque sa liste allowed_domains est vide, et valide la destination ainsi que chaque adresse résolue contre les plages de métadonnées, de boucle locale (loopback) et privées via crates/zeroclaw-tools/src/helpers/domain_guard.rs et crates/zeroclaw-infra/src/net_guard.rs. Deux chemins sortants dans un même processus offraient des garanties opposées.

La pression sur cette frontière augmente avec l’ADR-006, qui fait des plugins d’exécution la cible des canaux optionnels, car chaque canal de messagerie est un consommateur de flux sortants (egress). Aujourd’hui, wit/v0/inbound.wit indique clairement qu’un plugin de canal s’exécute sans réseau et sans sockets, et le dépôt officiel des plugins contient un brouillon d’interface de sockets à wit/unstable/sockets.wit pour un transport que l’hôte n’expose pas. Quel que soit le transport qui arrivera ensuite, il doit disposer d’une réponse existante à la question « cette instance peut-elle atteindre cette adresse ? », et non d’une troisième.

Une partie de cette orientation a été livrée. #9580 a déplacé l’egress HTTP intégré vers un garde-fou réseau partagé, #9137 a ajouté la base de la politique d’egress côté plugin, #9582 a appliqué la politique à la frontière wasi:http, et #9126 a ajouté la configuration d’instance typée. La procédure d’octroi par l’opérateur a été livrée pour l’installation et la liste dans #11098, qui a remplacé #9584, et pour les liaisons de canaux via zeroclaw plugin bind dans le cadre de #10996. Cet enregistrement énonce la décision que ces lots mettent en œuvre et achèvent. La RFC #8398 est fermée en tant que RFC omnibus remplacée ; cet ADR consigne la décision ciblée sur la sortie réseau qui a survécu à cette scission : Q1 pour les permissions réseau, Q4 pour les autorisations de destination étendues par l’utilisateur.

Les alternatives consistent à traiter la déclaration du manifeste elle-même comme l’octroi, à conserver une liste d’autorisation globale pour chaque plugin installé, ou à donner à chaque transport sa propre politique de destination. L’octroi par le manifeste préserve le chemin d’auto-attribution #9395 pour les paquets non signés, viole la doctrine default-closed et fait du manifeste une seconde source de vérité pour l’autorité en temps réel. Une liste d’autorisation globale empêche l’isolation par instance, car tout plugin installé pourrait alors atteindre chaque hôte dont un autre plugin a besoin. Des politiques par transport multiplient les paramètres pour une seule et même question et favorisent la dérive, alors que la décision de destination est indépendante du transport.

Décision

Utiliser une seule autorité de sortie avec un mécanisme de politique partagé et un refus par défaut

Tout le trafic réseau sortant des plugins, passant aujourd’hui par wasi:http et par tout futur import de socket, WebSocket ou profil TLS, est médié par une autorité de sortie unique détenue par l’hôte. La correspondance des destinations, la classification des adresses, la traduction NAT64 et le verdict après résolution se trouvent dans zeroclaw-infra::net_guard, que les outils intégrés utilisent déjà. crates/zeroclaw-plugins/src/egress.rs contient le service à portée d’instance qui consomme ces primitives sans en réimplémenter aucune, et zeroclaw-plugins ne dépend pas de zeroclaw-tools pour cela. Un plugin et un outil intégré ne doivent pas pouvoir être en désaccord sur l’accessibilité d’une destination.

Une instance de plugin sans destination accordée n’a aucune portée réseau. Aucun mode de compatibilité n’existe dans lequel http_client seul confère un accès HTTP sans restriction. La permission accorde la surface ; la liste de l’opérateur accorde les destinations.

Le service revérifie la permission requise par le transport à la frontière de chaque opération, bien que le linker n’expose que les imports qui ont été accordés à une instance admise. Cette double vérification est délibérée et relève de la défense en profondeur : un adaptateur de transport ne doit pas pouvoir transformer un import lié mais non accordé en accès réseau.

Appliquer la restriction à la frontière wasi:http avec un chemin d’envoi épinglé

La application de la politique se situe à la frontière de transport. Une implémentation de hooks ZeroClaw, stockée par plugin store, remplace les hooks par défaut de wasmtime. Son hook de requête évalue la politique pour chaque requête émise par l’invité. Le chemin d’envoi est géré par l’hôte et épinglé : la destination est résolue une seule fois, les adresses résolues sont validées via la garde partagée, puis la connexion ne s’établit qu’à ces adresses exactes, en utilisant le nom d’hôte pour SNI et la vérification du certificat. Une réponse DNS ne peut pas changer de classe entre la vérification et la connexion, car il n’y a pas de seconde résolution. ResolvedDestination garantit cette propriété de manière structurelle : il conserve les adresses validées et n’offre aucun moyen de revenir à une nouvelle recherche.

L’hôte ne suit jamais les redirections au nom d’un invité. Un invité qui choisit de suivre une redirection émet une nouvelle requête, et chaque saut est soumis indépendamment à la politique complète.

Un refus renvoie à l’invité une erreur masquée qui mentionne la politique plutôt que les éléments internes de l’hôte, et émet un événement de journal structuré côté hôte attribuant la tentative à l’instance exacte.

Cette jointure a été livrée dans #9582 pour wasi:http ; les étapes restantes couvrent l’intersection, la couverture des fixtures et le déploiement sur la flotte.

Laissez le manifeste déclarer et la configuration de l’opérateur accorder

Un manifeste de plugin peut déclarer les destinations dont le paquet a besoin dans une section [egress] : hôtes exacts ou motifs de suffixe explicites. Cette déclaration est une demande couverte par la signature, une déclaration d’intention qui accompagne le paquet. Elle n’est jamais consultée comme une autorisation au moment de la requête.

L’autorisation réside dans la configuration canonique de l’opérateur, sous forme de champs en clair sur la ligne [[plugins.entries]] de l’instance : egress_hosts pour les destinations et egress_allow_private pour l’exception de classe d’adresses. Les deux sont délibérément frères de la map config annotée #[secret], et jamais à l’intérieur de celle-ci, car la liste d’autorisation est ce qu’un opérateur audite, et elle doit rester lisible dans le fichier qu’il audite.

La portée effective d’une instance correspond à l’intersection de ce que son manifeste demande et de ce que l’opérateur a accordé. Une déclaration ne confère rien à elle seule, et une autorisation accordée par l’opérateur ne peut pas dépasser ce que le paquet a déclaré nécessaire. Deux chemins d’autorisation sont de première classe :

  1. Je n’ai pas reçu de chaîne à traduire. Votre message mentionne une déclaration « ci-dessous », mais aucun texte n’apparaît après. Collez la chaîne anglaise à traduire.
  2. Rédigé directement par l’opérateur, ce qui est la seule voie possible pour un plugin dont la destination est elle-même une configuration d’instance, comme un Gitea auto-hébergé, un homeserver Matrix ou un Nextcloud sur le LAN. L’auteur ne peut pas connaître l’hôte, c’est donc l’opérateur qui le configure qui l’accorde.

La politique est lue depuis la configuration canonique à chaque requête plutôt que d’être figée dans l’état de l’instance, de sorte qu’une modification faite par un opérateur s’applique à la prochaine connexion au lieu du prochain redémarrage. L’ADR-012, encore à l’état de proposition, décrit le mode d’application à portée de génération associé ; il sert ici de contexte, non d’autorité.

Les champs opérateur, la lecture par requête et la déclaration [egress] du manifeste sont fusionnés. L’intersection n’est pas encore implémentée : sur cette version, seule la permission accordée par l’opérateur régit, et la porte G1 la couvre.

Garder la grammaire de destination exempte de forme allow-all

Les entrées sont des hôtes exacts ou des motifs *.suffixe explicites. Un domaine nu n’implique jamais ses sous-domaines, un motif de suffixe ne correspond jamais au domaine apex, et l’absence d’entrée signifie « partout ». La validation refuse un * nu, et un * non validé ne correspondrait jamais qu’à un hôte littéralement nommé *, si bien qu’une liste ayant échappé à la validation échoue en mode fermé. Un hôte sous forme d’adresse IP littérale n’est associé qu’à une entrée strictement identique, et jamais à un motif de suffixe, car les adresses n’ont pas de structure de sous-domaines ; traiter une adresse comme un nom pointé permettrait à *.0.0.1 de correspondre à 127.0.0.1. Une liste vide ne correspond à rien. Les ports ne font pas partie d’une entrée : autoriser un hôte autorise tous les ports de cet hôte.

Gardez les classes d’adresses bloquées au-dessus de la liste d’autorisation

Les adresses de métadonnées cloud sont refusées sans condition, dans les deux modes d’accès, de sorte qu’un consentement explicite de l’opérateur pour les destinations privées ne les rouvre jamais. Les destinations loopback, link-local et de plage privée sont refusées même lorsqu’un motif correspond, sauf si l’entrée liste cet hôte dans egress_allow_private. L’exception n’assouplit que la classe d’adresses : un hôte figurant dans cette liste mais absent de egress_hosts reste refusé, et elle reprend la sémantique de allowed_private_hosts déjà utilisée par les outils intégrés, afin que les deux consommateurs partagent un même vocabulaire.

Un ensemble de réponses qui couvre les deux zones de confiance est rejeté plutôt que réduit à l’une d’elles, même lorsque l’accès privé est autorisé. Sinon, l’ordre du résolveur ou le repli de connexion déciderait silencieusement de la zone dans laquelle la connexion aboutit.

Exiger que les opérateurs déclarent leurs préfixes NAT64, et faire échouer en mode sécurisé toute liste mal formée

Un traducteur NAT64 délivre toute destination IPv6 située dans son préfixe configuré à l’adresse IPv4 intégrée dans cette destination. Le préfixe est un choix de déploiement et rien dans l’adresse ne le révèle. Un attaquant qui contrôle la réponse DNS d’un nom d’hôte peut donc renvoyer une adresse IPv6 d’apparence globale que le traducteur local délivre à 10.0.0.1 ou 169.254.169.254. security.nat64_prefixes est l’endroit où les opérateurs déclarent les traductions que leur réseau exploite réellement. Les entrées sont au format <ipv6>/<length>, avec les longueurs définies à la section 2.2 de la RFC 6052 (les six longueurs), et aucun bit positionné au-delà du préfixe.

Deux règles en découlent, et toutes deux sont plus strictes que l’implémentation évidente. D’abord, une entrée invalide rejette la liste entière au lieu de la réduire à son sous-ensemble bien formé, car une liste analysée en « aucun préfixe » ressemble exactement à un déploiement qui n’utilise aucun traducteur, et désactiverait la classification propre au réseau sans aucun signal. Ensuite, les préfixes déclarés peuvent se chevaucher, et une adresse peut alors se décoder vers une destination IPv4 différente selon chacun d’eux ; chacune de ces destinations est joignable, donc une adresse n’est acceptée que lorsque chaque traduction déclarée à laquelle elle correspond aboutit à un emplacement acceptable, et non pas à la première qui se trouve être acceptable.

Faire de l’octroi une cérémonie explicite

zeroclaw plugin install affiche les destinations déclarées d’un paquet et les injecte dans les entrées qu’il crée déjà. Une instance dont la liaison est créée plus tard, comme un alias de canal, reçoit son entrée de sortie lors de la création de cette liaison, à partir de la même déclaration et avec le même affichage. L’installation et la création de liaisons sont des actions explicites de l’opérateur, et la liste d’autorisation affichée et persistée en constitue la trace. zeroclaw plugin list affiche les destinations accordées à chaque instance afin de pouvoir auditer la portée d’accès sans lire le fichier de configuration.

Une mise à niveau de paquet dont la déclaration ajoute des destinations n’étend pas une entrée existante. La CLI affiche la différence, et l’opérateur l’applique délibérément. En l’absence d’entrée, le trafic sortant est refusé.

L’initialisation à l’installation, le diff de mise à niveau et le rapport zeroclaw plugin list correspondant ont été intégrés dans #11098, qui remplace #9584. L’initialisation au moment de la liaison a été intégrée avec zeroclaw plugin bind dans #10996, et zeroclaw plugin install --channel-alias exécute la même procédure pendant une installation. Comme l’opérateur indique l’alias à ce moment-là, la procédure de liaison traite l’autorisation comme un choix explicite : --egress declared initialise la déclaration avec la même sortie que celle affichée lors de l’installation, --egress none crée l’entrée avec une autorisation vide, et sans l’un ou l’autre de ces indicateurs, une liaison qui créerait l’entrée à partir d’une déclaration non vide est refusée. Ni l’une ni l’autre de ces commandes n’étend jamais une entrée existante.

Lier les futurs transports au même objet de politique

Les imports Socket, WebSocket et TLS-profile, lorsque l’hôte les adopte, utilisent cette même politique par instance. Une connexion socket ou une mise à niveau WebSocket vers un hôte et un port passe par la même liste d’autorisation et les mêmes règles de classe d’adresse, et un profil TLS sélectionne les éléments de confiance sans jamais contourner la politique de destination. Un import de transport qui ne peut pas faire passer sa décision de destination par ce service n’est pas intégré.

Les canaux TCP bruts sont hors du périmètre de cet enregistrement tant qu’une capacité de sockets WASI n’existe pas sur l’hôte. D’ici là, un canal dont le protocole n’est pas HTTP n’a aucun chemin côté plugin et reste natif au titre de la règle d’exception de capacité de l’ADR-006. Matrix est une telle exception, et cet enregistrement ne la retire pas : le chiffrement de bout en bout est la capacité hôte manquante identifiée, et l’implémentation native est maintenue jusqu’à ce que la frontière du plugin puisse la prendre en charge. Il s’agit d’une question de parité de capacités relevant de l’ADR-006, et rien ici ne la modifie.

Une autorisation est indépendante du protocole. Un opérateur qui autorise un hôte l’accorde pour le transport que les permissions de cette instance permettent déjà, de sorte qu’il n’existe pas d’axe distinct pour le texte en clair ni de champ egress_allow_plaintext. La confidentialité relève du contrat du transport avec son pair, et non d’une seconde liste de destinations. Lorsqu’un protocole négocie TLS en in-band, l’hôte gère la transition de phase et n’autorise jamais de repli vers le texte en clair une fois la mise à niveau commencée.

Les budgets de connexions par instance relèvent de la fondation plutôt que de chaque transport. plugins.limits.max_connections_per_instance est partagé entre tous les transports et tous les stores appartenant à une même instance logique, et une requête autorisée détient un bail pour toute la durée de vie de sa connexion.

Limiter l’entrée du manifeste non fiable à la même frontière

Un manifeste est une entrée contrôlée par un attaquant, située sur la même frontière de confiance qu’une destination de sortie (egress). Un manifeste peu coûteux en octets ne doit donc pas imposer un travail coûteux à l’hôte. Le travail de configuration d’instances typées livré dans #9126 borne la validation des motifs dans les schémas fournis par les manifestes en conséquence : les motifs sont compilés sur un moteur à temps linéaire, avec une limite explicite de 256 KiB pour le programme compilé et une limite de 1 MiB pour le DFA. Un motif qui dépasse l’une ou l’autre limite est rejeté comme manifeste invalide, sans être compilé. Rejeter le paquet est la direction sûre, car un auteur de plugin qui souhaite un motif aussi coûteux peut en écrire un plus petit, alors qu’un hôte qui a déjà commencé à compiler ce motif ne peut pas récupérer son ordonnanceur. Ceci est consigné ici parce que cela renforce la frontière dont cet ADR est responsable, et non parce que cela se situe sur le chemin de sortie (egress).

Critères d’acceptation

Cet ADR reste proposé jusqu’à ce que toutes ces conditions soient remplies :

  • Les primitives de garde partagées se trouvent dans zeroclaw-infra::net_guard, avec leurs deux consommateurs connectés : les hooks par magasin et le chemin d’envoi épinglé pour les magasins de plugins. La déclaration [egress] du manifeste existe, avec analyse, validation et couverture de signature. L’autorisation effective est l’intersection de cette déclaration et de l’entrée de l’opérateur. L’initialisation à l’installation et à la liaison, ainsi que la procédure de diff lors de la mise à niveau, fonctionnent (G1).
  • La CI requise prouve la frontière avec un jeu de test de composant réel : refus par défaut en l’absence d’entrée, autorisation via une entrée ensemencée, refus des métadonnées et des adresses privées malgré une liste d’autorisation correspondante, et un composant qui suit une redirection depuis un hôte autorisé vers une classe bloquée voit sa seconde requête refusée (G2) ;
  • le premier plugin de canal sélectionné par #8850 s’exécute sous une entrée préconfigurée pour son hôte d’API (G3) ; et
  • le déploiement sur le parc existant est terminé : les paquets http_client du registre officiel portent des déclarations [egress] dans les versions republiées avant l’activation de l’application par hôte, un diagnostic lors de la mise à niveau liste, pour chaque instance installée, les destinations refusées avec la commande d’amorçage exacte, et la version qui active l’application nomme la rupture dans son changelog, #9395 étant déjà fermée par la tranche d’application (G4).

La garde partagée, les champs opérateur en clair, la lecture de la politique à chaque requête, la grammaire stricte des destinations, la frontière NAT64, le budget de connexions, la déclaration [egress] dans le manifeste, ainsi que l’amorçage à l’installation et à la liaison avec le diff de mise à niveau sont en place. Le reste de G1, à savoir l’intersection de cette déclaration avec l’entrée de l’opérateur, ne l’est pas, pas plus que G2 à G4.

Conséquences

Conséquences positives :

  • The #9395 pivot closes by construction rather than by signature policy alone, because an unsigned or malicious component cannot convert a manifest self-request into network reach.
  • Les opérateurs disposent d’un paramètre en clair, auditable, par instance, et une modification s’applique à la connexion suivante sans réinstancier l’invité.
  • Une mise à niveau de paquet ne peut pas élargir silencieusement l’accès sortant.
  • Les outils intégrés et les plugins convergent vers un même vocabulaire de garde au lieu de diverger.
  • Un nouveau transport hérite de la décision de destination au lieu de la rouvrir.

Conséquences négatives :

  • Il s’agit d’un changement cassant pour chaque plugin http_client déjà installé : tant que son entrée n’a pas été générée ou rédigée, il n’a aucune sortie réseau. G4 existe précisément pour cette raison, et ce changement n’est livré qu’avec le diagnostic de migration et les déclarations republiées en place.
  • La cérémonie ajoute une étape au moment de l’installation, une étape au moment de la liaison, ainsi qu’un état de politique propre à chaque entrée.
  • Les plugins dont les besoins de destination sont dynamiques, comme les URL fournies par l’utilisateur, n’obtiennent pas d’accès sortant générique. Ils déclarent des motifs de suffixe acceptés par l’opérateur, s’appuient sur des entrées rédigées par l’opérateur, ou attendent un service de récupération médiatisé par l’hôte. C’est le coût délibéré de la réponse à la question Q4 de #8398.
  • Les déploiements derrière un traducteur NAT64 doivent déclarer leurs préfixes pour que la frontière reste précise, et une évaluation prudente des préfixes qui se chevauchent peut refuser une destination que l’opérateur considère comme légitime.
  • La lecture de la politique s’effectue à chaque requête plutôt qu’à chaque instanciation, de sorte que le résolveur de configuration se trouve sur le chemin de connexion.

Références