Key Takeaway
kubectl-ai asks for confirmation before any command its classifier does not mark read-only, but that classifier is a heuristic, and its MCP server mode skips the prompt and listens unauthenticated on every interface by default. Treat the prompt as a convenience, enforce limits with a read-only RBAC identity, and enable OAuth before exposing the HTTP endpoint.
I wanted to know one thing about Google's kubectl-ai before pointing it at a cluster: when it says it will ask before changing anything, what exactly decides what counts as a change? The README says skipPermissions defaults to false and that resource-modifying commands need confirmation. That is a sentence about intent. I wanted the function behind it.
So this guide is built from the source, not the marketing. I read the README, the MCP server documentation, and three Go files from the main branch at commit 2c8ff82 (15 July 2026), then built the binary and a throwaway test harness on my own machine to ask the classifier ten questions and the MCP server one. The latest release at the time of writing is v0.0.31 from March 2026, so main is ahead of it. Everything marked measured below is my own run, and anything I could not confirm is left out.
What kubectl-ai is, and the four ways to run it
kubectl-ai is an open-source Go agent that turns a request such as how is the nginx app doing into kubectl and bash calls, runs them, reads the output and keeps going until the task is done. It installs from a release binary, an install script or Krew (kubectl krew install ai), and because kubectl treats any executable named kubectl- on your PATH as a plugin, kubectl ai works too. Gemini is the default provider, with vertexai, azopenai, openai, grok, bedrock, ollama and llama.cpp also listed in the README.
# Pick one. All three are documented in the README.
curl -sSL https://raw.githubusercontent.com/GoogleCloudPlatform/kubectl-ai/main/install.sh | bash
kubectl krew install ai # then invoke it as: kubectl ai
# Gemini is the default provider; the key comes from the environment.
export GEMINI_API_KEY=your_api_key_here
kubectl-ai # 1. interactive chat, keeps context
kubectl-ai --quiet "fetch logs for nginx app in hello namespace" # 2. one-shot
cat error.log | kubectl-ai "explain the error" # 3. stdin pipeline
kubectl-ai --ui-type web --ui-listen-address localhost:8888 # 4. web UI
# Local model: small models need the tool-use shim to emit tool calls at all.
kubectl-ai --llm-provider ollama --model gemma3:12b-it-qat --enable-tool-use-shimIt runs four ways: an interactive chat that keeps context, a one-shot run with --quiet, a stdin pipeline, and a web UI. The README labels the project as not an officially supported Google product, and not eligible for Google's open source vulnerability rewards programme. That is worth reading before you give it credentials to anything that matters. The commands below are the ones the README documents.
The confirmation gate, read from the source
Every tool in the agent implements a CheckModifiesResource method that returns one of three strings: yes, no or unknown. The kubectl classifier parses the command with a shell parser, finds the verb and compares it against two hard-coded lists, one of read-only verbs and one of write verbs. A write verb with a --dry-run flag is downgraded to no.
The detail that matters is in the agent loop in conversation.go. The gate does not ask whether a command is a write. It asks whether the answer is anything other than no. So unknown fails closed: a verb the lists do not know, a flag in an unexpected position, a command that is not kubectl, or a pipeline with more than one command all produce a prompt. The code comment on the multi-command case cites the lethal trifecta, which tells you the authors were thinking about exfiltration, not only accidental deletes.
I ran the classifier from a test file placed next to the package. Here is what it returned, and whether the user would have been asked.
// pkg/tools/zz_probe_test.go: dropped next to the package, then
// go test ./pkg/tools -run TestProbe -v
package tools
import "testing"
func TestProbe(t *testing.T) {
for _, c := range []string{
"kubectl -n prod get pods", // spaced flag before the verb
"kubectl get secret db -o yaml", // a read that leaks
"kubectl auth reconcile -f rbac.yaml", // verb is "auth"
"kubectl get pods | xargs rm", // composite command
} {
t.Logf("%-45s => %s", c, kubectlModifiesResource(c))
}
}| Command | Classifier says | Prompt shown? |
|---|---|---|
| kubectl get pods -n prod | no | No, runs at once |
| kubectl -n prod get pods | unknown | Yes: a spaced flag before the verb cannot be parsed |
| kubectl delete pod web-1 | yes | Yes |
| kubectl delete pod web-1 --dry-run=client | no | No, dry-run is treated as a read |
| kubectl get pods; kubectl delete ns prod | yes | Yes |
| kubectl get pods | xargs rm | unknown | Yes: composite commands are never trusted |
| kubectl get secret db -o yaml | no | No, and the Secret lands in the chat context |
| kubectl auth reconcile -f rbac.yaml | no | No, although it writes RBAC objects |
| kubectl port-forward svc/db 5432:5432 | no | No, it opens a tunnel to your database |
| helm uninstall web | unknown | Yes: not kubectl, so never assumed safe |
Where the gate lets things through
The failure I would plan around is the classification of verbs, not the prompt itself. The classifier keys on the first word after kubectl, so auth is a read-only verb because can-i and whoami are reads, and auth reconcile, which applies RBAC, rides in under it. I ran it through the classifier and got no prompt. The same is true for get secret, which is a legitimate read that is also a data exfiltration step, since the output is appended to the conversation and sent to your model provider.
The second trap is in the confirmation menu. It offers three choices: Yes, Yes and do not ask me again, and No. The middle option sets SkipPermissions to true for the rest of the session. One tired keypress at the end of a long debugging chat turns the whole safeguard off, and the next command, however destructive, runs without a question. When you choose No, the model is told that the user declined and that the call is not retryable, so it should not keep pushing the same command at you.
The classifier decides from the text of the command, not from what the command does to the cluster. Reads that expose Secrets, tunnels, and subcommands such as auth reconcile are classified no and run unprompted. Do not rely on the prompt to protect Secrets or RBAC.
None of this makes the gate useless. It catches the common case well, including the careless multi-command pipeline. It means the gate is a speed bump in front of a model, not an authorisation system, and the two should not be confused when someone asks whether the agent is safe to run against production.
Quiet mode, maxIterations and unattended runs
In --quiet mode there is nobody to ask, and the code handles that bluntly. If the model proposes a command the gate would prompt for, the run exits with an error that says RunOnce mode cannot handle permission requests and lists the commands, and points you at --skip-permissions. So a one-shot run is read-only by default, which is a good property for a cron job, and the flag that lifts it is labelled dangerous in the flag help.
maxIterations defaults to 20 and bounds the number of agent loop turns for one task. When it is reached the agent prints Maximum number of iterations reached and stops. It is a loop breaker, not a spending cap, because the counter is reset to zero whenever you type a new query.
The config file lives at ~/.config/kubectl-ai/config.yaml and command-line flags win over it. This is the one I would start from for a read-mostly assistant.
# ~/.config/kubectl-ai/config.yaml
# Flags override this file; this file overrides defaults.
llmProvider: gemini
skipPermissions: false # the default. Never set true in a file you share.
maxIterations: 20 # loop breaker per task, not a cost cap
kubeconfig: ~/.kube/kubectl-ai-readonly.yaml # a dedicated identity, not your admin context
uiType: terminal
uiListenAddress: localhost:8888 # only matters when uiType is webPin the kubeconfig explicitly with --kubeconfig, and use a file that only ever holds the read-only identity. An agent that inherits your current context can point at production while you believe it is on staging.
MCP client mode: every external tool prompts
With --mcp-client (available from v0.0.12 per the README), kubectl-ai connects to the servers listed in ~/.config/kubectl-ai/mcp.yaml and offers their tools to the model next to kubectl and bash. The tool wrapper returns unknown from CheckModifiesResource for every external tool, with a comment saying it cannot know what an arbitrary tool does. Combined with the gate above, that means each external tool call asks for approval, which is the right default.
# ~/.config/kubectl-ai/mcp.yaml (list form, as in the README and client guide)
servers:
# stdio server: kubectl-ai starts it as a child process
- name: sequential-thinking
command: npx
args:
- -y
- "@modelcontextprotocol/server-sequential-thinking"
# HTTP server with a bearer token read from the environment
- name: custom-api
url: https://api.example.com/mcp
auth:
type: "bearer"
token: "${MCP_TOKEN}"
# Run with: kubectl-ai --mcp-client
# Every tool above reaches the model as modifies_resource = unknown, so each call prompts.It also means the Yes and do not ask me again option now covers tools you did not write. If you answer it once for a harmless documentation lookup, the rest of the session can call a mail or ticketing server without asking. Keep sessions short and keep tool sets small.
One practical snag: the docs disagree on the shape of mcp.yaml. The README and the client guide use a list of entries with a name field, while the example in the server guide uses a map keyed by name. I used the list form, which is the one the client documents.
MCP server mode: no gate, no auth, every interface
kubectl-ai can also be the server. With --mcp-server it exposes its bash and kubectl tools to another client such as Claude Desktop over stdio, and with --mcp-server-mode streamable-http --http-port 9080 it serves MCP over HTTP. In cmd/mcp.go the built-in tool handler decodes the arguments and calls tool.Run directly. I found no call to CheckModifiesResource on that path, so whatever approval happens is the connecting client's job, not kubectl-ai's.
The documentation says the HTTP endpoint listens at http://localhost:9080/mcp and warns that, without authentication, anyone who can reach the port can run kubectl and bash. The code passes the endpoint as a bare port. I started the server and listed its sockets: it was bound to *:19080, not 127.0.0.1. Then I sent an MCP initialize request to my machine's LAN address with no token and got a valid response back. That was measured on macOS with a binary built from the commit above, so check your own build with lsof before you trust the word localhost.
The fix is built in and opt-in. Setting --mcp-auth-issuer turns the endpoint into an OAuth 2.1 resource server that verifies the bearer JWT against the issuer's JWKS, and --mcp-auth-audience becomes mandatory. The audience check is what stops a token issued for a different service by the same authorisation server from working here, which is the confused-deputy case the MCP authorization specification calls out. Until the signing keys load, the endpoint answers 503 rather than letting requests through.
# Wrong: the documented quick start. Bound to every interface in my test,
# and any host that can reach :9080 gets the bash and kubectl tools.
kubectl-ai --mcp-server --mcp-server-mode streamable-http --http-port 9080
# Right: OAuth 2.1 resource server. aud must equal the audience string exactly.
kubectl-ai --mcp-server --mcp-server-mode streamable-http --http-port 9080 \
--mcp-auth-issuer https://authgate.corp \
--mcp-auth-audience https://kubectl-ai.corp/mcp
# Verify the server really is locked: expect 401 and a WWW-Authenticate header.
curl -i -X POST https://kubectl-ai.corp/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}'
# Discovery document (RFC 9728), served without a token:
curl -s https://kubectl-ai.corp/.well-known/oauth-protected-resource
# Check what the plain server bound to, on the machine that runs it:
lsof -nP -iTCP:9080 -sTCP:LISTEN # "*:9080" means every interface| Mode | Who approves a write | Network exposure |
|---|---|---|
| Interactive chat | You, per command, unless you pick do not ask again | None, a local terminal |
| --quiet one-shot | Nobody: the run aborts on a write unless --skip-permissions is set | None |
| --mcp-server over stdio | The MCP client, if it prompts; kubectl-ai has no gate on this path | A child process of the client |
| streamable-http, no auth flags | Nobody | Bound to all interfaces in my test |
| streamable-http with the issuer and audience flags | Whoever holds a valid token; still no gate | Authenticated, 401 without a token |
OAuth fixes who can call the endpoint. It does not add a confirmation step, and it does not narrow what a caller can run: an authenticated client still gets the bash and kubectl tools. Pair it with a read-only identity, or the token holder has whatever the kubeconfig has.
Put RBAC underneath the gate
The only control that does not depend on string matching is the cluster's own authorisation. Kubernetes ships a user-facing view ClusterRole that allows read-only access to most objects in a namespace, does not allow viewing roles or role bindings, and deliberately excludes Secrets. The RBAC documentation explains the reason: reading Secrets exposes ServiceAccount credentials, which would be a privilege escalation. That single role closes the two worst holes in the probe table: get secret returns a forbidden error, and auth reconcile cannot write.
Run kubectl-ai with a kubeconfig that holds a ServiceAccount bound to that role, then confirm with kubectl auth can-i before the first session. If the model needs to write, give it a separate, namespaced Role for exactly those verbs and switch kubeconfigs on purpose, rather than widening the standing identity.
What to do on Monday
A short list, in the order I would do it, for a team that wants to try kubectl-ai without making it a liability:
- Create a dedicated read-only identity bound to the view ClusterRole in the namespaces the assistant needs, and point kubectl-ai at its kubeconfig with --kubeconfig.
- Leave skipPermissions at false everywhere, and treat the Yes and do not ask me again answer as a way to disable the safeguard for the whole session.
- Use --quiet only for read-only questions, so it fails loudly on a write instead of being given --skip-permissions to make it pass.
- Do not run streamable-http mode on a shared network. If another tool must reach it, set --mcp-auth-issuer and --mcp-auth-audience, put it behind TLS, and confirm the 401 with curl.
- Add each MCP client server one at a time, and remember that its tools reach the model as unknown and so prompt, until the first do not ask me again.
- Check the build you run against the commit you read. This project moves quickly between tagged releases, and the date I read it is the date these findings are good for.
The commands below put steps one, four and six into a form you can run.
# 1. A read-only identity. The view ClusterRole excludes Secrets.
kubectl create rolebinding kubectl-ai-view \
--clusterrole=view --serviceaccount=acme:kubectl-ai --namespace=acme
# Prove it with the kubeconfig the agent will use, not your own.
kubectl --kubeconfig ~/.kube/kubectl-ai-readonly.yaml auth can-i delete pods -n acme # expect: no
kubectl --kubeconfig ~/.kube/kubectl-ai-readonly.yaml get secret db -n acme # expect: Forbidden
# 2. Start the agent on that identity only.
kubectl-ai --kubeconfig ~/.kube/kubectl-ai-readonly.yaml
# 6. If you build from source, record which commit your findings are about.
git -C kubectl-ai log -1 --format='%h %cd'The rule I take from this: a confirmation prompt that depends on parsing a command is a useful speed bump, and an authorisation boundary that depends on the cluster is the only thing you should call a control. kubectl-ai does the first one well, documents the second one honestly, and leaves the HTTP server open until you close it, so close it before anything else.
Sources
- GitHub: GoogleCloudPlatform/kubectl-ai, README
- GitHub: kubectl-ai MCP server documentation (docs/mcp-server.md)
- GitHub: kubectl-ai agent loop and confirmation prompt (pkg/agent/conversation.go)
- GitHub: kubectl-ai command classifier (pkg/tools/kubectl_filter.go)
- GitHub: kubectl-ai MCP server entry point (cmd/mcp.go)
- GitHub: kubectl-ai release v0.0.31
- Model Context Protocol: authorization specification, 2025-06-18
- Kubernetes documentation: using RBAC authorization
- Kubernetes documentation: extend kubectl with plugins
- Simon Willison: the lethal trifecta for AI agents

