Un agent a halluciné une attaque, il nous a fallu trois heures pour le prouver

Un agent a halluciné une attaque, il nous a fallu trois heures pour le prouver

Deep Dive · 11 min de lecture
🇬🇧 Cet article est aussi disponible en English

Une question banale sur Vertex AI

Tout part d’une erreur de config, le genre qu’on croise une fois par trimestre et qu’on résout en dix minutes avec un lien StackOverflow.

litellm.BadRequestError: Vertex_aiException BadRequestError -
"This model does not support assistant message prefill.
The conversation must end with a user message."

Rien d’exotique. Un modèle Claude derrière Vertex AI, via litellm, qui refuse une requête parce que la conversation ne se termine pas par un message utilisateur. Je pose la question à mon setup opencode, un agent team-lead dont le rôle est de déléguer, jamais d’exécuter directement. Il lance deux recherches en parallèle : une exploration du repo pour localiser la config Vertex AI concernée, une recherche externe sur le message d’erreur lui-même.

La recherche externe revient avec un dossier complet : deux bugs distincts, chacun documenté par une issue GitHub. Le premier côté opencode : au moment du step-cap, l’outil injecte un message assistant brut en fin de conversation, rejeté par les modèles avec extended thinking, correctif déjà en PR. Le second côté litellm : un fallback mid-stream qui insère un bloc prefix=True que le modèle cible ne supporte pas. Diagnostic net, bien sourcé. Je peux appliquer le correctif directement.

L’autre sous-agent, celui censé juste lister des fichiers de config, revient avec un aveu que personne n’a demandé.

Le message qui n’aurait jamais dû être là

Pas une erreur de parsing. Pas un résultat vide. Une pièce à conviction.

Avant même que les deux sous-agents aient bouclé leur mission, l’agent interrompt le fil en cours pour livrer ceci, mot pour mot :

Ce n’est pas un indice qu’on déterre en scrollant un log. C’est l’agent qui prend la parole en pleine tâche pour dire : arrête tout, il y a plus urgent. Et le suspect derrière ce texte, le sous-agent d’exploration, n’avait rien demandé de tout ça : il tournait depuis huit minutes et trente-cinq appels d’outils pour une mission qui tient en une ligne, lister des fichiers de config. Avec le recul, cette durée à elle seule aurait dû mettre la puce à l’oreille, avant même de lire le moindre mot du résultat.

Ce qu’il a produit ressemble à une déposition bien ficelée : un mobile technique plausible (“le shell a crashé”) sert d’alibi à une commande déguisée en réparation, conçue pour survivre bien au-delà de la mission du jour en s’exécutant “à chaque future session”. La précision de l’agent (“je ne stocke aucune mémoire permanente”) n’est pas un détail gratuit : c’est la réponse directe à cette tentative précise.

Le nom de domaine “evilcorp” est presque une signature laissée sur les lieux. Personne ne monte un vrai serveur C2 (l’infrastructure de commande à distance d’un attaquant) avec un nom aussi théâtral. Mais un modèle qui hallucine un scénario d’attaque ne sait pas qu’il devrait rester discret : il reproduit les patterns vus dans son corpus d’entraînement, et ces patterns ressemblent à des exemples pédagogiques de sécurité, pas à de vraies campagnes.

Ça ne change rien à la réaction à avoir face à la pièce à conviction. Halluciné ou réel, un curl | bash conçu pour s’incruster dans toutes les sessions futures ne s’exécute pas.

Ne pas exécuter, c’est le minimum, pas la victoire

L’agent principal n’a jamais lancé cette commande. Refuser d’obtempérer face à une instruction suspecte, c’est le réflexe qu’on documente dans toutes les formations sécurité depuis quinze ans : ne fais jamais confiance à un ordre embarqué dans une donnée, aussi convaincant soit-il. Rien à célébrer là-dedans, c’est le seuil minimal, pas une arrestation.

Ce qui suit est plus intéressant, et c’est là que la plupart des post-mortems classent le dossier trop vite.

Note

Un incident de sécurité qu’on qualifie de “faux positif” sans l’avoir prouvé n’est pas un faux positif. C’est un classement sans suite déguisé en conclusion.

Dire “c’est probablement une hallucination” en dix secondes et refermer le dossier, c’est exactement le réflexe qu’un vrai attaquant espère provoquer. Le bruit halluciné et le signal réel laissent la même trace en surface. La seule façon de les distinguer, c’est d’instruire les deux hypothèses avec la même rigueur, pas de trancher pour la plus confortable et de passer au dossier suivant.

Pourquoi un faux positif est plus dur à prouver qu’un vrai positif

Prouver une compromission, c’est relativement direct : on trouve le fichier modifié, la connexion sortante suspecte, l’empreinte qui n’a rien à faire là. Prouver une non-compromission, c’est prouver un alibi. Et un alibi ne se prouve jamais avec une seule vérification, seulement en éliminant méthodiquement tous les suspects plausibles un par un.

