agentskrillmsaltstack

From RAG to Agentic: How We Let an LLM Touch a 920-Node Fleet — Safely

From RAG to Agentic: How We Let an LLM Touch a 920-Node Fleet — Safely

The previous post built kri’s RAG pipeline: pgvector, nomic embeddings, BM25 + cosine Reciprocal Rank Fusion, grounding rules, citations. This post picks up where it left off — with the bugs that shipped the moment that pipeline hit production, the agentic design we red-teamed before writing a single line of implementation code, a 6-persona audit that discovered the entire control plane was silently broken, and the safety model we settled on for letting a language model act on real infrastructure.

1. The RAG Foundation

The prior post (Building a Robust RAG Pipeline for Fleet Management) designed the full retrieval stack: a seven-source data taxonomy (nodes, groups, Salt states, SLS files, playbook runs, playbook definitions, events), per-source chunking strategies that preserve structural boundaries, a 768-dimensional nomic-embed-text model running locally on the exo box, hybrid BM25 + cosine retrieval fused with RRF, live-state injection for volatile data, and a grounding-rules-last context assembly that forces the model to cite what it uses.

The architecture was sound. The bugs came from the gap between architecture and implementation.

768

embedding dims (nomic-embed-text)

500

every message on first deploy

4

red-team principals pre-build

6

personas in control plane audit

2

silent infra failures caught

2

approval layers before Salt runs

2. Bugs That Shipped

Shipping a RAG post without admitting what broke feels dishonest. Two bugs hit within hours of the assistant going live. Both were preventable with better type discipline and better error surfacing. Neither was subtle.

Bug 1: IPv4Address Is Not a String

The context builder pulled each node’s record from the database and assembled a human-readable summary table by iterating over fields and calling _sanitize_cell(value). That function called value.replace(...) to strip control characters. PostgreSQL’s INET column comes back from SQLAlchemy as a Python IPv4Address object, not a string. IPv4Address has no .replace() method. Result: every single chat message — even “hello” — returned HTTP 500.

Root Cause

_sanitize_cell assumed everything was a string. The fix is one line: coerce to str(value) before any string operation. The lesson: database types are not Python primitive types. SQLAlchemy gives you objects. Treat them as such.

INET column

IPv4Address object

value.replace() → AttributeError

HTTP 500

INET column

str(IPv4Address)

“192.0.2.23”

Sanitized, included in context

Bug 2: Opaque 404 from the LLM Endpoint

The exo box (192.0.2.23:52415) had stopped serving the model name that was baked into the assistant config. The request went out, the endpoint returned HTTP 404, and the assistant swallowed it — surfacing only a generic “the assistant is unavailable” message with no hint of cause.

The fix has two parts. First, propagate the actual response body from the LLM endpoint into the error the user sees — “Model ‘llama-3.2-3b’ not found. Available: [‘llama-3.3-70b’]” is actionable; “unavailable” is not. Second, degrade context gracefully: a bug in context assembly should not abort the entire request. The assistant can still respond (uselessly, but without 500-ing) if it gets an empty context, and the user can see the real error rather than a stack trace.

Design Rule

Every call to an LLM endpoint must log and propagate the raw response body on non-2xx. Model configuration drift (the model name changes; the endpoint moves) is a routine operational event in a self-hosted fleet. Make it diagnosable in 30 seconds, not 30 minutes.

3. Going Agentic — The Right Way

Read-only RAG is a safety ceiling. “Which node is offline and why?” is answerable from context. “Restart the salt-minion on mm2” is not — it requires an action. The assistant needed a tool interface, but a fleet of Mac Minis running production build workloads is not a sandbox. One wrong salt '*' cmd.run call can cascade through a dozen dependent jobs.

The design principle we settled on before writing a single line: the LLM is an untrusted actor. It emits intent; the platform decides whether that intent is safe and authorized to execute.

RAG to Agentic — Full Request Flow

User Message RAG Context pgvector + BM25 Live State Nodes / Alerts LLM (Untrusted Actor) emits tool_calls Tool Interceptor kri validates + routes READ Auto-run WRITE Approval gate Result injected into next turn

