Open source · ROSH™ Company Labs

llm-stream-guardrails masquer les données sensibles d'un flux LLM

Filtrage en flux, sûr aux frontières de morceaux, pour Node et les environnements edge. La bibliothèque retire les données personnelles, les clés d'API et les secrets de la réponse d'un modèle de langage pendant sa diffusion — sans mettre toute la réponse en tampon, et avec un résultat identique octet pour octet au filtrage du texte terminé. TypeScript, MIT, zéro dépendance.

Gratuite et sous licence MIT. Si elle vous fait gagner un après-midi :
Licence MIT Zéro dépendance TypeScript, ESM, Node 18+ Publiée avec une attestation de provenance

Pourquoi le streaming contourne la plupart des garde-fous

Un modèle envoie sa réponse par petits morceaux. Analysez chaque morceau isolément et tout ce qui chevauche deux d'entre eux passe sans être vu :

chunk 1:  "...your card is 4111 11"
chunk 2:  "11 1111 1111, charge it."

Aucun des deux morceaux ne contient un numéro de carte. Ensemble, si — et quand la réponse complète existe, le lecteur l'a déjà lue. Le contournement habituel consiste à cesser de diffuser : tout mettre en tampon, filtrer, puis envoyer. C'est renoncer à la seule raison de diffuser.

Données coupées entre deux chunks SSE : un manque connu

La même faille revient régulièrement dans l'écosystème :

  • LiteLLM #41611, 17 septembre 2026 — « Streaming guardrails: value split across two SSE chunks can pass per-chunk checks » — une valeur coupée entre deux chunks SSE passe les vérifications par chunk.
  • Mastra #23783, 13 septembre 2026 — « PIIDetector emits PII in the clear when a match is split across stream chunks » — les données personnelles sortent en clair quand la correspondance est coupée entre deux chunks.
  • LangChain #35011, 4 février 2026 — « Streaming bypasses guardrails/middleware » — en streaming, le middleware n'est jamais exécuté sur les jetons.

Jusqu'en septembre 2026, le Vercel AI SDK le disait dans son propre exemple de garde-fou : « streaming guardrails are difficult to implement, because you do not know the full content of the stream until it's finished » — difficiles à implémenter, parce qu'on ne connaît pas le contenu complet avant la fin du flux. Ce commentaire a disparu parce que l'exemple a été implémenté : en mettant chaque bloc de texte en tampon jusqu'à sa fin, ce qui, notent les docs, retarde la sortie.

La garantie : un résultat identique octet pour octet

Pour n'importe quelle entrée, n'importe quelle politique et n'importe quelle taille de morceau, le résultat diffusé est identique octet pour octet au filtrage de la chaîne entière — et chaque morceau émis est bien formé à lui seul.

Elle est vérifiée en intégration continue par un test de propriété qui croise entrées, politiques et tailles de morceaux, plutôt que promise dans un README. La démo en est la version courte : réglez la taille des morceaux sur un seul caractère, le résultat ne change pas.

Ouvrir la démo — elle exécute la version publiée dans votre navigateur. Rien de ce que vous tapez ne quitte votre appareil.

Installation — TypeScript, ESM, Node 18+

npm install llm-stream-guardrails
import { sieve } from 'llm-stream-guardrails';

for await (const token of sieve(result.textStream)) {
  process.stdout.write(token);   // secrets already gone
}

ESM uniquement, Node 18 ou plus récent. N'importe quel itérable asynchrone de chaînes convient : le textStream du Vercel AI SDK directement, les SDK OpenAI et Anthropic avec une ligne pour extraire le texte de leurs objets d'événement. Pour les Web Streams et les environnements edge, createSieveTransform() fournit un TransformStream.

Ce qu'elle détecte : données personnelles, clés d'API et secrets

  • Adresses e-mail, numéros de téléphone et numéros de sécurité sociale américains.
  • Cartes bancaires vérifiées par la clé de Luhn et IBAN vérifiés par le modulo 97 — une référence de commande à 16 chiffres n'est donc pas touchée.
  • 35 formes d'identifiants, dont les jetons OpenAI, Anthropic, Stripe, GitHub, GitLab, AWS, Google, Slack, SendGrid et npm, les JWT, et les blocs de clé privée PEM complets.
  • Les noms de champs autant que les valeurs, parce qu'un modèle écrit les fiches sous forme de lignes étiquetées.
  • Les adresses IP, en option, car elles abondent dans les sorties techniques.