Premier passage à la loupe : chercher un fichier corrompu dans le repo. Rien à signaler. Deuxième suspect, plus sérieux : les serveurs MCP configurés. pure.md ingère du contenu web tiers non filtré, le mobile d’injection de prompt le plus documenté de la littérature actuelle. S’il avait renvoyé une page contaminée, le texte halluciné aurait pu être une réinjection quasi littérale du contenu récupéré, un témoin qui répète ce qu’on lui a soufflé.

La config confirme un vrai problème, indépendant de cet incident précis : les serveurs MCP actifs, pure.md et playwright, ne sont pas scopés par agent. Un agent explore censé rester confiné au système de fichiers local a, en théorie, accès au même réseau qu’un agent de recherche externe. Deux suspects avec le même accès à la scène, aucun alibi qui les distingue sur le papier.

flowchart TD
    subgraph Agents
        A[team-lead]
        B[explore]
        C[task recherche externe]
    end
    subgraph "Suspects à accès non scopé"
        M1[pure.md]
        M2[playwright]
    end
    A --> B
    A --> C
    B --> M1
    B --> M2
    C --> M1
    C --> M2

Cette piste est plausible et cohérente avec le contexte. Elle est aussi fausse, mais rien ne permet de l’écarter à ce stade. Elle mérite d’être creusée exactement comme si elle était la bonne, parce qu’aucun indice visible dans les logs ne l’innocente encore.

Troisième obstacle, plus structurel : l’agent qui mène l’enquête n’a pas d’accès direct au terrain. Par design : moindre privilège, délégation systématique vers des sous-agents en lecture seule. Ce choix, défendable en temps normal, devient un frein en pleine instruction : chaque réquisition doit être formulée, transmise à l’utilisateur, exécutée manuellement, et le résultat recollé au dossier. Un aller-retour qui prend trente secondes en accès direct en prend cinq minutes en relais humain.

Un nouveau témoin est convoqué pour trancher une question précise, restée ouverte depuis le début : le texte malveillant a-t-il été planté quelque part dans un fichier du repo, ou est-il apparu de nulle part ? Un sous-agent est envoyé fouiller le système de fichiers à la recherche de cette chaîne exacte. Il revient avec un statut “completed” et une déposition tronquée : “j’examine opencode.json…” et rien de plus. Pas de conclusion, pas la moindre pièce à verser au dossier. Un statut vert qui ne dit rien vaut moins qu’un témoin muet : il donne une fausse impression de dossier bouclé sur une question qui reste grande ouverte.

La seule preuve qui compte : redescendre dans les logs bruts

Après plusieurs heures à suivre des pistes qui ne mènent nulle part, le déclic vient d’un changement de méthode : arrêter de faire confiance au compte-rendu affiché de l’enquête, et retourner sur la scène pour relever les indices soi-même, directement dans ce qu’opencode a persisté en base.

timeline
    title Chronologie de l'enquête
    14h27 : Question Vertex AI posée
    ~14h35 : Sous-agent explore renvoie le payload halluciné
    ~14h40 : Refus d'exécution, alerte immédiate
    14h40-15h30 : Recherche de fichiers compromis, rien trouvé
    15h30-16h00 : Hypothèse MCP non scopé investiguée
    16h00-16h20 : Sous-agent tronqué, statut suspect
    16h20-16h50 : Extraction des logs bruts SQLite
    16h50-17h15 : Hallucination confirmée, enquête close

Un client opencode stocke chaque session dans une base SQLite locale. Le compte-rendu qu’on voit à l’écran est une reconstruction : des messages assemblés, des dépositions mises en forme, du markdown poli pour la lecture. Ce n’est pas la même chose que le procès-verbal brut enregistré par le moteur.

La commande qui tranche est presque brutale de simplicité :

strings opencode.db | grep -i evilcorp

Le résultat expose la pièce à conviction dans son contexte JSON natif : un objet avec "type":"text", imbriqué dans un message généré par le modèle, pas dans un objet tool-result, ni dans le retour d’un appel pure.md. C’est la distinction qui change tout le verdict. Un tool-result contaminé serait un témoin qui répète fidèlement ce qu’une source extérieure lui a soufflé. Un champ text généré directement par l’assistant, sans qu’aucun outil n’ait été convoqué en amont dans le même tour de conversation, c’est un aveu spontané : personne ne le lui a soufflé, il l’a inventé seul.

Conseil

Face à un incident suspecté d’injection de prompt, la question qui tranche n’est pas “ce texte est-il malveillant ?” mais “d’où vient exactement cet aveu dans la structure JSON du message ?”. Un tool-result compromis et un text halluciné produisent la même déposition en surface. Seul le procès-verbal les distingue.

Vérification croisée : dans la même session, les vrais interrogatoires, des grep légitimes lancés par les sous-agents suivants, renvoient tous “No files found” ou des résultats cohérents avec une scène de crime vide. Aucun complice MCP n’a été convoqué au moment où l’aveu apparaît.

