Dans l’article précédent, j’ai présenté pi-secured-setup — une extension pi qui ajoute des Guards, des Scanners et un audit trail à votre agent de code IA. Elle est livrée avec des defaults pertinents : application de frontière, globbing de chemins protégés, classification de commandes bash, rédaction de secrets, vérification de skills.

Mais chaque projet a des risques uniques. Un shop Terraform n’a pas les mêmes règles qu’un monorepo Node.js. Une équipe avec des exigences de conformité strictes n’a pas la même granularité d’audit qu’un développeur solo.

La bonne nouvelle : les Guards et Scanners sont de pures fonctions, et le pipeline est plug-and-play. Aujourd’hui je vous montre comment fonctionnent les entrailles, comment écrire les vôtres, et comment les tester avec confiance.

Comment fonctionne le pipeline sous le capot

ADR-0001 : un handler pour les gouverner tous

Le système de guards entier enregistre un unique handler d’événement tool_call sur l’API d’extension de pi. Pas trois handlers. Pas un par module. Un seul.

Pourquoi ? Pi ne garantit pas l’ordre des handlers. Si chaque Guard enregistrait son propre listener tool_call, un appel d’outil touchant à la fois la frontière et les chemins protégés pourrait produire deux dialogues de confirmation. Ou pire, la commande dangereuse pourrait être approuvée par le handler bash avant que le handler boundary n’ait eu la chance de la bloquer.

Le handler unique appelle des fonctions d’évaluation pures dans un ordre fixe :

evaluateBoundary() → evaluateProtectedPaths() → classifyCommand()

Le premier bloc gagne. Pas de court-circuit devant une confirmation. Un verdict par appel d’outil, une entrée d’audit.

Fonctions pures, aucune dépendance pi

Chaque module Guard exporte une seule fonction avec cette signature :

function evaluateBoundary(
  toolName: string,
  input: Record<string, unknown>,
  config: Config,
): GuardVerdict

Remarquez ce qui manque : pas de ExtensionAPI, pas de ctx, pas d’objets event. La fonction prend des données brutes et renvoie des données brutes. Elle n’a aucune connaissance de l’existence de pi.

C’est délibéré. L’orchestrateur du pipeline (qui, lui, dépend de pi) gère toute la partie complexe — dialogues UI, logging d’audit, blocage de l’appel d’outil. Les fonctions d’évaluation sont de la logique pure, testable avec un simple assert.equal().

Le type GuardVerdict

Chaque Guard renvoie l’un de trois verdicts :

type GuardVerdict =
  | { action: "allow" }
  | { action: "block"; reason: string }
  | { action: "confirm"; message: string };
  • allow : le check passe, passer au guard suivant dans le pipeline
  • block : l’action est rejetée immédiatement, sans interaction utilisateur
  • confirm : afficher un dialogue à l’utilisateur, bloquer s’il décline

Le pipeline gère l’interaction UI. Le Guard dit simplement ce qui doit se passer.

L’orchestrateur

Voici ce que fait réellement guard-pipeline.ts — c’est la colle entre les fonctions pures et l’API de pi :

pi.on("tool_call", async (event, ctx) => {
  const config = getConfig();
  const toolName = event.toolName;
  const input = event.input as Record<string, unknown>;

  // Étape 1 : Boundary
  const boundaryVerdict = guards.evaluateBoundary(toolName, input, config);

  if (boundaryVerdict.action === "block") {
    auditLog("boundary.block", "warning", { ... });
    return { block: true, reason: boundaryVerdict.reason };
  }

  if (boundaryVerdict.action === "confirm") {
    const approved = await ctx.ui.confirm("🔒 Boundary Check", boundaryVerdict.message);
    if (!approved) {
      auditLog("boundary.block", "warning", { ... });
      return { block: true, reason: "User denied: outside boundary" };
    }
    auditLog("boundary.confirm", "info", { ... });
  }

  // Étape 2 : Protected Paths
  // ...même pattern...

  // Étape 3 : Bash Gate (bash uniquement)
  // ...même pattern...
});

