Annexe — Harnais pi Chapitre A3 / 43

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é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

// 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 :

  1. ls adws/adw_data passe.
  2. read .env est bloqué. Les secrets ne se lisent pas.
  3. bash rm -rf adws/adw_data/sessions est bloqué (« rm récursif »).
  4. bash git restore . relève d’une règle ask. Un dialogue s’ouvre, vous refusez, l’appel est bloqué. En pi -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_files du 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.
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

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ôlehandler tool_call d’une extensionhook PreToolUse, règles de permission
Refuserblock: true et reasoncode de sortie 2 ou "permissionDecision": "deny"
Demanderctx.ui.confirm"permissionDecision": "ask"
Demander sans humainbloqué par la piècerefusé en claude -p
Handler en erreurl’appel est bloquél’appel passe, sauf "onFailure": "block"
Arrêter l’agentterminate: true"continue": false
Protéger .envzeroAccessPathsRead(./.env) en deny
Commandes bashdétection textuelledé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.


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.