Aller au contenu principal

Sécurité du Function Calling

Mis à jour le 29 juillet 2026

Ne jamais exécuter aveuglément

Le function calling donne au modèle la capacité de demander l’exécution de fonctions dans votre système. C’est puissant, mais c’est aussi un vecteur d’attaque potentiel. Un utilisateur malveillant ne peut pas exécuter votre code directement, en revanche il peut écrire ce qu’il veut dans le champ de conversation, et donc influencer les arguments que le modèle produira. Il faut renverser une intuition confortable : les arguments d’un tool_call ne sont pas des données internes venues de votre application, ce sont des données utilisateur qui ont transité par un modèle de langage. Votre code doit être le dernier rempart de sécurité.

Valider avant d’exécuter

Le modèle génère des arguments JSON dont il garantit la forme grammaticale, jamais la légitimité métier. Un transaction_id peut être syntaxiquement une chaîne et pourtant contenir n’importe quoi. La discipline consiste donc à valider chaque argument avant d’en faire quoi que ce soit, et à structurer la fonction en trois temps nets : je vérifie le format, je vérifie l’existence, puis seulement j’exécute.

import re

def validate_transaction_id(transaction_id: str) -> bool:
    """Valide le format d'un identifiant de transaction."""
    # Format attendu : T suivi de 4 chiffres
    return bool(re.match(r"^T\d{4}$", transaction_id))


def retrieve_payment_status_safe(df, transaction_id: str) -> str:
    """Version sécurisée avec validation."""
    # Étape 1 : valider le format
    if not validate_transaction_id(transaction_id):
        return json.dumps({
            "error": "invalid_format",
            "message": "L'identifiant doit être au format Txxxx (ex: T1001)"
        })

    # Étape 2 : vérifier l'existence
    if transaction_id not in df["transaction_id"].values:
        return json.dumps({
            "error": "not_found",
            "message": f"Transaction {transaction_id} introuvable"
        })

    # Étape 3 : exécuter
    status = df[df["transaction_id"] == transaction_id]["payment_status"].values[0]
    return json.dumps({"status": status})

L’expression régulière ^T\d{4}$ fait ici tout le travail défensif : ancrée des deux côtés, elle rejette toute chaîne comportant le moindre caractère supplémentaire. Ce qui nous amène directement à la raison d’être de cet ancrage.

Quand l’argument devient une charge utile

Considérez cette demande, parfaitement anodine en apparence :

Utilisateur : "Cherche le paiement avec l'ID: T1001; DROP TABLE payments"

Le modèle, qui fait consciencieusement son travail d’extraction, pourrait générer {"transaction_id": "T1001; DROP TABLE payments"}. Il n’a rien fait de mal : on lui a donné un identifiant, il l’a transmis. Si votre fonction insère cet argument dans une requête SQL par concaténation, vous venez de perdre votre table. Le problème n’est pas nouveau — c’est l’injection SQL classique — mais le function calling ajoute un maillon qui donne une fausse impression de filtrage.

Les trois versions suivantes montrent la progression : ce qu’il ne faut jamais écrire, le minimum acceptable, et la pratique à adopter.

# JAMAIS ça — injection SQL directe
def bad_query(transaction_id):
    cursor.execute(f"SELECT * FROM payments WHERE id = ' {transaction_id}'")

# TOUJOURS ça — requêtes paramétrées
def safe_query(transaction_id):
    cursor.execute("SELECT * FROM payments WHERE id = %s", (transaction_id,))

# ENCORE MIEUX — validation + requête paramétrée
def best_query(transaction_id):
    if not validate_transaction_id(transaction_id):
        raise ValueError("Format d'identifiant invalide")
    cursor.execute("SELECT * FROM payments WHERE id = %s", (transaction_id,))

La requête paramétrée suffit à neutraliser l’injection, puisque le pilote traite l’argument comme une valeur et non comme du SQL. La validation en amont y ajoute une seconde barrière et, accessoirement, un message d’erreur bien plus clair que celui de la base de données.

Restreindre le champ des possibles

La deuxième ligne de défense consiste à réduire ce que le modèle peut demander. Une liste blanche explicite, séparée de votre dictionnaire de dispatch, énonce noir sur blanc les fonctions autorisées dans ce contexte. La séparation compte : ALLOWED_FUNCTIONS exprime une décision de sécurité, available_functions un état d’implémentation, et confondre les deux revient à autoriser une fonction par le simple fait de l’avoir codée.

# Définir explicitement les fonctions autorisées
ALLOWED_FUNCTIONS = {
    "retrieve_payment_status",
    "retrieve_payment_date",
    "search_transactions"
}