L’orchestrateur fait quatre choses que les fonctions pures ne touchent jamais :

  1. Récupère la config courante (via closure, supporte le hot-reload)
  2. Affiche les dialogues ctx.ui.confirm() quand un Guard renvoie confirm
  3. Écrit dans l’audit log
  4. Renvoie { block: true } à pi quand c’est approprié

Anatomie d’un Guard

Construisons-en un vrai. Disons que votre équipe a une règle : ne jamais commiter de fichiers .env. Pas “confirmer avant de commiter” — jamais. Le Guard protected-paths existant bloque les écritures vers .env, mais que se passe-t-il si quelqu’un stage un .env puis fait git commit ? C’est une commande bash, pas une écriture fichier. Le guard boundary ne l’attrapera pas. Le bash gate avec ses patterns par défaut ne l’attrapera pas non plus.

Voici le Guard :

// lib/no-dotenv-commit.ts
import type { Config } from "./config.js";
import type { GuardVerdict } from "./boundary.js";

export function evaluateNoDotenvCommit(
  toolName: string,
  input: Record<string, unknown>,
  config: Config,
): GuardVerdict {
  // Ne s'applique qu'à bash
  if (toolName !== "bash") return { action: "allow" };

  const command = input.command as string | undefined;
  if (!command) return { action: "allow" };

  // Matcher les commandes "git commit"
  if (!/\bgit\s+commit\b/.test(command)) return { action: "allow" };

  // Vérifier si des fichiers .env apparaissent dans la commande
  const envPatterns = /\.env\b|\.env\./;
  if (envPatterns.test(command)) {
    return {
      action: "block",
      reason: "committing .env files is forbidden by policy",
    };
  }

  return { action: "allow" };
}

Pas d’import pi. Pas de ExtensionAPI. Juste une fonction qui prend des données et renvoie un verdict.

Le brancher

Dans le point d’entrée (extensions/security.ts), ajoutez-le à l’objet evaluateurs du pipeline :

import { evaluateNoDotenvCommit } from "../lib/no-dotenv-commit.js";

registerGuardPipeline(
  pi,
  () => config,
  {
    evaluateBoundary,
    evaluateProtectedPaths,
    evaluateNoDotenvCommit,  // ← nouveau
    classifyCommand,
  },
);

Puis dans guard-pipeline.ts, ajoutez l’appel à la position souhaitée — après protected paths mais avant la classification bash a du sens :

// Étape 3 : vérification no-dotenv-commit
if (toolName === "bash") {
  const dotenvVerdict = guards.evaluateNoDotenvCommit(toolName, input, config);
  if (dotenvVerdict.action === "block") {
    auditLog("dotenv-commit.block", "warning", { tool: "bash", command: input.command });
    if (ctx.hasUI) ctx.ui.notify(`🚫 Blocked: ${dotenvVerdict.reason}`, "warning");
    return { block: true, reason: dotenvVerdict.reason };
  }
}

Terminé. Le Guard est dans le pipeline, l’orchestrateur gère l’UI et l’audit, et la logique d’évaluation est une fonction pure que vous pouvez tester isolément.

Anatomie d’un Scanner

Les Scanners diffèrent des Guards d’une manière fondamentale : ils ne bloquent jamais. Ils observent les données, les transforment optionnellement, et rapportent. Le scanner de secrets est l’exemple canonique — il modifie le payload provider pour rédact les secrets, mais il n’empêche jamais un outil de s’exécuter.

Le hook before_provider_request

Le scanner de secrets se branche sur before_provider_request — l’événement qui se déclenche après que pi a construit le payload spécifique au provider mais avant de l’envoyer au LLM. Le handler reçoit le payload entier comme objet plain, peut le modifier, et renvoyer la version modifiée.

