درس 4 — Choisir et exécuter

Scripts et bac à sable

7 دقائق قراءة

هذا المحتوى غير مترجم بعد. تُعرض النسخة الأصلية.

Scripts et bac à sable

Sans script, le modèle calcule le remboursement d'une note de frais « de tête ». Le jour de nos tests, sans skill, il a annoncé 196,80 € remboursables sur la note de Léa : le bon chiffre, obtenu en oubliant le plafond hôtel et en écartant un parking légitime. Deux erreurs qui s'annulent. Un script donne le même résultat à chaque exécution, et il se teste avec pytest. Mais un script de skill est du code écrit par un tiers, lancé à la demande d'un modèle, sur une machine qui a vos clés : il s'exécute dans une boîte.

Le mécanisme

Un script de skill sert à ce que le modèle fait mal : calcul exact, application d'une règle, analyse d'un fichier. Le guide Using scripts fixe le contrat avec l'agent : pas d'invite interactive, une aide --help, des messages d'erreur utiles, les données sur stdout et les diagnostics sur stderr, des codes de sortie distincts, une sortie de taille prévisible.

flowchart LR
  M["Modèle : run_skill_script(nom, scripts/x.py, args)"] --> V{"Chemin dans skill.resources et sous scripts/ ? extension .py ?"}
  V -->|"non"| E["Erreur renvoyée au modèle"]
  V -->|"oui"| C["Copie jetable du dossier du skill"]
  C --> U["unshare -rn : aucun réseau"]
  U --> L["rlimit : CPU, 1 Go, fichiers 10 Mo, 64 descripteurs"]
  L --> P["python -I, environnement vidé (PATH, LANG, HOME temporaire)"]
  P --> T{"Délai 10 s"}
  T -->|"dépassé"| K["SIGKILL du groupe de processus"]
  T -->|"terminé"| O["returncode, stdout et stderr tronqués à 8 000 caractères"]

Chaque couche répond à une menace précise :

CoucheMenace couverte
Liste blanche des chemins../../run.py, /bin/sh, un fichier de references/ exécuté comme code
Copie jetableun script qui modifie son propre SKILL.md ou ses barèmes pour la requête suivante
Environnement vidélecture de GOOGLE_API_KEY et de tout secret hérité du processus parent
unshare -rnexfiltration réseau, appel à une API non prévue
rlimit et délaiboucle infinie, allocation géante, disque rempli, processus orphelins
python -Imodules json.py piégés dans le dossier courant, PYTHONPATH hérité

La mise en œuvre

Le cœur (sandbox.py) tient en une fonction réutilisée par notre harness et par l'exécuteur ADK de la leçon suivante. L'isolation réseau échoue fermé : si unshare -rn n'est pas disponible, rien ne s'exécute sauf dérogation explicite.

files/skillkit/sandbox.py
def run_confined(argv, cwd, timeout=10, mem_mb=1024, stdin=None) -> RunResult:
    if network_isolation_available():
        argv = ["unshare", "-rn", *argv]
    elif os.environ.get("SKILL_SANDBOX_ALLOW_NETWORK") != "1":
        raise SandboxError("isolation réseau indisponible (unshare -rn) ; ...")
    env = {"PATH": "/usr/bin:/bin", "LANG": "C.UTF-8", "PYTHONIOENCODING": "utf-8",
           "HOME": str(cwd), "TMPDIR": str(cwd)}
    p = subprocess.Popen(argv, cwd=cwd, env=env, ..., start_new_session=True,
                         preexec_fn=lambda: _limits(timeout, mem_mb))
    try:
        out, err = p.communicate(stdin, timeout=timeout)
        return RunResult(p.returncode, _clip(out), _clip(err))
    except subprocess.TimeoutExpired:
        os.killpg(p.pid, signal.SIGKILL)   # tue aussi les processus lancés par le script
        ...

Côté skill, le script suit le contrat agentique : arguments explicites (--csv reçoit le texte complet, ce qui fonctionne aussi avec ADK, qui ne transmet pas de stdin), JSON sur stdout, code 2 et message sur stderr en cas d'entrée invalide.

