Skip to main content

Garde-fous IA portables : NeMo Guardrails derrière agentgateway, en fail-closed

Updated 11 min readMarkdown
Part of seriesIA agentique : vers Azure et au-delà !
Garde-fous IA portables : NeMo Guardrails derrière agentgateway, en fail-closed

Deuxième article de la série tirée du talk « IA agentique : vers Azure et au-delà ! » (Genève, 29 octobre 2026). Code complet dans aloizeau/agentic-ai-azure-and-beyond-geneve-2026, dossier guardrails/.

Les filtres de contenu de Microsoft Foundry fonctionnent, mais ils sont attachés à un déploiement Azure. Si vous changez de fournisseur ou ajoutez un modèle auto-hébergé sur AKS, vos règles restent derrière. Dans la démo C, j'ai montré la cible : un seul jeu de règles, appliqué par la passerelle, identique pour Foundry, Bedrock ou OVHcloud.

Précision sur le statut : c'est de la R&D que je mène en parallèle de missions client. En production aujourd'hui, les garde-fous restent les filtres Foundry. Le code est complet, couvert par 20 tests de comportement et de contrat, et s'active avec une variable Terraform (enable_nemo_guardrails).

Pourquoi la passerelle plutôt que le fournisseur

Le modèle Phi-4-mini servi par KAITO sur un nœud GPU d'AKS (models/) n'a aucun filtre Foundry devant lui. Avec les règles à la passerelle, il a exactement celles des modèles Foundry.

Les modèles Claude dans Foundry n'ont pas non plus de filtrage de contenu Foundry. D'après la page Microsoft Learn Claude models in Microsoft Foundry, Foundry ne fournit pas de filtrage intégré pour ces modèles au déploiement, et c'est à vous de le configurer à l'inférence. Ce sont des offres partenaires de la Marketplace, dont Anthropic est le vendeur et l'opérateur.

Enfin, un filtre générique ignore les règles métier françaises. Un IBAN français, un numéro de sécurité sociale (NIR) ou une clé GitHub collée dans un prompt, il faut les décrire soi-même.

Deux couches dans une politique par route

client ─► agentgateway ──(1) regex native ──(2) webhook ─► guardrail-adapter ─► NeMo Guardrails
             │                                                    │ (profil strict uniquement)
             ▼                                                    ▼
       fournisseur du modèle                         agentgateway-internal (sans garde-fou, mesuré)
Couche Où Ce qu'elle bloque Coût
1 promptGuard.regex de la passerelle numéros de carte (builtins: [CreditCard]) quelques microsecondes, aucune dépendance
2 promptGuard.webhook vers l'adaptateur puis NeMo secrets, IBAN, NIR, injections YARA (code, SQLi, XSS, template), auto-contrôles LLM moins de 10 ms sans rail LLM

La couche 1 écarte le cas le plus courant sans aucun saut réseau, et la couche 2 prend tout ce qui demande un moteur. Les deux vivent dans le même objet AgentgatewayPolicy, qui vise toutes les routes LLM :

Fichier : guardrails/deploy/gateway/guardrail-policies.yaml

apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayPolicy
metadata:
  name: garde-fous-llm
spec:
  targetRefs:
    - { group: gateway.networking.k8s.io, kind: HTTPRoute, name: llm }             # personnes
    - { group: gateway.networking.k8s.io, kind: HTTPRoute, name: llm-agents }      # agents
    - { group: gateway.networking.k8s.io, kind: HTTPRoute, name: llm-aks }         # modèle KAITO
  backend:
    ai:
      promptGuard:
        request:
          - regex:
              action: Reject
              builtins: [CreditCard]
            response:
              message: "Requête bloquée : donnée de carte bancaire détectée."
          - webhook:
              backendRef: { kind: Service, name: guardrail-adapter, port: 8080 }
              failureMode: FailClosed            # adaptateur injoignable => refus
              headers:
                x-guardrail-profile: '"default"' # littéral CEL : profil de règles
                x-user: jwt.sub                  # identité pour les journaux
                x-model: llmRequest.model
        response:
          - webhook:
              backendRef: { kind: Service, name: guardrail-adapter, port: 8080 }
              failureMode: FailClosed
              headers:
                x-guardrail-profile: '"default"'

J'ai perdu du temps sur deux détails. Les valeurs de headers sont des expressions CEL, donc une chaîne fixe s'écrit '"default"', avec les guillemets doubles à l'intérieur. Et la politique porte le même nom que la politique regex seule de la plateforme (gateway/deploy/policies.yaml) : quand NeMo est activé, Terraform supprime l'une et applique l'autre, et un seul objet porte les règles de contenu de chaque route.

