Damage control : le harnais sous contrôle
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. Comparé aux hooks et aux permissions de Claude Code. La pièce du jour : le damage control de plume-factory.
L’état des lieux du chapitre 10 juge après coup. Quand il détecte une brèche, la phase est finie,
ses jetons sont dépensés, et un git checkout -- . a déjà effacé votre travail non commité.
Ce chapitre pose un contrôle avant le geste. Il refuse l’appel d’outil, ou vous le soumet, et
rend au modèle un motif qu’il lit au tour suivant. La reprise coûte un tour, pas un run. La pièce
du jour est le damage control de plume-factory : des règles dans
.pi/damage-control-rules.yaml, appliquées par l’extension .pi/extensions/damage-control.ts.
Intercepter tool_call : bloquer ou demander
Le builder appelle bash avec git checkout -- .. Avant l’exécution, pi passe l’outil et ses
arguments à votre handler tool_call. Son retour décide :
| 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
// 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.` };
});
}
Une exception levée dans le handler bloque aussi l’appel : pi échoue fermé. event.input est
modifiable, mais la pièce ne réécrit rien, car une réécriture silencieuse rend le run illisible.
Côté Claude Code : le hook PreToolUse tient ce rôle. Il bloque par un code de sortie 2 ou
par "permissionDecision": "deny", dont le motif est montré à Claude. À l’inverse de pi, un hook
qui plante ne bloque pas par défaut, sauf si vous réglez "onFailure": "block".
Un blocage n’est pas un abandon
Sans damage control, le git checkout -- . passe, la phase continue, puis l’état des lieux
constate la réversion et tue le run. Avec damage control, le modèle reçoit le motif « efface le
travail non commité, ne contourne pas » comme un résultat d’outil ordinaire. Il poursuit sans
cette commande. Coût : un tour de plus, moins d’un centime.
Rédigez donc reason comme un rapport de gate : la règle, puis ce que vous attendez (« ne
contourne pas, signale »). Réservez terminate: true (ou ctx.abort()) aux cas où continuer n’a
plus de sens.
Côté Claude Code : "continue": false dans la sortie d’un hook arrête Claude entièrement,
comme terminate.
Bloquer ou demander, selon qui est là
ctx.ui.confirm(titre, message, { timeout }) ouvre un dialogue en TUI et en RPC, et retourne
false à l’expiration. En -p et --mode json, ctx.hasUI est faux. Personne ne répondra, donc
une règle « demander » bloque.
Côté Claude Code : un hook peut répondre "ask", et l’utilisateur confirme. En claude -p,
où personne ne peut répondre, l’appel est refusé et Claude lit le motif dans le résultat d’outil.
Chemins protégés : zero-access, read-only, no-delete
En session interactive, vous demandez de « nettoyer le dépôt avant la démo ». Quatre appels suivent :
ls adws/adw_datapasse.read .envest bloqué. Les secrets ne se lisent pas.bash rm -rf adws/adw_data/sessionsest bloqué (« rm récursif »).bash git restore .relève d’une règleask. Un dialogue s’ouvre, vous refusez, l’appel est bloqué. Enpi -p, il l’aurait été sans vous attendre.
Les trois cercles et ce qu’ils arrêtent
Trois cercles de chemins, plus des motifs bash pour les commandes destructives.
- zero-access : ni lire ni écrire. Les secrets, car le modèle n’a besoin d’aucune clé.
- read-only : lire oui, modifier non. La machinerie de l’usine (le
protected_filesdu chapitre 10) et.pi/, pour qu’un agent ne désactive pas son propre contrôle. - no-delete : modifier oui, supprimer ou déplacer non.
| 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 |
Mettez en ask ce qui peut être légitime sous vos yeux, comme git restore .. Ce qui doit
passer sans question ne figure pas dans le fichier.
Côté Claude Code : les chemins se protègent par des règles de permission, par exemple
Read(./.env) dans la liste deny. Les règles sont évaluées dans l’ordre deny, ask, allow, et la
première qui correspond décide.
Config — la forme des règles
Un YAML plat, versionné, que le modèle ne voit jamais. L’extension le lit ligne à ligne, sans 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/
La limite : bash
La détection sur bash est textuelle. Un chemin construit par variable ou un script lancé par
uv run passent sous le radar. La pièce arrête les erreurs ordinaires d’un agent, pas un
contournement délibéré. La garantie sur l’état final reste l’état des lieux du runner : le damage
control est la ceinture, pas l’airbag.
Côté Claude Code : même limite. Les règles deny sur Read et Edit couvrent cat, sed
ou une redirection, mais pas un script qui ouvre le fichier lui-même. Pour une protection au
niveau du système, la documentation renvoie au sandbox.
pi et Claude Code côte à côte
| pi (damage control) | Claude Code | |
|---|---|---|
| Point de contrôle | handler tool_call d’une extension | hook PreToolUse, règles de permission |
| Refuser | block: true et reason | code de sortie 2 ou "permissionDecision": "deny" |
| Demander | ctx.ui.confirm | "permissionDecision": "ask" |
| Demander sans humain | bloqué par la pièce | refusé en claude -p |
| Handler en erreur | l’appel est bloqué | l’appel passe, sauf "onFailure": "block" |
| Arrêter l’agent | terminate: true | "continue": false |
Protéger .env | zeroAccessPaths | Read(./.env) en deny |
| Commandes bash | détection textuelle | détection textuelle, sandbox en option |
Fil rouge — la pièce posée aujourd’hui
La pièce se pose dans le poste de pilotage, sous .pi/, et reprend les chemins protégés du
chapitre 10. La couture ne bouge pas : le runner possède le graphe des phases, et son état des
lieux reste la garantie. Le damage control agit dans la phase, au plus près du geste. L’agent
propose un tool_call, le code dispose. Coût : zéro jeton, un tour de plus par blocage. Gain :
moins de runs tués en fin de phase, moins de fichiers perdus.
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 complètes de plume-factory. Trois sont en ask.
# .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
La fonction pure decide() rend un verdict ou null. Le handler tool_call l’appelle, demande
si c’est permis, puis bloque. Lancé par Bun, le fichier sert aussi de gate : il vérifie huit
décisions sans dépenser un jeton.
// .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). Mêmes commandes dans bash et
PowerShell.
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 « 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
(« rm récursif ou forcé », puis « chemin zero-access .env »), sans rien supprimer ni lire. La
quatrième liste les deux fichiers, toujours là. Coût total : moins d’un centime.