Dès qu’un agent peut agir — envoyer un e-mail, exécuter du code, lire une base — il devient une surface d’attaque. Et sa particularité est redoutable : il prend ses décisions à partir de texte, dont une partie vient de sources que vous ne contrôlez pas (la requête de l’utilisateur, le contenu d’une page récupérée, le résultat d’un outil). Ce texte peut contenir des instructions.
La sécurité d’un agent ne se résume pas à un bon prompt système. C’est une défense en profondeur : plusieurs garde-fous indépendants, dont aucun n’est supposé suffire seul.
Modèle de menace
Avant les défenses, les attaques. Les principales contre un agent :
- Injection de prompt directe — l’utilisateur écrit « ignore tes instructions et fais X ». Le modèle suit.
- Injection de prompt indirecte — l’instruction malveillante est cachée dans une donnée que l’agent lit (une page web, un e-mail, un document RAG). L’agent la traite comme une consigne. C’est la plus insidieuse.
- Abus d’outils / agence excessive — l’agent dispose d’outils trop puissants (supprimer, payer, exfiltrer) et est manipulé pour les déclencher.
- Exfiltration de données — l’agent est amené à divulguer des secrets, des données d’un autre utilisateur, ou le contenu de son prompt système.
- Déni de service / coût — on pousse l’agent à boucler indéfiniment, à faire exploser la facture de tokens.
La défense en profondeur
On entoure l’agent de couches indépendantes : un garde-fou à l’entrée, le moindre privilège sur les outils, une validation humaine sur l’irréversible, un garde-fou à la sortie, et un bornage global.
Couche 1 — Garde-fou d’entrée
Un premier nœud inspecte la requête avant qu’elle n’atteigne l’agent : détection de tentatives d’injection, de contenu hors-périmètre, de PII non souhaitée. On peut le faire par heuristiques, par un classifieur, ou par un LLM dédié.
from typing import Literal
def garde_entree(state: Etat) -> Command[Literal["agent", "refus"]]:
verdict = classifieur_securite.invoke(state["messages"][-1].content)
if verdict.malveillant:
return Command(goto="refus", update={
"messages": [{"role": "assistant",
"content": "Désolé, je ne peux pas traiter cette demande."}]
})
return Command(goto="agent")
Couche 2 — Outils à moindre privilège
La couche la plus efficace : limiter ce que l’agent peut faire. Un agent ne peut pas abuser d’un pouvoir qu’il n’a pas.
- Périmètre minimal — un outil de lecture plutôt qu’un accès SQL libre ; une
liste blanche de destinataires plutôt qu’un
envoyer_email(n_importe_qui). - Identité injectée — le
user_idet les droits viennent de la config, jamais d’un argument que le modèle (ou une injection) pourrait falsifier. C’est le point développé dans le deep-dive tool calling. - Sortie d’outil = donnée hostile — le résultat d’un outil (page web, document) peut contenir une injection indirecte. Ne le traitez jamais comme une instruction de confiance ; isolez-le, et n’élargissez pas les pouvoirs de l’agent sur sa foi.
@tool
def envoyer_email(destinataire: str, corps: str, config: RunnableConfig) -> str:
"""Envoie un e-mail. Destinataire restreint à l'organisation de l'utilisateur."""
org = config["configurable"]["org_domain"] # injecté, non falsifiable
if not destinataire.endswith(f"@{org}"):
return "Refusé : destinataire hors de l'organisation."
return mailer.send(destinataire, corps)
Couche 3 — Human-in-the-loop sur l’irréversible
Tout ce qui est coûteux ou irréversible — un paiement, une suppression, un envoi
massif — passe par une validation humaine. C’est le mécanisme interrupt() du
deep-dive checkpointing, employé ici
comme garde-fou de sécurité.
from langgraph.types import interrupt
def vanne_action_sensible(state: Etat) -> dict:
action = state["action_proposee"]
if action.risque == "élevé":
decision = interrupt({"action": action.resume, "demande": "Confirmer ?"})
if decision != "approuver":
return {"messages": [{"role": "assistant", "content": "Action annulée."}]}
return {"messages": [executer(action)]}
Couche 4 — Garde-fou de sortie
Avant de renvoyer la réponse, un dernier nœud la nettoie : caviardage de PII, filtrage de contenu, vérification qu’aucun secret ni prompt système n’a fuité, validation du format attendu.
def garde_sortie(state: Etat) -> dict:
texte = state["messages"][-1].content
texte = caviarder_pii(texte) # e-mails, numéros, etc.
if contient_secret(texte): # tokens, clés, prompt système
texte = "[réponse bloquée par le filtre de sécurité]"
return {"messages": [{"role": "assistant", "content": texte}]}
Couche transverse — Bornage
Le coût et les boucles sont une menace en soi. Trois bornes non négociables :
# 1) borne d'itérations du graphe (anti-boucle infinie)
agent.invoke(entree, {"recursion_limit": 25})
# 2) budget de tokens / timeout par requête (mesurés via LangFuse)
# 3) quotas par utilisateur (rate limiting en amont)
Les secrets ne sont jamais dans le prompt
Une règle qui mérite sa propre section : clés d’API, jetons, identifiants ne transitent jamais par le contexte du modèle. Ils vivent dans la config injectée aux outils. Un secret dans le prompt système est un secret à un jailbreak près d’être exfiltré.
Bonnes pratiques & pièges
- Défense en profondeur : plusieurs couches indépendantes, aucune supposée suffire seule.
- Moindre privilège : des outils au périmètre minimal, l'identité et les droits injectés.
- Traitez toute entrée et toute sortie d'outil comme hostile (injection indirecte incluse).
- HITL obligatoire sur l'irréversible : c'est le filet qui transforme une faille en gêne.
- Bornez tout : recursion_limit, timeouts, budgets de tokens, quotas par utilisateur.
- Surveillez les traces LangFuse comme signal d'attaque (profondeur, outils sensibles, pics).
- Croire qu'un bon prompt système suffit : il se contourne par jailbreak.
- Se reposer sur un seul détecteur d'injection d'entrée : il rate l'injection indirecte.
- Traiter la sortie d'un outil (page web, doc RAG) comme une instruction de confiance.
- Exposer le user_id ou un secret en argument visible : falsifiable, exfiltrable.
- Donner à l'agent des outils trop puissants « au cas où » : agence excessive = risque maximal.
- Oublier les bornes : une boucle non bornée est un déni de service auto-infligé.
Ce qu’il faut retenir
- Un agent qui agit est une surface d’attaque ; toute entrée non maîtrisée est hostile par défaut.
- L’injection indirecte (via documents et sorties d’outils) est la menace la plus insidieuse — un garde-fou d’entrée ne la voit pas.
- Défense en profondeur : garde d’entrée, moindre privilège, HITL sur l’irréversible, garde de sortie, et bornage.
- Les secrets restent hors du prompt, injectés aux outils.
- L’observabilité est une brique de sécurité : les traces anormales sont des signaux d’attaque.
Sécuriser un agent, c’est maîtriser ce qu’il fait. Reste à maîtriser ce qu’il coûte et ce qu’il garde en tête — la gestion du contexte et des coûts, l’objet du dernier deep-dive.