NOPE LinkedIn

Catégories:
AI

Off-box: why the LLM must not live inside the firewall

Off-box: why the LLM must not live inside the firewall image

Rubrique: AI Tag: AI Tag: Security Tag: OPNsense Tag: portability Tag: no-GPU Tag: DevSecOps

🇫🇷 Version française 💻 Source code (AGPL): github.com/patlegu/cyber-agent-engine

A question of topology, not just security

The first three articles in this series detailed how cyber-agent-engine constrains an LLM that drives a production firewall: tokens rather than real values (article 2), a fail-closed policy and human approval (article 3). This one tackles a prior question, almost architectural before it is a security matter: where must this LLM physically run? On the equipment it drives, or remotely, on the other side of a network call?

The answer isn’t arbitrary — it has already been measured, in the other direction, by an earlier project on this blog.

The contrast: what the in-box approach measured

opnsense-ai-firewall (OAF) is a PoC that pushes the reverse logic all the way through: the LLM runs embedded inside the OPNsense VM itself. No sidecar, no network call to an external service — a llama-server compiled natively for FreeBSD, with a fine-tuned LoRA adapter, running on the same kernel as the pf that filters traffic, and driving the firewall’s REST API directly from a natural-language intent. The PoC works — 101 out of 102 functions validated, latencies measured — and it’s precisely because it works that its own conclusions matter: its docs list, methodically, four reasons why this topology must never go to production.

Attack surface. OAF itself puts a number on the cost: the added llama.cpp/ggml/OpenBLAS stack weighs roughly 125 MB of extra code to audit, patch and monitor, on a minimal OPNsense base of about 600 MB — a roughly 20% increase in the code surface of a device whose doctrine, historically, is precisely to stay minimal. Every CVE in that stack becomes a CVE of the firewall itself.

CPU contention. Inference measured by OAF takes about 10 seconds on the VM’s cores — the same cores that run pf. During that time, filtering continues, but under contention, and the data plane’s network latency feels it directly. A firewall normally has no reason for its filtering capacity to vary depending on whether a language model happens to be “thinking.”

Coupled lifecycle. llama.cpp evolves on a weekly cadence; OPNsense ships stable releases at a much slower pace. Embedding the former inside the latter forces two incompatible update cadences to cohabit in the same deployment artifact — every update to one becomes a regression risk for the other.

Compromised audit. When an LLM modifies config.xml directly, it isn’t, in compliance terms, an identifiable actor in the same way a named administrator going through an audit console is. OAF itself notes that the existing guardrail at this level (--confirm on the agent side, local log) is an operator control, not a legal audit trail nor a rampart against malicious action.

These four points aren’t an external critique leveled at OAF: they are its own conclusions, obtained by pushing the PoC all the way through so they could be measured rather than assumed. It’s precisely that work that makes the opposite conclusion — the one cyber-agent-engine implements — actionable rather than dogmatic.

The external answer: nothing gets inside the box

cyber-agent-engine inverts the topology term for term. The component that “thinks” — the CoordinatorLLM in coordinator/llm/coordinator_llm.py, which proposes intents from tokenized context — never runs on the network equipment it drives. It runs on the coordinator’s host, whatever that host is, and only talks to the firewall through an HTTPS call to its REST API, over the LAN. Term for term, OAF’s four objections dissolve:

  • Attack surface: the firewall gains no code, no dependency, no extra binary. It stays the OPNsense device it always was, queried by an external HTTP client — exactly the same kind of traffic as a monitoring dashboard or an ordinary automation script.
  • CPU contention: inference consumes the CPU/GPU cycles of the coordinator’s host, never the ones that filter network traffic. pf shares nothing with the LLM.
  • Lifecycle: the coordinator deploys, updates and restarts independently of the OPNsense firmware. Updating the reasoning backend implies no maintenance window on the firewall.
  • Audit: every call to the OPNsense API goes through the boundary described in articles 1 and 3 — fail-closed policy, human approval on sensitive capabilities, bounded audit log, tokens rather than real values. The actor modifying the configuration is no longer an opaque process inside the firewall: it’s an authenticated HTTP call, traced end to end, where every decision left an audit entry before execution.

The firewall becomes again what it should always be: a network device that exposes an API, nothing more. All the intelligence — and all the risk it carries — moves to the other side of the wire.

Portability: a GPU-free core, interchangeable backends

Taking the LLM out of the box only has value if the component that hosts it isn’t itself pinned to a particular machine. That’s the second benefit of the external topology, and it reads directly in pyproject.toml: the package’s base dependencies —

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",
]

— contain neither torch, nor vllm, nor any model weights. torch, vllm and unsloth are isolated in an optional extra, [gpu]:

[project.optional-dependencies]
gpu = [
    "torch>=2.1.0",
    "vllm>=0.6.0",
    "unsloth",
]

clients/gpu.py makes this boundary physical in the code: the in-process vLLM loader is never imported at module load time, only on demand, and if the [gpu] extra isn’t installed, the failure is an explicit GpuExtraRequired rather than an opaque ImportError in the middle of a stack trace. The coordinator’s core — policy, tokenization, gated loop, audit — installs and runs without ever touching a graphics card.

The corollary of this lightweight core is that the reasoning backend becomes a parameter, not a structural dependency. coordinator/llm/coordinator_llm.py states it unambiguously:

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)

