Annexe — Harnais pi Chapitre A3 / 42

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_call est un contrat à trois valeurs. { block: true, reason } refuse l’appel et le motif devient le résultat de l’outil. terminate: true en 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.input est 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 retourne false. En -p et --mode json, ctx.hasUI est 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 reason est 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écisionQuandCe que le modèle voitCe que le runner voit
Laisser passer (undefined)aucune règle ne correspondle résultat normal de l’outilrien de spécial
Bloquer (block: true)règle sans ask, ou ask sans UIle motif, comme résultat d’outilune phase qui continue
Demander puis bloquerrègle ask, TUI ou RPC, refus ou 30 s sans réponsele motif, mention « refusé »— (jamais en headless)
Bloquer et arrêter (terminate: true)cas extrêmes que la pièce ne prévoit pasle motif, puis plus rienune 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éservez terminate: true (ou ctx.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, edit et à toute commande bash qui cite le chemin. Read-only vise write, edit et les commandes qui redirigent, sed -i, rm, mv. No-delete vise les seuls rm et mv. Un cat PLAN.md passe, un echo x > PLAN.md ne passe pas.
  • Les chemins de l’usine y sont déjà. adws/adw_modules/, adws/adw_config/, les adws/adw_*.py, PLAN.md et .pi/ sont read-only : ce sont les mêmes que le protected_files de factory.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. .env et 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.
  • bash reste 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 ask est un choix de posture. git restore . ou git branch -D peuvent être légitimes sous vos yeux, et en headless ask bloque. 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

CercleOutils concernésExemple dans plume-factoryPasseNe passe pas
zero-accessread, grep, find, ls, write, edit, bash.env, *.pem, ~/.pi/agent/auth.jsonrienread .env, cat .env
read-onlywrite, 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-deletebash (rm, mv)adws/adw_data/, specs/, justfilewrite specs/nouvelle.mdrm -r adws/adw_data, mv justfile old
motifs bashbashrm -rf, git reset --hard, git push --forcegit push --force-with-leasegit 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 bash est textuelle, et un chemin construit par variable ou un script lancé par uv run passent 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.


Quiz — teste tes connaissances
Annexe — Harnais pi 7 questions Objectif : 5/7 minimum
0/7
bonnes reponses
Objectif non atteint (minimum 5/7 requis).
Remonte relire la fiche memo en pretant attention aux points manques, puis cliquer sur « Recommencer » pour retenter.