Le LLM ne voit que des jetons : tokenisation PII à la frontière d'exécution
🇬🇧 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é avantIP_ADDRESSpour qu’un sous-réseau ne ré-émette pas son IP nue.MAC_ADDRESS— matché avantIP_ADDRESScar 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 :::1ne présente pas de frontière\bexploitable).CVE— motifCVE-\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) :
À 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.
detokenizeprotège contre la reconnaissance de forme, pas contre l’absence de tokenisation en amont. La garantie dedetokenize— 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 :
- Un LLM avec les droits d’admin sur ton firewall — l’article 1 de cette série, qui pose la thèse générale du
core/de confiance. - Victor, l’anonymiseur de logs de sécurité — la cousine côté logs : NER + regex, sans dépendance externe.
