Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ skipVerifySSL: false # Skip SSL verification for LLM API calls

# Tool and permission settings
toolConfigPaths: ["~/.config/kubectl-ai/tools.yaml"] # Custom tools configuration paths
skipPermissions: false # Skip confirmation for resource-modifying commands
approvalPolicy: auto-approve-read # Approval policy: auto-approve-read, paranoid, or yolo
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to think a bit more on names of these, otherwise the PR is looking good.

Will come back to it tomorrow.

enableToolUseShim: false # Enable tool use shim for certain models

# MCP configuration
Expand Down Expand Up @@ -283,6 +283,12 @@ tracePath: "/tmp/kubectl-ai-trace.txt" # Path to trace file

</details>

The `approvalPolicy` setting (or the `--approval-policy` flag) controls how `kubectl-ai` requests permission before executing generated commands:

* `auto-approve-read` (default) skips prompts for commands the agent identifies as read-only.
* `paranoid` always asks for explicit approval before running any command.
* `yolo` never asks for approval—use with caution.

All these settings can be configured through either:

1. Command line flags (e.g., `--model=gemini-2.5-pro`)
Expand Down
43 changes: 40 additions & 3 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,9 @@ func BuildRootCommand(opt *Options) (*cobra.Command, error) {
type Options struct {
ProviderID string `json:"llmProvider,omitempty"`
ModelID string `json:"model,omitempty"`
// SkipPermissions is a flag to skip asking for confirmation before executing kubectl commands
// that modifies resources in the cluster.
// ApprovalPolicy controls how permission prompts are handled before executing kubectl commands.
ApprovalPolicy agent.ApprovalPolicy `json:"approvalPolicy,omitempty"`
// SkipPermissions is deprecated. Use ApprovalPolicy instead.
SkipPermissions bool `json:"skipPermissions,omitempty"`
// EnableToolUseShim is a flag to enable tool use shim.
// TODO(droot): figure out a better way to discover if the model supports tool use
Expand Down Expand Up @@ -142,6 +143,7 @@ func (o *Options) InitDefaults() {
o.ProviderID = "gemini"
o.ModelID = "gemini-2.5-pro"
// by default, confirm before executing kubectl commands that modify resources in the cluster.
o.ApprovalPolicy = agent.ApprovalPolicyAutoApproveRead
o.SkipPermissions = false
o.MCPServer = false
o.MCPClient = false
Expand Down Expand Up @@ -187,6 +189,26 @@ func (o *Options) LoadConfiguration(b []byte) error {
return nil
}

func (o *Options) ResolveApprovalPolicy() error {
if o.ApprovalPolicy == "" {
o.ApprovalPolicy = agent.ApprovalPolicyAutoApproveRead
}

if o.SkipPermissions {
if o.ApprovalPolicy == agent.ApprovalPolicyAutoApproveRead || o.ApprovalPolicy == "" {
o.ApprovalPolicy = agent.ApprovalPolicyYolo
} else if o.ApprovalPolicy != agent.ApprovalPolicyYolo {
fmt.Fprintln(os.Stderr, "warning: --skip-permissions is deprecated and conflicts with --approval-policy; ignoring --skip-permissions")
}
}

if !o.ApprovalPolicy.IsValid() {
return fmt.Errorf("invalid approval policy %q", o.ApprovalPolicy)
}

return nil
}

func (o *Options) LoadConfigurationFile() error {
configPaths := defaultConfigPaths
for _, configPath := range configPaths {
Expand Down Expand Up @@ -272,6 +294,10 @@ func run(ctx context.Context) error {
return fmt.Errorf("failed to load config file: %w", err)
}

if err := opt.ResolveApprovalPolicy(); err != nil {
return err
}

rootCmd, err := BuildRootCommand(&opt)
if err != nil {
return err
Expand Down Expand Up @@ -302,7 +328,14 @@ func (opt *Options) bindCLIFlags(f *pflag.FlagSet) error {

f.StringVar(&opt.ProviderID, "llm-provider", opt.ProviderID, "language model provider")
f.StringVar(&opt.ModelID, "model", opt.ModelID, "language model e.g. gemini-2.0-flash-thinking-exp-01-21, gemini-2.0-flash")
f.StringVar((*string)(&opt.ApprovalPolicy), "approval-policy", string(opt.ApprovalPolicy), "approval policy for executing kubectl commands. Supported values: auto-approve-read, paranoid, yolo")
f.BoolVar(&opt.SkipPermissions, "skip-permissions", opt.SkipPermissions, "(dangerous) skip asking for confirmation before executing kubectl commands that modify resources")
if err := f.MarkHidden("skip-permissions"); err != nil {
return err
}
if err := f.MarkDeprecated("skip-permissions", "use --approval-policy=yolo instead"); err != nil {
return err
}
f.BoolVar(&opt.MCPServer, "mcp-server", opt.MCPServer, "run in MCP server mode")
f.BoolVar(&opt.ExternalTools, "external-tools", opt.ExternalTools, "in MCP server mode, discover and expose external MCP tools")
f.StringArrayVar(&opt.ToolConfigPaths, "custom-tools-config", opt.ToolConfigPaths, "path to custom tools config file or directory")
Expand All @@ -328,6 +361,10 @@ func (opt *Options) bindCLIFlags(f *pflag.FlagSet) error {
func RunRootCommand(ctx context.Context, opt Options, args []string) error {
var err error // Declare err once for the whole function

if err = opt.ResolveApprovalPolicy(); err != nil {
return err
}

// Validate flag combinations
if opt.ExternalTools && !opt.MCPServer {
return fmt.Errorf("--external-tools can only be used with --mcp-server")
Expand Down Expand Up @@ -461,7 +498,7 @@ func RunRootCommand(ctx context.Context, opt Options, args []string) error {
Tools: tools.Default(),
Recorder: recorder,
RemoveWorkDir: opt.RemoveWorkDir,
SkipPermissions: opt.SkipPermissions,
ApprovalPolicy: opt.ApprovalPolicy,
EnableToolUseShim: opt.EnableToolUseShim,
MCPClientEnabled: opt.MCPClient,
RunOnce: opt.Quiet,
Expand Down
77 changes: 77 additions & 0 deletions cmd/options_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package main

import (
"testing"

"github.com/GoogleCloudPlatform/kubectl-ai/pkg/agent"
)

func TestResolveApprovalPolicy(t *testing.T) {
tests := []struct {
name string
opt Options
want agent.ApprovalPolicy
wantErr bool
}{
{
name: "default to auto approve read",
},
{
name: "skip permissions sets yolo",
opt: Options{SkipPermissions: true},
want: agent.ApprovalPolicyYolo,
},
{
name: "explicit paranoid preserved",
opt: Options{ApprovalPolicy: agent.ApprovalPolicyParanoid},
want: agent.ApprovalPolicyParanoid,
},
{
name: "invalid policy",
opt: Options{ApprovalPolicy: agent.ApprovalPolicy("invalid")},
wantErr: true,
},
{
name: "skip permissions overridden by explicit",
opt: Options{SkipPermissions: true, ApprovalPolicy: agent.ApprovalPolicyParanoid},
want: agent.ApprovalPolicyParanoid,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
opt := tt.opt
err := opt.ResolveApprovalPolicy()
if tt.wantErr {
if err == nil {
t.Fatalf("expected error but got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := tt.want
if want == "" {
want = agent.ApprovalPolicyAutoApproveRead
}
if opt.ApprovalPolicy != want {
t.Fatalf("expected approval policy %q, got %q", want, opt.ApprovalPolicy)
}
})
}
}
2 changes: 1 addition & 1 deletion k8s-bench/eval.go
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,7 @@ func (x *TaskExecution) runAgent(ctx context.Context) (string, error) {
fmt.Sprintf("--quiet=%t", x.llmConfig.Quiet),
"--model", x.llmConfig.ModelID,
"--trace-path", tracePath,
"--skip-permissions",
"--approval-policy=yolo",
"--show-tool-output",
}

Expand Down
33 changes: 33 additions & 0 deletions pkg/agent/approval_policy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package agent

type ApprovalPolicy string

const (
ApprovalPolicyAutoApproveRead ApprovalPolicy = "auto-approve-read"
ApprovalPolicyParanoid ApprovalPolicy = "paranoid"
ApprovalPolicyYolo ApprovalPolicy = "yolo"
)

// IsValid reports whether the policy is one of the supported values.
func (p ApprovalPolicy) IsValid() bool {
switch p {
case ApprovalPolicyAutoApproveRead, ApprovalPolicyParanoid, ApprovalPolicyYolo:
return true
default:
return false
}
}
82 changes: 59 additions & 23 deletions pkg/agent/conversation.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ type Agent struct {
// Kubeconfig is the path to the kubeconfig file.
Kubeconfig string

SkipPermissions bool
ApprovalPolicy ApprovalPolicy

Tools tools.Tools

Expand Down Expand Up @@ -607,15 +607,15 @@ func (c *Agent) Run(ctx context.Context, initialQuery string) error {
continue // Skip execution for interactive commands
}

if !c.SkipPermissions && modifiesResourceToolCallIndex >= 0 {
if c.shouldRequestApproval(modifiesResourceToolCallIndex) {
// In RunOnce mode, exit with error if permission is required
if c.RunOnce {
var commandDescriptions []string
for _, call := range c.pendingFunctionCalls {
commandDescriptions = append(commandDescriptions, call.ParsedToolCall.Description())
}
errorMessage := "RunOnce mode cannot handle permission requests. The following commands require approval:\n* " + strings.Join(commandDescriptions, "\n* ")
errorMessage += "\nUse --skip-permissions flag to bypass permission checks in RunOnce mode."
errorMessage += "\nUse --approval-policy=yolo to bypass permission checks in RunOnce mode."

log.Error(nil, "RunOnce mode cannot handle permission requests", "commands", commandDescriptions)
c.setAgentState(api.AgentStateExited)
Expand All @@ -630,13 +630,15 @@ func (c *Agent) Run(ctx context.Context, initialQuery string) error {
confirmationPrompt := "The following commands require your approval to run:\n* " + strings.Join(commandDescriptions, "\n* ")
confirmationPrompt += "\n\nDo you want to proceed ?"

options := []api.UserChoiceOption{{Value: "yes", Label: "Yes"}}
if c.ApprovalPolicy != ApprovalPolicyParanoid {
options = append(options, api.UserChoiceOption{Value: "yes_and_dont_ask_me_again", Label: "Yes, and don't ask me again"})
}
options = append(options, api.UserChoiceOption{Value: "no", Label: "No"})

choiceRequest := &api.UserChoiceRequest{
Prompt: confirmationPrompt,
Options: []api.UserChoiceOption{
{Value: "yes", Label: "Yes"},
{Value: "yes_and_dont_ask_me_again", Label: "Yes, and don't ask me again"},
{Value: "no", Label: "No"},
},
Prompt: confirmationPrompt,
Options: options,
}
c.setAgentState(api.AgentStateWaitingForInput)
c.addMessage(api.MessageSourceAgent, api.MessageTypeUserChoiceRequest, choiceRequest)
Expand Down Expand Up @@ -944,20 +946,29 @@ func (c *Agent) analyzeToolCalls(ctx context.Context, toolCalls []gollm.Function
return toolCallAnalysis, nil
}

func (c *Agent) shouldRequestApproval(modifiesResourceToolCallIndex int) bool {
switch c.ApprovalPolicy {
case ApprovalPolicyYolo:
return false
case ApprovalPolicyParanoid:
return len(c.pendingFunctionCalls) > 0
case ApprovalPolicyAutoApproveRead:
fallthrough
default:
return modifiesResourceToolCallIndex >= 0
}
}

func (c *Agent) handleChoice(ctx context.Context, choice *api.UserChoiceResponse) (dispatchToolCalls bool) {
log := klog.FromContext(ctx)
// if user input is a choice and use has declined the operation,
// we need to abort all pending function calls.
// update the currChatContent with the choice and keep the agent loop running.

// Normalize the input
switch choice.Choice {
case 1:
dispatchToolCalls = true
case 2:
c.SkipPermissions = true
dispatchToolCalls = true
case 3:
decline := func() {
if len(c.pendingFunctionCalls) == 0 {
return
}
c.currChatContent = append(c.currChatContent, gollm.FunctionCallResult{
ID: c.pendingFunctionCalls[0].FunctionCall.ID,
Name: c.pendingFunctionCalls[0].FunctionCall.Name,
Expand All @@ -970,13 +981,38 @@ func (c *Agent) handleChoice(ctx context.Context, choice *api.UserChoiceResponse
c.pendingFunctionCalls = []ToolCallAnalysis{}
dispatchToolCalls = false
c.addMessage(api.MessageSourceAgent, api.MessageTypeError, "Operation was skipped. User declined to run this operation.")
}

switch c.ApprovalPolicy {
case ApprovalPolicyParanoid:
switch choice.Choice {
case 1:
dispatchToolCalls = true
case 2:
decline()
default:
err := fmt.Errorf("invalid confirmation choice: %q", choice.Choice)
log.Error(err, "Invalid choice received from AskForConfirmation")
c.pendingFunctionCalls = []ToolCallAnalysis{}
dispatchToolCalls = false
c.addMessage(api.MessageSourceAgent, api.MessageTypeError, "Invalid choice received. Cancelling operation.")
}
default:
// This case should technically not be reachable due to AskForConfirmation loop
err := fmt.Errorf("invalid confirmation choice: %q", choice.Choice)
log.Error(err, "Invalid choice received from AskForConfirmation")
c.pendingFunctionCalls = []ToolCallAnalysis{}
dispatchToolCalls = false
c.addMessage(api.MessageSourceAgent, api.MessageTypeError, "Invalid choice received. Cancelling operation.")
switch choice.Choice {
case 1:
dispatchToolCalls = true
case 2:
c.ApprovalPolicy = ApprovalPolicyYolo
dispatchToolCalls = true
case 3:
decline()
default:
err := fmt.Errorf("invalid confirmation choice: %q", choice.Choice)
log.Error(err, "Invalid choice received from AskForConfirmation")
c.pendingFunctionCalls = []ToolCallAnalysis{}
dispatchToolCalls = false
c.addMessage(api.MessageSourceAgent, api.MessageTypeError, "Invalid choice received. Cancelling operation.")
}
}
return dispatchToolCalls
}
Expand Down
Loading
Loading