DEV Community

Cover image for Read-only by construction: why instructions aren't a security boundary for an AI agent in a Kubernetes cluster
r
r

Posted on

Read-only by construction: why instructions aren't a security boundary for an AI agent in a Kubernetes cluster

I keep running into the same setup: take an LLM, give it access to kubectl or the k8s API, write something like "you can only read, never delete or change anything" into the system prompt or a connected skill, and consider the problem solved. I've been through this myself, and at some point I realized: that's not a security boundary, it's a polite request.

This isn't a hypothetical risk: you've probably heard the story — in July 2025, a Replit agent deleted SaaStr's production database despite an explicit instruction not to touch anything. Not Kubernetes, not MCP, but the same pattern: the instruction "don't touch this" was right there in context — there was just nobody to enforce it except the model itself. Giving an agent write access to a k8s cluster means assembling exactly the same setup that already cost SaaStr their database.

I'm far from the first to write about this, and plenty of read-only MCP servers have shown up recently. What's surprising is how often "read-only" gets implemented wrong. For Kubernetes MCP servers specifically, "read-only" is often just an environment variable that filters the tools/list response, not the absence of a function in the registry. That's exactly how mcp-server-kubernetes (20K weekly npm downloads) was built: the ALLOW_ONLY_READONLY_TOOLS flag hid mutating tools from the list, but tools/call still accepted kubectl_delete directly, bypassing the filter. The result was CVE-2026-46519, CVSS 8.8 — the exact principle this article is about, taken all the way to a real exploit: a function hidden from the list isn't the same as a function that doesn't exist. And it's not just community projects — Azure/mcp-kubernetes, Microsoft's own official Kubernetes MCP server, is built the same way: --access-level readonly|readwrite instead of the absence of mutating tools in the first place.

Why instructions don't work as a boundary

A model is not a sandbox. If delete_pod or scale_deployment is in its list of available tools, it can technically call it regardless of what the system prompt says. For example, an attacker doesn't need cluster access for this — an ordinary HTTP request with a spoofed header value like User-Agent is enough. Nginx, or the pod itself, will log it verbatim: Kubernetes just captures the container's stdout/stderr, with no sanitization at all. Later, someone (or the assistant itself) asks the model to "check this pod's logs" — a completely innocent request — and the model reads that line as part of its context, with no way to tell it apart from the system prompt. Same story with an instruction from a connected skill or another plugin, which ends up in context just as "trusted"; with a jailbreak; with an ordinary hallucination while trying to "fix" a problem you only asked it to explain. An instruction — whether in the system prompt, in a skill, or in a container log — is data the model interprets, not code that constrains it.

So the only boundary that actually holds is which tools exist at all in the registry it has access to. If the delete_pod function doesn't exist, it doesn't matter what a prompt injection, a jailbreak, or the model itself in a fit of "helpfulness" says — there's nothing to call.

What this looks like in the tool registry

Take a concrete example: an MCP server for Kubernetes. It makes sense to register only read tools in it — list_pods, list_deployments, get_yaml, get_events, read_pod_logs, start_pod_log_stream, and so on, on the order of thirty of them. And not a single delete_*, scale_*, exec_*, apply_*, or port_forward_* — not because they're switched off by some flag, but because those functions simply don't exist in the code.

All mutating operations — scale, rollout restart, delete, cordon/drain — live on a separate, human-only path in a design like this: through a GUI with a confirmation dialog, through a CLI with an explicit flag or a y/n prompt, doesn't matter which — what matters is that a human confirms it, and only then does a direct call to the Kubernetes API happen, with no model and no AI tool registry involved at all. These are two separate code paths, not one gated by an allowed/forbidden flag.

One server, two transports

A separate problem shows up when an AI assistant is available in two forms: as an embedded panel inside a larger tool, and as a standalone binary for external MCP clients (Claude Desktop, Claude Code, etc.). The temptation is to throw together a separate tool set for the embedded version. Over time the two sets drift apart, and one ends up with an extra tool the other doesn't have.

More reliable: bring up the same MCP server in both cases and talk to it for real over the MCP protocol, just over different transports — in-memory for the embedded version, stdio for the external client:

server, shutdown := mcpserver.NewServer(ctx)
serverTransport, clientTransport := mcp.NewInMemoryTransports()
server.Connect(ctx, serverTransport, nil)

client := mcp.NewClient(&mcp.Implementation{Name: "desktop-assistant"}, nil)
session, _ := client.Connect(ctx, clientTransport, nil)
list, _ := session.ListTools(ctx, nil) // the same ListTools any external MCP client would call
Enter fullscreen mode Exit fullscreen mode

That means there's physically one server and one list of read-only tools in the codebase — not an original plus a separate copy for the GUI that someone forgets about. In practice, this closes off exactly one class of bug: the one where, six months into a refactor, a tool gets added to one list and someone forgets the other.

Read-only doesn't mean "nothing is visible"

Read-only solves the state-mutation problem, but not the problem of leaking data that's already sitting in the cluster. If the kubeconfig has access to read a Secret in a namespace, then in theory so does the model, by calling get_yaml. This has to be fought at the data layer, not the prompt layer: Secret values get redacted before the YAML ever reaches the tool response —

sec.Data[k] = []byte("<redacted>")
Enter fullscreen mode Exit fullscreen mode

— and the AI (whether the embedded assistant or an MCP client) physically never sees the decrypted value, because it's replaced with a placeholder before the YAML string is even formed. It's also worth rejecting an apply if the YAML still contains <redacted> — otherwise the placeholder could accidentally overwrite a real value.

What this approach doesn't solve

  • The model can still read a lot of data you already have RBAC access to. Read-only limits what it can do, not what it can see within the same permissions.
  • Read-only by itself doesn't limit load on the API server from a chatty tool-calling loop either — there are just reasonable measures here (a cap on iterations per conversation, a byte limit on tool results, timeouts on log reads, capped and idle-reaped streams), not some cryptographic guarantee.
  • If it matters that the prompt and whatever the tools read never leave the machine at all, that's a separate setup (a local model via Ollama/vLLM/LM Studio), not a consequence of the read-only architecture itself.
  • A compromised registry or altered tool descriptions are a separate story: tool poisoning works against read-only tools too, if the model trusts an instruction inside a description the same way it trusts the system prompt.

The whole approach described here is simple exactly because it deliberately doesn't solve the more general problem — giving an agent any write access at all. If that's genuinely needed (for production debugging with the ability to actually fix something, say), that's a fundamentally different, much heavier architecture: whitelisting specific commands, rate-limiting, role-based restrictions, an immutable audit log with alerts.

Why this isn't tied to a specific model

Since the boundary is the absence of a function, not model behavior, the guarantee works the same regardless of which engine you use — the Anthropic API, an existing Claude Code/Codex CLI login, or a local model via Ollama. You don't have to trust any particular vendor's alignment — the tool registry is the same one for everyone who connects to it, no matter who they are.


If you want to look at the concrete Go implementation, it's open — I added an MCP server to a tool I originally built for myself, and later open-sourced. Repository: https://github.com/cyb3rKn1ght/nereida (cmd/nereida-mcp has a separate README about the MCP server itself).

Top comments (0)