KubeGlanceDownload
Giving an AI coding agent read-only access to your Kubernetes cluster

security

Giving an AI coding agent read-only access to your Kubernetes cluster

Give an AI coding agent its own read-only Kubernetes identity: a ServiceAccount config bound to a get/list/watch ClusterRole, checked with kubectl auth can-i.

· 10 min read

An AI coding agent that runs kubectl should not run it as you. If Claude Code, Cursor or Copilot is shelling out to kubectl and picking up your default kubeconfig, it is acting with your identity — which, on most engineers’ laptops, is cluster-admin. The fix is not a prompt asking it to be careful. It is a separate identity that the API server will not let write anything, whatever the agent decides to try.

That identity is a ServiceAccount, bound to a read-only ClusterRole, handed to the agent as a standalone kubeconfig carrying a short-lived token. The guarantee is enforced server-side, so it holds regardless of what the agent, its wrapper, or any MCP server in between believes about its own permissions.

Everything below was built and verified on a single-node cluster running Kubernetes v1.36.1.

The fastest version

Four objects and a file. Create the identity, bind read-only rules to it, mint a bounded token, and assemble a kubeconfig around that token:

kubectl create namespace k28agent
kubectl -n k28agent create serviceaccount coding-agent
namespace/k28agent created
serviceaccount/coding-agent created

The role is the part worth getting right, so it has its own section below. Once it exists and is bound, the whole point is provable in one command — as the token, not as yourself. Skip to proving it if you only want the check.

The role: read-only is a verb set, not a mode

Kubernetes has no read-only switch. Read-only is exactly three verbs — get, list, watch — and it means whatever resources you attach them to. A ClusterRole that grants those verbs across the resources an agent actually needs to reason about a cluster, and nothing else:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: k28agent-readonly
rules:
  - apiGroups: [""]
    resources: ["pods", "pods/log", "services", "endpoints", "configmaps",
                "events", "namespaces", "nodes", "persistentvolumeclaims",
                "persistentvolumes", "serviceaccounts"]
    verbs: ["get", "list", "watch"]
  - apiGroups: ["apps"]
    resources: ["deployments", "replicasets", "statefulsets", "daemonsets"]
    verbs: ["get", "list", "watch"]
  - apiGroups: ["batch"]
    resources: ["jobs", "cronjobs"]
    verbs: ["get", "list", "watch"]
  - apiGroups: ["networking.k8s.io"]
    resources: ["ingresses", "networkpolicies"]
    verbs: ["get", "list", "watch"]

Bind it cluster-wide, so the agent can read every namespace it might be asked about, and apply both:

kubectl apply -f role.yaml
kubectl create clusterrolebinding k28agent-readonly \
  --clusterrole=k28agent-readonly \
  --serviceaccount=k28agent:coding-agent
clusterrole.rbac.authorization.k8s.io/k28agent-readonly created
clusterrolebinding.rbac.authorization.k8s.io/k28agent-readonly created

For the reasoning behind each verb and resource — including the cluster-scoped reads the built-in view role silently omits — see RBAC for a read-only Kubernetes user. This article is about handing that role to a non-human that will try things you did not anticipate.

The Secrets question: leave them out

Notice what is not in that role: secrets. This is deliberate, and it is the one decision people get wrong when they set an agent up “to be helpful”.

get secrets is not read access in the ordinary sense. A Secret contains a credential, so reading it is holding the credential — there is no difference between “can read the database password” and “has the database password”. An agent with get secrets cluster-wide can read every service account token, every TLS private key, and every application password in the cluster, and an agent is precisely the kind of process that will paste one into a log, a prompt, or a file it then commits.

So the role excludes Secrets, and the check confirms it:

kubectl auth can-i list secrets -A
no

Worth knowing the boundary is leakier than it looks: the role grants pods/log, and applications log secrets all the time. There is no RBAC rule that fixes that — only application discipline. Read access to logs is genuinely useful to an agent and genuinely a data-exposure route, and both are true at once.

Mint a token with a lifetime

Since Kubernetes 1.24 a ServiceAccount does not get a permanent token Secret. That is a feature. Request a signed, time-bound one instead:

kubectl -n k28agent create token coding-agent --duration=8h