La détection travaille sur une vue canonique du texte : ni un chiffre pleine chasse, ni une espace insécable, ni un caractère de largeur nulle, ni un homoglyphe ne fait passer une valeur devant un motif écrit en ASCII.

Ce qu'elle n'est pas

C'est une défense en profondeur. Ce n'est pas un dispositif de conformité.

Elle reconnaît des formats, pas du sens : un secret écrit en toutes lettres, ou une forme d'identifiant inventée le mois dernier, passe. Le rappel mesuré sur des corpus tiers, écrits ailleurs, va de 47 % à 81 %, pas 100 %. Elle n'est certifiée ni RGPD, ni HIPAA, ni PCI-DSS, et aucune affirmation de conformité n'est faite ici : ce sont des propriétés d'un système et de son exploitant, jamais d'une dépendance. Placez-la devant un flux qui ne devrait pas transporter de secrets, et continuez tout ce que vous faites déjà.

Chiffres

  • 79 tests, dont une série d'attaques et un test de propriété qui compare la sortie diffusée au filtrage de la chaîne entière à chaque frontière de morceau.
  • Aucun faux positif sur un corpus propre de 39 échantillons — identifiants de code, prix, numéros de commande et quasi-correspondances volontaires, qui doivent passer intacts.
  • Version 0.7.6, publiée avec une attestation de provenance vérifiée : l'archive peut être rattachée au workflow qui l'a construite.
  • Première semaine sur npm (21–22 septembre 2026) : 353 téléchargements et 268 clones du dépôt par 95 utilisateurs distincts.

Contribution en amont

Pendant l'écriture de la bibliothèque, deux problèmes sont apparus dans la documentation Language Model Middleware du Vercel AI SDK : l'exemple de garde-fou en streaming laissait wrapStream non implémenté, et les cinq exemples de middleware omettaient un champ obligatoire. L'issue #21209 a signalé les deux.

Trois pull requests s'y référant ont été fusionnées le 22 septembre 2026 — #21210 sur main, #21211 sur release-v6.0 et #21212 sur release-v5.0. Elles ont été ouvertes par l'automatisation du projet lui-même, et le commit fusionné sur la branche par défaut porte Co-authored-by: roshcompanylabs.

Cette classe de défauts porte désormais un nom

Le 22 septembre 2026, Ninad Phalak a publié Split-Boundary Leaks in Streaming Guardrails (Zenodo, CC BY 4.0, doi.org/10.5281/zenodo.22909585) : un rapport qui rassemble quatre implémentations indépendantes du même défaut — LiteLLM, Mastra, LangChain et NVIDIA NeMo Guardrails — aux côtés de l’exemple laissé non implémenté dans la documentation du Vercel AI SDK. Il nomme l’invariant qui manque aux quatre : « pour n’importe quelle découpe d’une même entrée, le résultat diffusé concaténé doit être identique octet pour octet au filtrage de la chaîne entière ».

C’est la garantie affichée en haut de cette page, et la conception que le rapport désigne comme la seule qui préserve la diffusion tout en satisfaisant l’invariant — calculer un point de règlement à chaque morceau — est celle qu’implémente cette bibliothèque. Le rapport retrace aussi l’origine du correctif de documentation chez Vercel, en créditant ce compte nommément : un mainteneur externe d’une bibliothèque de masquage en flux qui a signalé, le 20 septembre 2026, que les exemples de middleware ne compilaient pas et que l’exemple de garde-fou en streaming était resté non implémenté.

Deux de ses questions ouvertes méritent d’être citées, car ce projet en tranche une. Le rapport note qu’aucun projet ne publie l’invariance au découpage comme contrat affiché, et que le coût en latence de cette conception n’est mesuré nulle part. L’invariant est le contrat affiché de cette bibliothèque, et les chiffres de rétention donnés dans la FAQ proviennent du banc d’essai livré avec le paquet. Le rapport n’examine pas cette bibliothèque et n’affirme rien à son sujet : il est cité ici parce qu’il a nommé la classe. Nous avons traité la classe en entier — les quatre projets, l’invariant et le test qui l’attrape — dans Données coupées entre deux chunks.

Mainteneur

