💡 Originally published on devtocash.com — where this guide stays updated. I write hands-on DevOps/SRE deep-dives there weekly.
RBAC is the ceiling, everything else is decoration
Every guide to giving an LLM agent cluster access — including my own kubectl MCP server — says the same thing in passing: "run it under a least-privilege ServiceAccount." Then it moves on to the fun part, the tools. This post is the part everyone skips.
Here is the short version. An AI agent that touches Kubernetes gets its own dedicated ServiceAccount, bound to a namespaced Role with read verbs only, with no access to Secrets, pods/exec, or impersonation, authenticated by a short-lived token (minutes, not months), with its API activity isolated in the audit log and its bindings frozen by an admission policy. Below is the exact YAML, the token setup, the auth can-i test matrix, and the Kyverno policy that stops the ceiling from quietly drifting upward.
Why so paranoid? Because tool allowlists, prompts, and approval gates are all application-layer controls. If the credential behind them can delete namespaces, one bug in your MCP server — or one prompt injection that talks the model into a "creative" tool call — inherits that power. RBAC is enforced by the API server; it's the one layer the agent cannot hallucinate its way past. As I argued in the agent harness post, permissions are the agent's IAM. This is what that IAM should literally be.
Why an agent SA is not a CI bot SA
You already run non-humans against the API server: CI pipelines, controllers, operators. The instinct is to clone one of those ServiceAccounts. Resist it. Agents differ in two ways that change the RBAC design:
- Agents generate their own requests. A CI job runs the commands in a reviewed pipeline file. An agent decides what to call at runtime, influenced by whatever text landed in its context — logs, alerts, PR comments. Its request stream is untrusted input by construction, so the ceiling must assume the requester is confused or manipulated.
-
Agents read everything they can. A controller reads the three resource types it manages. An agent exploring an incident will happily
getanything readable — and if Secrets are readable, secret material ends up in the model context, the conversation transcript, and possibly a third-party API. That's a credential leak with extra steps, which is why secrets management for agents starts with "the agent's SA cannot read Secrets, full stop."
So the design rule: one agent, one ServiceAccount, one namespace, read-only, deny-by-omission. Never reuse a human kubeconfig, never bind view cluster-wide "for now," and never share one SA across agents — shared identity destroys the audit trail you'll need the first time something weird happens at 03:00.
The ServiceAccount and Role
Everything the diagnostic agent needs, nothing else. Note what is absent from the resource list — secrets above all:
apiVersion: v1
kind: ServiceAccount
metadata:
name: ops-agent-readonly
namespace: staging
automountServiceAccountToken: false # nothing should mount this implicitly
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: ops-agent-readonly
namespace: staging
rules:
- apiGroups: [""]
resources: ["pods", "pods/log", "services", "events",
"configmaps", "endpoints", "resourcequotas"]
verbs: ["get", "list", "watch"]
- apiGroups: ["apps"]
resources: ["deployments", "replicasets", "statefulsets", "daemonsets"]
verbs: ["get", "list", "watch"]
- apiGroups: ["metrics.k8s.io"]
resources: ["pods"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: ops-agent-readonly
namespace: staging
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: ops-agent-readonly
subjects:
- kind: ServiceAccount
name: ops-agent-readonly
namespace: staging
Three deliberate choices worth calling out:
-
pods/logis granted explicitly. Log access is a subresource — grantingpodsalone does not grant logs, and vice versa. Diagnostic agents live on logs, so grant it; but remember logs can contain secrets too, which is an argument for piping the agent through a log-search MCP server with redaction instead of rawpods/login sensitive namespaces. -
configmapsis a judgment call. Agents often need ConfigMaps to debug config-drift incidents, but teams stuff credentials into ConfigMaps more often than anyone admits. If that's your shop, drop it from the rule and let the agent ask a human. -
No ClusterRole. A Role caps the agent to
staging. If the agent later needs a second namespace, create a second RoleBinding to the same shared ClusterRole-style rules — don't reach for a cluster-wide grant because it's fewer YAML lines.
The subresources that actually hurt you
The scary permissions are mostly subresources and RBAC-meta verbs, and they're easy to grant by accident with a wildcard:
-
pods/exec,pods/attach,pods/portforward— a shell inside a pod is effectively arbitrary code execution with the pod's own credentials. No autonomous agent gets these. -
secretswithgetorlist—listalone returns full Secret objects, base64 and all. Both verbs are radioactive here. -
serviceaccounts/token— the ability to mint tokens for other ServiceAccounts is privilege escalation as a service. -
impersonate,escalate,bind— the RBAC-meta verbs that let an identity become someone else or grow its own role. If you see any of these on an agent's Role, treat it as an incident.
The rule of thumb: never use wildcards in an agent Role. resources: ["*"] silently includes every subresource above, today and after every future cluster upgrade.
Short-lived tokens, not forever-keys
The classic mistake is creating a ServiceAccount token Secret and pasting it into a kubeconfig that lives on the agent box for a year. Since Kubernetes 1.24, the right pattern is the TokenRequest API — tokens that expire and are bound to an audience:
# Mint a 1-hour token for the agent SA (requires the TokenRequest API, v1.24+)
TOKEN=$(kubectl create token ops-agent-readonly -n staging --duration=1h)
# Build the kubeconfig the MCP server will use — and nothing else will
kubectl config set-credentials ops-agent \
--token="$TOKEN"
kubectl config set-context mcp-readonly@cluster \
--cluster=cluster --user=ops-agent --namespace=staging
In production don't do this by hand: the wrapper that launches the MCP server mints a fresh token at startup and re-mints on expiry. If the agent runs inside the cluster, use a projected token volume with expirationSeconds: 3600 instead — the kubelet rotates it for you. Either way the property you're buying is the same: a leaked credential is worth an hour, not a year. The token lands in exactly one place — the MCP server's environment — and never in the model's context, its prompt, or its memory.
Prove the ceiling: the auth can-i test matrix
RBAC you haven't tested is RBAC you're guessing about. kubectl auth can-i --as lets you check the agent's exact permissions without holding its token:
AS="system:serviceaccount:staging:ops-agent-readonly"
# Must all say "yes"
kubectl auth can-i get pods -n staging --as="$AS"
kubectl auth can-i get pods/log -n staging --as="$AS"
kubectl auth can-i list deployments -n staging --as="$AS"
# Must all say "no"
kubectl auth can-i get secrets -n staging --as="$AS"
kubectl auth can-i create pods/exec -n staging --as="$AS"
kubectl auth can-i delete pods -n staging --as="$AS"
kubectl auth can-i get pods -n prod --as="$AS"
kubectl auth can-i '*' '*' -A --as="$AS"
Put this in CI as a script that fails on any unexpected answer, right next to the RBAC manifests. It's the cheapest eval your ops agent will ever have: deterministic, sub-second, and it catches the "someone helpfully widened the Role" regression before the agent does.
The write path gets a different identity
Sooner or later the agent graduates from diagnosing to fixing. Do not widen ops-agent-readonly. Create a second ServiceAccount — ops-agent-write — bound to the narrowest mutation surface you can defend (say, patch on deployments for restarts and replica changes), and route every call through a dry-run-plus-approval flow like the one in human-in-the-loop approval gates. Two identities means the read loop that runs unattended all day physically cannot mutate anything, and the write identity can be disabled in one command during an incident. Better still, skip direct writes entirely and have the agent open pull requests instead of running kubectl — then the write SA belongs to your GitOps controller, which you already trust.
Audit the agent like the untrusted user it is
A dedicated SA makes the audit log useful: every request the agent makes carries user.username: system:serviceaccount:staging:ops-agent-readonly. Give it its own audit policy stanza so agent activity is captured at higher fidelity than normal system noise:
apiVersion: audit.k8s.io/v1
kind: Policy
rules:
- level: RequestResponse # full bodies for anything the agent does
users: ["system:serviceaccount:staging:ops-agent-readonly",
"system:serviceaccount:staging:ops-agent-write"]
- level: Metadata # everything else stays cheap
Ship those entries to the same place as your other telemetry and alert on two conditions: any 403 Forbidden from an agent SA (the agent is trying to exceed its ceiling — maybe a bug, maybe injection), and any request from the write SA outside an approved change window. This is the API-server-side complement to tracing the agent's own tool calls: the agent's logs tell you what it meant to do; the audit log tells you what it actually did.
Freeze the ceiling with an admission policy
The last failure mode is drift: six months from now, someone debugging at 2 a.m. binds cluster-admin to the agent SA "temporarily." A Kyverno policy turns that into a hard error instead of a time bomb:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: freeze-agent-rbac
spec:
validationFailureAction: Enforce
rules:
- name: only-approved-roles-for-agent-sas
match:
any:
- resources:
kinds: ["RoleBinding", "ClusterRoleBinding"]
preconditions:
any:
- key: "ops-agent-readonly"
operator: AnyIn
value: "{{ request.object.subjects[].name || `[]` }}"
- key: "ops-agent-write"
operator: AnyIn
value: "{{ request.object.subjects[].name || `[]` }}"
validate:
message: "Agent ServiceAccounts may only bind their approved Roles (change via GitOps review)."
deny:
conditions:
all:
- key: "{{ request.object.roleRef.name }}"
operator: AnyNotIn
value: ["ops-agent-readonly", "ops-agent-write"]
Any binding that hands an agent SA a role outside the approved list is rejected at admission, cluster-wide, no matter who applies it. Changes to the agent's power now have exactly one path: edit the Role in Git, pass review, let the CI auth can-i matrix confirm the new ceiling.
Takeaway
The tool allowlist in your MCP server is a UX feature. The RBAC layer is the security boundary. Give each agent its own ServiceAccount; grant read verbs on an explicit resource list with no Secrets, no exec, no wildcards; authenticate with tokens that die in an hour; split the write path onto a second, gated identity; watch the audit log for 403s; and freeze the bindings with admission policy so the ceiling can't drift. Do that, and the day your agent hallucinates a destructive command, the API server — not your prompt — is what says no.
📌 Read the latest version of this guide — plus the full library of DevOps, SRE, Kubernetes, observability & cloud-cost guides — on devtocash.com.
Top comments (0)