That prints a JWT to stdout. --duration requests a lifetime; the API server may return a shorter one if it is configured with a lower cap, so treat the value as a ceiling rather than a promise. Eight hours is a working session — long enough to be useful, short enough that a leaked token is worth nothing tomorrow. If you need the agent to work unattended past that, mint a fresh token, do not reach for a long-lived Secret you then have to remember to rotate.

Build a standalone kubeconfig

Do not merge this into ~/.kube/config, where a name collision could shadow it or your own context could be picked up by mistake. Build a separate file, and build it with kubectl config rather than by hand so every field is well-formed. The --kubeconfig flag makes each command write to that file instead of your default:

SERVER=$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')
kubectl config view --raw --minify \
  -o jsonpath='{.clusters[0].cluster.certificate-authority-data}' | base64 -d > ca.crt
TOKEN=$(kubectl -n k28agent create token coding-agent --duration=8h)

kubectl config --kubeconfig=agent.kubeconfig set-cluster target \
  --server="$SERVER" --certificate-authority=ca.crt --embed-certs
kubectl config --kubeconfig=agent.kubeconfig set-credentials coding-agent \
  --token="$TOKEN"
kubectl config --kubeconfig=agent.kubeconfig set-context agent \
  --cluster=target --user=coding-agent --namespace=k28agent
kubectl config --kubeconfig=agent.kubeconfig use-context agent
Cluster "target" set.
User "coding-agent" set.
Context "agent" created.
Switched to context "agent".

Each flag earns its place. --embed-certs inlines the CA into the file so it is self-contained and you can hand it over as one artifact; without it the file would carry a path to ca.crt that has to travel with it. set-credentials --token stores the bearer token and nothing else — no certificate, no exec plugin, no way to widen the grant. set-context joins a cluster to a user and pins a default namespace; use-context marks it current so the agent needs no --context flag.

Lock the file down — it is a live credential until the token expires:

chmod 600 agent.kubeconfig

Prove it, don’t trust it

The whole design collapses to one command. Ask the cluster what this identity may do, using the file you just built:

KUBECONFIG=./agent.kubeconfig kubectl auth can-i --list
Resources                        Non-Resource URLs   Resource Names   Verbs
...
configmaps                       []                  []               [get list watch]
endpoints                        []                  []               [get list watch]
events                           []                  []               [get list watch]
namespaces                       []                  []               [get list watch]
nodes                            []                  []               [get list watch]
persistentvolumeclaims           []                  []               [get list watch]
persistentvolumes                []                  []               [get list watch]
pods/log                         []                  []               [get list watch]
pods                             []                  []               [get list watch]
serviceaccounts                  []                  []               [get list watch]
services                         []                  []               [get list watch]
daemonsets.apps                  []                  []               [get list watch]
deployments.apps                 []                  []               [get list watch]
replicasets.apps                 []                  []               [get list watch]
statefulsets.apps                []                  []               [get list watch]
cronjobs.batch                   []                  []               [get list watch]
jobs.batch                       []                  []               [get list watch]
ingresses.networking.k8s.io      []                  []               [get list watch]
networkpolicies.networking.k8s.io []                 []               [get list watch]

get, list, watch. No create, update, patch or delete anywhere, and no secrets. The negative cases are the specification, and they are worth stating explicitly:

KUBECONFIG=./agent.kubeconfig kubectl auth can-i get secrets -A       # no
KUBECONFIG=./agent.kubeconfig kubectl auth can-i delete pods -A       # no
KUBECONFIG=./agent.kubeconfig kubectl auth can-i patch deployments -A # no
KUBECONFIG=./agent.kubeconfig kubectl auth can-i create pods -A       # no

Every one of those printed no. And the reads that should work, do — the actual API server responses, not a promise:

KUBECONFIG=./agent.kubeconfig kubectl get pods -n k28agent
KUBECONFIG=./agent.kubeconfig kubectl get nodes
NAME     READY   STATUS    RESTARTS   AGE
target   1/1     Running   0          39s
NAME                        STATUS   ROLES           AGE   VERSION
glance-blog-control-plane   Ready    control-plane   31d   v1.36.1

Why the API server, and not the agent’s “read-only mode”

Authentication resolves the token to an identity; authorisation checks the verb against the bound role. Both happen server-side, on every request.

Many agents and MCP servers advertise a “read-only mode”. Understand where it sits. That mode is a client-side gate: it decides whether the agent sends a mutating request. It runs inside the process, before anything reaches the network. RBAC runs on the API server, after the request arrives, and it is the only thing that decides whether a request that was sent takes effect.

