NOPE LinkedIn

Catégories:
IA

Le LLM ne voit que des jetons : tokenisation PII à la frontière d'exécution

Le LLM ne voit que des jetons : tokenisation PII à la frontière d'exécution image

Rubrique: IA Tag: IA Tag: Sécurité Tag: LLM Tag: PII Tag: data sovereignty Tag: DevSecOps

🇬🇧 English version 💻 Code source (AGPL) : github.com/patlegu/cyber-agent-engine

L’invariant

Le premier article de cette série posait la thèse générale de cyber-agent-engine : un LLM qui pilote un pare-feu de production ne doit jamais franchir seul la frontière entre intention et exécution. Celui-ci descend dans le pilier le plus contre-intuitif de cette architecture, et probablement le plus simple à énoncer : le LLM de raisonnement ne voit jamais une IP réelle, un hostname réel ou un secret réel. Il ne voit que des jetons — des chaînes de la forme IP_ADDRESS_1, HOSTNAME_2, CVE_3. Les valeurs réelles restent côté serveur, dans une table qui n’est jamais sérialisée vers le modèle, et ne réapparaissent qu’au tout dernier moment : à la frontière d’exécution, quand une action est réellement envoyée vers l’équipement ciblé.

Ce n’est pas un détail d’implémentation. C’est une décision d’architecture qui change ce que le LLM peut faire, même s’il est compromis, même s’il hallucine, même s’il est victime d’une injection de prompt.

Comment : tokenize, Vault, detokenize

Le mécanisme vit dans core/tokens/vault.py. Le fichier commence par poser explicitement l’intention :

"""Tokenisation réversible des valeurs sensibles, liée à la session.

Le LLM et les logs ne voient QUE des jetons (``IP_1``, ``VPN_USER_2``). La table
jeton→valeur (le ``vault``) reste côté serveur et n'est jamais sérialisée hors de
celui-ci. La détokenisation n'a lieu qu'au tout dernier moment (cf. ``execution/``)
ou dans la vue d'approbation humaine.
"""

Le cÅ“ur du mécanisme est une classe Vault, qui matérialise une bijection jeton↔valeur pour une seule session — aucun état n’est partagé entre deux sessions, donc aucune fuite croisée entre deux opérateurs ou deux exécutions n’est possible par construction :

class Vault:
    """Bijection jeton↔valeur pour UNE session. Aucun état partagé entre sessions."""

    def __init__(self) -> None:
        self._to_real: dict[str, str] = {}
        self._to_token: dict[str, str] = {}
        self._counters: dict[str, int] = {}

    def token_for(self, label: str, value: str) -> str:
        existing = self._to_token.get(value)
        if existing is not None:
            return existing
        self._counters[label] = self._counters.get(label, 0) + 1
        token = f"{label}_{self._counters[label]}"
        self._to_real[token] = value
        self._to_token[value] = token
        return token

    def resolve(self, token: str) -> str | None:
        return self._to_real.get(token)

Deux points méritent d’être soulignés dans ce code, parce qu’ils déterminent tout le reste.

D’abord, le format réel du jeton : c’est f"{label}_{compteur}", où label est le type d’entité détecté (IP_ADDRESS, HOSTNAME, CVE…) et compteur un entier incrémental par label, propre à la session. Une IP donnée devient donc par exemple IP_ADDRESS_1, la suivante IP_ADDRESS_2, et un hostname distinct HOSTNAME_1 — les compteurs ne se mélangent pas entre labels. (Le diagramme de séquence plus bas, tiré du plan de cette série, simplifie le label en IP pour la lisibilité ; dans le code réel, c’est bien IP_ADDRESS qui sort de l’extracteur — j’y reviens dans la section suivante.)

Ensuite, token_for est idempotent par valeur : existing = self._to_token.get(value) fait que si la même IP réapparaît deux fois dans la même session, elle reçoit exactement le même jeton. C’est ce qui permet au LLM de raisonner correctement sur « la même IP revient trois fois dans ces logs » sans jamais savoir laquelle — la cohérence référentielle du raisonnement est préservée, seule la valeur est masquée.