pi.on("before_provider_request", (event, _ctx) => {
  const redactions: Redaction[] = [];
  const payload = event.payload as Record<string, unknown>;

  walkAndRedact(payload, redactions);

  if (redactions.length > 0) {
    // Logger et notifier...
    return payload;  // Renvoyer le payload modifié
  }

  return undefined;  // Pas de changements — garder l'original
});

Construire un Scanner custom : rédaction de PII

Disons que votre organisation traite des données clients et que les adresses email ne doivent jamais atteindre le contexte LLM. Voici un scanner pour ça :

// lib/pii-scanner.ts
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import type { Config } from "./config.js";
import { auditLog } from "./audit.js";

const EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;

function redactEmails(value: string): { result: string; count: number } {
  let count = 0;
  const result = value.replace(EMAIL_PATTERN, (match) => {
    count++;
    return "***REDACTED:email***";
  });
  return { result, count };
}

function walkAndRedactEmails(obj: unknown, depth = 0): number {
  if (depth > 50 || obj === null || obj === undefined) return 0;

  if (typeof obj === "string") {
    const { result, count } = redactEmails(obj);
    if (count > 0) {
      // Les chaînes sont immuables dans le payload, donc on ne peut pas muter en place.
      // En pratique, il faut renvoyer l'objet modifié — voir walkAndRedact dans
      // secret-scanner.ts pour le pattern complet de mutation récursive.
      return count;
    }
    return 0;
  }

  let total = 0;
  if (Array.isArray(obj)) {
    for (let i = 0; i < obj.length; i++) {
      total += walkAndRedactEmails(obj[i], depth + 1);
    }
  } else if (typeof obj === "object") {
    for (const key of Object.keys(obj as Record<string, unknown>)) {
      total += walkAndRedactEmails((obj as Record<string, unknown>)[key], depth + 1);
    }
  }
  return total;
}

export function registerPiiScanner(pi: ExtensionAPI, _getConfig: () => Config): void {
  pi.on("before_provider_request", (event, _ctx) => {
    const payload = event.payload as Record<string, unknown>;
    const count = walkAndRedactEmails(payload);

    if (count > 0) {
      auditLog("pii.redacted", "warning", { patternName: "email", count });
    }

    return undefined;  // Dans une implémentation réelle, renvoyer le payload modifié
  });
}

Même pattern que le scanner de secrets : parcourir le payload, matcher les patterns, remplacer, logger. Le scanner est provider-agnostic — il ne sait pas et ne se soucie pas de savoir si le payload est au format Anthropic, OpenAI ou Google. Il trouve juste des chaînes et les transforme.

Écrire des tests — pas de mocks nécessaires

C’est ici que l’architecture en fonctions pures paie. Puisque chaque Guard est une fonction plain qui prend des données et renvoie des données, vous pouvez la tester avec de simples assertions Node.js. Pas de frameworks de mock. Pas de fausses instances pi. Pas de clés API.

Le helper makeConfig

Chaque fichier de test commence de la même façon — un helper qui construit un objet Config valide avec des defaults sensés, surchargeable par test :

function makeConfig(overrides: Partial<Config> = {}): Config {
  return {
    cwd: "/home/user/project",
    protectedPaths: { patterns: [], writeAction: "block", readAction: "confirm" },
    commandRules: { safe: [], moderate: [], dangerous: [], external: [] },
    allowedExternal: { paths: [] },
    audit: { maxFileSize: 10_000_000, maxFiles: 3 },
    ...overrides,
  };
}

C’est le seul boilerplate dont vous aurez jamais besoin.

Un vrai test

Voici un test réel de la suite — boundary.test.ts :

import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { evaluateBoundary } from "../lib/boundary.js";
import type { Config } from "../lib/config.js";

