Security model
Giving a language model a path into your cluster is a real decision. This page is about what that path is bounded by — and, just as importantly, what it is not bounded by.
The two layers, and only one is a boundary
Two independent things decide what can happen to your cluster:
| Layer | Configured by | Enforced by | Is it a boundary? |
|---|---|---|---|
| RBAC — what the credential is able to do | rbac.yaml |
The Kubernetes API server | Yes. Real, and enforced no matter who holds the token. |
| Env vars — what this server is willing to do | MICROK8S_MCP_* |
This Python process | No. A convenience. Anything else holding the token ignores it. |
The asymmetry matters, and it only runs one way. A narrow RBAC binding makes a permissive
env var harmless: if the token is bound only to the reader role, then
MICROK8S_MCP_MODE=read-write still cannot hurt you, because the API server refuses.
The reverse is not true. MICROK8S_MCP_NAMESPACES=apps against a cluster-wide
ClusterRoleBinding still leaves a token that can write anywhere — this server simply declines
to be the one doing it. Anything else that reads the kubeconfig has no such scruples.
Set both layers. Use the env vars to keep the agent focused, and RBAC to make the boundary true.
What rbac.yaml grants
Applying it creates the mcp-system namespace, a claude-mcp
ServiceAccount, a long-lived token Secret, and two ClusterRoles:
claude-mcp-reader — always bound
get/list/watch across the cluster, except Secrets. That exclusion is deliberate and it is the point: your database passwords, TLS keys and API tokens live in Secrets, and nothing that can read them should be feeding a model context window.
claude-mcp-operator — bound as shipped
create/update/patch/delete on deployments, statefulsets, daemonsets, pods, services, configmaps, PVCs, jobs, cronjobs, ingresses and networkpolicies — plus node patch and pod eviction. Secrets stay excluded here too, and the credential cannot escalate its own permissions.
The operator ClusterRoleBinding is active in the repository as shipped.
kubectl apply -f rbac.yaml therefore grants cluster-wide write, in
every namespace. That is a defensible default for a homelab you can rebuild in ten
minutes, and the wrong one for anything else. Decide before you apply.
Three ways to shape the grant
In descending order of how much they actually protect you:
| Approach | Enforced by | Effect |
|---|---|---|
Comment out the claude-mcp-operator binding |
API server | No writes at all |
Replace it with per-namespace RoleBindings |
API server | Writes only in the namespaces you name |
MICROK8S_MCP_NAMESPACES=apps,staging |
This server | Server declines elsewhere; the credential could still do it |
To narrow rather than remove, swap the ClusterRoleBinding for a RoleBinding per namespace —
same roleRef and subjects, plus a namespace:
kind: RoleBinding
metadata:
name: claude-mcp-operator
namespace: apps # one per namespace you want writable
Removing a binding needs more than an edit. kubectl apply
will not delete a binding you merely deleted from the YAML. If you already applied it and
want it gone now:
kubectl delete clusterrolebinding claude-mcp-operator
Audit what you actually have
Do not trust either layer's configuration — ask the API server. This is the only answer that counts:
kubectl --kubeconfig ~/.kube/claude-mcp.kubeconfig auth can-i --list
Two specific checks worth running after any change:
# Secrets must be unreadable — this is the one that must never come back "yes"
kubectl --kubeconfig ~/.kube/claude-mcp.kubeconfig auth can-i list secrets -A
# Can it write where you think it cannot?
kubectl --kubeconfig ~/.kube/claude-mcp.kubeconfig auth can-i delete deployments -n kube-system
The kubeconfig itself holds a live bearer token. Keep it at mode 600 — the setup targets write it that way — and delete it when you tear the identity down, since the token stays valid until the Secret is gone.
What is deliberately missing
Some capabilities are absent by design, not oversight:
- No
kubectl exec, no port-forward, no raw shell. An exec tool is arbitrary code execution inside your cluster, driven by model output. If you want that, add it yourself and understand what you are accepting. - No Secret reads in the bundled role, as above.
- No bulk deletes.
delete_resourcetakes one kind and one name; there is no--alland no selector path, so no single call can cascade. - No shell interpolation. Commands are built as argument lists, never
strings. Kinds, names and namespaces are regex-validated, so nothing can be smuggled in as
a kubectl flag. Remote SSH commands are
shlex.quoted per argument. - No privilege escalation. Neither role grants the verbs that would let the credential widen its own bindings.
The kubectl fallback, and why it matters
One path deserves singling out. If the server cannot find a local kubectl, it
falls back to running microk8s kubectl on the node — which executes under that
node's admin credential. On that path the scoped kubeconfig and both roles
stop applying, and Secrets become readable again.
This is a genuine escalation, and it happens quietly. The trigger is mundane: a kubectl in
~/.local/bin is on your PATH but not necessarily on the PATH Claude Code
hands the server.
Two defences:
- Set
MICROK8S_MCP_KUBECTLto an absolute path.make registerdoes this for you. - The server logs a warning whenever it takes the fallback. Grep your MCP logs for
falling back to `microk8s kubectl`.
Audit logging
Every command the server executes is logged to stderr before it runs, with arguments quoted. Capture that stream if you want a trail of everything the agent did:
--env MICROK8S_MCP_LOG_LEVEL=INFO
Startup also logs the effective policy — mode, kubeconfig, backend and namespace allowlist — so the first lines of any session tell you what this process believes it is allowed to do.
Reporting a vulnerability: please open an issue on GitHub. This is a small hobby project, not a vendor product — there is no SLA, and you should read the code before trusting it with anything that matters.