LLM emits intent; kri intercepts and decides. READ tools execute automatically; WRITE tools require approval.

Tool Categories

CategoryExamplesExecutionApproval
READget_node_status, list_alerts, get_process_listAuto, from cached DBNone — read-only, safe
WRITE — guardedrun_salt_cmd, restart_service, apply_stateDry-run first, then guarded SaltTwo-layer (see §5)
DENIEDAnything targeting protected targets, rm -rf, etc.NeverDenylist, no override

4. Tool Adapter Discovery — Why exo Forced a Design Decision

Tool calling is not universal across LLM providers. We needed to probe the actual endpoint before committing to an architecture. The design called for three adapters:

  • Native tool_calls — structured JSON in the response’s tool-use field. Supported by vLLM, Ollama in recent versions, OpenAI-compatible servers.
  • Anthropic tool_use — Claude’s native format, with explicit input blocks per tool call.
  • Universal content-parse fallback — for models that lack structured tool support and emit JSON inside their text output.

We probed the exo box. exo was running Llama 3.3 70B and emitting tool calls in the Llama 3 native format — a <|python_tag|> delimiter followed by a JSON blob inside the content field, not a structured tool_calls array. This is not the OpenAI tool-calling spec. It settled the design: the universal content-parse fallback was not a graceful degradation path — it was the primary path for the current host model.

Probe Before You Design

Do not assume your LLM endpoint implements the spec you read on GitHub. Send a test request with a tool definition and inspect the raw response body. exo’s Llama 3.3 output would have been silently dropped by a naive tool_calls parser. The content-fallback adapter caught it.

# Llama native format inside content — NOT structured tool_calls
{
  "choices": [{
    "message": {
      "content": "<|python_tag|>{\"name\": \"get_node_status\", \"parameters\": {\"node_id\": \"mm2\"}}",
      "tool_calls": null   // absent parse from content
    }
  }]
}

5. The Pre-Build Red-Team

Before writing the agentic backend, we put the design in front of four external principals and asked them to break it. The findings changed the architecture materially.

Two-Layer Approval Gate — Write Tool Path

LLM Proposes action Dry-Run Resolve + preview LAYER 1 Operator Confirms intent "restart salt-minion on mm2 — confirm?" summary only (not verbatim args) LAYER 2 Admin Inbox Approves VERBATIM resolved action "salt mm2 service.restart salt-minion" — approve? exact bytes that run Guarded Salt Run Denied → action cancelled

The critical distinction: Layer 1 sees a human-readable summary of intent; Layer 2 approves the exact verbatim bytes that will execute. Social-engineering the summary does not change what runs.

Architect

Redesign Foundation First

The LLM caller (exo / Llama 3.3) did not support the tool-calling protocol at all in the format assumed. There was no foundation to build an agent loop on top of. Fix the adapter layer before any agentic behavior. Settled: universal content-parse fallback becomes primary path.

Red-Team LLM

Proceed With Changes

Two attack vectors: (1) summary-vs-verbatim social engineering — an LLM can propose “restart salt-minion on mm2” in the summary but embed different args in the tool call; fix: Layer 2 must approve verbatim resolved args, never the summary. (2) Prompt injection via process names/cmdlines — a cmdline containing "Ignore previous instructions" entered the model context.

SRE

Proceed With Changes

The agent loop can’t live in a request/response cycle. If a WRITE action pauses for human approval (potentially hours), the HTTP connection is long dead. The loop must be a durable state machine — Celery task or persistent DB state, not an in-memory coroutine. Also: one looping agent query could exhaust the DB connection pool and take down the entire control plane at 2am.

DevOps

Proceed With Changes

Cmdline arguments passed to the model (e.g., ps aux output) can contain secrets (tokens, passwords, API keys passed as CLI args). These must be scrubbed before entering model context and before being written to audit logs. The scrubbing must happen at collection time, not at display time.

6. The 6-Persona Control Plane Audit

Before the agentic layer could land, we audited the control plane it would ride on. The agentic design assumes approved actions execute reliably, are observable, and are correctly authorized. None of those assumptions were safe to make without checking.

