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
guardrails/: l'adaptateur, ses tests, les profils de règles et les manifestes, avec un README détaillé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.
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
Comments
Join the discussionNo comments yet. Be the first to comment.
IA agentique : vers Azure et au-delà !
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.