Maintenue par Redouane — ROSH™ Company Labs. Les bogues et les demandes de fonctionnalités vont dans les issues GitHub. Un moyen de tromper un détecteur est le rapport le plus utile que ce projet puisse recevoir ; SECURITY.md explique comment l'envoyer en privé.

FAQ

Les questions que posent les développeurs

Mon garde-fou vérifie chaque chunk et un e-mail complet est quand même passé. Comment ?

Chaque chunk est vérifié isolément : une valeur coupée en « user@exa » + « mple.com » ne correspond à rien dans l’une ni dans l’autre moitié — et le navigateur la recompose à l’écran. La vérification doit porter sur le texte accumulé, pas sur chaque delta.

Mon middleware détecte bien la valeur, mais l’utilisateur l’a déjà vue. Pourquoi masquer ne suffit pas ?

Pour le lecteur, un flux ne fait qu’ajouter. Un jeton émis ne se reprend pas : un verdict qui arrive après les jetons change vos journaux, pas l’écran. La décision doit précéder l’envoi des octets.

Peut-on analyser le contenu d’abord et continuer à diffuser ?

Oui, à condition de ne retenir que la partie qui pourrait encore devenir une correspondance. La bibliothèque émet tout ce qu’elle a fini d’analyser et garde la fin non résolue : la sortie commence immédiatement, au lieu d’attendre la génération complète.

Est-ce qu’elle met toute la réponse en tampon ? Quel coût en latence ?

Non. Sur du texte écrit avec des espaces, elle retient une vingtaine de caractères : médiane 18, moyenne 18 sur 1 792 appels à push(), pour quatre corpus de texte espacé et huit tailles de morceaux, re-mesuré sur la 0.7.6 — le banc d’essai est livré avec le paquet (npm run latency). En japonais, en chinois et en thaï, elle ne retient presque rien : une réponse japonaise de 330 caractères n’a rien retenu et a émis dès le premier caractère, parce qu’un idéogramme ne peut faire partie d’aucun identifiant reconnu par les détecteurs. À proximité d’une valeur qui pourrait encore s’allonger, elle en retient davantage, à dessein ; et il n’existe aucun réglage de taille de tampon, donc aucune configuration ne peut provoquer de fuite.

Diffuse-t-elle le japonais, le chinois, le coréen ou le thaï ?

Oui, depuis la 0.7.3 — et pas avant. En 0.7.2, le moteur considérait un paragraphe sans espaces comme un seul bloc insécable et ne le relâchait qu’à la fin : une réponse japonaise de 2 640 caractères n’émettait rien avant d’être terminée. Mesuré ici sur la 0.7.3, la même réponse ne retient aucun caractère et émet dès le premier ; et une adresse e-mail, un numéro de carte et une clé d’API insérés dans du japonais restent masqués exactement comme au filtrage du texte entier, à toutes les tailles de morceaux testées. Si vous êtes figé en 0.7.2, mettez à jour.

Quelle est exactement la garantie ?

Pour n’importe quelle entrée, n’importe quelle politique et n’importe quel découpage, la sortie diffusée concaténée est identique octet pour octet au filtrage de la chaîne entière, et chaque morceau émis est bien formé à lui seul. C’est une propriété du moteur, pas une promesse au mieux.

Comment est-ce testé ?

Un test de propriété rejoue les entrées pour chaque politique et chaque taille de morceau — y compris des morceaux d’un seul caractère et des coupures placées à l’intérieur d’une correspondance — et vérifie que le résultat concaténé égale celui du texte entier. Le projet compte 79 tests, dont une série d’attaques.

N’est-ce pas simplement retenir N octets de fin ?

Non. Une retenue fixe échoue dès qu’une correspondance dépasse N, et elle échoue en silence : la sortie a l’air filtrée. Ici le point de règlement découle des motifs eux-mêmes : la position la plus lointaine au-delà de laquelle aucune correspondance ne peut encore s’étendre.

Fonctionne-t-elle avec le Vercel AI SDK, LangChain ou derrière un proxy ?

C’est une transformation de flux, pas une intégration à un framework : elle se place là où vous tenez déjà les morceaux — dans wrapStream, dans un handler de proxy, ou autour de n’importe quel itérable asynchrone de chaînes. createSieveTransform() fournit un TransformStream pour les Web Streams et les environnements edge.

Puis-je changer ce qu’elle détecte et ce qui se passe en cas de correspondance ?