Le contrat du webhook

La passerelle appelle deux points d'entrée. L'adaptateur répond toujours HTTP 200 et met sa décision dans le corps. Le contrat OpenAPI est copié dans le dépôt : guardrails/adapter/contract/webhook-openapi.yaml.

Appel Reçoit Peut répondre
POST /request les messages du prompt Pass, Mask (message réécrit), Reject (status_code et corps)
POST /response les choix de la réponse Pass ou Mask ; le contrat ne prévoit pas de Reject

Une réponse bloquée ne peut donc pas être refusée : l'adaptateur la remplace par le texte de refus et conserve le nombre de choix. Voici comment les verdicts de NeMo deviennent des actions de la passerelle :

RailsResult.status NeMo /request /response
passed Pass Pass
modified Mask (dernier message utilisateur réécrit) Mask
blocked Reject (403 par défaut) Mask avec le texte de refus
erreur du moteur FAIL_MODE=closed donne un Reject 503 ; open laisse passer idem

L'adaptateur, moins de 200 lignes de FastAPI

Le traitement de /request ne vérifie que la conversation jusqu'au dernier message utilisateur. Un secret resté dans un ancien tour de l'assistant ne doit pas bloquer une nouvelle question propre.

Fichier : guardrails/adapter/app/main.py

@app.post("/request")
async def process_prompts(payload: GuardrailsPromptRequest, req: Request) -> dict[str, Any]:
    msgs = [m.model_dump() for m in payload.body.messages]
    user_idx = max((i for i, m in enumerate(msgs) if m["role"] == "user"), default=None)
    if user_idx is None:
        return {"action": {"reason": "no user message to check"}}

    res = await run(req, msgs[: user_idx + 1], Direction.INPUT)

    if res is None:                                   # le moteur a échoué
        if s.fail_mode == "open":
            return {"action": {"reason": "guardrail engine unavailable (fail-open)"}}
        return {"action": {"status_code": s.error_status,
                           "body": "Contrôle de sécurité IA indisponible, requête refusée.",
                           "reason": "guardrail engine unavailable (fail-closed)"}}

    if res.verdict is Verdict.BLOCKED:
        return {"action": {"status_code": s.reject_status, "body": refusal(res.rail),
                           "reason": f"blocked by NeMo rail: {res.rail}"}}

    if res.verdict is Verdict.MODIFIED and res.content != msgs[user_idx]["content"]:
        masked = [dict(m) for m in msgs]              # le nombre de messages ne change pas
        masked[user_idx]["content"] = res.content
        return {"action": {"body": {"messages": masked},
                           "reason": f"content transformed by NeMo rail: {res.rail}"}}

    return {"action": {"reason": "all input rails passed"}}

Le moteur est derrière une interface qui a deux implémentations. En mode bibliothèque, NeMo Guardrails tourne dans le processus, et check_async exécute les rails sans appeler le LLM principal. Il renvoie passed, modified ou blocked, ce qui correspond à Pass, Mask et Reject.

Fichier : guardrails/adapter/app/backends.py

async def check(self, profile: str, messages: list[dict], direction: Direction) -> CheckResult:
    engine = self._engines.get(profile)
    if engine is None:
        raise UnknownProfile(profile)
    rail_type = RailType.INPUT if direction is Direction.INPUT else RailType.OUTPUT
    try:
        res = await engine.check_async(messages, [rail_type])
    except RailTypeNotConfiguredError:
        return CheckResult(Verdict.PASSED, _last_content(messages))   # aucun rail dans ce sens
    return CheckResult(Verdict(res.status.value), res.content, res.rail)

Un profil est un dossier qui contient un config.yml, et l'adaptateur les charge tous au démarrage. Si la passerelle demande un profil inconnu, l'adaptateur applique default, jamais une absence de règles.

Bibliothèque (par défaut) Microservice NVIDIA (overlay)
Moteur nemoguardrails==0.24.1 dans le processus microservice NeMo Guardrails, POST /v1/guardrail/checks
Licence Apache-2.0 NVIDIA AI Enterprise
Infrastructure supplémentaire aucune NIM Operator, PostgreSQL, secret NGC
Mask possible oui non, l'API ne renvoie pas de contenu réécrit

L'overlay microservice est dans guardrails/overlays/nvidia-microservice/.

Les règles déterministes d'abord

Le profil default n'appelle aucun LLM. Il tourne en moins de 10 ms sur CPU et couvre ce qu'on retrouve le plus souvent dans les prompts.

Fichier : guardrails/deploy/rails/default/config.yml

