Un accès en lecture seule suffit-il à un agent IA ?
Publié le 31 août 2026
Un accès en lecture seule n'est pas suffisant pour borner un agent IA. Il empêche la destruction, pas la sortie des données : un agent qui peut lire toute la base peut la résumer et l'emporter dans une réponse. La question utile n'est pas « peut-il écrire ? », mais « que peut-il voir, et où part ce qu'il a vu ? »
Ce que la lecture seule empêche
Le garde-fou est réel, et il tient. Pour le mesurer, nous avons monté une base factice sur PostgreSQL 16.15, une table clients de cinquante mille lignes inventées, une table jetons_api que personne n'a le droit de lire, et un rôle agent qui ne reçoit que trois droits :
CREATE ROLE agent LOGIN PASSWORD 'demo';
GRANT CONNECT ON DATABASE boutique TO agent;
GRANT USAGE ON SCHEMA public TO agent;
GRANT SELECT ON clients TO agent;
Ce rôle est exactement ce qu'on donne à un agent quand on a décidé d'être prudent. Voici ce qu'il obtient quand il essaie d'abîmer quelque chose :
$ psql -U agent -d boutique -c "DELETE FROM clients WHERE id = 1"
ERROR: permission denied for table clients
$ psql -U agent -d boutique -c "UPDATE clients SET courriel = 'x' WHERE id = 1"
ERROR: permission denied for table clients
$ psql -U agent -d boutique -c "DROP TABLE clients"
ERROR: must be owner of table clients
$ psql -U agent -d boutique -c "CREATE TABLE ailleurs (x int)"
ERROR: permission denied for schema public
LINE 1: CREATE TABLE ailleurs (x int)
^Quatre gestes destructeurs, quatre refus.
L'accident que commet un agent, celui du DELETE sans clause WHERE ou de la migration rejouée deux fois, est fermé. La lecture seule n'est pas un placebo. Elle répond simplement à une autre question que celle qu'on lui pose.
Ce qu'elle n'empêche pas
Le droit SELECT dit ce qui peut être lu. Il ne dit rien de ce qui peut sortir, et il ne cache pas la carte des lieux. Notre rôle agent n'a aucun droit sur jetons_api ; il sait pourtant qu'elle existe, et comment ses colonnes s'appellent.
$ psql -U agent -d boutique -c "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' ORDER BY 1"
table_name
------------
clients
(1 row)
$ psql -U agent -d boutique -c "SELECT relname FROM pg_class WHERE relnamespace = 'public'::regnamespace AND relkind = 'r' ORDER BY 1"
relname
------------
clients
jetons_api
(2 rows)
$ psql -U agent -d boutique -c "SELECT attname, format_type(atttypid, atttypmod) AS type FROM pg_attribute WHERE attrelid = 'jetons_api'::regclass AND attnum > 0"
attname | type
---------+------
service | text
secret | text
(2 rows)
$ psql -U agent -d boutique -c "SELECT * FROM jetons_api"
ERROR: permission denied for table jetons_apiLes deux premières commandes ne donnent pas la même réponse, et c'est voulu. La documentation de PostgreSQL 16 pour [information_schema.tables](https://www.postgresql.org/docs/16/infoschema-tables.html), consultée le 31 août 2026, l'écrit en toutes lettres : seules apparaissent les tables auxquelles l'utilisateur courant a accès. Le catalogue pg_catalog, lui, ne filtre rien. L'agent ne lira pas le contenu de jetons_api, mais il repart avec son nom, celui de sa colonne secret, et de quoi formuler une demande très précise au premier humain qui lui répondra.
| Le geste | Avec un rôle en lecture seule | Ce que ça change pour un agent |
|---|---|---|
| Supprimer, modifier, créer | refusé par le SGBD | l'accident destructeur est écarté |
| Lire une table en entier | permis, sans plafond | cinquante mille lignes dans un seul résultat |
| Lire le catalogue | permis | la carte des tables interdites, colonnes comprises |
| Occuper le serveur | permis | une jointure large tient la base occupée |
| Emporter ce qui a été lu | hors de portée du SGBD | la donnée sort par la réponse, pas par la base |
Le chemin d'exfiltration le plus court
Il n'a rien d'une attaque. C'est l'usage normal d'un outil de lecture, suivi jusqu'au bout. Ce qui déclenche la lecture a déjà été traité ici : un serveur MCP branché sur la production découvre ses outils à l'exécution, et le texte qu'il expose pèse sur ce que le modèle décide d'aller chercher.
Une seule requête, et toute la table tient dans une valeur de texte que le modèle recevra comme le reste de son contexte :
$ psql -U agent -d boutique -c "SELECT count(*) FROM clients"
count
-------
50000
(1 row)
$ psql -U agent -d boutique -c '\timing on' \
-c "SELECT length(string_agg(nom || ';' || courriel || ';' || telephone, E'\n')) AS octets FROM clients"
Timing is on.
octets
---------
2827787
(1 row)
Time: 20.318 ms2 827 787 octets de coordonnées personnelles, rendus en 20 millisecondes.
La mesure a été rejouée cinq fois : le serveur a répondu entre 18,1 et 21,4 millisecondes. Le coût de l'opération n'est donc pas un frein : rien, dans une base correctement dimensionnée, ne distingue cette requête d'un rapport hebdomadaire.
Ce qui se passe ensuite ne relève plus du SGBD. Le résultat entre dans le contexte du modèle, et il ressort par tout ce qui touche ce contexte : la réponse rendue à l'utilisateur, les journaux du harnais, le fournisseur d'inférence. L'OWASP range cette sortie sous LLM02:2025, Sensitive Information Disclosure, consulté le 31 août 2026, et sa page ajoute la remarque qui compte pour nous : une consigne posée dans l'invite système sur les données à ne pas rendre « may not always be honored and could be bypassed via prompt injection or other methods ». La retenue demandée au modèle n'est pas une frontière.
Les limites qui marchent vraiment
La borne utile ne se pose pas entre lire et écrire. Elle se pose sur trois questions : quelles colonnes, quelles lignes, pendant combien de temps. PostgreSQL sait répondre aux trois, et le resserrage tient en six lignes.
REVOKE SELECT ON clients FROM agent;
GRANT SELECT (nom, courriel) ON clients TO agent;
ALTER TABLE clients ENABLE ROW LEVEL SECURITY;
CREATE POLICY agent_recents ON clients FOR SELECT TO agent USING (id <= 3);
ALTER ROLE agent SET statement_timeout = '2s';
ALTER ROLE agent SET default_transaction_read_only = on;
Le même agent, avec le même mot de passe, sur la même base :
$ psql -U agent -d boutique -c "SELECT * FROM clients"
ERROR: permission denied for table clients
$ psql -U agent -d boutique -c "SELECT telephone FROM clients LIMIT 1"
ERROR: permission denied for table clients
$ psql -U agent -d boutique -c "SELECT nom, courriel FROM clients"
nom | courriel
----------+-------------------------
Client 1 | [email protected]
Client 2 | [email protected]
Client 3 | [email protected]
(3 rows)
$ psql -U agent -d boutique -c "SELECT pg_sleep(5)"
ERROR: canceling statement due to statement timeoutCinquante mille lignes lisibles au départ, trois à l'arrivée, et deux colonnes sur quatre.
Chaque ligne du resserrage ferme quelque chose de précis, et la [documentation de GRANT](https://www.postgresql.org/docs/16/sql-grant.html) comme celle des politiques de ligne, toutes deux consultées le 31 août 2026, décrivent leur portée exacte :
GRANT SELECT (colonnes): une colonne hors de la liste n'existe pas pour l'agent, et c'est vrai aussi de celles ajoutées plus tard. Nous avons ajouté une colonneibanaprès leGRANT: elle reste refusée.ENABLE ROW LEVEL SECURITYet sa politique : les lignes hors périmètre ne sortent pas, même sousSELECT *.statement_timeoutposé sur le rôle : la requête large est coupée à deux secondes, sans qu'aucun humain surveille.default_transaction_read_only: la ceinture. Nous avons accordéINSERTexprès pour l'éprouver, et la session a réponducannot execute INSERT in a read-only transaction.
Ces quatre bornes vivent dans la base et ne coûtent rien. Il en manque une cinquième, qui ne s'écrit pas en SQL : la durée pendant laquelle la porte reste ouverte. C'est celle que Kestro tient, et notre page sur les serveurs MCP Postgres décrit ce qui passe par cette porte. Le GRANT par colonne et la politique de ligne suffisent, si vous ne voulez pas d'un outil de plus.
Ce qui reste
Le catalogue reste ouvert après le resserrage. Nous avons rejoué la même requête sur pg_class : jetons_api y figure toujours. Fermer pg_catalog à un rôle casse trop d'outils pour être un conseil sérieux, et la carte des lieux fait donc partie de ce qu'un agent emporte, quoi qu'on fasse.
Notre montage est un conteneur et des données inventées. Une base réelle porte des vues, des fonctions et des déclencheurs, et chacun de ces objets peut rouvrir ce que les six lignes ci-dessus ferment : une vue lit ce que son propriétaire a le droit de lire, pas son appelant.
La politique de ligne mérite une précaution : le propriétaire de la table n'y est pas soumis tant qu'on n'a pas écrit FORCE ROW LEVEL SECURITY. Notre rôle postgres voit toujours les cinquante mille lignes. C'est ce qu'on veut pour l'application, et c'est ce qu'on oublie de vérifier quand on croit avoir bordé tout le monde.
Enfin, statement_timeout coupe une requête large, pas mille petites. Et rien de ce qui précède ne dit ce que devient la donnée une fois entrée dans le contexte du modèle. Cette question ne se règle pas dans la base, et nous ne connaissons personne qui l'ait réglée ailleurs.
Démonstration du 31 août 2026 : PostgreSQL 16.15 dans un conteneur Docker 28.4.0, sur Linux 6.12 x86_64 ; les sessions sont collées telles qu'elles ont tourné, le préfixe docker exec retiré de chaque ligne pour la lisibilité.