diff --git a/agent/approval.go b/agent/approval.go index 68417fad4..9712c833f 100644 --- a/agent/approval.go +++ b/agent/approval.go @@ -225,11 +225,12 @@ func approvalRequestWithEvaluation(req ApprovalRequest, evaluation ApprovalEvalu } func approvalSessionKey(req ApprovalRequest) string { - switch req.ToolName { - case "bash": + if IsShellToolName(req.ToolName) { if command, ok := stringApprovalArg(req.Args, "command"); ok { - return "bash:" + command + return req.ToolName + ":" + command } + } + switch req.ToolName { case "edit": if path, ok := stringApprovalArg(req.Args, "path"); ok { return "edit:" + path @@ -261,8 +262,8 @@ func (DefaultApprovalPolicy) EvaluateApproval(_ context.Context, req ApprovalReq return evaluateWebApproval(req) case "edit": return evaluateEditApproval(req) - case "bash": - return evaluateBashApproval(req) + case "bash", "powershell": + return evaluateShellApproval(req) default: return ApprovalEvaluation{ RequirePrompt: true, @@ -351,10 +352,10 @@ func sanitizeApprovalDisplay(value string) string { return value } -func evaluateBashApproval(req ApprovalRequest) ApprovalEvaluation { +func evaluateShellApproval(req ApprovalRequest) ApprovalEvaluation { command, ok := stringApprovalArg(req.Args, "command") if !ok || strings.TrimSpace(command) == "" { - return denyApproval("bash", ApprovalRiskHigh, "missing command argument") + return denyApproval(req.ToolName, ApprovalRiskHigh, "missing command argument") } risk, reasons := classifyBashCommand(command) @@ -364,9 +365,9 @@ func evaluateBashApproval(req ApprovalRequest) ApprovalEvaluation { return ApprovalEvaluation{ RequirePrompt: true, Risk: risk, - Summary: "Bash wants to run a command", + Summary: fmt.Sprintf("%s wants to run a command", ToolDisplayName(req.ToolName)), Reasons: reasons, - SessionKey: "bash:" + command, + SessionKey: req.ToolName + ":" + command, } } @@ -380,7 +381,7 @@ func denyApproval(toolName string, risk ApprovalRisk, reason string) ApprovalEva } } -// Bash approval is static analysis of model-generated commands, not a sandbox. +// Shell approval is static analysis of model-generated commands, not a sandbox. // Globs, aliases, environment, and runtime shell state are intentionally out of scope. func classifyBashCommand(command string) (ApprovalRisk, []string) { classifier := bashClassifier{risk: ApprovalRiskMedium} diff --git a/agent/approval_test.go b/agent/approval_test.go index def64b8f4..5c3c8dfed 100644 --- a/agent/approval_test.go +++ b/agent/approval_test.go @@ -223,6 +223,23 @@ func TestBashApprovalClassifiesHighRiskShell(t *testing.T) { } } +func TestPowerShellApprovalUsesShellPolicy(t *testing.T) { + evaluation := DefaultApprovalPolicy{}.EvaluateApproval(context.Background(), ApprovalRequest{ + ToolName: "powershell", + Args: map[string]any{"command": "Remove-Item -Recurse tmp"}, + }) + + if !evaluation.RequirePrompt { + t.Fatal("powershell should require prompt") + } + if evaluation.Summary != "PowerShell wants to run a command" { + t.Fatalf("summary = %q", evaluation.Summary) + } + if evaluation.SessionKey != "powershell:Remove-Item -Recurse tmp" { + t.Fatalf("session key = %q", evaluation.SessionKey) + } +} + func TestBashApprovalClassifiesDynamicShellEvasions(t *testing.T) { tests := []struct { name string diff --git a/agent/tool_display.go b/agent/tool_display.go index 214732a22..9fbff0161 100644 --- a/agent/tool_display.go +++ b/agent/tool_display.go @@ -18,6 +18,8 @@ func ToolDisplayName(name string) string { return "Web Fetch" case "bash": return "Bash" + case "powershell": + return "PowerShell" case "read": return "Read" case "list": @@ -39,7 +41,7 @@ func ToolInvocationLabel(name string, args map[string]any) string { displayName := ToolDisplayName(name) for _, key := range []string{"query", "url", "command", "path", "name"} { if value, ok := displayStringArg(args, key); ok { - if name == "bash" && key == "command" { + if IsShellToolName(name) && key == "command" { value = truncateDisplayRunes(value, maxToolInvocationCommandRunes) } return fmt.Sprintf("%s(%s)", displayName, strconv.Quote(value)) @@ -51,6 +53,11 @@ func ToolInvocationLabel(name string, args map[string]any) string { return fmt.Sprintf("%s(%s)", displayName, formatDisplayArgs(args)) } +// IsShellToolName reports whether name identifies a platform shell tool. +func IsShellToolName(name string) bool { + return name == "bash" || name == "powershell" +} + func displayStringArg(args map[string]any, key string) (string, bool) { value, ok := args[key].(string) if !ok || strings.TrimSpace(value) == "" { diff --git a/agent/tool_display_test.go b/agent/tool_display_test.go index 15473c12b..c958c0f37 100644 --- a/agent/tool_display_test.go +++ b/agent/tool_display_test.go @@ -22,3 +22,12 @@ func TestToolInvocationLabelCountsRunes(t *testing.T) { t.Fatalf("label = %q, want %q", label, want) } } + +func TestToolInvocationLabelTruncatesLongPowerShellCommand(t *testing.T) { + command := strings.Repeat("a", 101) + label := ToolInvocationLabel("powershell", map[string]any{"command": command}) + want := `PowerShell("` + strings.Repeat("a", 100) + `...")` + if label != want { + t.Fatalf("label = %q, want %q", label, want) + } +} diff --git a/agent/tools/bash.go b/agent/tools/bash.go index e53db3eb0..cb7e0ddde 100644 --- a/agent/tools/bash.go +++ b/agent/tools/bash.go @@ -26,18 +26,18 @@ func NewBash() *Bash { } func (b *Bash) Name() string { - return "bash" + return shellToolName() } func (b *Bash) Description() string { - return "Execute a shell command on the system. Use this to run shell commands, inspect files, run tests, and perform development tasks." + return shellToolDescription() } func (b *Bash) Schema() api.ToolFunction { props := api.NewToolPropertiesMap() props.Set("command", api.ToolProperty{ Type: api.PropertyType{"string"}, - Description: "The shell command to execute.", + Description: shellCommandDescription(), }) return api.ToolFunction{ Name: b.Name(), diff --git a/agent/tools/bash_unix.go b/agent/tools/bash_unix.go index 375d2a4ec..88f572f96 100644 --- a/agent/tools/bash_unix.go +++ b/agent/tools/bash_unix.go @@ -8,6 +8,18 @@ import ( "syscall" ) +func shellToolName() string { + return "bash" +} + +func shellToolDescription() string { + return "Execute a bash command on the system. Use this to inspect files, run tests, and perform development tasks." +} + +func shellCommandDescription() string { + return "The bash command to execute." +} + func newBashCommand(ctx context.Context, command, cwdPath string) *exec.Cmd { script := command + "\n__ollama_status=$?\npwd -P > " + shellQuote(cwdPath) + "\nexit $__ollama_status" cmd := exec.CommandContext(ctx, "bash", "-c", script) diff --git a/agent/tools/bash_windows.go b/agent/tools/bash_windows.go index 836511f40..dc7cfe3ae 100644 --- a/agent/tools/bash_windows.go +++ b/agent/tools/bash_windows.go @@ -14,6 +14,18 @@ import ( var bashJobHandles sync.Map +func shellToolName() string { + return "powershell" +} + +func shellToolDescription() string { + return "Execute a PowerShell command on the system. Use this to inspect files, run tests, and perform development tasks." +} + +func shellCommandDescription() string { + return "The PowerShell command to execute." +} + func newBashCommand(ctx context.Context, command, cwdPath string) *exec.Cmd { return exec.CommandContext( ctx, diff --git a/cmd/agent_tui.go b/cmd/agent_tui.go index 097f949db..98bce1d29 100644 --- a/cmd/agent_tui.go +++ b/cmd/agent_tui.go @@ -768,7 +768,7 @@ func agentToolsRegistry(ctx context.Context, client *api.Client, modelName strin } registry := coreagent.NewRegistry() - if os.Getenv("OLLAMA_AGENT_DISABLE_BASH") == "" { + if os.Getenv("OLLAMA_AGENT_DISABLE_SHELL") == "" { registry.Register(agenttools.NewBash()) } registry.Register(agenttools.NewRead()) diff --git a/cmd/agent_tui_test.go b/cmd/agent_tui_test.go index f98071a11..57ad3a717 100644 --- a/cmd/agent_tui_test.go +++ b/cmd/agent_tui_test.go @@ -16,6 +16,7 @@ import ( coreagent "github.com/ollama/ollama/agent" "github.com/ollama/ollama/agent/skills" + agenttools "github.com/ollama/ollama/agent/tools" "github.com/ollama/ollama/api" agentchat "github.com/ollama/ollama/cmd/tui/chat" "github.com/ollama/ollama/envconfig" @@ -208,7 +209,7 @@ func captureStderr(t *testing.T, fn func()) string { func TestAgentToolsRegistryNoCloudDisablesWebTools(t *testing.T) { t.Setenv("OLLAMA_NO_CLOUD", "1") - t.Setenv("OLLAMA_AGENT_DISABLE_BASH", "") + t.Setenv("OLLAMA_AGENT_DISABLE_SHELL", "") t.Setenv("OLLAMA_AGENT_DISABLE_WEBSEARCH", "") statusCalls := 0 @@ -235,7 +236,7 @@ func TestAgentToolsRegistryNoCloudDisablesWebTools(t *testing.T) { if registry == nil { t.Fatal("registry = nil, want local tools") } - if !registry.Has("bash") || !registry.Has("read") || !registry.Has("edit") { + if !registry.Has(agenttools.NewBash().Name()) || !registry.Has("read") || !registry.Has("edit") { t.Fatalf("local tools missing: %v", registry.Names()) } if registry.Has("list") { diff --git a/cmd/tui/chat/approval.go b/cmd/tui/chat/approval.go index 04afe2930..95b5bf814 100644 --- a/cmd/tui/chat/approval.go +++ b/cmd/tui/chat/approval.go @@ -222,13 +222,14 @@ func (m chatModel) renderApprovalPromptLines(width int) []string { } func approvalRequestDetail(request coreagent.ApprovalRequest, width int) string { - switch request.ToolName { - case "bash": + if coreagent.IsShellToolName(request.ToolName) { command, ok := rawStringArg(request.Args, "command") if !ok { return "" } - return strings.Join(wrapChatText("$ "+command, width), "\n") + return strings.Join(wrapChatText(shellPromptPrefix(request.ToolName)+command, width), "\n") + } + switch request.ToolName { case "edit": path, ok := rawStringArg(request.Args, "path") if !ok { diff --git a/cmd/tui/chat/chat.go b/cmd/tui/chat/chat.go index 45d2f0071..a76d2c3fd 100644 --- a/cmd/tui/chat/chat.go +++ b/cmd/tui/chat/chat.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "hash/fnv" + "runtime" "slices" "strings" @@ -236,7 +237,11 @@ func Run(ctx context.Context, opts Options) (*Result, error) { m.preloadingModel = strings.TrimSpace(opts.Model) } - p := tea.NewProgram(m, tea.WithReportFocus(), tea.WithMouseCellMotion()) + programOptions := []tea.ProgramOption{tea.WithReportFocus()} + if runtime.GOOS != "windows" { + programOptions = append(programOptions, tea.WithMouseCellMotion()) + } + p := tea.NewProgram(m, programOptions...) finalModel, err := p.Run() if err != nil { return nil, err @@ -429,6 +434,9 @@ func (m *chatModel) enterFullScreen() { } func (m chatModel) updateMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + if runtime.GOOS == "windows" { + return m, nil + } if m.modelPicker != nil { switch msg.Type { case tea.MouseWheelUp: diff --git a/cmd/tui/chat/render.go b/cmd/tui/chat/render.go index 76f1b5782..33395aee3 100644 --- a/cmd/tui/chat/render.go +++ b/cmd/tui/chat/render.go @@ -1076,11 +1076,12 @@ func renderToolCallDetailLines(entry chatEntry, width int) []string { if len(entry.args) == 0 { return nil } - switch entry.detail { - case "bash": + if coreagent.IsShellToolName(entry.detail) { if command, ok := rawStringArg(entry.args, "command"); ok { - return renderToolCallArgLine("$ "+command, width) + return renderToolCallArgLine(shellPromptPrefix(entry.detail)+command, width) } + } + switch entry.detail { case "web_search": if query, ok := rawStringArg(entry.args, "query"); ok { return renderToolCallArgLine("query: "+query, width) @@ -1093,6 +1094,13 @@ func renderToolCallDetailLines(entry chatEntry, width int) []string { return renderToolCallArgs(entry.args, width) } +func shellPromptPrefix(toolName string) string { + if toolName == "powershell" { + return "PS> " + } + return "$ " +} + func renderToolCallArgs(args map[string]any, width int) []string { keys := make([]string, 0, len(args)) for key := range args {