files/skills/expense-report-checker/scripts/check_expenses.py
def main() -> int:
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--csv", required=True, help="texte CSV complet, en-tête compris")
    a = p.parse_args()
    try:
        print(json.dumps(controler(a.csv.replace("\\n", "\n")), ensure_ascii=False))
    except ValueError as e:
        print(f"Entrée invalide : {e}", file=sys.stderr)
        return 2
    return 0

Le corps du SKILL.md dit au modèle de recopier le total du script sans le recalculer : le chiffre affiché à l'utilisateur doit être celui que le test vérifie.

Résultat obtenu

pytest tests/test_sandbox.py -v et essais manuels, 2026-10-15 :

tests/test_sandbox.py ............                                       12 passed in 1.34s
réseau   : 1 urllib.error.URLError: <urlopen error [Errno -3] Temporary failure in name resolution>
mémoire  : 1 MemoryError     (bytearray de 2 Go refusé par RLIMIT_AS)

Les tests couvrent les règles R1 à R4 du barème (196,80 € remboursables sur 269,20 € demandés), le calcul de congés (6 jours au 2026-10-15), le profil CSV, le code 2 sur entrée invalide, quatre chemins refusés, le délai, l'absence de GOOGLE_API_KEY dans l'environnement du script, l'absence de réseau et l'intégrité du skill d'origine.

Ce qui casse en production

SymptômeCauseParade
Le script fonctionne en local, échoue dans l'agent avec ModuleNotFoundErrorpython -I et l'environnement vidé ignorent le PYTHONPATH et les paquets de l'utilisateurdépendances déclarées (PEP 723 et uv run, ou image dédiée), stdlib de préférence
L'agent attend indéfinimentle script demande une confirmation interactiveaucune invite ; stdin fermé ; délai qui tue le groupe de processus
Réponse tronquée au milieu d'un JSONsortie de 200 Ko coupée par le harnessrésumé par défaut, option --offset ou --output pour le détail
Le bac à sable refuse de démarrer en CIAppArmor interdit les espaces de noms utilisateur non privilégiésrunner où unshare -rn true passe, ou conteneur sans réseau ; jamais de dérogation silencieuse
Un skill tiers lit ~/.sshle script s'exécute avec les droits de l'utilisateur, unshare ne cache pas le système de fichierspour du code non relu : conteneur ou micro-VM (gVisor, Firecracker) avec montage du seul dossier du skill

La règle de décision

Mettez dans un script tout calcul dont le résultat doit être exact ou vérifiable, toute règle métier chiffrée, tout traitement de fichier. Laissez au modèle la rédaction et la mise en forme. Exécutez les scripts de skills que vous avez écrits ou relus dans un bac à sable de processus comme celui-ci ; les autres dans un conteneur. Surveillez le taux d'erreurs de scripts par skill : une hausse signale un changement de format d'entrée côté utilisateurs.

Pratique

Un skill de profilage CSV doit télécharger un fichier depuis un bucket avant de l'analyser. Le bac à sable coupe le réseau. Que faites-vous ?

Le script leave_balance.py renvoie un solde juste, mais la réponse finale annonce un autre chiffre. Où regardez-vous d'abord ?

Exercice. Écrivez un test qui prouve qu'un script ne peut pas laisser de processus derrière lui : le script lance sleep 60 en arrière-plan puis se termine. Que se passe-t-il, et comment le harness le détecte-t-il ?

Voir la solution
def test_pas_de_processus_orphelin(tmp_path):
    code = "import subprocess; subprocess.Popen(['sleep', '60']); print('fini')"
    r = run_confined([sys.executable, "-c", code], cwd=tmp_path, timeout=3)
    assert r.timed_out

communicate() attend la fermeture de stdout ; le sleep hérite du descripteur, donc l'appel bloque jusqu'au délai, puis os.killpg tue tout le groupe (créé par start_new_session=True). Le test passe parce que le délai est atteint : c'est le comportement voulu, un script qui laisse un processus vivant est traité comme un échec. Si vous voulez qu'il passe sans délai, le script doit rediriger la sortie du processus fils ou l'attendre.

  • Je sais ce qu'un script de skill doit faire pour être utilisable par un agent
  • Je sais isoler un script : copie jetable, environnement vidé, réseau coupé, limites, délai
  • Je sais pourquoi l'isolation doit échouer fermé
  • Je sais quand un bac à sable de processus ne suffit plus
التالي