The two are not substitutes, because the failure modes that matter are exactly the ones a client-side gate cannot cover — a bug in the gate, a prompt injection that talks the agent past it, a tool the wrapper did not know mutates, a future version that changes the default. A client-side switch is a promise made by the code you are trusting not to have a bug. A token that cannot write is a fact, enforced by a component that is not the agent and does not run the agent’s code. Set the agent’s read-only mode as well by all means — defence in depth is real — but the boundary is the token. If kubectl auth can-i --list says the identity cannot write, it cannot write, no matter what the agent tries to send.

(This is a claim about mechanism, not about any specific product. If a particular MCP server’s read-only mode does more than gate outbound calls, check its documentation — the general point is that no client-side control can be stronger than the RBAC behind it.)

Verbs that look like reads and are not

A read-only role is not finished when you have removed delete. Several subresources are reached with verbs that look harmless, and a few have a quirk on current Kubernetes worth checking against your own cluster with can-i:

  • pods/exec opens a shell in a container — total control of the workload. It is created, not read, so the role above cannot reach it. But there is a subtlety: since the exec transport moved to a GET-initiated WebSocket upgrade, a role granting get pods/exec reports can-i get pods --subresource=exec as yes, which looks alarming. On 1.36 it is not sufficient on its own: the feature gate AuthorizePodWebsocketUpgradeCreatePermission (Beta, on by default since 1.35 — verified on 1.36.1, source-checked on release-1.36) forces an additional create check on the upgrade. An identity with only get on the subresource gets 403 ... cannot create resource "pods/exec" when it actually tries. The safe rule is unchanged: grant neither get nor create on pods/exec, pods/attach or pods/portforward, and confirm with can-i.
  • pods/portforward gives network reach to anything the pod can reach, from the agent’s host. create, and excluded here.
  • nodes/proxy is the sharp one. get nodes/proxy proxies straight to the kubelet API, which can run exec on any pod on that node — so a role with get nodes and get nodes/proxy is effectively exec-everywhere, dressed as a read. The role above grants get nodes but deliberately not nodes/proxy; kubectl auth can-i get nodes --subresource=proxy returns no.
  • serviceaccounts/token lets the holder mint tokens for other ServiceAccounts in scope, including more privileged ones — a direct escalation. create, and not granted; can-i create serviceaccounts --subresource=token -A returns no.

Every one of those no answers came out of kubectl auth can-i against the real role on 1.36.1. When you audit any role that calls itself read-only, grep for exec, attach, portforward, proxy and token before you grep for delete.

Handing it to the agent

The agent needs one environment variable pointing at the file. However you launch it:

KUBECONFIG=/path/to/agent.kubeconfig claude

or set it in the shell the agent inherits. It will pick up the context, act as system:serviceaccount:k28agent:coding-agent, and get a 403 the instant it tries to write — the same 403 that kubectl, KubeGlance, or anything else holding that file would get. That symmetry is the point: the security review of any tool you point at a cluster is kubectl auth can-i --list with its credentials, and the answer is the same whoever is asking.

Preventing the quiet re-widening

  • Keep the kubeconfig separate and mode 600. It is a bearer credential. If you ever do merge kubeconfigs, know which file wins — see how KUBECONFIG merges multiple files.
  • Re-run can-i --list after anyone touches RBAC. The view-family roles are aggregated and can be widened cluster-wide by an unrelated change; the same auditing that applies to human roles applies here, and it is covered in RBAC for a read-only Kubernetes user.
  • Prefer a fresh short token over a long-lived one. A token minted with --duration expires on its own. A Secret-backed token is a credential you now own forever.
  • Do not scope down by editing the file. A kubeconfig grants nothing by itself; the identity inside it does. Issue a narrower identity instead, as in what a kubeconfig actually grants.

The honest version of a read-only guarantee is not a setting in the agent. It is a token the API server will not let write, and one command that proves it.

#rbac #security #serviceaccount #kubeconfig #ai agents #read-only

The Kubernetes dashboard that fits in your pocket

KubeGlance is a native Kubernetes client for iPhone and iPad — the real dashboard, not a companion — with a full Mac app on the same core. Pods, workloads, logs and events, straight from your kubeconfig.

Download KubeGlance