Le roster YAML & les permissions
L'usine gagne sa feuille de distribution : un roster YAML qui déclare qui tourne — un agent, un rôle, un modèle — et un état des lieux en code qui vérifie, après chaque phase, ce que chacun avait le droit de changer.
Hier, vous avez typé l’enveloppe : ce qui traverse la couture a désormais une forme nommée. Mais
posez-vous deux questions sur le run que vous avez lancé. Qui a répondu ? Le modèle par défaut du
harnais, vous ne l’avez choisi nulle part. Et qu’avait-il le droit de faire ? Tout : lire,
écrire, exécuter, n’importe où dans le repo. À la fin de ce chapitre, ces deux réponses ne seront
plus acceptables : vous saurez déclarer, dans un fichier YAML que le code valide avant tout
lancement, qui tourne (quel modèle, avec quelle profondeur de réflexion, quels outils) et
ce que chacun a le droit de changer, puis le faire vérifier par du code, après coup, contre
le repo lui-même. La pièce du jour est un trio : factory.config.yaml le roster, roster.py qui
le charge et le valide, permissions.py l’état des lieux. Elle se pose à côté du port
(chapitre 7), du runner (chapitre 8) et des enveloppes (chapitre 9), et le chapitre 11
la branchera sur les premiers vrais agents de l’usine.
Le roster : un agent, un prompt, un modèle
L’idée en une phrase
Le roster est la feuille de distribution de l’usine : chaque agent y est déclaré une fois
(un nom, un rôle (purpose), un modèle, un niveau de réflexion, des outils, des droits
d’écriture) et les ADW nomment des agents, jamais des modèles. Cette pièce vit entièrement
côté code déterministe, dans un YAML chargé et validé par roster.py avant que quoi que ce
soit ne démarre.
Points clés
- Un agent = un rôle nommé. L’entrée déclare
name,purpose, puis seulement ce qui diffère desdefaults: la fusion se fait clé par clé au chargement. Un roster lisible est un roster où chaque agent ne dit que ce qui le distingue. - Les ADW nomment des agents, jamais des modèles. Vos scripts diront
scoutoubuilder, et le modèle qui incarne le rôle se change en une ligne de YAML, sans toucher un seul script. C’est ce qui rendra les rosters interchangeables au module 4. - Toujours
provider/id. Un motif nu commeglm-5.3devient ambigu dès que deux fournisseurs portent le même modèle, et cela peut arriver sans que vous touchiez au fichier, simplement parce qu’un fournisseur de plus l’a ajouté à son catalogue. Qualifier (z-ai/glm-5.3) coûte quelques caractères et élimine la classe d’erreur. - La validation précède le lancement.
roster.load()vérifie tout (noms uniques, harnais connu, modèle qualifié, échelle de réflexion, outils connus) et échoue avec le motif exact. Aucun agent n’est jamais lancé contre une config à moitié valide : une erreur de roster coûte zéro token. thinkingest l’échelle de réflexion :off→max, sept crans côtépi, et l’adaptateur Claude Code la traduira en budget de réflexion. En première approche : haut pour ceux qui décident (planner, reviewer), bas pour ceux qui rapportent (scout).
Exemple concret
Prenez le roster posé aujourd’hui, aux prix relevés ce jour sur openrouter.ai/models. Un futur
run plan+build : le scout lit le repo sur un modèle léger (~7 centimes le million de tokens en
entrée) pour ~1 centime. Le planner réfléchit sur un modèle frontier (~5 $ le million en
entrée, ~25 $ en sortie) pour ~20 à 25 centimes, une trentaine de milliers de tokens lus et
quelques milliers écrits. Le builder implémente sur un workhorse (~1,40 $/M en entrée) pour
~15 à 20 centimes. Total : quelques dizaines de centimes. Le même run tout-frontier
coûterait un facteur 5 à 10 de plus, pour un scout et un builder qui n’en avaient pas besoin. La
différence entre les deux ? Trois lignes model: dans un YAML, aucun script modifié.
Le roster du jour (exemples datés, relevés le 23 août 2026)
| Agent | Modèle (au moment d’écrire) | Thinking | Prix entrée/sortie (par M tokens) | Rôle |
|---|---|---|---|---|
| scout | deepseek/deepseek-v4-flash-0731 | low | ~0,07 $ / ~0,18 $ | repérer, ne rien changer |
| planner | anthropic/claude-opus-5 | high | ~5 $ / ~25 $ | décider — le plan porte tout le run |
| builder | z-ai/glm-5.3 | high | ~1,40 $ / ~4,40 $ | implémenter le plan |
| reviewer | google/gemini-3.7-flash | high | ~0,38 $ / ~1,90 $ | confirmer, ne rien changer |
Ces identifiants sont des exemples datés : les catalogues et les prix bougent chaque mois. Vérifiez-les sur openrouter.ai/models le jour où vous poserez la pièce.
Commande — ce que le roster deviendra derrière le port
Le roster est déclaratif, et c’est l’adaptateur du port qui le traduira en drapeaux au chapitre 11. Les deux dialectes existent déjà, et ils ne se ressemblent pas :
# pi — les cles du roster se traduisent presque mot a mot
pi --model z-ai/glm-5.3 --thinking high --tools read,bash,edit,write -p "..."
# Claude Code — memes capacites, autre vocabulaire : outils avec majuscules,
# modele par alias ou nom complet
claude -p --model opus --allowedTools "Read,Bash,Edit,Write" "..."
La traduction vivra dans l’adaptateur, jamais dans vos scripts : les ADW continueront de dire
scout, et le port parlera le dialecte du harnais choisi.
Piège courant : « un roster, c’est une liste de modèles » est inexact, c’est une liste de rôles. Le modèle n’est qu’une clé parmi six, et la moins stable : les identifiants changent chaque mois, le rôle reste. Si vos scripts nommaient des modèles, chaque nouveau catalogue serait une migration. Parce qu’ils nomment des agents, c’est une ligne de YAML.
Permissions : tools, writes, fichiers protégés
L’idée en une phrase
Les permissions de l’usine tiennent en trois crans : tools, les capacités offertes à
l’agent, writes, ce qu’il a le droit de changer dans le repo, et protected_files, ce que
personne ne touche sans le nommer. Comme une liste de capacités n’est pas un bac à sable,
la pièce permissions.py vérifie tout après coup, côté code déterministe, en comparant
l’état du repo avant et après la phase.
Points clés
toolsne suffit jamais.bashexécute n’importe quoi (git checkout,sed -i, une redirection shell) etwriteatteint n’importe quel chemin. Retireredità un reviewer dit votre intention, seul le contrôle a posteriori la garantit.- La sémantique de
writestient en trois cas : clé absente = libre dans le repo (maisprotected_filestient toujours),writes: []= lecture seule vis-à-vis du repo, une liste = uniquement ces chemins. Et nommer un chemin protégé, c’est le déverrouiller, explicitement, dans le YAML, sous vos yeux. - Le runtime est toujours ouvert. Tout ce qui vit sous
data_dir(sessions, rapports, enveloppes) est accessible à tous les agents :writes: []signifie lecture seule vis-à-vis du repo, jamais muet, et un agent peut toujours écrire son propre rapport. - L’état des lieux compte tout : un chemin apparu, disparu ou réécrit entre
snapshot()etenforce()est un changement, y compris une réversion. Un agent qui faitgit checkoutsur vos modifications non commitées ne « nettoie » pas : il modifie, et c’est détecté. - Une brèche n’est pas une gate. Une gate échoue sur un travail que l’agent peut refaire,
une brèche est une écriture déjà faite qu’on ne corrige pas en re-promptant.
enforce()annule ce que l’agent a introduit hors de sa liste, puis fait mourir la phase en nommant chaque chemin.
Exemple concret
Projetez-vous au chapitre 13 : le reviewer (déclaré writes: []) relit le travail du builder,
trouve une coquille et, les modèles sont serviables, la corrige au passage dans
apps/plume/editor.ts. Sans état des lieux, cette écriture passerait inaperçue : le run serait
vert, et un agent censé confirmer aurait modifié, la revue ne vaudrait plus rien.
Avec la pièce du jour : snapshot() avait relevé l’entrée, enforce() compare la sortie,
détecte le chemin, restaure le fichier, et la phase meurt avec un motif qui nomme le chemin
et le périmètre violé. Coût de la vérification : zéro token, quelques dizaines de
millisecondes, deux commandes git. Ce qu’elle protège : la seule chose qui rend une revue
crédible, le fait que le reviewer n’ait pas pu toucher ce qu’il jugeait.
Trois crans, trois garanties
| Cran | Déclare | Qui l’applique | Quand |
|---|---|---|---|
tools | les capacités offertes (read, bash…) | le harnais (--tools côté pi, --allowedTools côté Claude Code) | pendant la session |
writes | ce que l’agent peut changer dans le repo | permissions.enforce(), en code | après la phase |
protected_files | ce que personne ne touche sans le nommer | permissions.enforce(), en code | après la phase |
Les motifs de chemins suivent trois règles simples : un / final est un préfixe de dossier, *
s’arrête au séparateur (adws/adw_*.py ne descend pas dans les sous-dossiers), et seul **
traverse les dossiers.
Piège courant : « il suffit de retirer
editetwritedestoolspour rendre un agent inoffensif » est inexact : tant quebashest dans la liste, l’agent peut modifier le repo par une commande, et un scout sansbashne peut plus lancer un test pour vérifier ce qu’il avance. La liste de capacités règle l’interface, seul l’état des lieux règle les effets. Les deux crans travaillent ensemble, aucun ne remplace l’autre.
Fil rouge — la pièce posée aujourd’hui
La pièce du jour est un trio posé dans la zone « squelette ADW » : adws/adw_config/factory.config.yaml
(la feuille de distribution), adws/adw_modules/roster.py (chargement, fusion, validation) et
adws/adw_modules/permissions.py (l’état des lieux). Ses voisins déjà posés : le port du
chapitre 7, le runner du chapitre 8, les enveloppes du chapitre 9, aucun n’est modifié
aujourd’hui. La loi du livre s’applique deux fois : le roster décide qui propose (et avec
quels moyens), l’état des lieux décide si ce qui a été fait était permis. Deux décisions
côté code, zéro token. À l’usage, la pièce est gratuite : une validation de YAML et deux
commandes git par phase. Ce qu’elle économise se compte autrement : un facteur 5 à 10 sur les
runs qui n’ont plus besoin de frontier partout, et une classe entière d’accidents (l’agent qui
édite la machinerie qui le juge) qui coûtait des heures de méfiance à chaque soupçon. Demain,
le chapitre 11 branche ce roster sur le port : le scout et le planner seront les premiers agents
à tourner sous cette feuille de distribution.
Travaux pratiques — la pièce du jour
Une pièce complète à poser dans le repo compagnon plume-factory, qui devient, chapitre après
chapitre, votre usine logicielle agentique. Aujourd’hui, trois fichiers : le roster, son chargeur
validant, et l’état des lieux. Rien de posé n’est modifié : adw_prompt.py continue de tourner
tel quel, et le chapitre 11 branchera le roster sur le port. Première nouveauté d’intendance :
roster.py lit du YAML, c’est la première dépendance externe de l’usine. Les gates
ci-dessous la fournissent avec uv run --with pyyaml, et les ADW du chapitre 11 la déclareront
dans leur en-tête PEP 723.
Pièce — adws/adw_config/factory.config.yaml
La feuille de distribution. Les identifiants de modèles et les prix en commentaire sont ceux relevés sur openrouter.ai/models au 23 août 2026. Vérifiez-les le jour où vous poserez la pièce, c’est une ligne à changer.
# factory.config.yaml — le roster de l'usine : un agent, un role, un modele.
# Les ADW nomment des agents, jamais des modeles : changer de moteur, c'est
# changer UNE ligne ici, aucun script. Identifiants et prix releves sur
# openrouter.ai/models le 2026-08-23 — des exemples dates, pas des verites.
defaults:
harness: pi # pi | claude — les deux adaptateurs du port (ch. 7)
model: deepseek/deepseek-v4-flash-0731 # leger : ~0,07 $/M entree, ~0,18 $/M sortie
thinking: medium # off | minimal | low | medium | high | xhigh | max
tools: [read, bash, grep, find, ls] # lecture + execution ; ecrire se merite
# Interdit a tout agent qui ne le nomme pas lui-meme dans son `writes` :
# un agent ne doit pas pouvoir editer la machinerie qui juge son travail.
protected_files:
- adws/adw_modules/
- adws/adw_config/
- adws/adw_*.py
- PLAN.md
data_dir: adws/adw_data # le runtime : TOUJOURS ouvert, jamais commite
agents:
- name: scout
purpose: Reperer ou vivent les choses dans le repo ; ne rien changer.
thinking: low # il rapporte, il ne decide pas
writes: [] # lecture seule vis-a-vis du repo — pas muet :
# son rapport atterrit sous data_dir
# write : indispensable pour poser ce rapport — l'allowlist regle
# l'interface, c'est writes: [] (verifie apres coup) qui protege le repo.
tools: [read, bash, grep, find, ls, write]
- name: planner
purpose: Transformer une demande en plan que le builder implemente sans questions.
model: anthropic/claude-opus-5 # frontier : ~5 $/M entree, ~25 $/M sortie
thinking: high # le plan porte tout le run — c'est ici qu'on paie
writes:
- specs/ # le plan est la seule trace qu'il laisse au repo
tools: [read, bash, grep, find, ls, write]
- name: builder
purpose: Implementer le plan, exactement ; declarer chaque fichier modifie.
model: z-ai/glm-5.3 # workhorse : ~1,40 $/M entree, ~4,40 $/M sortie
thinking: high
# pas de cle writes : libre dans le repo — mais protected_files tient toujours.
tools: [read, bash, grep, find, ls, edit, write]
- name: reviewer
purpose: Confirmer que ce qui est construit est ce qui etait demande ; ne rien changer.
model: google/gemini-3.7-flash # intermediaire rapide : ~0,38 $/M entree
thinking: high # juger demande de reflechir, pas d'ecrire
writes: [] # un reviewer qui ne peut pas corriger ne peut pas
# corriger en douce — regle, plus promesse
Pièce — adws/adw_modules/roster.py
Le chargeur validant. Il vit entièrement côté déterministe : il lit le YAML, fusionne chaque
agent sur les défauts, et refuse tout roster à moitié valide avant qu’un seul token ne soit
dépensé. Comme les autres modules, il s’importe, sans en-tête PEP 723, mais il porte sa
propre gate : lancé en module (python -m), il affiche le roster validé.
"""roster — la feuille de distribution de l'usine : qui tourne, avec quels moyens.
Les ADW nomment des agents, jamais des modeles. Ce module charge
factory.config.yaml, fusionne chaque agent sur les defauts (cle par cle),
et valide TOUT avant le moindre lancement : une erreur de roster coute
zero token — c'est le but.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import yaml
DEFAULT_PATH = Path("adws/adw_config/factory.config.yaml")
# Les deux dialectes du port (ch. 7), l'echelle de reflexion, et les sept
# outils integres de pi — l'adaptateur Claude Code traduira (ch. 11).
HARNESSES = ("pi", "claude")
THINKING = ("off", "minimal", "low", "medium", "high", "xhigh", "max")
KNOWN_TOOLS = ("read", "bash", "edit", "write", "grep", "find", "ls")
class RosterError(ValueError):
"""Un roster invalide — le motif exact, avant tout lancement."""
@dataclass(frozen=True)
class AgentSpec:
"""Un agent du roster, defauts fusionnes : pret a etre lance tel quel."""
name: str
purpose: str
harness: str
model: str
thinking: str
tools: tuple[str, ...]
writes: tuple[str, ...] | None # None = libre · () = lecture seule · (...) = ces chemins
@dataclass(frozen=True)
class Roster:
"""Le roster charge et valide, plus les regles communes a tous les agents."""
agents: dict[str, AgentSpec]
protected_files: tuple[str, ...]
data_dir: str
def load(path: str | Path = DEFAULT_PATH) -> Roster:
"""Charge, fusionne, valide — dans cet ordre, et tout ou rien."""
raw = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
defaults = raw.get("defaults") or {}
entries = raw.get("agents") or []
if not entries:
raise RosterError(f"{path} : aucun agent declare")
agents: dict[str, AgentSpec] = {}
for entry in entries:
spec = _merge(defaults, entry)
if spec.name in agents:
raise RosterError(f"agent {spec.name!r} declare deux fois")
agents[spec.name] = spec
roster = Roster(
agents=agents,
protected_files=tuple(defaults.get("protected_files") or ()),
data_dir=str(defaults.get("data_dir", "adws/adw_data")),
)
for spec in agents.values():
_validate(spec)
return roster
def _merge(defaults: dict, entry: dict) -> AgentSpec:
"""L'agent par-dessus les defauts, cle par cle : il ne dit que ce qui differe."""
writes = entry.get("writes", None) # cle absente = None = libre dans le repo
return AgentSpec(
name=str(entry.get("name", "")),
purpose=str(entry.get("purpose", "")),
harness=str(entry.get("harness", defaults.get("harness", "pi"))),
model=str(entry.get("model", defaults.get("model", ""))),
thinking=str(entry.get("thinking", defaults.get("thinking", "medium"))),
tools=tuple(entry.get("tools", defaults.get("tools") or ())),
writes=None if writes is None else tuple(writes),
)
def _validate(spec: AgentSpec) -> None:
"""Chaque miss echoue AVANT le lancement — jamais pendant, jamais en facture."""
if not spec.name:
raise RosterError("un agent sans nom n'est pas adressable")
if not spec.purpose:
raise RosterError(f"agent {spec.name!r} : purpose manquant — un agent, un role")
if spec.harness not in HARNESSES:
raise RosterError(f"agent {spec.name!r} : harnais inconnu {spec.harness!r} "
f"— disponibles : {list(HARNESSES)}")
if "/" not in spec.model:
raise RosterError(
f"agent {spec.name!r} : modele {spec.model!r} — toujours provider/id : "
"un motif nu devient ambigu des que deux fournisseurs portent le meme modele")
if spec.thinking not in THINKING:
raise RosterError(f"agent {spec.name!r} : thinking {spec.thinking!r} "
f"hors echelle {list(THINKING)}")
if not spec.tools:
raise RosterError(f"agent {spec.name!r} : aucun outil — "
"un agent sans outils ne peut rien proposer")
unknown = [tool for tool in spec.tools if tool not in KNOWN_TOOLS]
if unknown:
raise RosterError(f"agent {spec.name!r} : outils inconnus {unknown} "
f"— integres : {list(KNOWN_TOOLS)}")
if __name__ == "__main__":
# La gate du module : le roster se charge, se fusionne et se valide.
# Lancer depuis la racine : uv run --with pyyaml python -m adws.adw_modules.roster
loaded = load()
for agent_name in sorted(loaded.agents):
spec = loaded.agents[agent_name]
print(f"{spec.name:9} {spec.harness:7} {spec.model:34} "
f"thinking={spec.thinking:7} writes={spec.writes}")
print("proteges :", ", ".join(loaded.protected_files))
Pièce — adws/adw_modules/permissions.py
L’état des lieux. Lui aussi vit entièrement côté déterministe : deux commandes git pour relever
l’état du repo, une comparaison, et un verdict qui annule ce qui dépasse. Il s’appuie sur le
.gitignore du chapitre 1 (le runtime sous adw_data/ n’apparaît jamais dans un relevé) et sur
les types de roster.py posés à l’instant. Comme lui, il porte sa gate : lancé en module,
il met en scène une écriture interdite et prouve qu’elle est refusée puis annulée.
"""permissions — ce qu'un agent a le droit de CHANGER, verifie par le code.
`tools:` est une liste de capacites, pas un bac a sable : bash execute
n'importe quoi, write atteint n'importe quel chemin. La permission se
verifie donc comme toute autre affirmation de ce systeme : apres coup,
contre le repo. snapshot() releve l'etat des lieux d'entree ; enforce()
compare a la sortie, annule ce qui depasse, et nomme chaque chemin.
"""
from __future__ import annotations
import re
import subprocess
from pathlib import Path
from .roster import AgentSpec, Roster
class PermissionBreach(RuntimeError):
"""Un agent a modifie un chemin qui ne lui etait pas permis."""
def _git(args: list[str], cwd: str | Path) -> str:
result = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
return result.stdout if result.returncode == 0 else ""
def snapshot(repo_root: str | Path = ".") -> dict[str, str]:
"""L'etat des lieux : l'empreinte de chaque chemin qui differe de HEAD.
Les fichiers suivis portent leurs comptes de lignes — une edition d'un
fichier deja modifie reste donc visible. Les fichiers non suivis sont
listes par nom. Les chemins gitignores n'apparaissent jamais : le
runtime sous data_dir, ou les rapports atterrissent, est hors constat.
"""
prints: dict[str, str] = {}
for line in _git(["diff", "HEAD", "--numstat"], repo_root).splitlines():
cells = line.split("\t")
if len(cells) >= 3:
prints[cells[-1].strip()] = f"{cells[0]},{cells[1]}"
for path in _git(["ls-files", "--others", "--exclude-standard"],
repo_root).splitlines():
if path.strip():
prints[path.strip()] = "untracked"
return prints
def changed_paths(before: dict[str, str], after: dict[str, str]) -> list[str]:
"""Tout chemin dont l'etat differe : apparu, disparu, ou reecrit."""
return sorted({p for p in set(before) | set(after)
if before.get(p) != after.get(p)})
def _glob(pattern: str) -> re.Pattern:
"""Traduit un motif : `*` s'arrete au separateur de chemin, `**` traverse.
fnmatch laisserait `*` franchir les `/`, ce qui elargirait chaque motif
en silence : adws/adw_*.py ne doit pas attraper les fichiers des
sous-dossiers. Dire « je traverse », c'est le role de `**`.
"""
out, i = [], 0
while i < len(pattern):
if pattern.startswith("**", i):
out.append(".*")
i += 2
elif pattern[i] == "*":
out.append("[^/]*")
i += 1
elif pattern[i] == "?":
out.append("[^/]")
i += 1
else:
out.append(re.escape(pattern[i]))
i += 1
return re.compile("".join(out))
def _matches(path: str, pattern: str) -> bool:
if pattern.endswith("/"): # prefixe de dossier
return path.startswith(pattern)
if "*" in pattern or "?" in pattern:
return _glob(pattern).fullmatch(path) is not None
return path == pattern
def permitted(path: str, agent: AgentSpec, roster: Roster) -> bool:
"""Le runtime d'abord, puis la liste de l'agent, puis ce qui est protege."""
if _matches(path, roster.data_dir.rstrip("/") + "/"):
return True # toujours ouvert : un agent n'est jamais muet sur son rapport
if any(_matches(path, p) for p in (agent.writes or ())):
return True # nommer un chemin — y compris protege — c'est le deverrouiller
if any(_matches(path, p) for p in roster.protected_files):
return False
return agent.writes is None # None = libre · () = lecture seule vis-a-vis du repo
def _roll_back(repo_root: str | Path, path: str,
before: dict[str, str], after: dict[str, str]) -> str:
"""Annule UN changement introduit par l'agent — jamais le travail anterieur.
Un chemin deja modifie avant l'agent est laisse tel quel : c'etait du
travail non commite de l'operateur, et le jeter pour faire propre serait
exactement le mal que ce module previent.
"""
if path in before:
return ("REVERSION par l'agent — travail non commite perdu, irrecuperable"
if path not in after else "laisse tel quel (deja modifie avant)")
if after.get(path) == "untracked":
try:
(Path(repo_root) / path).unlink()
return "supprime"
except OSError as error:
return f"suppression impossible ({error})"
result = subprocess.run(["git", "checkout", "--", path],
cwd=repo_root, capture_output=True, text=True)
return "restaure" if result.returncode == 0 else "restauration impossible"
def enforce(repo_root: str | Path, agent: AgentSpec, roster: Roster,
before: dict[str, str]) -> list[str]:
"""Compare a l'etat des lieux d'entree ; annule et leve si l'agent a depasse.
Rend les chemins legitimement modifies — la trace saura ce que l'agent a
reellement touche, pas seulement ce qu'il a declare dans son enveloppe.
Une breche n'est PAS une gate : on ne re-prompte pas une ecriture deja
faite. La phase meurt, motif et chemins a l'appui.
"""
after = snapshot(repo_root)
touched = changed_paths(before, after)
breaches = [p for p in touched if not permitted(p, agent, roster)]
if not breaches:
return touched
outcomes = {p: _roll_back(repo_root, p, before, after) for p in breaches}
scope = ("en lecture seule" if agent.writes == ()
else f"limite a {list(agent.writes)}" if agent.writes
else f"exclu de {list(roster.protected_files)}")
detail = "\n".join(f" - {p} — {outcome}" for p, outcome in outcomes.items())
raise PermissionBreach(
f"{agent.name} est {scope} mais a modifie {len(breaches)} chemin(s) :\n{detail}")
if __name__ == "__main__":
# La gate du module : detecter, refuser, annuler — zero token.
# Lancer depuis la racine : uv run --with pyyaml python -m adws.adw_modules.permissions
from .roster import load
loaded = load()
assert permitted("apps/plume/editor.ts", loaded.agents["builder"], loaded)
assert not permitted("adws/adw_modules/envelopes.py", loaded.agents["builder"], loaded)
assert not permitted("apps/plume/editor.ts", loaded.agents["reviewer"], loaded)
assert permitted("adws/adw_data/sessions/x/report.md", loaded.agents["reviewer"], loaded)
before = snapshot(".")
Path("intrus.tmp").write_text("ce que le reviewer n aurait jamais du ecrire")
try:
enforce(".", loaded.agents["reviewer"], loaded, before)
raise SystemExit("gate en echec : la breche aurait du etre levee")
except PermissionBreach as breach:
print("breche refusee :", breach)
assert not Path("intrus.tmp").exists(), "le rollback devait supprimer le fichier intrus"
print("permissions : OK")
La gate du TP
Deux commandes, à la racine de plume-factory, chaque module portant sa propre gate. La première
prouve que le roster se charge, se fusionne et se valide. La seconde, que l’état des lieux
détecte, refuse et annule une écriture interdite, en créant puis en regardant disparaître un
fichier intrus.
# 1) le roster se charge et se valide — zero token
uv run --with pyyaml python -m adws.adw_modules.roster
# 2) l'etat des lieux detecte, refuse et annule — toujours zero token
uv run --with pyyaml python -m adws.adw_modules.permissions
Attendu : la première commande affiche les quatre agents avec leur modèle et leurs droits, puis
la liste des chemins protégés. La seconde affiche breche refusee : avec le chemin intrus.tmp
marqué supprime, puis permissions : OK. Coût : zéro token, quelques secondes. Le premier
lancement télécharge PyYAML, les suivants sont instantanés. Quand les deux passent, commitez :
l’usine sait désormais qui tourne, et vérifie ce que chacun a touché.