# 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](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026), dossier [`guardrails/`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/tree/main/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/`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/tree/main/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`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/guardrails/deploy/gateway/guardrail-policies.yaml)

```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`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/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`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/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`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/guardrails/adapter/app/main.py)

```python
@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`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/guardrails/adapter/app/backends.py)

```python
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/`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/tree/main/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`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/guardrails/deploy/rails/default/config.yml)

```yaml
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`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/guardrails/deploy/rails/strict/config.yml)

```yaml
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`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/guardrails/deploy/gateway/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`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/guardrails/deploy/gateway/guardrail-policies.yaml)

```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`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/guardrails/deploy/base/network-policies.yaml)

```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`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/guardrails/deploy/base/observability.yaml)

```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`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/guardrails/adapter/tests/test_adapter.py)

```python
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 :

```powershell
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

- [`guardrails/`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/tree/main/guardrails) : l'adaptateur, ses tests, les profils de règles et les manifestes, avec un [README](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/guardrails/README.md) détaillé
- [`demos/C-nemo-guardrails/`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/tree/main/demos/C-nemo-guardrails) : la démo avant/après, avec l'option panne

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