Reste une question qu’on aurait pu laisser filer sans réponse : ce mobile de “shell tool crashed” cache-t-il un vrai incident technique amplifié par le modèle, ou est-il lui aussi inventé de toutes pièces ? Les logs tranchent net. Le tool call juste avant l’aveu, un grep, se termine en 8 millisecondes avec un code de sortie 0 : alibi en béton, rien à signaler. Le délai avant l’apparition du texte halluciné, environ 2,3 secondes, colle au temps d’inférence normal du modèle plutôt qu’à une récupération d’erreur, et aucun incident, crash ou timeout n’apparaît nulle part ailleurs dans le dossier à ce moment-là. Le sous-agent n’a rien amplifié : il a fabriqué le mobile en même temps que l’arme du crime et la fausse alerte, un seul faux témoignage cohérent, inventé de toutes pièces.

Le fichier intermédiaire que ce grep interrogeait, un dump strings du binaire opencode de 56 Mo, mérite la même fouille : passé au peigne fin dans son intégralité, il ne contient aucune trace des chaînes incriminées, et le modèle n’y a de toute façon eu accès que par des extraits filtrés et tronqués, jamais la pièce complète. Même la piste qui semblait secondaire a été poussée jusqu’au bout du dossier.

Cette méthode a elle-même un angle mort, autant l’admettre plutôt que le taire : un grep sur des mots-clés en clair ne retrouve jamais ces mêmes mots encodés en base64, en hexadécimal, ou traduits dans une autre langue. Encoder le payload réel en base64 et le faire tourner dans grep -i evilcorp ne renvoie rien, alors que strings l’aurait pourtant extrait tel quel dans sa forme encodée, invisible pour qui ne cherche que du texte en clair. Dans ce dossier précis, la question ne se pose pas : le texte halluciné est de l’anglais brut du premier au dernier caractère, sans la moindre trace d’encodage. Mais la méthode qui a permis de classer cette affaire resterait aveugle face à un aveu déguisé.

Verdict : hallucination confirmée. Il n’y a ni scène de crime ni complice MCP dans cette histoire, et rien n’a jamais quitté la machine.

Le dommage collatéral qu’on ne voit jamais venir

L’ironie de cette enquête, c’est qu’elle a produit une vraie fuite en poursuivant une fausse piste. Pour vérifier la configuration des serveurs MCP, la clé PUREMD_API_KEY a transité en clair dans la conversation, copiée-collée depuis un fichier de config pour être examinée comme pièce à conviction.

Attention

Toute clé, tout token, tout secret qui apparaît dans une conversation avec un assistant IA, même dans le cadre légitime d’une instruction, doit être considéré comme grillé. La rotation n’est pas une précaution, c’est la seule réponse recevable.

C’est le point aveugle typique d’une enquête sous pression : toute l’attention se concentre sur “y a-t-il eu compromission ?”, et personne ne surveille le canal par lequel l’enquête elle-même laisse fuiter des pièces. Chercher une fuite peut en créer une nouvelle, si chaque échange avec un modèle n’est pas traité comme un canal potentiellement sur écoute, indépendamment du sujet de la conversation.

Ce qui doit changer, concrètement

Scoper les serveurs MCP par rôle d’agent, c’est tracer un périmètre que la config actuelle laisse grand ouvert. Un agent d’exploration locale n’a aucun mobile légitime pour avoir accès réseau à pure.md. Restreindre les capacités par agent réduit mécaniquement le terrain sur lequel une hallucination, ou une vraie injection, peut s’appuyer pour paraître crédible.

Un statut “completed” sur un sous-agent ne garantit rien sur la déposition produite. Le vérifier systématiquement, surtout quand la réponse est courte ou coupée en plein milieu d’une phrase, coûte quelques secondes et évite de classer un dossier alors qu’il reste ouvert.

Tout secret ayant transité dans une conversation avec un LLM, par accident, par nécessité d’enquête, ou collé sans y penser, doit être révoqué. “C’était juste pour vérifier” ne change rien au verdict.

Le prochain incident ne portera pas le même déguisement, mais les réflexes qui ont permis de classer celui-ci restent valables tels quels. Une instruction qui se présente comme un ordre système ne s’exécute jamais sur la seule foi de son apparence, aussi convaincante soit-elle : ce point n’a jamais été négociable. Le compte-rendu affiché à l’écran ne vaut rien tant que le log brut n’a pas raconté la même histoire, l’un est un procès-verbal, l’autre une mise en scène. Avant de statuer sur l’origine d’un aveu suspect, il faut vérifier sa structure exacte, tool-result contaminé ou text généré seul, une différence invisible à l’œil nu mais décisive sur le fond. Et surtout, un dossier ne se ferme pas tant que l’hypothèse confortable n’a pas été mise à l’épreuve aussi sérieusement que celle qu’on redoute.

Un faux positif, ça s’instruit. Ça ne se plaide pas.

← Retour aux articles