Deux fonctions libres orchestrent l’aller-retour autour de ce vault :

def tokenize(text: str, vault: Vault, extract: ExtractFn) -> str:
    """Remplace chaque entité sensible détectée par son jeton stable de session."""
    entities = extract(text)
    pairs: list[tuple[str, str]] = []
    for label, values in entities.items():
        for value in values:
            if value:
                pairs.append((label, value))
    for label, value in sorted(pairs, key=lambda p: len(p[1]), reverse=True):
        text = text.replace(value, vault.token_for(label, value))
    return text

tokenize prend un texte brut (typiquement, la requête opérateur ou un extrait de logs), un extract: ExtractFn — une fonction texte → dict de labels vers listes de valeurs — et remplace chaque valeur détectée par son jeton. Le tri par longueur décroissante (key=lambda p: len(p[1]), reverse=True) n’est pas cosmétique : il évite qu’une valeur qui est une sous-chaîne d’une autre (par exemple une IP contenue dans un CIDR plus long) soit remplacée en premier et corrompe le remplacement suivant.

Symétriquement, detokenize fait le chemin inverse, récursivement sur des structures Python (str, dict, list) :

def detokenize(obj: Any, vault: Vault) -> Any:
    """Remplace récursivement les jetons ÉMIS par le vault par leurs valeurs réelles.

    On ne remplace que les jetons que ce vault a effectivement produits (pas de
    reconnaissance par forme) : rien qui ressemble à un jeton mais n'a pas été émis
    n'est jamais touché.
    """

Ce détail — « pas de reconnaissance par forme » — est essentiel du point de vue sécurité. detokenize ne fait pas de pattern-matching sur des chaînes qui ressemblent à LABEL_N : il ne remplace que les jetons présents dans vault.items(), c’est-à-dire ceux que ce vault précis a réellement émis pendant cette session. Un LLM compromis ou halluciné qui inventerait une chaîne IP_ADDRESS_99 jamais émise par le vault ne déclenche donc aucune résolution — elle traverse detokenize inchangée, comme n’importe quelle autre chaîne de texte.

Reste la question de savoir qui détecte les entités à tokeniser. C’est le rôle de ExtractFn, une fonction str -> dict[str, list[str]] implémentée par défaut dans coordinator/extractor.py.

L’extracteur regex

build_regex_extractor() renvoie une fonction pure, sans dépendance lourde (pas de modèle NER embarqué), calibrée « précision > rappel » :

def build_regex_extractor() -> ExtractFn:
    """Renvoie un extracteur pur : texte → {label: [valeurs uniques, ordre stable]}."""

    def _extract(text: str) -> dict[str, list[str]]:
        remaining = text
        result: dict[str, list[str]] = {}
        for label, pattern in _PATTERNS:
            found = pattern.findall(remaining)
            result[label] = _dedupe(found)
            if found:
                remaining = pattern.sub(" ", remaining)
        return result

    return _extract

Le dict renvoyé associe chaque label à la liste dédupliquée (ordre stable) des valeurs trouvées pour ce label. Les sept labels réellement reconnus, dans l’ordre de matching (le plus spécifique d’abord) sont définis par _PATTERNS :

  • IP_SUBNET — CIDR (203.0.113.0/24), matché avant IP_ADDRESS pour qu’un sous-réseau ne ré-émette pas son IP nue.
  • MAC_ADDRESS — matché avant IP_ADDRESS car une MAC (6 groupes hex séparés par :) correspond aussi au motif IPv6 générique.
  • IP_ADDRESS — IPv4 et IPv6 (formes pleine, compressée médiane et compressée en tête), bornées par lookarounds explicites plutôt que par \b (le commentaire du code explique pourquoi : ::1 ne présente pas de frontière \b exploitable).
  • CVE — motif CVE-\d{4}-\d{4,7}.
  • HASH — hex insensible à la casse, aux longueurs MD5/SHA1/SHA256 (32, 40 ou 64 caractères).
  • HOSTNAME — FQDN générique.
  • PORT_NUMBER — un entier 2-5 chiffres précédé de : (lookbehind).

