Leçon 4 — Sécurité

Les permissions

7 min de lecture

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).

Familleask (défaut)auto (CI, make e2e)deny (audit)
Lecture : list_files, read_file, searchautoriséeautoriséeautorisée
Écriture : edit_filedemandée, avec le diffautorisée (confinée au dépôt, point de reprise avant)refusée
Commande en liste blancheautoriséeautoriséerefusée
Commande hors liste blanchedemandéerefuséerefusée
Commande avec métacaractère shell (;, barre verticale, &, >, <, $, accent grave)refuséerefuséerefusé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

files/harness/permissions.py
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().

make run : l'humain approuve la première édition sur diff, puis répond « toujours »

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 édition

Le 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 o sans 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 e2e affiche.
  • Contournement par l'interpréteur. python -m pytest en 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 mode ask, approbation des créations de fichiers dans tests/.
  • 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 ?

Votre équipe veut ajouter « git » à la liste blanche pour que l'agent puisse lire l'historique. Que proposez-vous ?

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
Suivant