describe("evaluateBoundary", () => {
  it("blocks write outside boundary", () => {
    const config = makeConfig();
    const result = evaluateBoundary(
      "write",
      { path: "/home/user/other-project/file.ts" },
      config,
    );
    assert.equal(result.action, "block");
  });

  it("confirms read outside boundary", () => {
    const config = makeConfig();
    const result = evaluateBoundary(
      "read",
      { path: "/home/user/other-project/file.ts" },
      config,
    );
    assert.equal(result.action, "confirm");
  });

  it("allows write outside boundary if in allowed-external", () => {
    const config = makeConfig({
      allowedExternal: { paths: ["/tmp"] },
    });
    const result = evaluateBoundary("write", { path: "/tmp/output.txt" }, config);
    assert.equal(result.action, "allow");
  });

  it("allows bash commands (ADR-0003)", () => {
    const config = makeConfig();
    const result = evaluateBoundary("bash", { command: "rm -rf /" }, config);
    assert.equal(result.action, "allow");
  });
});

Quatre tests. Quatre assertions. Zéro mock. Chaque test crée une config, appelle la fonction, vérifie le verdict. C’est tout.

Tester le Guard custom

Pour notre Guard no-dotenv-commit, les tests s’écrivent tout seuls :

describe("evaluateNoDotenvCommit", () => {
  it("allows non-bash tools", () => {
    const result = evaluateNoDotenvCommit("write", { path: ".env" }, makeConfig());
    assert.equal(result.action, "allow");
  });

  it("allows git commit without .env", () => {
    const result = evaluateNoDotenvCommit(
      "bash",
      { command: "git commit -m 'fix: typo'" },
      makeConfig(),
    );
    assert.equal(result.action, "allow");
  });

  it("blocks git commit with .env reference", () => {
    const result = evaluateNoDotenvCommit(
      "bash",
      { command: "git commit .env.production -m 'add env'" },
      makeConfig(),
    );
    assert.equal(result.action, "block");
  });
});

Lancer la suite complète

npm test
▶ splitCommand
  ✔ splits by pipe
  ✔ extracts subshells
  ✔ handles no pipes or subshells
  ✔ handles multiple pipes
✔ splitCommand
▶ classifySegment
  ✔ classifies ls as safe
  ✔ classifies npm as moderate
  ✔ classifies rm -rf as dangerous
  ✔ classifies curl as external
  ✔ returns null for unknown commands
✔ classifySegment
▶ evaluateBoundary
  ✔ allows bash commands (ADR-0003)
  ✔ blocks write outside boundary
  ✔ confirms read outside boundary
  ✔ allows write outside boundary if in allowed-external
  ...
▶ redactString
  ✔ redacts AWS access keys
  ✔ redacts Anthropic keys
  ✔ redacts private key headers
  ✔ redacts GitHub tokens
  ✔ skips comment lines entirely
  ...
ℹ tests 98
ℹ suites 15
ℹ pass 98
ℹ fail 0
ℹ duration_ms 171ms

98 tests en 171ms. Pas d’appels réseau. Pas de clés API. Pas d’attentes flaky. Les fonctions pures signifient des tests déterministes et instantanés.

Le bug que les tests ont attrapé

La suite de tests a attrapé un vrai bug pendant le développement. Voici l’histoire.

Le scanner de secrets a une mitigation des faux positifs — il ignore les lignes qui ressemblent à des commentaires. L’implémentation initiale vérifiait si une ligne commence par -- :

// VERSION BUGGUÉE
function isCommentLine(text: string): boolean {
  const trimmed = text.trim();
  if (trimmed.startsWith("--")) return true;  // ← attrape trop de choses
  ...
}

Ça marchait pour les commentaires SQL (-- comment) et les commentaires INI (--password=secret). Mais ça attrapait aussi quelque chose qui ne devrait pas l’être :

-----BEGIN RSA PRIVATE KEY-----

Les en-têtes PEM de clés privées commencent par -----, qui commence par --. La fonction isCommentLine renvoyait true, le scanner sautait la ligne, et les en-têtes de clés privées n’étaient jamais rédactées.