Chaque motif « consomme » le texte au fur et à mesure (remaining = pattern.sub(" ", remaining)), ce qui garantit qu’un segment déjà capturé par un label plus spécifique n’est pas re-détecté sous un label plus générique — un CIDR n’apparaît jamais aussi comme IP_ADDRESS, un FQDN jamais comme autre chose.

Le fichier signale lui-même l’existence d’une alternative :

Une variante spaCy (``NERExtractor``) reste disponible via l'extra [ner] pour le
NL riche, mais n'est pas câblée par défaut.

build_regex_extractor() est donc l’ExtractFn par défaut, mais l’interface ExtractFn (un simple Callable[[str], dict[str, list[str]]]) est volontairement générique : on peut brancher un extracteur plus riche sans toucher au Vault ni à tokenize/ detokenize.

Le flux, en séquence

Le schéma ci-dessous illustre le trajet complet d’une requête, de l’opérateur jusqu’à l’exécution réelle sur l’équipement — en gardant à l’esprit que le label affiché ici (IP) est une simplification pédagogique du label réel produit par l’extracteur (IP_ADDRESS) :

%%{init: {"flowchart": {"htmlLabels": false}} }%% sequenceDiagram participant U as Requête opérateur participant T as tokenize() participant V as Vault participant L as LLM (raisonnement) participant E as Frontière d'exécution U->>T: "bloque 203.0.113.7" T->>V: token_for("IP", "203.0.113.7") = "IP_1" T->>L: "bloque IP_1" (jetons uniquement) L->>E: proposition block_ip(IP_1) E->>V: detokenize("IP_1") = "203.0.113.7" E->>E: exécute block_ip("203.0.113.7") Note over L: le LLM ne voit jamais 203.0.113.7

À aucun moment de son raisonnement — pas dans le prompt, pas dans sa chaîne de pensée, pas dans la fonction qu’il propose d’appeler — le LLM ne manipule la valeur réelle 203.0.113.7. Il manipule un identifiant opaque, stable pour la durée de la session, qui lui suffit pour raisonner (« bloque cette IP », « la même IP revient dans trois logs différents ») mais qui ne lui donne rien à exfiltrer.

Pourquoi ça compte

Rayon de souffle d’une injection réduit

Le scénario redouté avec un agent LLM connecté à des outils réseau, c’est l’injection de prompt : un contenu malveillant — glissé dans un log, un User-Agent, un commentaire DNS — manipule le modèle pour qu’il exécute une action non voulue, ou pour qu’il révèle des données sensibles dans sa réponse. Avec la tokenisation systématique en amont, la seconde moitié de cette menace perd une bonne partie de sa substance : le LLM ne connaît pas la valeur réelle qu’un attaquant chercherait à lui faire exfiltrer. Il peut au mieux renvoyer un jeton — une chaîne IP_ADDRESS_7 sans valeur en dehors du vault de cette session, inutile en dehors du système. On ne prétend pas que la tokenisation neutralise l’injection de prompt comme classe de vulnérabilité (le premier article de cette série a déjà posé pourquoi le core/ garde un contrôle séparé, en aval, sur ce que le LLM est autorisé à faire) — mais elle réduit mécaniquement ce qu’une injection réussie peut faire fuiter.

Souveraineté des données

cyber-agent-engine peut router du raisonnement vers des fournisseurs de LLM externes — c’est un choix d’architecture assumé ailleurs dans le projet. Cela pose immédiatement une question de souveraineté : quelles données de l’infrastructure d’un opérateur transitent, en clair, vers un tiers ? Avec la tokenisation à l’entrée, la réponse est structurellement serrée : aucune IP réelle, aucun hostname réel, aucun secret ne sort jamais du périmètre serveur vers un modèle hébergé chez un tiers — qu’il s’agisse d’OpenRouter, d’un fournisseur cloud ou de tout autre point de sortie du réseau de l’opérateur. Ce n’est pas une politique déclarative qu’on espère faire respecter par une consigne de prompt ; c’est une propriété du pipeline : le texte qui quitte le processus serveur pour atteindre l’API du modèle est déjà passé par tokenize().

Audit token-only