(Configurable backends via COORDINATOR_BACKEND: "anthropic" — Claude API, recommended, no local service required; "openai" — OpenAI-compatible API, also works with vLLM HTTP on port 8000; "vllm" — direct NativeVLLMClient, separate instance loading Qwen2.5-7B; "ollama" — local Ollama, if available.)

And in the code, the selection is a simple branch on an environment variable, defaulting to anthropic:

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),
        )

Four paths, a single public interface (chat(messages, max_tokens)), and none of the coordinator’s three other callers — proposer, loop, audit — needs to know which one is active. In practice, that means a deployment can start on the Anthropic API (zero local service, latency of a few seconds, as mentioned in an earlier article on the OpenRouter/LoRA trade-offs (fr) on this blog), then switch to a self-hosted vLLM or a local Ollama for sovereignty or cost reasons, without touching a single line of core/, the policy, or the gated loop. Reasoning is a replaceable module; structural trust is not.

This portability also rests on the fact that cyber-agent-engine doesn’t depend on a private training pipeline to function. The public repo this whole article is built on is self-sufficient: coordinator/, core/, clients/ and agents/ are the only modules the reasoning depends on, and none of them import any proprietary LoRA training package. Manufacturing the LoRA adapters — when the vllm backend is chosen with a fine-tuned model anyway — is the subject of a separate, decoupled repo: the coordinator knows nothing about how a weight was produced, it consumes a generic chat interface. It’s this decoupling, as much as the absence of a GPU in the core, that makes the backend choice reversible rather than fixed at design time.

Targeting a real firewall: a REST client like any other

Concretely, how does the coordinator reach the equipment? Not through a homegrown protocol, nor through any privileged access — through a standard HTTP client, OPNsenseAPIClient (clients/opnsense_api_client.py), whose constructor hides no magic:

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): HTTP Basic authentication, the same the OPNsense REST API natively expects — nothing specific to this project. verify_ssl stays an explicit parameter, never implicitly disabled in the client’s own code.

On the agent-server side, server.py reads four environment variables to build this 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 has a code default of https://192.168.1.1 — the most common LAN gateway of a freshly installed OPNsense, not a secret, just a reasonable default to override. OPNSENSE_API_KEY and OPNSENSE_API_SECRET have no default: without them, OPNSENSE_KEY stays None and the OPNsense agent switches to simulation mode rather than failing silently. OPNSENSE_VERIFY_SSL defaults to False — consistent with the self-signed certificate a freshly deployed OPNsense admin interface exposes on its LAN, to be overridden to true as soon as a trusted certificate is in place.

The project’s README documents this section under the heading “Targeting a real OPNsense (interop)”, with the same variable table and the same recommendation: reachability is verified by a GET /api/core/system/status, and it’s exactly this endpoint that server.py probes at startup to confirm the connection works before marking the OPNsense agent as operational. The README goes as far as explicitly naming OAF as a possible test target for anyone wanting to experiment with this external architecture without standing up an OPNsense VM from scratch — the loop closes: the project that measured why the LLM must not live inside the firewall also serves as the test bench for the topology that makes it live next to it.

The non-negotiable caveat

Pointing the coordinator at a real OPNsense widens the exposure surface of exactly one thing: the REST API itself. Hence a rule that isn’t a suggestion but a deployment condition: the OPNsense API must be exposed on the LAN interface only, never on the WAN, and a firewall rule must explicitly authorize the host running the coordinator to reach that port — not a generous any, a named rule. It’s the same least-privilege discipline the rest of the architecture applies to the LLM’s reasoning; it applies just as strictly to the network connectivity that links it to the equipment it drives. Taking the model out of the box waives nothing on the network side — it simply moves the perimeter to protect from an embedded process to a LAN HTTPS path, easier to isolate, log and cut off.

flowchart LR
  subgraph Box["OPNsense VM"]
    API["OPNsense REST API — LAN interface"]
    inbox["in-box approach (OAF)<br/>LLM inside the firewall"]
  end
  Coord["cyber-agent-engine<br/>off-box coordinator, on the LAN"] -->|"HTTPS LAN + key/secret"| API
  Coord -->|"LLM backend (off-box)"| LLM["Anthropic / OpenAI-compat / vLLM / Ollama"]
  inbox -.->|"CPU contention, attack surface"| API

What this article changes in how the series should be read

The previous articles showed a trust boundary internal to the coordinator — tokens, policy, approval. This one adds a topological boundary: the LLM itself sits on the other side of a network call, never colocated with the equipment it governs. The two boundaries complement rather than replace each other — taking the model out of the box waives none of the guarantees described in articles 2 and 3, and conversely, a perfectly designed fail-closed core/ doesn’t make an inference running inside the firewall it drives any less consequential. It’s the combination of both that makes the whole thing operable in production — the subject of the next article in this series.


This article is part of the cyber-agent-engine series. The first article lays out the five principles of the trust boundary; an upcoming article will show how these building blocks — policy, gated loop, approval, audit, and now the off-box topology — assemble into a system operable in production.

To go further:

  • opnsense-ai-firewall (fr) — the in-box PoC whose four measured limits (attack surface, CPU contention, lifecycle, audit) directly motivate the external architecture described here.
  • Article 1 — trust core — the series' general thesis and the role of the fail-closed core/.