An agent in a cluster, holding nothing
This is the third world with a Kubernetes cluster in it. The agent reaches the cluster only through an rta instance, and that instance holds only an identity you minted for it: one namespace, a few named reads, and a token the cluster itself stops honouring when its time is up. The agent holds a bearer token for its own instance and nothing else, and every call it makes lands in that instance's record.
It is the narrow answer to giving an instance access to the cluster it runs in: no ClusterRole, no automounted ServiceAccount token, and a deadline that does not depend on anybody remembering to revoke.
The first two steps run on your own machine, with your own cluster access; the rest put the result somewhere the agent can reach and you can watch.
1. Name the cluster once
rta plugin index add official
rta plugin install kube
rta plugin allow kube
rta profile set payments-staging --note "payments, staging" \
--plugin kube --set context=staging --set namespace=paymentskube shells out to kubectl, and kubectl needs your kubeconfig — a location rta denies every plugin until you allow it, a second decision after installing. The profile names the context and the namespace the next steps work in, so none of them repeats --context or --namespace; rta profile show payments-staging says what it covers.
2. Mint the identity
rta kube serviceaccount provision agent-payments --profile payments-staging \
--grant kube.pod.list --grant kube.event.list --grant kube.deployment.list \
--ttl 24h --dry-runThe dry run creates nothing, and prints the Role it would bind:
would create ServiceAccount/Role/RoleBinding agent-payments in namespace payments, mint a token valid for 24h, and return the assembled kubeconfig.
Role rules:
apps/deployments: get,list
/events: get,list
/pods: get,listThen the same line without --dry-run, and with a file to write:
rta kube serviceaccount provision agent-payments --profile payments-staging \
--grant kube.pod.list --grant kube.event.list --grant kube.deployment.list \
--ttl 24h --out ~/minted/agent-payments.kubeconfig- Each
--grantis a capability the agent will call, and costs exactly the rules that capability's reads need. The bare words it also takes —logs,workloads,services,rollout— are permissions for a client other than rta, and an agent that reaches the cluster only through rta has no capability that would use them. A cluster-wide read such askube.node.listcannot be put in a namespaced Role at all, and is refused rather than half-granted. - The file holds no credential of yours: the cluster's address as your context names it, the cluster's CA, and the new token, at
0600.--outrefuses a file that already exists — before anything is created on the cluster — unless--forcesays to replace it. - The answer's
actual token expiryis the one that counts. A cluster may cap a token below what--ttlasked for, silently, and the token is the only place that shows.--ttltakes ten minutes at the least, the TokenRequest API's own floor. - An agent cannot do this for itself. Provisioning is for the person at the terminal, and is on no agent's tool list, however the server is started.
3. Build the image the agent talks to
The image is the plugin allowlist. The published narrow image carries no plugin and no kubectl, and the full one carries every plugin, so this one is derived: the full image's own Dockerfile, narrowed to one plugin by a build argument.
git clone --depth 1 --branch v<version> https://github.com/this-is-tobi/rta.git && cd rta
docker build -f Dockerfile.full --build-arg VERSION=<version> --build-arg RTA_PLUGINS=official:kube \
-t registry.example.com/platform/rta-kube:<version> .
docker push registry.example.com/platform/rta-kube:<version>kube is installed from the official index the way rta plugin install installs it — fetched, checked against the index's sha256, trusted by the digest rta computed — into the read-only system root a data volume cannot hide, with kubectl beside it. The external tools the full image carries come along, but an agent reaches a tool only through a capability — rta's own, or one a plugin in the image declares — and the only plugin here is kube. The image's entrypoint is what reads the chart's plugins.allow, below.
4. Deploy it, holding the minted kubeconfig
kubectl create namespace rta
kubectl -n rta create secret generic agent-payments-kubeconfig \
--from-file=config="$HOME/minted/agent-payments.kubeconfig"
( umask 077
printf 'laptop %s\n' "$(rta gen token -o json | jq -r '.pairs[] | select(.key == "token") | .value')" \
> rta-tobi-tokens.txt )
kubectl -n rta create secret generic rta-tobi-tokens --from-file=tokens=rta-tobi-tokens.txtTwo Secrets: the minted kubeconfig, and the token file the instance checks every caller against — one label token line per caller, and laptop is the label its record will name. Then rta-values.yaml:
image:
registry: registry.example.com
repository: platform/rta-kube
tag: "<version>" # a digest is better; see Kubernetes
servers:
tobi:
host: rta-tobi.example.com
roots: ["/work"]
auth:
tokens:
enabled: true
existingSecret: rta-tobi-tokens
plugins:
allow: ["kube"]
env:
- name: HOME
value: /home/rta
extraVolumes:
- name: minted-kubeconfig
secret:
secretName: agent-payments-kubeconfig
extraVolumeMounts:
- name: minted-kubeconfig
mountPath: /home/rta/.kube
readOnly: true
ingress:
enabled: true
className: nginx
tlsSecretName: rta-tobi-tlshelm install rta oci://ghcr.io/this-is-tobi/rta/rta-chart --namespace rta --values rta-values.yamlHOMEand the mount put the minted file where kubectl looks,$HOME/.kube/config: rta hands a plugin noKUBECONFIG, for the reason Kubernetes gives.plugins.allowis rta's own question about that file — maykuberead it — answered for this instance.- Nothing else in the pod reaches the cluster. The chart leaves the pod's own ServiceAccount token unmounted and binds it no role, so the minted identity is the only one there.
- The config holds no profile. One cluster and one identity make the identity the bound: a
kuberead is an ungated read, and each one lands in the record. A profile there would put every call behind a grant issued on the instance — the heavier shape, for when the operator channel is set up anyway. - The pod has to reach the cluster at the address your context names, since that is the address in the file. With the chart's NetworkPolicy on, that is one more egress rule.
5. Connect the agent
claude mcp add --transport http rta-payments https://rta-tobi.example.com/ \
--header 'Authorization: Bearer ${RTA_PAYMENTS_TOKEN}'with RTA_PAYMENTS_TOKEN holding the token on the laptop line of rta-tobi-tokens.txt in the environment Claude Code starts from — your shell profile, or a secret manager that exports it.
Any client that speaks MCP over HTTP takes the same two things: the instance's URL, and the token as a bearer header. Ask the agent which pods in payments are not ready: it calls kube_pod_list, and the call is in the instance's record under the agent tobi and the credential laptop:
kubectl -n rta exec deploy/rta-rta-chart-tobi -- rta agent log --limit 20The agent's tool list carries the kube reads and no way to mint an identity, and nothing that describes the machine the instance runs on — a remote transport never registers those.
The header is the whole credential on this transport, so the command writes it as a reference: the single quotes keep the shell from expanding it, Claude Code expands it from the environment it starts in, and its configuration, which every process you run can read, names the variable and never holds the token. Pasted in as the token itself, the header is one rta audit clients fails, and --fix says where it belongs instead — the environment that launches the client, named from the file the way that client reads it, or the client's own credential helper. A static token names whoever holds it; Kubernetes weighs it against OIDC for an instance a person uses.
When the token runs out
The minted token stops working at its expiry, whatever rta does, so the instance loses its reach on schedule rather than keeping it forever. To keep going, mint another — provisioning refuses a name already in use — swap it into the Secret, then revoke the old one:
rta kube serviceaccount list --profile payments-staging
rta kube serviceaccount provision agent-payments-2 --profile payments-staging \
--grant kube.pod.list --grant kube.event.list --grant kube.deployment.list \
--ttl 24h --out ~/minted/agent-payments-2.kubeconfig
kubectl -n rta create secret generic agent-payments-kubeconfig \
--from-file=config="$HOME/minted/agent-payments-2.kubeconfig" --dry-run=client -o yaml \
| kubectl apply -f -
rta kube serviceaccount revoke agent-payments --profile payments-staginglist is an estimate from what provisioning recorded, since Kubernetes keeps no object for a token to ask about. The kubelet refreshes a mounted Secret in place and kubectl reads the file on every call, so the instance needs no restart; revoke once the new file has landed. Revoking deletes the ServiceAccount, which is also the one way to end a token before its time: every token minted against it stops working at once.
That takes the reach away. To stop the agent itself, lock it on the instance — kubectl -n rta exec deploy/rta-rta-chart-tobi -- rta lock add tobi — and every call it makes is refused from the next one.
Related
- Kubernetes — the decisions to make before any value, and day two
- The kube plugin — every capability, and the rules each grant name costs
- For a security team — the rest of what owning the boundary involves
Next
Start here — pick another track.