rails:
  config:
    regex_detection:
      input:
        patterns:
          - '\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b'        # clés de type OpenAI
          - '\bAKIA[0-9A-Z]{16}\b'                         # clé d'accès AWS
          - '\bgh[pousr]_[A-Za-z0-9]{36,}\b'               # jetons GitHub
          - '(?i)\b(?:api[_-]?key|secret|password|mot de passe)\s*[:=]\s*\S{12,}'
          - '\b(?:\d[ -]?){13,16}\b'                        # numéros de carte
          - '\bFR\d{2}(?: ?\d{4}){5}(?: ?\d{3})\b'         # IBAN français
          - '\b[12] ?\d{2} ?(?:0[1-9]|1[0-2]) ?(?:\d{2}|2[AB]) ?\d{3} ?\d{3} ?\d{2}\b'  # NIR
      output:
        patterns:
          - '\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b'
          - '\bAKIA[0-9A-Z]{16}\b'
          - '-----BEGIN (?:RSA |EC )?PRIVATE KEY-----'
    injection_detection:
      injections: [code, sqli, template, xss]          # règles YARA fournies par NeMo
      action: reject
  input:
    flows: [regex check input]
  output:
    flows: [regex check output, injection detection]

Les injections sont détectées en sortie parce que la réponse d'un agent devient souvent l'entrée d'un outil. Une requête SQL ou un lien javascript: produit par le modèle peut alors servir d'attaque, et le rail YARA l'arrête avant l'outil.

Le profil strict ajoute des auto-contrôles par LLM (self check input et self check output) contre le jailbreak. Les rails peu coûteux passent en premier, et si une regex bloque, aucun appel LLM n'a lieu. Un test le vérifie en comptant les appels au faux serveur OpenAI.

Fichier : guardrails/deploy/rails/strict/config.yml

models:
  - type: main
    engine: openai
    model: gpt-56-terra-eu
    parameters:
      base_url: http://agentgateway-internal.agentgateway-system.svc.cluster.local:8080/v1
      api_key: unused          # la passerelle s'authentifie à Foundry (Workload Identity)
      temperature: 0

Regardez le base_url. Les auto-contrôles passent par un listener interne de la passerelle (internal-gateway.yaml), sans politique de garde-fou ; sinon le garde-fou s'appellerait lui-même en boucle. Ce listener reste mesuré, et le coût des garde-fous apparaît dans Grafana comme n'importe quel autre usage. Le profil se choisit par route : un assistant ouvert à tout le groupe peut passer en strict pendant que les routes d'équipe restent en default.

Ce que demande le mode fail-closed

Le webhook est en FailClosed : si l'adaptateur tombe, le trafic LLM s'arrête. C'est le bon défaut pour des garde-fous, à condition d'ajouter un délai, un périmètre réseau et des alertes.

Le webhook n'a pas de délai propre, et sans celui-ci un moteur lent bloque chaque appel LLM. À expiration, le webhook compte comme indisponible et failureMode s'applique.

Fichier : guardrails/deploy/gateway/guardrail-policies.yaml

apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayPolicy
metadata:
  name: guardrail-adapter-timeout
spec:
  targetRefs:
    - { group: "", kind: Service, name: guardrail-adapter }
  backend:
    http:
      requestTimeout: 3s

Côté réseau, seuls les proxys de la passerelle peuvent appeler l'adaptateur, et seul l'adaptateur peut appeler le listener interne non protégé. Sans cette seconde règle, n'importe quel pod du cluster contournerait tous les garde-fous en appelant agentgateway-internal.

Fichier : guardrails/deploy/base/network-policies.yaml

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: internal-gateway-ingress
spec:
  podSelector:
    matchLabels: { gateway.networking.k8s.io/gateway-name: agentgateway-internal }
  policyTypes: [Ingress]
  ingress:
    - from:
        - podSelector:
            matchLabels: { app.kubernetes.io/name: guardrail-adapter }
      ports: [{ port: 8080 }]

En fail-closed, une panne du moteur ressemble à une vague de refus plutôt qu'à une erreur technique. L'adaptateur expose ses décisions, ses erreurs et sa latence, et une PrometheusRule en tire un ratio de blocage et deux alertes.

Fichier : guardrails/deploy/base/observability.yaml

