Les permissions
Deux échecs opposés tuent l'adoption d'un agent de code. Le premier : il demande la permission pour chaque cat, le développeur répond « oui » quarante fois sans lire, puis accepte sans le voir le rm -rf build/ ~/ de la quarante et unième. Le second : il a tous les droits « pour gagner du temps », et un mardi il pousse sur main. Un système de permissions utile demande rarement, et seulement quand la réponse change quelque chose.
Le mécanisme
La politique classe chaque appel d'outil en trois familles et applique un mode choisi par session (APPROVAL).
| Famille | ask (défaut) | auto (CI, make e2e) | deny (audit) |
|---|---|---|---|
Lecture : list_files, read_file, search | autorisée | autorisée | autorisée |
Écriture : edit_file | demandée, avec le diff | autorisée (confinée au dépôt, point de reprise avant) | refusée |
| Commande en liste blanche | autorisée | autorisée | refusée |
| Commande hors liste blanche | demandée | refusée | refusée |
Commande avec métacaractère shell (;, barre verticale, &, >, <, $, accent grave) | refusée | refusée | refusée |
La liste blanche compare des préfixes de jetons, pas de caractères : python -m pytest autorise python -m pytest -q tests/, mais pas python -m pytestmalin ni python -c …. Un refus n'est pas une exception : il revient au modèle comme résultat d'outil, avec la consigne de ne pas réessayer à l'identique.
flowchart TD
A["appel d'outil"] --> B{"lecture ?"}
B -->|"oui"| OK["exécuter"]
B -->|"non"| C{"mode deny ?"}
C -->|"oui"| NO["refus renvoyé au modèle"]
C -->|"non"| D{"commande ?"}
D -->|"oui"| E{"métacaractères shell ?"}
E -->|"oui"| NO
E -->|"non"| F{"liste blanche ?"}
F -->|"oui"| OK
F -->|"non"| G{"mode auto ?"}
G -->|"oui"| NO
G -->|"non"| H["demander à l'humain"]
D -->|"non : écriture"| I{"mode auto ?"}
I -->|"oui"| CP["point de reprise"] --> OK
I -->|"non"| H
H -->|"o / t"| CP
H -->|"n"| NO
La mise en œuvre
def check(self, tool: str, args: dict, preview: str = "") -> Decision:
if tool in READ_TOOLS:
return Decision(True, "lecture libre")
if self.mode == "deny":
return Decision(False, "mode lecture seule")
if tool in COMMAND_TOOLS:
command = str(args.get("command", ""))
if SHELL_META & set(command):
return Decision(False, "métacaractères shell refusés : une seule commande, sans redirection")
if self.whitelisted(shlex.split(command)):
return Decision(True, "liste blanche")
if self.mode == "auto":
return Decision(False, "hors liste blanche (mode auto)")
return self._ask(tool, f"commande : {command}", command)
if self.mode == "auto":
return Decision(True, "écriture approuvée automatiquement (mode auto)")
return self._ask(tool, f"modification de {args.get('path')}", preview)L'humain voit le diff calculé par plan_edit() avant l'écriture, et peut répondre t (« toujours ») pour ne plus être interrogé sur ce type d'outil pendant la session. Sans terminal (tâche de fond, CI), console_ask répond « non » au lieu de bloquer sur input().
Résultat obtenu
Sur le run réel make e2e du 2026-10-15 (mode auto), le journal contient deux refus, tous deux utiles :
tour 1 run_command {"command": "pytest"} refus : hors liste blanche (mode auto)
tour 2 run_command {"command": "python -m pytest -q"} liste blanche, code de sortie 1
...
tour 13 run_command {"command": "git diff"} refus : hors liste blanche (mode auto)
tour 14 read_file facturation/remises.py le modèle relit le fichier pour vérifier son éditionLe modèle s'est adapté sans intervention : il a reformulé sa commande au tour 2, puis remplacé git diff par deux lectures. Coût du premier refus : un tour de plus, 599 tokens d'entrée et 59 de sortie.
Ce qu'une liste blanche autorise vraiment
Autoriser python -m pytest, c'est autoriser l'exécution de tout code Python que l'agent peut écrire. Il lui suffit de créer tests/conftest.py : pytest l'importe au démarrage. En mode auto, les écritures sont approuvées, donc la liste blanche ne protège rien contre un agent manipulé. C'est le bac à sable de la leçon 3 qui porte la sécurité, pas la liste blanche. La liste blanche sert à autre chose : empêcher les erreurs coûteuses d'un agent de bonne foi (git push, pip install, rm).
Ce qui casse en production
- Fatigue d'approbation. Au-delà de quelques demandes par tâche, les gens tapent
osans lire. Symptôme : délai médian de réponse humaine sous la seconde dans le journal. Parade : lecture libre, liste blanche généreuse pour les commandes sans effet de bord, et demandes réservées aux écritures et aux commandes inconnues. - Le modèle modifie les tests. Il « corrige » un test au lieu du code ; la suite passe, le bug reste. La consigne système l'interdit, ce qui ne suffit pas. Parade : chemins protégés (exercice) et revue du diff final, que
make e2eaffiche. - Contournement par l'interpréteur.
python -m pytesten liste blanche, l'agent écrit un script et le fait importer par un test. Parade : bac à sable sans réseau et sans accès aux fichiers hors du dépôt, et en modeask, approbation des créations de fichiers danstests/. - Refus en boucle. Un modèle qui ne comprend pas un refus réessaie la même commande. Parade : message de refus explicite et détection de trois appels identiques (leçon 1).
La règle de décision
Mode ask pour un développeur sur son poste, avec une liste blanche limitée aux commandes de lecture et de test. Mode auto uniquement dans un environnement jetable (copie du dépôt, conteneur, CI) où le pire résultat est un diff à jeter. Mode deny pour un audit ou une revue de code. Surveillez le taux de refus par session et la part des écritures approuvées en moins d'une seconde.
Pratique
Un agent en mode auto fait passer la suite au vert. Le diff final montre une modification de tests/test_facture.py qui remplace 0.13 par 0.12. Quelle mesure empêche cela durablement ?
Une consigne est une préférence, pas une garantie : elle est déjà présente dans SYSTEM. Une règle dans le harness ne dépend pas du modèle. Retirer pytest rend l'agent aveugle. Un modèle plus grand réduit la fréquence sans l'annuler.
Votre équipe veut ajouter « git » à la liste blanche pour que l'agent puisse lire l'historique. Que proposez-vous ?
La liste blanche compare des préfixes de jetons : « git » autorise aussi git push, git reset --hard et git clean -fdx. Des préfixes en lecture donnent le besoin exprimé sans les effets de bord. Tout refuser prive l'agent d'un contexte utile, comme le montre son git diff refusé dans le run réel. En mode ask, la commande serait autorisée sans question, puisqu'elle est en liste blanche.
Exercice. Ajoutez une variable PROTECTED_PATHS (motifs fnmatch, par exemple tests/*,*.lock) : une écriture sur ces chemins est refusée en mode auto et toujours demandée en mode ask, même après « toujours ».
Voir la solution
from fnmatch import fnmatch
class Permissions:
def __init__(self, mode, allowed_commands, ask=console_ask, protected=()):
...
self.protected = tuple(protected)
def check(self, tool, args, preview=""):
...
if tool in WRITE_TOOLS and any(fnmatch(str(args.get("path", "")), p) for p in self.protected):
if self.mode == "auto":
return Decision(False, "chemin protégé : modifiez le code, pas les tests")
answer = self.ask(f"chemin protégé {args.get('path')}", preview) # jamais mémorisé
return Decision(answer == "o", "chemin protégé, décision humaine")
...Placez cette règle avant le if self.mode == "auto" des écritures. Test : en mode auto, edit_file sur tests/test_facture.py est refusé et aucun point de reprise n'est créé ; en mode ask avec un faux ask qui répond t puis o, la seconde demande a bien lieu.
- Je sépare lecture, écriture et commande, avec un mode par session
- J'écris une liste blanche par préfixes de jetons, sans shell
- Je sais expliquer pourquoi autoriser pytest revient à autoriser l'exécution de code
- Je renvoie les refus au modèle au lieu d'interrompre la session