Le test l’a attrapé immédiatement :

it("redacts private key headers", () => {
  const { result } = redactString("-----BEGIN RSA PRIVATE KEY-----");
  assert.ok(result.includes("***REDACTED:private-key***"));  // ← ÉCHOUÉ
});

Le correctif : exiger que -- soit suivi d’un espace ou d’une fin de ligne, distinguant les commentaires SQL (-- comment) des en-têtes PEM (-----BEGIN) :

// VERSION CORRIGÉE
if (/^--(?:\s|$)/.test(trimmed)) return true;

C’est exactement pourquoi on teste les edge cases. Le bug était subtil, le correctif tenait en une ligne, et le test garantit qu’il ne régressera jamais.

Patterns pratiques et pièges

L’ordre compte

Les Guards s’exécutent dans une séquence fixe. Un guard qui bloque doit venir avant un qui confirme, quand les deux pourraient s’appliquer au même appel d’outil. Dans pi-secured-setup :

boundary → protected-paths → bash-gate

Boundary est vérifié en premier car c’est le filtre le plus large — “ce fichier est-il même dans le bon projet ?” Protected paths vient en second car c’est un check plus spécifique — “ce fichier est-il sensible ?” Bash gate est dernier car il ne s’applique qu’à un seul outil.

Si vous ajoutez un guard custom, réfléchissez à sa place dans cette progression.

Ne pas sur-bloquer

Utilisez block pour les dangers clairs. Utilisez confirm pour les zones grises. Un guard qui bloque tout sans recours sera désactivé — et alors vous n’aurez plus aucune protection.

Le bash gate applique bien ce principe :

CatégorieVerdictRaisonnement
SAFEallowPas de risque, auto-approuver
MODERATEallowRisque faible, auto-approuver mais logger
DANGEROUSconfirmRisque réel, laisser l’utilisateur décider
EXTERNALconfirmDonnées qui quittent la machine, laisser l’utilisateur décider
InconnueconfirmImpossible d’évaluer le risque, laisser l’utilisateur décider

Seules deux catégories bloquent réellement sans demander : les verdicts block dans boundary (écriture hors projet) et protected paths (écriture vers .env). Tout le reste laisse le choix à l’utilisateur.

Performance des regex

Gardez les patterns simples. Le scanner de secrets parcourt chaque chaîne du payload provider entier à chaque tour. Un regex pathologique sur un gros payload ralentira chaque appel LLM. Testez vos patterns contre des entrées réalistes — pas seulement les matches, mais aussi contre les chaînes qui presque matchent.

La limite de profondeur

La fonction walkAndRedact a une limite de profondeur de 50 niveaux. Ça empêche la récursion infinie sur les références circulaires (qui ne devraient pas exister dans un payload JSON, mais le codage défensif ne coûte rien). Si votre scanner custom parcourt des objets profondément imbriqués, envisagez d’ajouter une garde similaire.

Conclusion

L’extension est conçue pour être étendue. Chaque guard est une fonction pure — (toolName, input, config) → verdict — sans dépendance pi. L’orchestrateur du pipeline gère la partie complexe. Les Scanners suivent la même philosophie : observer, transformer, rapporter. Ne jamais bloquer.

Cette architecture signifie que vous pouvez ajouter vos propres règles de sécurité sans comprendre les entrailles de pi. Écrivez une fonction. Écrivez des tests pour elle. Branchez-la dans le pipeline. Les 98 tests existants sont votre filet de sécurité — ils garantissent que la logique principale ne casse pas quand vous étendez.

Les sources sont sur GitHub. Si vous construisez un guard ou scanner dont d’autres pourraient bénéficier — un scanner PII, un check de conformité PCI-DSS, un classifieur de commandes custom — je serais ravi de voir une PR.

Quel guard ou scanner écririez-vous pour votre équipe ? Dites-le-moi en commentaire !