
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.
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”
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/execopens 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 aGET-initiated WebSocket upgrade, a role grantingget pods/execreportscan-i get pods --subresource=execasyes, which looks alarming. On 1.36 it is not sufficient on its own: the feature gateAuthorizePodWebsocketUpgradeCreatePermission(Beta, on by default since 1.35 — verified on 1.36.1, source-checked on release-1.36) forces an additionalcreatecheck on the upgrade. An identity with onlygeton the subresource gets403 ... cannot create resource "pods/exec"when it actually tries. The safe rule is unchanged: grant neithergetnorcreateonpods/exec,pods/attachorpods/portforward, and confirm withcan-i.pods/portforwardgives network reach to anything the pod can reach, from the agent’s host.create, and excluded here.nodes/proxyis the sharp one.get nodes/proxyproxies straight to the kubelet API, which can run exec on any pod on that node — so a role withget nodesandget nodes/proxyis effectively exec-everywhere, dressed as a read. The role above grantsget nodesbut deliberately notnodes/proxy;kubectl auth can-i get nodes --subresource=proxyreturnsno.serviceaccounts/tokenlets 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 -Areturnsno.
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 --listafter anyone touches RBAC. Theview-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
--durationexpires 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.
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