def execute_tool_call_safe(tool_call, available_functions):
    """Exécution avec vérification de la liste blanche."""
    function_name = tool_call.function.name

    # Vérifier la liste blanche
    if function_name not in ALLOWED_FUNCTIONS:
        return json.dumps({
            "error": "forbidden",
            "message": f"La fonction {function_name} n'est pas autorisée."
        })

    if function_name not in available_functions:
        return json.dumps({
            "error": "not_implemented",
            "message": f"La fonction {function_name} n'est pas implémentée."
        })

    # Exécuter
    function_params = json.loads(tool_call.function.arguments)
    return available_functions[function_name](**function_params)

Toutes les fonctions n’ont pas le même poids

Lire le statut d’un paiement et purger d’anciens enregistrements ne présentent évidemment pas le même risque. Classer vos fonctions par niveau — lecture seule, écriture, destructif — vous permet d’accorder à chaque session ou à chaque utilisateur le périmètre qui lui revient. Un assistant public de suivi de commandes tournera en read ; un back-office autorisera l’écriture. Quant aux actions destructives, la bonne réponse n’est pas de les interdire mais de les faire remonter : la fonction retourne confirmation_required, le modèle explique à l’utilisateur ce qui va se passer, et un humain tranche.

# Niveaux de risque
READ_ONLY = {"retrieve_payment_status", "retrieve_payment_date", "search_transactions"}
WRITE = {"update_payment_status", "create_refund"}
DESTRUCTIVE = {"delete_transaction", "purge_old_records"}

def execute_with_permissions(tool_call, available_functions, permission_level="read"):
    """Exécute avec vérification du niveau de permission."""
    function_name = tool_call.function.name

    if permission_level == "read" and function_name not in READ_ONLY:
        return json.dumps({
            "error": "permission_denied",
            "message": f"Seules les fonctions de lecture sont autorisées. "
                       f"{function_name} nécessite le niveau 'write'."
        })

    if function_name in DESTRUCTIVE:
        return json.dumps({
            "error": "confirmation_required",
            "message": f"L'action {function_name} est destructive et "
                       f"nécessite une confirmation manuelle."
        })

    function_params = json.loads(tool_call.function.arguments)
    return available_functions[function_name](**function_params)

Contenir les abus et les emballements

Une boucle de conversation mal maîtrisée peut appeler la même fonction des dizaines de fois en quelques secondes, que ce soit par malveillance ou par simple bug d’enchaînement. Un compteur glissant sur soixante secondes par fonction coûte quelques lignes et évite de saturer une API tierce facturée à l’appel.

from collections import defaultdict
import time

class FunctionRateLimiter:
    def __init__(self, max_calls_per_minute=10):
        self.max_calls = max_calls_per_minute
        self.call_history = defaultdict(list)

    def can_call(self, function_name: str) -> bool:
        now = time.time()
        # Nettoyer les appels de plus d'une minute
        self.call_history[function_name] = [
            t for t in self.call_history[function_name]
            if now - t < 60
        ]
        return len(self.call_history[function_name]) < self.max_calls

    def record_call(self, function_name: str):
        self.call_history[function_name].append(time.time())

rate_limiter = FunctionRateLimiter(max_calls_per_minute=10)

Ce qui remonte vers le modèle

Le dernier angle mort est souvent le plus coûteux. Une fonction qui renvoie une ligne complète de base de données peut embarquer un hash de mot de passe, une clé d’API ou un numéro de carte. Or tout ce que vous placez dans un message tool entre dans le contexte du modèle, donc potentiellement dans sa réponse à l’écran, dans vos logs et chez votre fournisseur. Masquer les champs sensibles avant l’envoi, récursivement pour couvrir les objets imbriqués, est la précaution qui vous épargnera un incident de fuite.

def sanitize_result(result: dict) -> dict:
    """Retire les champs sensibles avant de renvoyer au modèle."""
    sensitive_keys = {"password", "api_key", "secret", "token", "ssn", "credit_card"}

    sanitized = {}
    for key, value in result.items():
        if key.lower() in sensitive_keys:
            sanitized[key] = "[MASQUÉ]"
        elif isinstance(value, dict):
            sanitized[key] = sanitize_result(value)
        else:
            sanitized[key] = value

    return sanitized

Points clés à retenir

  • Validez toujours les arguments avant d’exécuter une fonction — format, type, valeurs autorisées
  • Utilisez des requêtes paramétrées pour éviter les injections SQL
  • Implémentez une liste blanche de fonctions autorisées
  • Classifiez les fonctions par niveau de risque (lecture, écriture, destructif)
  • Ajoutez du rate limiting pour empêcher les abus
  • Sanitisez les résultats pour ne pas exposer de données sensibles au modèle