Accueil / Blog / Gouvernance et IA

Gouvernance et IA

Lecture seule sur l'arbre SQL, pas sur le premier mot

Un serveur MCP qui expose un moteur SQL à des agents promet presque toujours la même chose : il est en lecture seule. La promesse est simple à formuler et difficile à tenir. La plupart des implémentations que nous lisons la tiennent avec un filtre sur le premier mot-clé : si la requête commence par SELECT, SHOW ou EXPLAIN, elle passe ; sinon, elle est refusée. Nous avons commencé de la même façon dans akko-mcp-trino, puis nous avons cherché à casser notre propre garde. Voici les six défauts que nous avons rencontrés, dans notre code ou dans celui des autres, et la forme que la garde a prise ensuite.

Ce que le premier mot ne dit pas

Un filtre par préfixe répond à la question « par quoi commence le texte ? ». La bonne question est « que fera le moteur si je lui envoie ce texte ? ». Les deux questions n’ont la même réponse que dans les cas simples. Chaque défaut ci-dessous est un cas où elles divergent.

1. Les requêtes empilées. SELECT 1; DROP TABLE t commence par SELECT. Selon le pilote et le moteur, la seconde instruction est exécutée, refusée, ou silencieusement ignorée. Une garde ne doit pas dépendre de ce comportement. Elle exige exactement une instruction et refuse le reste.

2. Une écriture dans un CTE. WITH w AS (DELETE FROM t) SELECT 1 commence par WITH, que le filtre autorise, et sa racine est bien un SELECT. Nous avons trouvé ce cas le 12 septembre sur notre propre garde, avec la preuve adverse que nous rejouons sur un cluster réel : la garde ne regardait que le nœud racine de l’arbre, et l’écriture enveloppée atteignait Trino. Trino l’a refusée comme une erreur de syntaxe ; nous n’avons pas envie de compter là-dessus.

3. Une écriture dans une sous-requête. SELECT * FROM (DROP TABLE t) x est la même idée sous une autre forme. La conclusion est la même : il faut parcourir tout l’arbre, et refuser dès qu’un nœud d’écriture apparaît, où qu’il soit.

4. EXPLAIN ANALYZE exécute. Sur Trino, EXPLAIN planifie une requête sans la lancer, mais EXPLAIN ANALYZE la lance pour en mesurer le plan. EXPLAIN ANALYZE INSERT INTO t VALUES (1) commence par EXPLAIN et écrit une ligne. Nous avons trouvé ce cas le 13 septembre en lisant la garde d’un autre serveur, et le nôtre avait le même trou. La règle est devenue : EXPLAIN est autorisé si et seulement si l’instruction expliquée est elle-même une lecture, et ANALYZE est toujours refusé.

5. Les commandes qui ne s’appellent pas des écritures. GRANT, SET SESSION, CALL ne modifient pas une table et ne figurent pas dans la liste habituelle des verbes interdits. GRANT change qui peut lire quoi. SET SESSION change le comportement des requêtes suivantes de la session. CALL lance une procédure dont personne ne connaît les effets à l’avance. Une garde en lecture seule les refuse tous les trois.

6. Ce que le filtre ne sait pas lire. /* note */ DELETE FROM t commence par /*. Un texte que l’analyseur ne comprend pas, ou qui ne correspond à aucune forme connue, doit être refusé, pas transmis au moteur en espérant qu’il tranche. C’est le principe du refus par défaut : l’incertitude ferme, elle n’ouvre pas.

La garde que nous avons gardée

La version actuelle tient en une fonction. Elle analyse le texte avec sqlglot dans le dialecte Trino, exige une seule instruction, puis parcourt l’arbre entier et refuse dès qu’un nœud appartient à la famille des écritures et des effets de bord (Insert, Update, Delete, Merge, Create, Drop, Alter, Truncate, Grant, Set, commande générique). SHOW et DESCRIBE, que sqlglot ne structure pas, sont acceptés par leur mot-clé de tête, et EXPLAIN est réanalysé sur l’instruction qu’il enveloppe. Toute exception de l’analyseur vaut refus.

statements = sqlglot.parse(sql, read="trino")
if len(statements) != 1:
    return False
root = statements[0]
if isinstance(root, (exp.Select, exp.Union)):
    return not any(isinstance(n, WRITE_TYPES) for n in root.walk())

Chacun des six cas ci-dessus est un test dans le dépôt, et la preuve adverse en rejoue une partie sur un cluster réel à chaque livraison. Nous ne considérons pas la liste comme close. C’est la raison pour laquelle nous l’avons publiée.

Ce que la garde ne remplace pas

Une garde en lecture seule côté serveur est une seconde ligne. La première reste le moteur, qui applique ses propres permissions à la personne pour qui l’agent travaille. C’est ce que fait akko-mcp-trino en portant l’identité de l’utilisateur jusqu’à Trino ; nous l’avons décrit dans notre premier article. Si le moteur reçoit chaque requête sous la bonne identité, une écriture qui passerait la garde serait encore refusée à qui n’a pas le droit d’écrire. Si le moteur reçoit tout sous un compte de service, la meilleure garde du monde protège une porte qui n’a plus de serrure.

Le code est sur GitHub, sous licence Apache 2.0, et le paquet s’installe avec pip install akko-mcp-trino. Si vous cassez la garde, ouvrez une issue : c’est exactement ce que nous cherchons.