Oui. L’action est mask, block ou report ; vous pouvez remplacer l’ensemble des détecteurs, ajouter des mots interdits, fournir votre propre fonction de masquage et recevoir un rappel onDetect déclenché exactement une fois par détection, avec la valeur complète.

Des astuces Unicode peuvent-elles faire passer une valeur ?

La détection travaille sur une vue canonique du texte : chiffres pleine chasse, espaces insécables, caractères de largeur nulle et homoglyphes sont repliés avant la comparaison. La normalisation peut être désactivée, et la désactiver rend les détecteurs trivialement contournables.

Que ne détecte-t-elle pas ?

Elle reconnaît des formats, pas du sens : un secret écrit en toutes lettres, ou une forme d’identifiant inventée le mois dernier, passe, et un motif collé à des caractères de mot est volontairement ignoré. Le rappel mesuré sur des corpus tiers va de 47 % à 81 %.

Est-ce un dispositif de conformité RGPD, HIPAA ou PCI-DSS ?

Non, et rien de tel n’est affirmé. C’est une couche parmi d’autres sur le chemin de sortie — une défense en profondeur. La conformité est une propriété d’un système et de son exploitant, jamais d’une dépendance.

Casse-t-elle les appels d’outils si les arguments sont coupés entre deux chunks ?

Elle filtre le flux que vous lui donnez : confiez-lui le canal de texte. Le JSON d’un appel d’outil se fragmente exactement comme de la prose, et masquer à l’intérieur des arguments corromprait l’appel — filtrez ce canal séparément, ou pas du tout.

Garde-t-elle une fenêtre glissante entre les chunks, ou vérifie-t-elle chaque chunk isolément ?

Elle conserve le texte accumulé et recalcule à chaque morceau un point de règlement : la position la plus lointaine au-delà de laquelle aucune correspondance ne peut s’étendre. Il n’y a pas de fenêtre fixe, et une correspondance qui touche la fin du tampon n’est jamais considérée comme définitive.

L’exemple wrapStream du AI SDK était vide. Que met-on dedans ?

Depuis le correctif de documentation de septembre 2026, elle contient un exemple fonctionnel : il met en tampon chaque bloc de texte jusqu’à text-end puis masque le texte complet, ce qui ferme la faille des correspondances coupées — et, comme le notent les docs, retarde la sortie et consomme une mémoire proportionnelle à la taille du bloc. Pour continuer à diffuser, passez les deltas de texte dans sieve() à l’intérieur de wrapStream et réémettez-les.

Pourquoi ne pas appeler l’endpoint non-streaming et rejouer la réponse en faux jetons ?

Cela fonctionne, et certains mainteneurs le conseillent, mais le temps jusqu’au premier jeton devient celui de la génération complète — précisément ce que le streaming évite. Ne retenir que la fin non résolue garde un flux qui reste un flux.

Qu’est-ce qui a été corrigé depuis la publication ?

Quatre choses, toutes au changelog. 0.7.3 : un texte écrit sans espaces était retenu jusqu’à la fin au lieu d’être diffusé. 0.7.4 : le garde-fou qui évite de couper entre une lettre et son signe ne connaissait que les signes combinants latins — arabes, hébreux, dévanagaris et thaïs n’étaient pas traités comme tels. 0.7.5 : un motif n’était pas borné, si bien qu’analyser 8 000 caractères sans coupure prenait 846 ms là où de la prose ordinaire prenait 3 ms — mesuré ici à 18 ms après correction — et un JWT dont la charge utile dépassait 1 024 caractères n’était pas détecté du tout. 0.7.6 : un appelant qui abaissait maxRetention pouvait demander un plafond plus court que la plus longue correspondance possible ; cette configuration est désormais inatteignable. Aucune des quatre n’a été trouvée par la suite de tests avant publication : chacune vient d’une question ou d’une mesure postérieure, et chacune est couverte par un test aujourd’hui.

Quelle est sa maturité ?

Elle a été publiée le 21 septembre 2026, et la 0.7.3 a suivi le lendemain pour corriger la diffusion dans les langues écrites sans espaces. La garantie est énoncée précisément et couverte par 79 tests que vous pouvez lire, mais elle n’a pas encore tourné à grande échelle ailleurs que chez son auteur. Lisez la suite de tests avant de lui faire confiance, et ouvrez une issue si vous passez devant un détecteur.