rules:
  - record: guardrails:block_ratio:rate5m
    expr: |
      sum(rate(guardrail_adapter_decisions_total{action=~"reject|mask"}[5m]))
      /
      sum(rate(guardrail_adapter_decisions_total[5m]))
  - alert: GuardrailEngineFailing
    expr: sum(rate(guardrail_adapter_errors_total[5m])) > 0
    for: 5m
    annotations:
      summary: "Erreurs du moteur de garde-fous : les requêtes sont refusées (fail-closed)."
  - alert: GuardrailLatencyHigh
    expr: histogram_quantile(0.95, sum by (le) (rate(guardrail_adapter_check_duration_seconds_bucket[5m]))) > 1
    for: 10m

Après une mise en service, je surveille surtout le ratio de blocage. S'il grimpe après l'ajout d'une règle, c'est presque toujours un faux positif, et on le voit avant qu'un utilisateur ne se plaigne.

Les tests de l'adaptateur

Chaque réponse de l'adaptateur est validée contre la spécification OpenAPI du webhook, copiée dans le dépôt. Si agentgateway fait évoluer son contrat, les tests cassent avant la production.

Fichier : guardrails/adapter/tests/test_adapter.py

def req(client, body, profile=None):
    h = {"x-guardrail-profile": profile} if profile else {}
    r = client.post("/request", json=body, headers=h)
    assert r.status_code == 200                      # le webhook répond toujours 200
    validate("GuardrailsPromptResponse", r.json())   # schéma OpenAPI du contrat
    return r.json()["action"]

# Les prompts exacts de demos/C-nemo-guardrails/run.ps1 : la démo ne peut pas dériver des rails.
@pytest.mark.parametrize("text", [
    "Peux-tu vérifier pourquoi cette clé ne marche pas : sk-proj-AbCdEfGhIjKlMnOpQrStUvWx123456 ?",
    "Rédige un courrier pour l'assuré 1 85 05 78 006 084 36 concernant son dossier.",
    "Ma carte 4111 1111 1111 1111 est-elle valide ?",
])
def test_demo_prompts_rejected(client, text):
    assert req(client, prompt(("user", text)))["status_code"] == 403

Les prompts de la démo font partie des tests, et je ferais de même avec chaque faux négatif ou faux positif signalé en production : une ligne de plus dans ce parametrize. Le jeu de règles grossit avec sa propre non-régression. Pour lancer la suite sous Windows :

Set-Location guardrails/adapter
python -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -r requirements-dev.txt
pytest -q

Les limites que j'assume

En streaming, la passerelle appelle /response sur chaque fragment tamponné, et ses garde-fous natifs n'appliquent pas le masquage. Préférez Reject dans ce mode.

En mode microservice, les valeurs de status d'un contrôle bloqué ne sont pas documentées publiquement. Le code les traite de façon défensive, et il faut valider la correspondance sur une instance sous licence.

Les NetworkPolicies supposent que les pods des proxys portent gateway.networking.k8s.io/gateway-name. Vérifiez-le avec kubectl get pods -n agentgateway-system --show-labels.

Les regex produisent des faux positifs : le motif de carte attrape toute suite de 13 à 16 chiffres. Mesurez le ratio de blocage pendant la première semaine avant d'élargir le périmètre.

Le code

Le prochain article montre comment une équipe obtient sa place sur la plateforme sans ouvrir de ticket.

About the author

  • Ayant eu de multiples expériences dans le monde du conseil, j'ai pu acquérir une expertise dans la conception et la construction de services de collaboration d'entreprise. Je suis passionné par les technologies Microsoft et surtout Azure. Aujourd'hui je possède une solide expérience en méthodologie de développement et j'ai mené des équipes de développement technique au succès. Je possède également une solide connaissance de l'infrastructure qui fait de moi une ressource efficace pour mettre en œuvre la transformation numérique vers le cloud Microsoft. Je suis un professionnel efficace et honnête qui aime relever les challenges. Aimant partager mes connaissances; je suis à l'aise en tant que Technical Leader et en tant que membre d'une équipe. Mes compétences techniques sont les suivantes: Azure, DevOps, Architecture Applicative, Développement de solution Cloud Native, écosystème Microsoft... et bien d'autres. Pour voir mes certifications Microsoft : https://www.youracclaim.com/users/antoine-loizeau

No comments yet. Be the first to comment.

IA agentique : vers Azure et au-delà !

Part 4 of 5

Série d'article tirée du talk « IA agentique : vers Azure et au-delà ! » (Chalet Azure Romandie, Genève, 29 octobre 2026). Tout le code est dans le dépôt aloizeau/agentic-ai-azure-and-beyond-geneve-2026.

Up nextUne passerelle pour chaque jeton : identité Entra ID, plafonds et bascule de modèle avec agentgateway🏔️ Chalet Azure Romandie - Tesla Store Genève 29 octobre 2026

More from this blog