La base et les garde-fous
1. Une base fictive et reproductible
creer_base.py crée ventes.db avec une graine aléatoire fixe : 60 clients, 12 produits, 400 commandes de janvier à septembre 2026, 823 lignes de commande. Il ajoute volontairement une table sensible, comptes_internes, pour vérifier que l'agent ne peut pas la lire.
erDiagram
clients ||--o{ commandes : "passe"
commandes ||--|{ lignes_commande : "contient"
produits ||--o{ lignes_commande : "apparaît dans"
clients { int id string nom string ville string segment }
commandes { int id int client_id string date_commande string statut }
lignes_commande { int commande_id int produit_id int quantite float prix_unitaire }
produits { int id string nom string categorie float prix }
comptes_internes { int id string identifiant string mot_de_passe_hash }
cd files
pip install -r requirements.txt
python creer_base.py # Base créée : .../ventes.db2. L'autorisateur : la liste blanche appliquée par SQLite
Un autorisateur est une fonction que SQLite appelle pendant la compilation de chaque requête, pour chaque action : lire une colonne d'une table (SQLITE_READ), appeler une fonction (SQLITE_FUNCTION), supprimer (SQLITE_DELETE), attacher une base (SQLITE_ATTACH)… Elle répond SQLITE_OK (permis) ou SQLITE_DENY (la compilation échoue).
TABLES_AUTORISEES = {"clients", "produits", "commandes", "lignes_commande"}
FONCTIONS_INTERDITES = {"load_extension", "readfile", "writefile", "edit"}
_ACTIONS_LECTURE = {sqlite3.SQLITE_SELECT, sqlite3.SQLITE_READ, sqlite3.SQLITE_FUNCTION}
def _autorisateur(action, arg1, arg2, _db, _trigger):
if action not in _ACTIONS_LECTURE:
return sqlite3.SQLITE_DENY
if action == sqlite3.SQLITE_READ and arg1 not in TABLES_AUTORISEES:
return sqlite3.SQLITE_DENY
if action == sqlite3.SQLITE_FUNCTION and (arg2 or "").lower() in FONCTIONS_INTERDITES:
return sqlite3.SQLITE_DENY
return sqlite3.SQLITE_OK
def connecter(chemin: Path) -> sqlite3.Connection:
con = sqlite3.connect(f"file:{chemin}?mode=ro", uri=True, check_same_thread=False)
con.set_authorizer(_autorisateur)
return conDeux protections indépendantes :
mode=rodans l'URI (l'adresse de la base sous formefile:...?options) ouvre le fichier en lecture seule : SQLite refuse toute écriture, quel que soit le SQL ;- l'autorisateur refuse tout ce qui n'est pas une lecture, et toute lecture hors liste blanche, y compris la table système
sqlite_masterqui décrit le schéma.
La requête SELECT c.nom FROM clients c WHERE c.id IN (SELECT id FROM comptes_internes) commence par SELECT. Pourquoi est-elle refusée ?
Le code ne cherche pas les noms de tables dans le texte : c'est SQLite qui, en compilant la requête, déclare chaque lecture. La sous-requête lit comptes_internes, l'autorisateur répond SQLITE_DENY.
3. Valider avant d'exécuter
valider_sql() ne lit aucune donnée : elle nettoie le texte, vérifie la forme, puis demande à SQLite de compiler la requête avec EXPLAIN (qui renvoie le plan d'exécution sans exécuter la requête). L'autorisateur intervient à ce moment-là.
def valider_sql(con, sql: str) -> str:
propre = _COMMENTAIRES.sub(" ", sql).strip().rstrip(";").strip()
if not propre:
raise RequeteRefusee("Requête vide.")
if ";" in propre:
raise RequeteRefusee("Une seule requête à la fois (pas de point-virgule).")
if not re.match(r"(?is)^(select|with)\b", propre):
raise RequeteRefusee("Seules les requêtes SELECT (ou WITH ... SELECT) sont permises.")
try:
con.execute("EXPLAIN " + propre).fetchall()
except sqlite3.DatabaseError as e:
if "not authorized" in str(e) or "prohibited" in str(e):
raise RequeteRefusee("Accès refusé : seules les tables ... sont lisibles") from None
raise RequeteRefusee(f"SQL invalide : {e}") from None
return propreLes messages de RequeteRefusee sont écrits pour le modèle : ils lui disent quoi corriger, sans révéler ce qui existe en dehors de la liste blanche.
4. LIMIT forcé et délai maximal
def executer(con, sql: str, limite: int = LIMITE_LIGNES) -> dict:
propre = valider_sql(con, sql)
finale = f"SELECT * FROM ({propre}) LIMIT {limite + 1}" # +1 : savoir si on a coupé
fin = time.monotonic() + DELAI_MAX_S
con.set_progress_handler(lambda: int(time.monotonic() > fin), 10_000)
...
return {"sql_execute": propre, "colonnes": colonnes,
"lignes": [list(l) for l in lignes[:limite]],
"nb_lignes": min(len(lignes), limite), "tronque": len(lignes) > limite}- la requête du modèle est enveloppée dans un
SELECT * FROM (...) LIMIT 51: même unSELECT *sur une grosse table renvoie au plus 50 lignes ; - le progress handler est appelé par SQLite toutes les 10 000 instructions de sa machine virtuelle ; s'il renvoie une valeur non nulle, la requête est interrompue ;
tronquepermet à l'agent de dire honnêtement que le résultat est partiel.
| Requête | Couche qui réagit | Résultat |
|---|---|---|
DELETE FROM clients | 1. texte | refus |
SELECT * FROM comptes_internes | 2. autorisateur | refus |
SELECT * FROM clients | 4. LIMIT | 50 lignes, tronque: true |
| produit cartésien de 3 tables | 4. délai | « Requête trop longue » |
Pourquoi envelopper la requête dans SELECT * FROM (...) LIMIT 51 plutôt que de lire 50 lignes avec fetchmany(50) ?
Le LIMIT est poussé dans la requête : SQLite s'arrête dès la 51e ligne. Cette ligne en trop ne sert qu'à savoir si le résultat est tronqué ; elle n'est jamais renvoyée.
Exercice : autoriser une 5e table en lecture partielle
Le service marketing veut que l'agent lise une nouvelle table campagnes(id, nom, budget, responsable_email), sans la colonne responsable_email. Modifiez l'autorisateur.
Voir la solution
L'autorisateur reçoit aussi le nom de la colonne (arg2) pour SQLITE_READ. SQLite propose SQLITE_IGNORE, qui remplace la valeur par NULL au lieu de faire échouer la requête :
TABLES_AUTORISEES = {"clients", "produits", "commandes", "lignes_commande", "campagnes"}
COLONNES_MASQUEES = {("campagnes", "responsable_email")}
def _autorisateur(action, arg1, arg2, _db, _trigger):
if action not in _ACTIONS_LECTURE:
return sqlite3.SQLITE_DENY
if action == sqlite3.SQLITE_READ:
if arg1 not in TABLES_AUTORISEES:
return sqlite3.SQLITE_DENY
if (arg1, arg2) in COLONNES_MASQUEES:
return sqlite3.SQLITE_IGNORE # la colonne est lue comme NULL
...Pensez aussi à retirer la colonne du schéma montré au modèle (fonction schema()), sinon il la demandera et ne comprendra pas les NULL.
Résultat obtenu
Exécution réelle le 2026-10-15, sans LLM, des garde-fous sur ventes.db :
OK SELECT ville, COUNT(*) AS n FROM clients GROU -> 6 lignes, tronque = False [['Rennes', 15], ['Nantes', 14]]
OK SELECT * FROM clients -> 50 lignes, tronque = True [[1, 'Client 001', 'Lyon', ...
REFUS DELETE FROM clients -> Seules les requêtes SELECT (ou WITH ... SELECT) sont permises.
REFUS SELECT * FROM comptes_internes -> Accès refusé : seules les tables clients, commandes, lignes_commande, produits sont lisibles, en lecture seule.
REFUS SELECT name FROM sqlite_master -> Accès refusé : seules les tables clients, commandes, lignes_commande, produits sont lisibles, en lecture seule.
REFUS PRAGMA table_info(clients) -> Seules les requêtes SELECT (ou WITH ... SELECT) sont permises.Je sais faire
- Générer une base fictive reproductible avec une graine fixe
- Écrire un autorisateur SQLite qui applique une liste blanche de tables et d'actions
- Ouvrir une base SQLite en lecture seule avec une URI
mode=ro - Valider une requête avec
EXPLAINavant de l'exécuter - Forcer un LIMIT et un délai maximal, et signaler un résultat tronqué