A 6-persona audit walked every failure mode: Architect, Developer, SRE, QA, Security Auditor, and Operations. The headline finding was stark: approved actions were not executing at all. They entered executing state and stayed there forever.

Finding 1 — Celery Callback on Wrong Queue

Approved Actions Stuck in “executing” Forever

The result-tracking callback was registered on a Celery queue named node_actions_result. No worker was consuming that queue. The approved action dispatched to Salt, Salt ran it, the Celery task completed — but the callback that should have updated the action record to completed or failed never fired. The action stayed executing in the DB until the platform was restarted. Every status check returned a permanent lie.

Finding 2 — salt-api ACL Mismatch

Salt-API ACL Denied the Functions the Actions Needed

The salt-api configuration had a PAM-authenticated ACL that allowed a specific set of Salt functions for the kri service user. The node action executor called service.restart and cmd.run — neither of which was in the ACL. Every approved action that reached Salt returned a 403-equivalent from salt-api. The execute path was double-broken: first the callback queue was wrong, and even if it hadn’t been, Salt would have rejected the call.

Finding 3 — Vacuous Contract Tests

Tests Greping for Strings Gave False Green

The existing “source-contract” tests verified that certain function names existed in the source file using grep-style checks. A function could be completely broken — wrong queue, wrong arguments, wrong return type — and the test would still pass because the symbol was present. These were not behavioral tests. They were presence tests. They provided false confidence across the entire test run.

Resolution

Both Infrastructure Bugs Fixed; Tests Replaced with Behavioral Assertions

The Celery callback was moved to the queue that the worker was actually consuming. The salt-api ACL was updated to permit service.restart, service.status, cmd.run, and state.apply scoped to the kri service account. Vacuous grep-based tests were replaced with behavioral tests that mock the Celery/Salt boundary and assert on state transitions, not symbol presence.

Defense-in-Depth — Layers Between LLM and Infrastructure

TRUST BOUNDARY — LLM Is Untrusted Tool calls parsed and validated by kri backend. LLM cannot call Salt or any API directly. Protected-Target Denylist Hardcoded list of nodes/services that can never be targeted. No override path. Dry-Run Preview Every WRITE action runs in dry-run first. Resolved command shown to operator before any approval is sought. Two-Layer Approval (Operator Intent + Admin Verbatim) Summary ≠ what executes. Admin approves the exact resolved bytes. Social-engineering the summary is harmless. Guarded Salt Dispatch + Full Audit Trail salt-api ACL enforced at execution. Every action logged with actor, timestamp, verbatim command, and result.

Each layer is independent — a breach of Layer 1 does not bypass Layer 2. Prompt injection that fools the LLM still hits the denylist, still requires both approvals, still runs under salt-api ACL.

7. Behavioral Tests Replace Grep-Based Contracts

The audit’s third finding was the least dramatic and the most pervasive. “Source-contract tests” that check whether a function exists tell you nothing about whether it works. They are a false safety blanket.

Anti-Pattern: Presence Test

Asserts that execute_node_action exists in the module and that the string "celery" appears somewhere in the file. This test passes even if the Celery call is wired to the wrong queue, the wrong task signature, or a function that raises on every call.

# BEFORE: grep-style presence test (useless)
def test_execute_node_action_exists():
    import fleet_platform.services.node_action_svc as svc
    assert hasattr(svc, "execute_node_action")
    # passes even if the function 500s on every call
# AFTER: behavioral test with mocked Celery boundary
async def test_execute_approved_action_updates_state(db, mock_celery):
    action = await create_approved_action(db, node="mm2", cmd="service.restart")
    await execute_node_action(db, action.id)
    await db.refresh(action)
    # assert the state machine transitioned correctly
    assert action.status == "executing"
    mock_celery.apply_async.assert_called_once_with(
        args=[action.id],
        queue="node_actions"  # the queue workers actually consume
    )

Rule

Every test must be able to fail if the feature is broken. If you cannot write a scenario where the test fails with a broken implementation, the test proves nothing. Treat tests that can only pass as P1 bugs.

