Damage control : le harnais sous contrôle
L'état des lieux du chapitre 10 juge après coup ; aujourd'hui vous posez un contrôle avant le geste. Une extension pi intercepte chaque tool_call, bloque ou demande selon des règles YAML, et rend le motif au modèle par la même porte qu'un rapport de gate — la pièce qui met le nœud pi de plume-factory sous contrôle, headless compris.
Au chapitre 10, vous avez donné à l’usine son état des lieux : un constat d’entrée, un constat de
sortie, et une brèche sur PLAN.md ou adws/ qui tue le run. C’est la bonne garantie, mais elle
arrive tard : quand le runner découvre la brèche, la phase est finie, ses jetons sont dépensés,
et un git checkout -- . lancé par le builder a déjà effacé votre travail non commité. Hier,
au chapitre A2, vous avez vu que tool_call est le seul événement du cycle de vie qui peut
bloquer. À la fin de ce chapitre, vous saurez transformer ce retour en un contrôle qui agit
avant le geste : bloquer net, ou demander quand quelqu’un est là pour répondre, et rendre au
modèle un motif qu’il lit au tour suivant, et la reprise coûte alors un tour, pas un run. La pièce
du jour est le damage control de plume-factory : un fichier de règles
.pi/damage-control-rules.yaml et l’extension .pi/extensions/damage-control.ts qui l’applique.
Elle vit côté pi de la couture, à côté du pied de page d’hier, et ne remplace pas l’état des lieux
du runner : elle lui évite d’avoir à servir.
Intercepter tool_call : bloquer ou demander
L’idée en une phrase
Un handler tool_call reçoit le nom de l’outil et ses arguments avant l’exécution, et son
retour décide : undefined laisse passer, { block: true, reason } refuse et renvoie reason
au modèle comme résultat d’outil. Cette pièce vit dans le harnais, côté code déterministe :
une règle est une regex ou un chemin, évaluée en quelques microsecondes, jamais un jugement
du modèle.
Points clés
- Le retour de
tool_callest un contrat à trois valeurs.{ block: true, reason }refuse l’appel et le motif devient le résultat de l’outil.terminate: trueen plus demande à pi de ne pas relancer le modèle après ce lot d’appels, utile pour un arrêt franc, inutile pour une correction. Une exception levée dans le handler bloque aussi : pi est conçu pour échouer fermé. event.inputest mutable, sans revalidation : une extension peut réécrire une commande avant qu’elle ne parte. La pièce du jour ne s’en sert pas : réécrire en silence ce que l’agent a décidé est la meilleure façon de rendre un run illisible dans les traces du module 5.- Bloquer ou demander, selon qui est là.
ctx.ui.confirm(titre, message, { timeout })ouvre un dialogue en TUI et en RPC, et à l’expiration il retournefalse. En-pet--mode json,ctx.hasUIest faux et une règle « demander » doit bloquer : personne ne répondra, et la valeur par défaut d’un dialogue qui ne s’ouvre pas ne protège rien, vous l’avez vu hier. - Le motif est une enveloppe. Ce que vous mettez dans
reasonest ce que le modèle lira : nommez la règle, le chemin ou le motif, et dites ce que vous attendez (« ne contourne pas, signale »), le même soin que pour un rapport de gate du chapitre 12. - Un blocage n’est pas un abandon. Le modèle reçoit le refus comme n’importe quel résultat d’outil, la session reste vivante, et il choisit une autre voie au tour suivant. Le runner ne voit rien passer tant que la phase rend son enveloppe.
Exemple concret
Phase build d’un run SDLC, workhorse GLM 5.3 à 1,40 $ le million de jetons en entrée et 4,40 $
en sortie au moment d’écrire. Au quatrième tour, le builder décide de « repartir propre » et
appelle bash avec git checkout -- .. Sans damage control : la commande passe, vos
modifications non commitées disparaissent, la phase continue trois tours, rend son enveloppe,
et l’état des lieux du chapitre 10 constate une réversion : brèche, run tué. Coût du run perdu :
quelques centimes de jetons, et surtout quelques minutes à comprendre ce qui s’est passé.
Avec damage control : l’extension refuse l’appel avec le motif « discards uncommitted changes,
règle du projet, ne contourne pas ». Le modèle lit le motif dans le résultat d’outil, explique
qu’il ne peut pas réinitialiser l’arbre de travail, et poursuit avec ce qu’il a. Coût du
blocage : un tour de plus, quelques milliers de jetons, moins d’un centime, et rien à
reconstruire.
Trois façons de dire non
| Décision | Quand | Ce que le modèle voit | Ce que le runner voit |
|---|---|---|---|
Laisser passer (undefined) | aucune règle ne correspond | le résultat normal de l’outil | rien de spécial |
Bloquer (block: true) | règle sans ask, ou ask sans UI | le motif, comme résultat d’outil | une phase qui continue |
| Demander puis bloquer | règle ask, TUI ou RPC, refus ou 30 s sans réponse | le motif, mention « refusé » | — (jamais en headless) |
Bloquer et arrêter (terminate: true) | cas extrêmes que la pièce ne prévoit pas | le motif, puis plus rien | une phase qui s’arrête tôt |
Config — le cœur d’un handler qui bloque
Seule la version pi existe : l’annexe équipe le côté pi de la couture (Claude Code a ses hooks
PreToolUse, hors programme, et l’adaptateur _run_claude n’en dépend pas). Le squelette
ci-dessous est celui de la pièce du jour, réduit à la décision.
// Le principe : décider AVANT l'exécution, rendre le motif au modèle, échouer fermé.
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
pi.on("tool_call", async (event, ctx) => {
// Une décision pure : (outil, arguments) → { reason, ask } ou null.
const verdict = decide(event.toolName, event.input);
if (!verdict) return undefined; // aucune règle : l'outil s'exécute.
// « Demander » n'a de sens que si quelqu'un peut répondre ; sinon on bloque.
if (verdict.ask && ctx.hasUI) {
const ok = await ctx.ui.confirm("Damage control", verdict.reason, { timeout: 30_000 });
if (ok) return undefined;
}
// Le motif est une enveloppe : le modèle le lit comme résultat d'outil.
return { block: true, reason: `Refusé par le damage control de l'usine : ${verdict.reason}. Ne contourne pas ; signale-le.` };
});
}
Piège courant : « bloquer un
tool_call, c’est arrêter l’agent » est inexact. L’appel refusé devient un résultat d’outil ordinaire, et le modèle continue son tour suivant avec cette information. C’est exactement ce que vous voulez : une correction en session vivante coûte un tour, un run relancé coûte tout. Réservezterminate: true(ouctx.abort()) aux cas où continuer n’a plus de sens.
Chemins protégés : zero-access, read-only, no-delete
L’idée en une phrase
Les règles de la pièce classent les chemins en trois cercles : zero-access (ni lire ni écrire : secrets), read-only (lire oui, modifier non : la machinerie de l’usine) et no-delete (modifier oui, supprimer ou déplacer non : ce qui doit survivre). S’y ajoute une liste de motifs bash pour les commandes destructives. Le tout dans un YAML côté code déterministe, versionné avec le projet, que le modèle ne voit jamais.
Points clés
- Chaque cercle vise un outil différent. Zero-access s’applique à
read,grep,find,ls,write,editet à toute commandebashqui cite le chemin. Read-only visewrite,editet les commandes qui redirigent,sed -i,rm,mv. No-delete vise les seulsrmetmv. Uncat PLAN.mdpasse, unecho x > PLAN.mdne passe pas. - Les chemins de l’usine y sont déjà.
adws/adw_modules/,adws/adw_config/, lesadws/adw_*.py,PLAN.mdet.pi/sont read-only : ce sont les mêmes que leprotected_filesdefactory.config.yaml(chapitre 10), plus le nœud pi lui-même : un agent ne doit pas pouvoir désactiver le disjoncteur qui le surveille. - Les secrets sont zero-access, même en lecture.
.envet ses variantes,*.pem,*.key,~/.ssh/, et~/.pi/agent/auth.json: la frontière des credentials du chapitre 21 tenait sur la variable d’environnement chargée par le runner. Ici vous fermez aussi la porte de la lecture. Le modèle n’a besoin de connaître aucune clé pour faire son travail. bashreste le trou dans la raquette, et la pièce l’assume : la détection sur une commande est textuelle (le chemin apparaît-il ? la commande ressemble-t-elle à une écriture ?). Elle arrête les gestes ordinaires d’un agent, pas un contournement délibéré. La garantie reste l’état des lieux du runner, et le damage control est la ceinture, pas l’airbag.- Une règle
askest un choix de posture.git restore .ougit branch -Dpeuvent être légitimes sous vos yeux, et en headlessaskbloque. Tout ce que vous voulez laisser passer sans question ne doit simplement pas figurer dans le fichier.
Exemple concret
Session interactive dans plume-factory : vous demandez au workhorse de « nettoyer le dépôt
avant la démo ». Il enchaîne quatre appels d’outils. ls adws/adw_data : passe (le runtime est
ouvert, comme au chapitre 10). read .env pour « vérifier la config » : bloqué, zero-access,
et le motif lui dit que les secrets ne se lisent pas. bash rm -rf adws/adw_data/sessions :
bloqué, motif « rm récursif », et no-delete sur adws/adw_data/ aurait pris le relais.
bash git restore . : la règle est ask, vous êtes en TUI, un dialogue s’ouvre avec la commande
et 30 secondes de compte à rebours. Vous refusez, l’appel est bloqué avec la mention
« refusé ». Le modèle rend compte des quatre décisions et s’arrête. Bilan : zéro fichier
perdu, un tour de plus, moins d’un centime, et le même prompt lancé par pi -p aurait
bloqué la quatrième commande sans vous attendre.
Les trois cercles et ce qu’ils arrêtent
| Cercle | Outils concernés | Exemple dans plume-factory | Passe | Ne passe pas |
|---|---|---|---|---|
| zero-access | read, grep, find, ls, write, edit, bash | .env, *.pem, ~/.pi/agent/auth.json | rien | read .env, cat .env |
| read-only | write, edit, bash (écriture) | adws/adw_modules/, PLAN.md, .pi/ | cat PLAN.md, grep gate adws/ | edit PLAN.md, sed -i … adws/adw_modules/runner.py |
| no-delete | bash (rm, mv) | adws/adw_data/, specs/, justfile | write specs/nouvelle.md | rm -r adws/adw_data, mv justfile old |
| motifs bash | bash | rm -rf, git reset --hard, git push --force | git push --force-with-lease | git push --force |
Config — la forme des règles
Le fichier est du YAML plat : quatre clés de premier niveau, des listes de chaînes ou de petits objets. L’extension le lit ligne à ligne, comme le pied de page lisait le roster hier. Pas de dépendance npm, donc pas de parseur YAML complet : restez dans cette forme.
# .pi/damage-control-rules.yaml — extrait ; le fichier complet est dans les travaux pratiques.
bashToolPatterns:
- pattern: '\bgit\s+reset\s+--hard\b'
reason: git reset --hard efface le travail non commité
- pattern: '\bgit\s+restore\s+\.'
reason: git restore . efface le travail non commité
ask: true # demandé en TUI/RPC, bloqué en headless
zeroAccessPaths: # ni lire, ni écrire
- .env
- "~/.pi/agent/auth.json"
readOnlyPaths: # lire oui, modifier non ; un / final = tout le dossier
- adws/adw_modules/
- PLAN.md
noDeletePaths: # modifier oui, rm/mv non
- adws/adw_data/
Piège courant : « avec ces règles, l’agent ne peut plus rien casser » est inexact. La détection sur
bashest textuelle, et un chemin construit par variable ou un script lancé paruv runpassent sous le radar. Ce que la pièce garantit, c’est que les gestes ordinaires d’un agent qui se trompe sont arrêtés avant d’avoir coûté quelque chose. La garantie sur l’état final reste au runner, et c’est bien ainsi que la couture doit tenir.
Fil rouge — la pièce posée aujourd’hui
Sur le plan de l’usine, la pièce se pose dans le poste de pilotage, sous .pi/, entre le
settings.json du chapitre A1 et le pied de page du chapitre A2. Elle regarde vers la zone
permissions du chapitre 10, dont elle reprend les chemins protégés. La couture ne bouge pas :
le runner Python possède toujours le graphe des phases et son état des lieux reste la
garantie. Le damage control travaille à l’intérieur d’une phase, sur le graphe des tours que
pi possède, et applique la loi du livre au plus près du geste : l’agent propose un tool_call,
le code dispose avant l’exécution. L’enveloppe qui traverse, c’est le motif : il revient au
modèle comme résultat d’outil, par la même porte qu’un rapport de gate, et la correction se fait
en session vivante. Coût : zéro jeton pour l’extension, quelques microsecondes par appel
d’outil, et le seul surcoût est le tour supplémentaire qu’un blocage provoque, moins d’un
centime sur le workhorse. Ce qu’elle économise : les runs tués par une brèche découverte à la
fin, et les fichiers perdus qu’aucune enveloppe ne rendra.
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.
Pièce — .pi/damage-control-rules.yaml
Les règles de plume-factory. Elles reprennent le protected_files de factory.config.yaml
(chapitre 10) en read-only, y ajoutent le nœud pi, ferment les secrets en zero-access, protègent
le runtime et les specs contre la suppression, et refusent les commandes destructives courantes.
Trois règles sont en ask : légitimes sous vos yeux, bloquées en headless.
# .pi/damage-control-rules.yaml — les règles du damage control de plume-factory.
# Lu par .pi/extensions/damage-control.ts au démarrage de chaque session (et à /reload).
# Forme volontairement plate : quatre clés, des listes ; pas de YAML avancé.
# Commandes bash refusées (regex JavaScript). ask: true = demander en TUI/RPC, bloquer en headless.
bashToolPatterns:
- pattern: '\brm\s+(-[^\s]*)*-[rRf]'
reason: rm récursif ou forcé
- pattern: '\brm\s+--(recursive|force)\b'
reason: rm récursif ou forcé
- pattern: '\bsudo\b'
reason: sudo n'a rien à faire dans un run de l'usine
- pattern: '\bchmod\s+(-[^\s]+\s+)*777\b'
reason: chmod 777
- pattern: '\bgit\s+reset\s+--hard\b'
reason: git reset --hard efface le travail non commité
- pattern: '\bgit\s+clean\s+(-[^\s]*)*-[fd]'
reason: git clean -f/-d supprime les fichiers non suivis
- pattern: '\bgit\s+push\s+.*--force(?!-with-lease)'
reason: git push --force (utiliser --force-with-lease)
- pattern: '\bgit\s+push\s+(-[^\s]*)*-f\b'
reason: git push -f (utiliser --force-with-lease)
- pattern: '\bgit\s+stash\s+(clear|drop)\b'
reason: suppression définitive d'un stash
- pattern: '\bgit\s+checkout\s+--\s*\.'
reason: git checkout -- . efface le travail non commité
ask: true
- pattern: '\bgit\s+restore\s+\.'
reason: git restore . efface le travail non commité
ask: true
- pattern: '\bgit\s+branch\s+(-[^\s]*)*-D\b'
reason: suppression forcée d'une branche
ask: true
- pattern: '\bDROP\s+TABLE\b'
reason: DROP TABLE (les traces de factory.db ne se suppriment pas)
- pattern: '\bDELETE\s+FROM\s+\w+\s*;'
reason: DELETE sans WHERE
# Ni lire, ni écrire : les secrets. Un chemin sans / final se compare au nom de fichier
# (avec * comme joker) ; ~ désigne le dossier personnel.
zeroAccessPaths:
- .env
- .env.local
- "*.pem"
- "*.key"
- "~/.ssh/"
- "~/.aws/"
- "~/.pi/agent/auth.json"
# Lire oui, modifier non : la machinerie de l'usine (= protected_files du chapitre 10) + le nœud pi.
readOnlyPaths:
- adws/adw_modules/
- adws/adw_config/
- "adws/adw_*.py"
- PLAN.md
- .pi/
- uv.lock
- bun.lock
- bun.lockb
# Modifier oui, supprimer ou déplacer non : ce qui doit survivre à un run.
noDeletePaths:
- adws/adw_data/
- specs/
- .git/
- .gitignore
- justfile
- package.json
- README.md
Pièce — .pi/extensions/damage-control.ts
L’extension qui applique les règles. Une fonction pure decide() prend un outil et ses
arguments et rend un verdict ou null. Le handler tool_call ne fait qu’appeler decide(),
demander si c’est permis, et bloquer. Le fichier porte sa propre gate : lancé par Bun, il charge
les règles du projet et vérifie huit décisions attendues, sans dépenser un jeton. Côté pi,
côté code déterministe, et en headless toute règle ask bloque.
// .pi/extensions/damage-control.ts — décider avant le geste, rendre le motif au modèle.
// Aucune dépendance npm : le YAML plat des règles est lu ligne à ligne.
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { isAbsolute, join, relative, resolve } from "node:path";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
const RULES_FILE = join(".pi", "damage-control-rules.yaml");
interface BashRule { pattern: RegExp; reason: string; ask: boolean }
interface Rules {
bashToolPatterns: BashRule[];
zeroAccessPaths: string[];
readOnlyPaths: string[];
noDeletePaths: string[];
}
interface Verdict { reason: string; ask: boolean }
// ---------- lecture des règles : un sous-ensemble de YAML, suffisant et prévisible ----------
const unquote = (s: string) => s.trim().replace(/^(['"])(.*)\1$/, "$2");
function parseRules(text: string): Rules {
const rules: Rules = { bashToolPatterns: [], zeroAccessPaths: [], readOnlyPaths: [], noDeletePaths: [] };
let section: keyof Rules | null = null;
let current: { pattern?: string; reason?: string; ask?: boolean } | null = null;
const flush = () => {
if (current?.pattern) {
rules.bashToolPatterns.push({
pattern: new RegExp(current.pattern, "i"),
reason: current.reason ?? current.pattern,
ask: current.ask === true,
});
}
current = null;
};
for (const raw of text.split("\n")) {
// Les commentaires en fin de ligne sont retirés, sauf à l'intérieur d'une regex quotée.
const line = /^\s*-\s*pattern:/.test(raw) ? raw.trimEnd() : raw.replace(/\s+#.*$/, "").trimEnd();
if (!line.trim() || line.trim().startsWith("#")) continue;
const header = line.match(/^(\w+):\s*$/);
if (header) { flush(); section = header[1] as keyof Rules; continue; }
if (!section) continue;
if (section === "bashToolPatterns") {
const pattern = line.match(/^\s*-\s*pattern:\s*(.+)$/);
const reason = line.match(/^\s*reason:\s*(.+)$/);
const ask = line.match(/^\s*ask:\s*(true|false)\s*$/);
if (pattern) { flush(); current = { pattern: unquote(pattern[1]) }; }
else if (reason && current) current.reason = unquote(reason[1]);
else if (ask && current) current.ask = ask[1] === "true";
continue;
}
const item = line.match(/^\s*-\s*(.+)$/);
if (item) rules[section].push(unquote(item[1]));
}
flush();
return rules;
}
function loadRules(cwd: string): Rules {
const path = join(cwd, RULES_FILE);
return existsSync(path) ? parseRules(readFileSync(path, "utf8")) : parseRules("");
}
// ---------- correspondance de chemins : / final = dossier, * = joker sur le nom ----------
const toPosix = (p: string) => p.replace(/\\/g, "/");
const expandHome = (p: string) => (p.startsWith("~") ? join(homedir(), p.slice(1)) : p);
function pathMatches(target: string, pattern: string, cwd: string): boolean {
const abs = toPosix(resolve(cwd, expandHome(target)));
const rel = toPosix(relative(cwd, abs));
const pat = toPosix(expandHome(pattern));
if (pat.endsWith("/")) {
// Un dossier : tout ce qui est dessous, en absolu ou relatif au projet.
const dir = isAbsolute(pat) ? pat : toPosix(resolve(cwd, pat)) + "/";
return (abs + "/").startsWith(dir);
}
const rx = new RegExp("^" + pat.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*") + "$");
const base = abs.slice(abs.lastIndexOf("/") + 1);
return rx.test(rel) || rx.test(abs) || (!pat.includes("/") && rx.test(base));
}
// Le chemin apparaît-il dans la commande, comme mot entier ?
function commandMentions(command: string, pattern: string): boolean {
const needle = toPosix(pattern).replace(/\/$/, "");
if (needle.includes("*")) {
const rx = new RegExp("(^|[\\s/\"'=])" + needle.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^\\s/\"']*") + "($|[\\s/\"';)])");
return rx.test(command);
}
const rx = new RegExp("(^|[\\s\"'=])" + needle.replace(/[.+^${}()|[\]\\]/g, "\\$&") + "($|[\\s/\"';)])");
return rx.test(command) || (needle.startsWith("~") && rx.test(command.replace(homedir(), "~")));
}
// ---------- la décision : pure, testable, sans pi ----------
export function decide(toolName: string, input: Record<string, unknown>, cwd: string, rules: Rules): Verdict | null {
const path = typeof input.path === "string" ? input.path : undefined;
// 1. zero-access : quel que soit l'outil, un chemin interdit est interdit.
if (path && ["read", "write", "edit", "grep", "find", "ls"].includes(toolName)) {
const zap = rules.zeroAccessPaths.find((z) => pathMatches(path, z, cwd));
if (zap) return { reason: `chemin zero-access ${zap} (ni lecture ni écriture)`, ask: false };
}
// 2. read-only : write et edit ne modifient pas la machinerie de l'usine.
if (path && (toolName === "write" || toolName === "edit")) {
const rop = rules.readOnlyPaths.find((r) => pathMatches(path, r, cwd));
if (rop) return { reason: `chemin read-only ${rop} (lecture seule)`, ask: false };
}
if (toolName !== "bash" || typeof input.command !== "string") return null;
const command = input.command;
// 3. motifs bash : la première règle qui correspond décide.
const rule = rules.bashToolPatterns.find((r) => r.pattern.test(command));
if (rule) return { reason: rule.reason, ask: rule.ask };
// 4. un secret cité dans une commande : refusé, même pour le lire.
const zap = rules.zeroAccessPaths.find((z) => commandMentions(command, z));
if (zap) return { reason: `la commande cite le chemin zero-access ${zap}`, ask: false };
// 5. read-only cité par une commande qui ressemble à une écriture (redirection, sed -i, rm, mv, tee).
const writes = /(^|[^<])>|\bsed\s+-i|\brm\b|\bmv\b|\btee\b|\btruncate\b/.test(command);
const rop = rules.readOnlyPaths.find((r) => commandMentions(command, r));
if (rop && writes) return { reason: `la commande pourrait modifier le chemin read-only ${rop}`, ask: false };
// 6. no-delete : rm et mv ne touchent pas à ce qui doit survivre.
if (/\b(rm|rmdir|mv)\b/.test(command)) {
const ndp = rules.noDeletePaths.find((n) => commandMentions(command, n));
if (ndp) return { reason: `la commande supprime ou déplace le chemin no-delete ${ndp}`, ask: false };
}
return null;
}
const ruleCount = (r: Rules) =>
r.bashToolPatterns.length + r.zeroAccessPaths.length + r.readOnlyPaths.length + r.noDeletePaths.length;
// ---------- l'extension : charger, décider, bloquer ----------
export default function (pi: ExtensionAPI) {
let rules = parseRules("");
let blocked = 0;
pi.on("session_start", async (_event, ctx) => {
rules = loadRules(ctx.cwd);
blocked = 0;
if (ctx.hasUI) ctx.ui.setStatus("damage-control", `🛡 ${ruleCount(rules)} règles`);
});
pi.on("tool_call", async (event, ctx) => {
const verdict = decide(event.toolName, event.input as Record<string, unknown>, ctx.cwd, rules);
if (!verdict) return undefined;
const what = event.toolName === "bash" ? String(event.input.command) : JSON.stringify(event.input);
// Demander seulement si quelqu'un peut répondre ; 30 s sans réponse = refus.
if (verdict.ask && ctx.hasUI) {
const ok = await ctx.ui.confirm("Damage control", `${verdict.reason}\n\n${what}\n\nAutoriser ?`, { timeout: 30_000 });
pi.appendEntry("damage-control", { tool: event.toolName, input: event.input, rule: verdict.reason, action: ok ? "confirmed" : "refused" });
if (ok) return undefined;
} else {
pi.appendEntry("damage-control", { tool: event.toolName, input: event.input, rule: verdict.reason, action: "blocked" });
}
blocked += 1;
if (ctx.hasUI) {
ctx.ui.notify(`Damage control : ${event.toolName} refusé — ${verdict.reason}`, "warning");
ctx.ui.setStatus("damage-control", `🛡 ${ruleCount(rules)} règles · ${blocked} refus`);
}
// Le motif est l'enveloppe : le modèle le lit comme résultat d'outil et corrige au tour suivant.
return {
block: true,
reason: `Refusé par le damage control de l'usine : ${verdict.reason}${verdict.ask ? " (refusé par l'utilisateur)" : ""}. ` +
"Ne contourne pas cette règle par une autre commande, un autre chemin ou un autre outil. Signale ce refus tel quel et poursuis sans cette action.",
};
});
pi.registerCommand("damage-control", {
description: "Afficher les règles chargées et le nombre de refus de la session",
handler: async (_args, ctx) => {
if (!ctx.hasUI) return;
ctx.ui.notify(
`${rules.bashToolPatterns.length} motifs bash · ${rules.zeroAccessPaths.length} zero-access · ` +
`${rules.readOnlyPaths.length} read-only · ${rules.noDeletePaths.length} no-delete · ${blocked} refus`,
"info",
);
},
});
}
// ---------- la gate du fichier : `bun .pi/extensions/damage-control.ts`, zéro jeton ----------
if (import.meta.main) {
const cwd = process.cwd();
const rules = loadRules(cwd);
const cases: Array<[string, Record<string, unknown>, boolean, string]> = [
["read", { path: "PLAN.md" }, false, "lire PLAN.md est permis"],
["read", { path: ".env" }, true, "lire .env est zero-access"],
["edit", { path: "adws/adw_modules/runner.py" }, true, "éditer la machinerie est read-only"],
["write", { path: "src/editor.ts" }, false, "écrire dans le payload est permis"],
["bash", { command: "bun test" }, false, "bun test est permis"],
["bash", { command: "git checkout -- ." }, true, "git checkout -- . est refusé (ask, donc bloqué ici)"],
["bash", { command: "rm -rf adws/adw_data/sessions" }, true, "rm -rf est refusé"],
["bash", { command: "echo ok > PLAN.md" }, true, "rediriger vers PLAN.md est read-only"],
];
let failures = 0;
for (const [tool, input, expectBlocked, label] of cases) {
const verdict = decide(tool, input, cwd, rules);
const ok = Boolean(verdict) === expectBlocked;
if (!ok) failures += 1;
console.log(`${ok ? "OK " : "KO "} ${label}${verdict ? ` — ${verdict.reason}` : ""}`);
}
console.log(`${ruleCount(rules)} règles chargées depuis ${RULES_FILE} · ${cases.length - failures}/${cases.length} décisions attendues`);
process.exit(failures === 0 && ruleCount(rules) > 0 ? 0 : 1);
}
La gate du TP
Depuis la racine de plume-factory, projet approuvé (chapitre A1). Quatre commandes, une par
ligne, identiques dans bash et PowerShell. La première ne coûte rien, les trois autres lancent
un tour headless chacune.
bun .pi/extensions/damage-control.ts
pi -p "Avec l'outil bash, exécute exactement : rm -rf adws/adw_data. Puis réponds uniquement par le message d'erreur reçu, sans commentaire."
pi -p "Lis le fichier .env avec l'outil read et réponds uniquement par le message d'erreur reçu, sans commentaire."
pi -p "Avec l'outil bash, exécute exactement : ls PLAN.md justfile. Réponds uniquement par la sortie."
Résultat attendu : la première commande affiche huit lignes OK et se termine par « 36 règles
chargées … 8/8 décisions attendues », code de sortie 0. La deuxième et la troisième répondent
par le motif du damage control (« rm récursif ou forcé », puis « chemin zero-access .env »),
sans aucune suppression ni aucun contenu de .env, et la quatrième liste les deux fichiers, toujours là.
Coût : la gate Bun ne dépense rien, les trois tours headless coûtent ensemble moins
d’un centime, en quelques secondes, sur le workhorse du chapitre A1. Variante éco : elle est
déjà dans la pièce, le modèle est celui de .pi/settings.json.