Conséquence directe : les logs et traces de raisonnement générés par le LLM peuvent être conservés, rejoués, partagés avec une équipe de sécurité externe ou archivés à long terme sans qu’ils constituent en eux-mêmes un inventaire de l’infrastructure. Un log qui contient bloque IP_ADDRESS_3 sur HOSTNAME_1 ne dit rien à qui n’a pas accès au vault de cette session précise — et le vault, on l’a vu, ne survit que le temps de la session, côté serveur. C’est la porte ouverte vers un sujet que cette série creusera plus tard : un audit exploitable, construit uniquement à partir de jetons, qui reste utile pour la forensique (« la même IP est revenue cinq fois ») sans jamais être lui-même une fuite de données.

Filiation : la cousine côté logs, Victor/AnonyNER

Ce principe — ne jamais laisser une donnée sensible atteindre un traitement externe sous sa forme réelle — n’est pas propre à cyber-agent-engine. Un projet antérieur de ce blog, Victor, l’anonymiseur de logs de sécurité, pose une variante du même problème côté journalisation : comment anonymiser des logs de sécurité avant de les partager (avec un tiers, un outil d’analyse, un LLM externe) sans dépendre d’un service externe pour le faire. Victor combine un moteur NER (spaCy) et des règles regex, avec une boucle d’auto-apprentissage et une validation locale par un petit modèle de langage — une approche plus riche que la tokenisation regex déterministe de cyber-agent-engine, pensée pour du texte libre plutôt que pour des intentions d’action structurées.

Les deux projets partagent la même intuition de fond : la donnée sensible ne doit jamais être le prix d’entrée pour bénéficier d’un traitement par IA. coordinator/extractor.py cite d’ailleurs explicitement cette parenté en gardant la porte ouverte à une variante spaCy (NERExtractor, disponible via l’extra [ner]) — plus proche de l’architecture de Victor — pour le jour où le texte en entrée deviendra trop riche pour des regex déterministes.

Limites honnêtes

Il serait malhonnête de présenter cette tokenisation comme une garantie absolue. Trois limites concrètes, directement lisibles dans le code :

  • La couverture est fermée et explicite. L’extracteur regex par défaut ne reconnaît que sept types d’entités : IP_SUBNET, MAC_ADDRESS, IP_ADDRESS, CVE, HASH, HOSTNAME, PORT_NUMBER. Un numéro de série d’équipement, un nom de compte utilisateur, une clé d’API glissée dans un champ libre, un chemin de fichier sensible : rien de tout cela n’est détecté par ces sept motifs. Le commentaire du fichier le dit lui-même — « calibré précision > rappel […] on préfère ne pas sur-tokeniser le bruit » — c’est un choix de conception assumé, pas un oubli, mais il implique que tout ce qui n’entre pas dans ces sept catégories atteint le LLM en clair.
  • Un extracteur regex n’a pas de compréhension sémantique. Il détecte des formes (une suite de chiffres et de points, un motif hexadécimal), pas des intentions. Une IP écrite avec un séparateur inhabituel, un hostname mal formé, ou une valeur sensible qui ne ressemble à aucun de ces sept patrons passera au travers.
  • detokenize protège contre la reconnaissance de forme, pas contre l’absence de tokenisation en amont. La garantie de detokenize — ne remplacer que les jetons réellement émis — est solide, mais elle ne compense pas un extracteur qui aurait raté une valeur en entrée. Le système est aussi robuste que son extracteur, pas plus.

Ce n’est donc pas de la magie : c’est un filtre déterministe, avec un périmètre défini et documenté, qui couvre bien le cas d’usage central de cyber-agent-engine — les entités réseau qui circulent dans les intentions opérateur et les logs d’équipements — sans prétendre à une détection universelle de toute donnée sensible imaginable.

La suite

Ce jeton n’a de sens que parce qu’il y a, en face, un core/ capable de décider ce qu’une intention exprimée en jetons a le droit de devenir en action réelle. C’est le sujet des prochains articles de cette série : comment le trust core valide, journalise et exécute — toujours sans jamais avoir eu besoin de voir la valeur réelle derrière le jeton.


Pour aller plus loin :