Hors de la boîte : pourquoi le LLM ne doit pas vivre dans le firewall
🇬🇧 English version 💻 Code source (AGPL) : github.com/patlegu/cyber-agent-engine
Une question de topologie, pas seulement de sécurité
Les trois premiers articles de cette série ont détaillé comment cyber-agent-engine
encadre un LLM qui pilote un pare-feu de production : jetons plutĂ´t que valeurs
réelles (article 2), politique fail-closed et
approbation humaine (article 3). Celui-ci s’attaque Ă
une question antĂ©rieure, presque architecturale avant d’ĂŞtre sĂ©curitaire : oĂą
ce LLM doit-il physiquement s’exĂ©cuter ? Sur l’Ă©quipement qu’il pilote, ou Ă
distance, de l’autre cĂ´tĂ© d’un appel rĂ©seau ?
La rĂ©ponse n’est pas arbitraire — elle a dĂ©jĂ Ă©tĂ© mesurĂ©e, dans l’autre sens, par un projet prĂ©cĂ©dent de ce blog.
Le contraste : ce que l’approche in-box a mesurĂ©
opnsense-ai-firewall (OAF) est un PoC qui pousse
la logique inverse jusqu’au bout : le LLM tourne embarquĂ© Ă l’intĂ©rieur mĂŞme
de la VM OPNsense. Pas de sidecar, pas d’appel rĂ©seau vers un service externe —
un llama-server compilé natif pour FreeBSD, avec un adaptateur LoRA fine-tuné,
tournant sur le mĂŞme noyau que le pf qui filtre le trafic, et pilotant
directement l’API REST du firewall depuis une intention en langage naturel. Le
PoC fonctionne — 101 fonctions sur 102 validĂ©es, latences mesurĂ©es — et c’est
prĂ©cisĂ©ment parce qu’il fonctionne que ses propres conclusions comptent : ses
docs listent, méthodiquement, quatre raisons pour lesquelles cette topologie ne
doit jamais aller en production.
Surface d’attaque. OAF chiffre lui-mĂŞme le coĂ»t : la stack
llama.cpp/ggml/OpenBLAS ajoutĂ©e pèse environ 125 Mo de code supplĂ©mentaire Ă
auditer, patcher et surveiller, sur une base OPNsense minimale d’environ 600 Mo
— une hausse de l’ordre de 20 % de la surface de code d’un Ă©quipement dont la
doctrine, historiquement, est justement de rester minimaliste. Chaque CVE dans
cette stack devient une CVE du firewall lui-mĂŞme.
Contention CPU. L’infĂ©rence mesurĂ©e par OAF prend environ 10 secondes sur
les cœurs de la VM — les mêmes cœurs qui font tourner pf. Pendant ce temps, le
filtrage continue, mais en contention, et la latence réseau du plan de données
s’en ressent directement. Un firewall n’a normalement aucune raison de voir sa
capacitĂ© de filtrage varier selon qu’un modèle de langage est ou non en train de
« réfléchir ».
Cycle de vie couplé. llama.cpp évolue à un rythme hebdomadaire ; OPNsense
publie des versions stables Ă un rythme bien plus lent. Embarquer le premier
dans le second, c’est forcer deux cadences de mise Ă jour incompatibles Ă
cohabiter dans le mĂŞme artefact de dĂ©ploiement — chaque mise Ă jour de l’un
devient un risque de rĂ©gression pour l’autre.
Audit compromis. Quand un LLM modifie directement config.xml, il n’est
pas, au sens compliance, un acteur identifiable de la mĂŞme façon qu’un
administrateur nommĂ© passant par une console d’audit. OAF note lui-mĂŞme que le
garde-fou existant à ce niveau (--confirm côté agent, log local) est un
contrĂ´le opĂ©rateur, pas une piste d’audit lĂ©gale ni un rempart anti-malveillant.
Ces quatre points ne sont pas une critique externe portée sur OAF : ce sont
ses propres conclusions, obtenues en poussant le PoC jusqu’au bout pour
pouvoir les mesurer plutĂ´t que les supposer. C’est prĂ©cisĂ©ment ce travail qui
rend la conclusion inverse — celle que cyber-agent-engine met en œuvre —
actionnable plutĂ´t que dogmatique.
La réponse externe : rien ne rentre dans la boîte
cyber-agent-engine inverse la topologie terme Ă terme. Le composant qui
« réfléchit » — le CoordinatorLLM de coordinator/llm/coordinator_llm.py, qui
propose des intentions à partir du contexte tokenisé — ne tourne jamais sur
l’Ă©quipement rĂ©seau qu’il pilote. Il tourne sur l’hĂ´te du coordinateur, quel
qu’il soit, et ne parle au firewall qu’au travers d’un appel HTTPS vers son API
REST, sur le rĂ©seau LAN. Terme Ă terme, les quatre objections d’OAF
s’Ă©vanouissent :
- Surface d’attaque : le firewall ne gagne aucun code, aucune dĂ©pendance, aucun binaire supplĂ©mentaire. Il reste l’Ă©quipement OPNsense qu’il Ă©tait, interrogĂ© par un client HTTP externe — exactement le mĂŞme genre de trafic qu’un tableau de bord de supervision ou un script d’automatisation classique.
- Contention CPU : l’infĂ©rence consomme les cycles CPU/GPU de l’hĂ´te du
coordinateur, jamais ceux qui filtrent le trafic réseau.
pfne partage rien avec le LLM. - Cycle de vie : le coordinateur se dĂ©ploie, se met Ă jour et se redĂ©marre indĂ©pendamment du firmware OPNsense. Une mise Ă jour du backend de raisonnement n’implique aucune fenĂŞtre de maintenance sur le firewall.
- Audit : chaque appel Ă l’API OPNsense passe par la frontière dĂ©crite dans les articles 1 et 3 — politique fail-closed, approbation humaine sur les capacitĂ©s sensibles, journal d’audit bornĂ©, jetons plutĂ´t que valeurs rĂ©elles. L’acteur qui modifie la configuration n’est plus un processus opaque Ă l’intĂ©rieur du firewall : c’est un appel HTTP authentifiĂ©, tracĂ© de bout en bout, dont chaque dĂ©cision a laissĂ© une entrĂ©e d’audit avant exĂ©cution.
Le firewall redevient ce qu’il devrait toujours ĂŞtre : un Ă©quipement rĂ©seau qui expose une API, rien de plus. Toute l’intelligence — et tout le risque qu’elle porte — se dĂ©place de l’autre cĂ´tĂ© du câble.
Portabilité : un cœur sans GPU, des backends interchangeables
Sortir le LLM de la boĂ®te n’a de valeur que si le composant qui l’hĂ©berge n’est
pas lui-mĂŞme figĂ© sur une machine particulière. C’est le deuxième bĂ©nĂ©fice de
la topologie externe, et il se lit directement dans pyproject.toml : les
dépendances de base du paquet —
dependencies = [
"pydantic>=2.0.0",
"httpx>=0.25.0",
"pyyaml>=6.0",
"jinja2>=3.0.0",
"cryptography>=42.0.0",
"fastapi>=0.110.0",
"requests>=2.31.0",
"anthropic>=0.40.0",
"uvicorn>=0.30.0",
]
— ne contiennent ni torch, ni vllm, ni aucun poids de modèle. torch,
vllm et unsloth sont isolés dans un extra optionnel, [gpu] :
[project.optional-dependencies]
gpu = [
"torch>=2.1.0",
"vllm>=0.6.0",
"unsloth",
]
clients/gpu.py matérialise cette frontière dans le code : le chargeur vLLM
in-process n’est jamais importĂ© au chargement d’un module, uniquement Ă la
demande, et si l’extra [gpu] n’est pas installĂ©, l’Ă©chec est un
GpuExtraRequired explicite plutĂ´t qu’un ImportError opaque au milieu d’une
stack trace. Le cœur du coordinateur — politique, tokenisation, boucle gatée,
audit — s’installe et tourne sans jamais toucher une carte graphique.
Le corollaire de ce cĹ“ur allĂ©gĂ©, c’est que le backend de raisonnement devient
un paramètre, pas une dépendance structurelle. coordinator/llm/coordinator_llm.py
le documente sans ambiguïté :
Backends configurables via COORDINATOR_BACKEND :
- "anthropic" : Claude API (claude-sonnet-4-6) — recommandé, aucun service local requis
- "openai" : API OpenAI-compatible — fonctionne aussi avec vLLM HTTP (port 8000)
- "vllm" : NativeVLLMClient direct (instance séparée, charge Qwen2.5-7B)
- "ollama" : Ollama local (si disponible)
Et dans le code, la sélection est un simple branchement sur une variable
d’environnement, avec anthropic en valeur par dĂ©faut :
COORDINATOR_BACKEND = os.getenv("COORDINATOR_BACKEND", "anthropic")
async def init(self) -> None:
"""Initialise le backend LLM sélectionné."""
if self._backend == "anthropic":
self._init_anthropic()
elif self._backend == "openai":
self._init_openai_client()
elif self._backend == "vllm":
await self._init_vllm()
else: # ollama
self._http = httpx.AsyncClient(
base_url=OLLAMA_BASE_URL,
timeout=httpx.Timeout(120.0),
)
Quatre chemins, une seule interface publique (chat(messages, max_tokens)), et
aucun des trois autres appelants du coordinateur — proposer, boucle, audit — n’a
besoin de savoir lequel est actif. En pratique, ça veut dire qu’on peut dĂ©marrer
un dĂ©ploiement sur l’API Anthropic (zĂ©ro service local, latence de quelques
secondes, comme évoqué dans un article précédent sur les compromis
OpenRouter/LoRA de ce
blog), puis basculer vers un vLLM auto-hébergé ou un Ollama local pour des
raisons de souveraineté ou de coût, sans toucher une ligne du core/, de la
politique ou de la boucle gatée. Le raisonnement est un module remplaçable ;
la confiance structurelle ne l’est pas.
Cette portabilitĂ© tient aussi Ă ce que cyber-agent-engine ne dĂ©pend pas d’un
pipeline d’entraĂ®nement privĂ© pour fonctionner. Le repo public sur lequel
repose tout cet article s’auto-suffit : coordinator/, core/, clients/ et
agents/ sont les seuls modules dont dĂ©pend le raisonnement, et aucun d’eux
n’importe de paquet d’entraĂ®nement LoRA propriĂ©taire. La fabrication des
adaptateurs LoRA — quand on choisit malgré tout le backend vllm avec un
modèle fine-tuné — est un sujet de dépôt séparé, découplé : le coordinateur ne
sait rien de la façon dont un poids a été produit, il consomme une interface de
chat gĂ©nĂ©rique. C’est ce dĂ©couplage, autant que l’absence de GPU dans le cĹ“ur,
qui rend le choix du backend réversible plutôt que figé au moment du design.
Cibler un vrai firewall : un client REST comme un autre
Concrètement, comment le coordinateur atteint-il l’Ă©quipement ? Pas par un
protocole maison, ni par un accès privilégié quelconque — par un client HTTP
standard, OPNsenseAPIClient (clients/opnsense_api_client.py), dont le
constructeur ne cache aucune magie :
def __init__(
self,
base_url: str,
api_key: str,
api_secret: str,
verify_ssl: bool = True,
timeout: int = 30
):
self.base_url = base_url.rstrip('/')
self.api_key = api_key
self.api_secret = api_secret
self.verify_ssl = verify_ssl
self.timeout = timeout
self.client = httpx.AsyncClient(
base_url=self.base_url,
auth=(api_key, api_secret),
verify=verify_ssl,
timeout=timeout
)
auth=(api_key, api_secret) : de l’authentification HTTP Basic, la mĂŞme que
l’API REST d’OPNsense attend nativement — rien de spĂ©cifique Ă ce projet.
verify_ssl reste un paramètre explicite, jamais implicitement désactivé dans
le code du client lui-mĂŞme.
CĂ´tĂ© serveur d’agents, server.py lit quatre variables d’environnement pour
construire cette configuration :
OPNSENSE_URL = os.getenv("OPNSENSE_URL", "https://192.168.1.1")
OPNSENSE_KEY = os.getenv("OPNSENSE_API_KEY")
OPNSENSE_SECRET = os.getenv("OPNSENSE_API_SECRET")
"verify_ssl": os.getenv("OPNSENSE_VERIFY_SSL", "False").lower() == "true",
OPNSENSE_URL a pour défaut de code https://192.168.1.1 — la passerelle LAN
la plus courante d’un OPNsense fraĂ®chement installĂ©, pas un secret, juste un
défaut raisonnable à surcharger. OPNSENSE_API_KEY et OPNSENSE_API_SECRET
n’ont, eux, aucun dĂ©faut : sans eux, OPNSENSE_KEY reste None et l’agent
OPNsense bascule en mode simulation plutĂ´t que d’Ă©chouer silencieusement.
OPNSENSE_VERIFY_SSL est à False par défaut — cohérent avec le certificat
auto-signĂ© qu’expose une interface d’administration OPNsense fraĂ®chement
dĂ©ployĂ©e sur son LAN, Ă surcharger Ă true dès qu’un certificat de confiance
est en place.
Le README du projet documente cette section sous le titre « Targeting a real
OPNsense (interop) », avec le même tableau de variables et la même
recommandation : la joignabilitĂ© est vĂ©rifiĂ©e par un GET /api/core/system/status, et c’est prĂ©cisĂ©ment ce endpoint que server.py
sonde au démarrage pour confirmer que la connexion fonctionne avant de marquer
l’agent OPNsense comme opĂ©rationnel. Le README va jusqu’Ă nommer explicitement
OAF comme cible de test possible pour quiconque veut expérimenter cette
architecture externe sans monter une VM OPNsense de zéro — la boucle se
referme : le projet qui a mesuré pourquoi le LLM ne doit pas vivre dans le
firewall sert aussi de banc d’essai pour la topologie qui le fait vivre
à côté.
La réserve qui ne se négocie pas
Pointer le coordinateur sur un vrai OPNsense Ă©largit la surface d’exposition
d’une seule chose : l’API REST elle-mĂŞme. D’oĂą une règle qui n’est pas une
suggestion mais une condition de dĂ©ploiement : l’API OPNsense doit ĂŞtre
exposĂ©e sur l’interface LAN uniquement, jamais sur le WAN, et une règle de
pare-feu doit explicitement autoriser l’hĂ´te qui fait tourner le coordinateur
Ă atteindre ce port — pas un any gĂ©nĂ©reux, une règle nominative. C’est la
mĂŞme discipline de moindre privilège que le reste de l’architecture applique
au raisonnement du LLM ; elle s’applique tout aussi strictement Ă la
connectivitĂ© rĂ©seau qui le relie Ă l’Ă©quipement qu’il pilote. Sortir le modèle
de la boîte ne dispense de rien côté réseau — ça déplace simplement le
pĂ©rimètre Ă protĂ©ger d’un processus embarquĂ© vers un chemin HTTPS LAN, plus
facile Ă isoler, Ă journaliser et Ă couper.
flowchart LR
subgraph Box["VM OPNsense"]
API["API REST OPNsense — interface LAN"]
inbox["approche in-box (OAF)<br/>LLM dans le firewall"]
end
Coord["cyber-agent-engine<br/>coordinateur hors-boîte, sur le LAN"] -->|"HTTPS LAN + clé/secret"| API
Coord -->|"backend LLM (hors-boîte)"| LLM["Anthropic / OpenAI-compat / vLLM / Ollama"]
inbox -.->|"contention CPU, surface d'attaque"| API
Ce que cet article change dans la lecture de la série
Les articles précédents montraient une frontière de confiance interne au
coordinateur — jetons, politique, approbation. Celui-ci ajoute une frontière
topologique : le LLM lui-mĂŞme est de l’autre cĂ´tĂ© d’un appel rĂ©seau,
jamais colocalisĂ© avec l’Ă©quipement qu’il gouverne. Les deux frontières se
complètent plutĂ´t que de se substituer l’une Ă l’autre — retirer le modèle de
la boĂ®te ne dispense d’aucune des garanties dĂ©crites dans les articles 2 et 3,
et inversement, un core/ fail-closed parfaitement conçu ne rend pas
anodine une infĂ©rence tournant Ă l’intĂ©rieur du pare-feu qu’elle pilote. C’est
la combinaison des deux qui rend l’ensemble opĂ©rable en production — le sujet
du prochain article de cette série.
Cet article fait partie de la série sur cyber-agent-engine. Le
premier article pose les cinq principes de
la frontière de confiance ; un article à venir montrera comment ces briques —
politique, boucle gatée, approbation, audit, et maintenant la topologie
hors-boĂ®te — s’assemblent en un système opĂ©rable en production.
Pour aller plus loin :
- opnsense-ai-firewall — le PoC in-box dont les quatre limites mesurĂ©es (surface d’attaque, contention CPU, cycle de vie, audit) motivent directement l’architecture externe dĂ©crite ici.
- Article 1 — trust core — la thèse générale
de la série et le rôle du
core/fail-closed.