8. The Safety Model

After the red-team, the audit, and the fixes, the safety model crystallized into a small set of principles that generalize beyond this project.

Trust Boundary — LLM vs. Control Plane

UNTRUSTED ZONE LLM Untrusted actor Prompt Injection Process names, cmdlines user text → model context Secret Leakage Tokens in cmdline args Scrub at collection Social Engineering Summary ≠ verbatim args Admin approves exact bytes TRUST BOUNDARY TRUSTED ZONE kri Backend Validates, routes enforces denylist Approval Inbox Durable state machine (Celery + DB) Salt-API ACL PAM-authenticated Function allowlist Audit Log Actor, cmd, result Immutable, timestamped tool_call

The trust boundary is enforced by the kri backend, not by the LLM. Anything the LLM does inside the untrusted zone is subject to full validation before it crosses the boundary.

PrincipleWhat It PreventsImplementation
LLM is untrustedTreating model output as authoritativeAll tool calls validated and routed by kri backend, never executed directly
Approve verbatim bytesSummary/args divergence attackLayer 2 inbox shows exact resolved command; summary is for UX only
Durable state machineIn-memory agent loop dying on HTTP timeoutCelery task + DB state columns; actions survive restarts
Protected denylistLLM targeting critical infraHardcoded at backend; no override path
Scrub secrets at collectionTokens/passwords entering model context or audit logsProcess cmdlines redacted before storage and before context assembly
Rate-limit agentic queriesLoop query exhausting DB pool (2am cascade)Per-user per-minute rate limit on agent endpoints
Behavioral tests onlyFalse confidence from presence testsEvery test has a scenario that fails if the feature is broken

9. Lessons

Building an agentic layer on top of real infrastructure surfaced patterns that generic “build an AI agent” tutorials skip entirely, because they run against sandboxes.

Probe Before You Design

We assumed exo would emit structured tool_calls. It did not. We discovered this by sending a test request and inspecting the raw JSON. Had we not probed, the entire adapter layer would have silently swallowed every tool call and returned a confused non-answer. Probe every integration at the protocol level before building on top of it.

Adversarial Review Before Implementation

The red-team found the summary-vs-verbatim attack before we wrote a single approval UI element. Finding it in a design doc costs nothing. Finding it in production after a rogue action runs on a build node costs a great deal. The four principals had a combined 20 minutes of review time and prevented at least two material security issues.

Tests That Can Only Pass Are Bugs

The Celery queue mismatch was invisible for months because the source-contract tests said everything was fine. They checked that a function existed. The function existed. The tests passed. The feature was completely broken. The audit discovered it by asking “what happens when an approved action runs?” — a question the test suite had never asked.

Infrastructure Assumptions Must Be Verified

We designed the agentic approval flow assuming the underlying executor worked. It did not — the salt-api ACL rejected every call, and the callback queue had no consumer. “The control plane is ready” was an assumption, not a fact. Verify the substrate before building on top of it, especially when the substrate is a distributed system with multiple failure points (Celery workers, salt-api, PAM authentication, queue consumers).

Where We Landed

A RAG-grounded fleet assistant that can propose actions, run them through dry-run, send them through two independent approval layers, execute them via guarded Salt dispatch, and record an immutable audit trail — all without the LLM having direct access to any execution primitive. The control plane bugs that would have made it silently non-functional are fixed. The vacuous tests that hid them are replaced with behavioral assertions. The red-team’s attacks are mitigated in the design, not patched after the fact.

The safety model is not novel. It is the same principle used in every secure system: trust is a property of the verification layer, not of the actor. The LLM is an actor. kri is the verification layer. They do not share trust.


Part of the kri fleet platform engineering series. The RAG foundation is covered in Building a Robust RAG Pipeline for Fleet Management. Agentic concepts and MCP are covered in Agentic AI, MCP & Gateways — Complete Visual Guide.

Enjoyed this post?

Get the next one in your inbox — only when I ship something worth reading.

Newsletter form not configured.

Or follow on Substack for the newsletter.

Comments via GitHub Discussions

Comments not configured. Set GISCUS env vars to enable.