ollama/x/agent/approval.go

954 lines
27 KiB
Go

// Package agent provides agent loop orchestration and tool approval.
package agent
import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"golang.org/x/term"
)
// ApprovalDecision represents the user's decision for a tool execution.
type ApprovalDecision int
const (
// ApprovalDeny means the user denied execution.
ApprovalDeny ApprovalDecision = iota
// ApprovalOnce means execute this one time only.
ApprovalOnce
// ApprovalAlways means add to session allowlist.
ApprovalAlways
)
// ApprovalResult contains the decision and optional deny reason.
type ApprovalResult struct {
Decision ApprovalDecision
DenyReason string
}
// Option labels for the selector (numbered for quick selection)
var optionLabels = []string{
"1. Execute once",
"2. Always allow",
"3. Deny",
}
// autoAllowCommands are commands that are always allowed without prompting.
// These are zero-risk, read-only commands.
var autoAllowCommands = map[string]bool{
"pwd": true,
"echo": true,
"date": true,
"whoami": true,
"hostname": true,
"uname": true,
}
// autoAllowPrefixes are command prefixes that are always allowed.
// These are read-only or commonly-needed development commands.
var autoAllowPrefixes = []string{
// Git read-only
"git status", "git log", "git diff", "git branch", "git show",
"git remote -v", "git tag", "git stash list",
// Package managers - run scripts
"npm run", "npm test", "npm start",
"bun run", "bun test",
"uv run",
"yarn run", "yarn test",
"pnpm run", "pnpm test",
// Package info
"go list", "go version", "go env",
"npm list", "npm ls", "npm version",
"pip list", "pip show",
"cargo tree", "cargo version",
// Build commands
"go build", "go test", "go fmt", "go vet",
"make", "cmake",
"cargo build", "cargo test", "cargo check",
}
// denyPatterns are dangerous command patterns that are always blocked.
var denyPatterns = []string{
// Destructive commands
"rm -rf", "rm -fr",
"mkfs", "dd if=", "dd of=",
"shred",
"> /dev/", ">/dev/",
// Privilege escalation
"sudo ", "su ", "doas ",
"chmod 777", "chmod -R 777",
"chown ", "chgrp ",
// Network exfiltration
"curl -d", "curl --data", "curl -X POST", "curl -X PUT",
"wget --post",
"nc ", "netcat ",
"scp ", "rsync ",
// History and credentials
"history",
".bash_history", ".zsh_history",
".ssh/id_rsa", ".ssh/id_dsa", ".ssh/id_ecdsa", ".ssh/id_ed25519",
".ssh/config",
".aws/credentials", ".aws/config",
".gnupg/",
"/etc/shadow", "/etc/passwd",
// Dangerous patterns
":(){ :|:& };:", // fork bomb
"chmod +s", // setuid
"mkfifo",
}
// denyPathPatterns are file patterns that should never be accessed.
// These are checked as exact filename matches or path suffixes.
var denyPathPatterns = []string{
".env",
".env.local",
".env.production",
"credentials.json",
"secrets.json",
"secrets.yaml",
"secrets.yml",
".pem",
".key",
}
// ApprovalManager manages tool execution approvals.
type ApprovalManager struct {
allowlist map[string]bool // exact matches
prefixes map[string]bool // prefix matches for bash commands (e.g., "cat:tools/")
mu sync.RWMutex
}
// NewApprovalManager creates a new approval manager.
func NewApprovalManager() *ApprovalManager {
return &ApprovalManager{
allowlist: make(map[string]bool),
prefixes: make(map[string]bool),
}
}
// IsAutoAllowed checks if a bash command is auto-allowed (no prompt needed).
func IsAutoAllowed(command string) bool {
command = strings.TrimSpace(command)
// Check exact command match (first word)
fields := strings.Fields(command)
if len(fields) > 0 && autoAllowCommands[fields[0]] {
return true
}
// Check prefix match
for _, prefix := range autoAllowPrefixes {
if strings.HasPrefix(command, prefix) {
return true
}
}
return false
}
// IsDenied checks if a bash command matches deny patterns.
// Returns true and the matched pattern if denied.
func IsDenied(command string) (bool, string) {
commandLower := strings.ToLower(command)
// Check deny patterns
for _, pattern := range denyPatterns {
if strings.Contains(commandLower, strings.ToLower(pattern)) {
return true, pattern
}
}
// Check deny path patterns
for _, pattern := range denyPathPatterns {
if strings.Contains(commandLower, strings.ToLower(pattern)) {
return true, pattern
}
}
return false, ""
}
// FormatDeniedResult returns the tool result message when a command is blocked.
func FormatDeniedResult(command string, pattern string) string {
return fmt.Sprintf("Command blocked: this command matches a dangerous pattern (%s) and cannot be executed. If this command is necessary, please ask the user to run it manually.", pattern)
}
// extractBashPrefix extracts a prefix pattern from a bash command.
// For commands like "cat tools/tools_test.go | head -200", returns "cat:tools/"
// For commands without path args, returns empty string.
func extractBashPrefix(command string) string {
// Split command by pipes and get the first part
parts := strings.Split(command, "|")
firstCmd := strings.TrimSpace(parts[0])
// Split into command and args
fields := strings.Fields(firstCmd)
if len(fields) < 2 {
return ""
}
baseCmd := fields[0]
// Common commands that benefit from prefix allowlisting
// These are typically safe for read operations on specific directories
safeCommands := map[string]bool{
"cat": true, "ls": true, "head": true, "tail": true,
"less": true, "more": true, "file": true, "wc": true,
"grep": true, "find": true, "tree": true, "stat": true,
"sed": true,
}
if !safeCommands[baseCmd] {
return ""
}
// Find the first path-like argument (must contain / or start with .)
// First pass: look for clear paths (containing / or starting with .)
for _, arg := range fields[1:] {
// Skip flags
if strings.HasPrefix(arg, "-") {
continue
}
// Skip numeric arguments (e.g., "head -n 100")
if isNumeric(arg) {
continue
}
// Only process if it looks like a path (contains / or starts with .)
if !strings.Contains(arg, "/") && !strings.HasPrefix(arg, ".") {
continue
}
// If arg ends with /, it's a directory - use it directly
if strings.HasSuffix(arg, "/") {
return fmt.Sprintf("%s:%s", baseCmd, arg)
}
// Get the directory part of a file path
dir := filepath.Dir(arg)
if dir == "." {
// Path is just a directory like "tools" or "src" (no trailing /)
return fmt.Sprintf("%s:%s/", baseCmd, arg)
}
return fmt.Sprintf("%s:%s/", baseCmd, dir)
}
// Second pass: if no clear path found, use the first non-flag argument as a filename
for _, arg := range fields[1:] {
if strings.HasPrefix(arg, "-") {
continue
}
if isNumeric(arg) {
continue
}
// Treat as filename in current dir
return fmt.Sprintf("%s:./", baseCmd)
}
return ""
}
// isNumeric checks if a string is a numeric value
func isNumeric(s string) bool {
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return len(s) > 0
}
// isCommandOutsideCwd checks if a bash command targets paths outside the current working directory.
// Returns true if any path argument would access files outside cwd.
func isCommandOutsideCwd(command string) bool {
cwd, err := os.Getwd()
if err != nil {
return false // Can't determine, assume safe
}
// Split command by pipes and semicolons to check all parts
parts := strings.FieldsFunc(command, func(r rune) bool {
return r == '|' || r == ';' || r == '&'
})
for _, part := range parts {
part = strings.TrimSpace(part)
fields := strings.Fields(part)
if len(fields) == 0 {
continue
}
// Check each argument that looks like a path
for _, arg := range fields[1:] {
// Skip flags
if strings.HasPrefix(arg, "-") {
continue
}
// Treat POSIX-style absolute paths as outside cwd on all platforms.
if strings.HasPrefix(arg, "/") || strings.HasPrefix(arg, "\\") {
return true
}
// Check for absolute paths outside cwd
if filepath.IsAbs(arg) {
absPath := filepath.Clean(arg)
if !strings.HasPrefix(absPath, cwd) {
return true
}
continue
}
// Check for relative paths that escape cwd (e.g., ../foo, /etc/passwd)
if strings.HasPrefix(arg, "..") {
// Resolve the path relative to cwd
absPath := filepath.Join(cwd, arg)
absPath = filepath.Clean(absPath)
if !strings.HasPrefix(absPath, cwd) {
return true
}
}
// Check for home directory expansion
if strings.HasPrefix(arg, "~") {
home, err := os.UserHomeDir()
if err == nil && !strings.HasPrefix(home, cwd) {
return true
}
}
}
}
return false
}
// AllowlistKey generates the key for exact allowlist lookup.
func AllowlistKey(toolName string, args map[string]any) string {
if toolName == "bash" {
if cmd, ok := args["command"].(string); ok {
return fmt.Sprintf("bash:%s", cmd)
}
}
return toolName
}
// IsAllowed checks if a tool/command is allowed (exact match or prefix match).
func (a *ApprovalManager) IsAllowed(toolName string, args map[string]any) bool {
a.mu.RLock()
defer a.mu.RUnlock()
// Check exact match first
key := AllowlistKey(toolName, args)
if a.allowlist[key] {
return true
}
// For bash commands, check prefix matches
if toolName == "bash" {
if cmd, ok := args["command"].(string); ok {
prefix := extractBashPrefix(cmd)
if prefix != "" && a.prefixes[prefix] {
return true
}
}
}
// Check if tool itself is allowed (non-bash)
if toolName != "bash" && a.allowlist[toolName] {
return true
}
return false
}
// AddToAllowlist adds a tool/command to the session allowlist.
// For bash commands, it adds the prefix pattern instead of exact command.
func (a *ApprovalManager) AddToAllowlist(toolName string, args map[string]any) {
a.mu.Lock()
defer a.mu.Unlock()
if toolName == "bash" {
if cmd, ok := args["command"].(string); ok {
prefix := extractBashPrefix(cmd)
if prefix != "" {
a.prefixes[prefix] = true
return
}
// Fall back to exact match if no prefix extracted
a.allowlist[fmt.Sprintf("bash:%s", cmd)] = true
return
}
}
a.allowlist[toolName] = true
}
// RequestApproval prompts the user for approval to execute a tool.
// Returns the decision and optional deny reason.
func (a *ApprovalManager) RequestApproval(toolName string, args map[string]any) (ApprovalResult, error) {
// Format tool info for display
toolDisplay := formatToolDisplay(toolName, args)
// Enter raw mode for interactive selection
fd := int(os.Stdin.Fd())
oldState, err := term.MakeRaw(fd)
if err != nil {
// Fallback to simple input if terminal control fails
return a.fallbackApproval(toolDisplay)
}
// Flush any pending stdin input before starting selector
// This prevents buffered input from causing double-press issues
flushStdin(fd)
// Check if bash command targets paths outside cwd
isWarning := false
if toolName == "bash" {
if cmd, ok := args["command"].(string); ok {
isWarning = isCommandOutsideCwd(cmd)
}
}
// Run interactive selector
selected, denyReason, err := runSelector(fd, oldState, toolDisplay, isWarning)
if err != nil {
term.Restore(fd, oldState)
return ApprovalResult{Decision: ApprovalDeny}, err
}
// Restore terminal
term.Restore(fd, oldState)
// Map selection to decision
switch selected {
case -1: // Ctrl+C cancelled
return ApprovalResult{Decision: ApprovalDeny, DenyReason: "cancelled"}, nil
case 0:
return ApprovalResult{Decision: ApprovalOnce}, nil
case 1:
return ApprovalResult{Decision: ApprovalAlways}, nil
default:
return ApprovalResult{Decision: ApprovalDeny, DenyReason: denyReason}, nil
}
}
// formatToolDisplay creates the display string for a tool call.
func formatToolDisplay(toolName string, args map[string]any) string {
var sb strings.Builder
// For bash, show command directly
if toolName == "bash" {
if cmd, ok := args["command"].(string); ok {
sb.WriteString(fmt.Sprintf("Tool: %s\n", toolName))
sb.WriteString(fmt.Sprintf("Command: %s", cmd))
return sb.String()
}
}
// For web search, show query
if toolName == "web_search" {
if query, ok := args["query"].(string); ok {
sb.WriteString(fmt.Sprintf("Tool: %s\n", toolName))
sb.WriteString(fmt.Sprintf("Query: %s", query))
return sb.String()
}
}
// Generic display
sb.WriteString(fmt.Sprintf("Tool: %s", toolName))
if len(args) > 0 {
sb.WriteString("\nArguments: ")
first := true
for k, v := range args {
if !first {
sb.WriteString(", ")
}
sb.WriteString(fmt.Sprintf("%s=%v", k, v))
first = false
}
}
return sb.String()
}
// selectorState holds the state for the interactive selector
type selectorState struct {
toolDisplay string
selected int
totalLines int
termWidth int
termHeight int
boxWidth int
innerWidth int
denyReason string // deny reason (always visible in box)
isWarning bool // true if command targets paths outside cwd (red box)
}
// runSelector runs the interactive selector and returns the selected index and optional deny reason.
// If isWarning is true, the box is rendered in red to indicate the command targets paths outside cwd.
func runSelector(fd int, oldState *term.State, toolDisplay string, isWarning bool) (int, string, error) {
state := &selectorState{
toolDisplay: toolDisplay,
selected: 0,
isWarning: isWarning,
}
// Get terminal size
state.termWidth, state.termHeight, _ = term.GetSize(fd)
if state.termWidth < 20 {
state.termWidth = 80 // fallback
}
// Calculate box width: 90% of terminal, min 24, max 60
state.boxWidth = (state.termWidth * 90) / 100
if state.boxWidth > 60 {
state.boxWidth = 60
}
if state.boxWidth < 24 {
state.boxWidth = 24
}
// Ensure box fits in terminal
if state.boxWidth > state.termWidth-1 {
state.boxWidth = state.termWidth - 1
}
state.innerWidth = state.boxWidth - 4 // account for "│ " and " │"
// Calculate total lines (will be updated by render)
state.totalLines = calculateTotalLines(state)
// Hide cursor during selection (show when in deny mode)
fmt.Fprint(os.Stderr, "\033[?25l")
defer fmt.Fprint(os.Stderr, "\033[?25h") // Show cursor when done
// Initial render
renderSelectorBox(state)
numOptions := len(optionLabels)
for {
// Read input
buf := make([]byte, 8)
n, err := os.Stdin.Read(buf)
if err != nil {
clearSelectorBox(state)
return 2, "", err
}
// Process input byte by byte
for i := 0; i < n; i++ {
ch := buf[i]
// Check for escape sequences (arrow keys)
if ch == 27 && i+2 < n && buf[i+1] == '[' {
oldSelected := state.selected
switch buf[i+2] {
case 'A': // Up arrow
if state.selected > 0 {
state.selected--
}
case 'B': // Down arrow
if state.selected < numOptions-1 {
state.selected++
}
}
if oldSelected != state.selected {
updateSelectorOptions(state)
}
i += 2 // Skip the rest of escape sequence
continue
}
switch {
// Ctrl+C - cancel
case ch == 3:
clearSelectorBox(state)
return -1, "", nil // -1 indicates cancelled
// Enter key - confirm selection
case ch == 13:
clearSelectorBox(state)
if state.selected == 2 { // Deny
return 2, state.denyReason, nil
}
return state.selected, "", nil
// Number keys 1-3 for quick select
case ch >= '1' && ch <= '3':
selected := int(ch - '1')
clearSelectorBox(state)
if selected == 2 { // Deny
return 2, state.denyReason, nil
}
return selected, "", nil
// Backspace - delete from reason (UTF-8 safe)
case ch == 127 || ch == 8:
if len(state.denyReason) > 0 {
runes := []rune(state.denyReason)
state.denyReason = string(runes[:len(runes)-1])
updateReasonInput(state)
}
// Escape - clear reason
case ch == 27:
if len(state.denyReason) > 0 {
state.denyReason = ""
updateReasonInput(state)
}
// Printable ASCII (except 1-3 handled above) - type into reason
case ch >= 32 && ch < 127:
maxLen := state.innerWidth - 2
if maxLen < 10 {
maxLen = 10
}
if len(state.denyReason) < maxLen {
state.denyReason += string(ch)
// Auto-select Deny option when user starts typing
if state.selected != 2 {
state.selected = 2
updateSelectorOptions(state)
} else {
updateReasonInput(state)
}
}
}
}
}
}
// wrapText wraps text to fit within maxWidth, returning lines
func wrapText(text string, maxWidth int) []string {
if maxWidth < 5 {
maxWidth = 5
}
var lines []string
for _, line := range strings.Split(text, "\n") {
if len(line) <= maxWidth {
lines = append(lines, line)
continue
}
// Wrap long lines
for len(line) > maxWidth {
// Try to break at space
breakAt := maxWidth
for i := maxWidth; i > maxWidth/2; i-- {
if i < len(line) && line[i] == ' ' {
breakAt = i
break
}
}
lines = append(lines, line[:breakAt])
line = strings.TrimLeft(line[breakAt:], " ")
}
if len(line) > 0 {
lines = append(lines, line)
}
}
return lines
}
// getHintLines returns the hint text wrapped to terminal width
func getHintLines(state *selectorState) []string {
hint := "↑/↓ navigate, Enter confirm, 1-3 quick, Ctrl+C cancel"
if state.termWidth >= len(hint)+1 {
return []string{hint}
}
// Wrap hint to multiple lines
return wrapText(hint, state.termWidth-1)
}
// calculateTotalLines calculates how many lines the selector will use
func calculateTotalLines(state *selectorState) int {
toolLines := wrapText(state.toolDisplay, state.innerWidth)
hintLines := getHintLines(state)
// top border + (warning line if applicable) + tool lines + separator + options + bottom border + hint lines
warningLines := 0
if state.isWarning {
warningLines = 1
}
return 1 + warningLines + len(toolLines) + 1 + len(optionLabels) + 1 + len(hintLines)
}
// renderSelectorBox renders the complete selector box
func renderSelectorBox(state *selectorState) {
toolLines := wrapText(state.toolDisplay, state.innerWidth)
hintLines := getHintLines(state)
// Use red for warning (outside cwd), cyan for normal
boxColor := "\033[36m" // cyan
if state.isWarning {
boxColor = "\033[91m" // bright red
}
// Draw box top
fmt.Fprintf(os.Stderr, "%s┌%s┐\033[0m\033[K\r\n", boxColor, strings.Repeat("─", state.boxWidth-2))
// Draw warning line if needed (inside the box)
if state.isWarning {
warning := "!! OUTSIDE PROJECT !!"
padding := (state.innerWidth - len(warning)) / 2
if padding < 0 {
padding = 0
}
fmt.Fprintf(os.Stderr, "%s│\033[0m %s%s%s %s│\033[0m\033[K\r\n", boxColor,
strings.Repeat(" ", padding), warning, strings.Repeat(" ", state.innerWidth-len(warning)-padding), boxColor)
}
// Draw tool info
for _, line := range toolLines {
fmt.Fprintf(os.Stderr, "%s│\033[0m %-*s %s│\033[0m\033[K\r\n", boxColor, state.innerWidth, line, boxColor)
}
// Draw separator
fmt.Fprintf(os.Stderr, "%s├%s┤\033[0m\033[K\r\n", boxColor, strings.Repeat("─", state.boxWidth-2))
// Draw options with numbers (Deny option includes reason input)
for i, label := range optionLabels {
if i == 2 { // Deny option - show with reason input beside it
denyLabel := "3. Deny: "
availableWidth := state.innerWidth - 2 - len(denyLabel)
if availableWidth < 5 {
availableWidth = 5
}
inputDisplay := state.denyReason
if len(inputDisplay) > availableWidth {
inputDisplay = inputDisplay[len(inputDisplay)-availableWidth:]
}
if i == state.selected {
fmt.Fprintf(os.Stderr, "%s│\033[0m \033[1;32m> %s\033[0m%-*s %s│\033[0m\033[K\r\n", boxColor, denyLabel, availableWidth, inputDisplay, boxColor)
} else {
fmt.Fprintf(os.Stderr, "%s│\033[0m \033[90m%s\033[0m%-*s %s│\033[0m\033[K\r\n", boxColor, denyLabel, availableWidth, inputDisplay, boxColor)
}
} else {
displayLabel := label
if len(displayLabel) > state.innerWidth-2 {
displayLabel = displayLabel[:state.innerWidth-5] + "..."
}
if i == state.selected {
fmt.Fprintf(os.Stderr, "%s│\033[0m \033[1;32m> %-*s\033[0m %s│\033[0m\033[K\r\n", boxColor, state.innerWidth-2, displayLabel, boxColor)
} else {
fmt.Fprintf(os.Stderr, "%s│\033[0m %-*s %s│\033[0m\033[K\r\n", boxColor, state.innerWidth-2, displayLabel, boxColor)
}
}
}
// Draw box bottom
fmt.Fprintf(os.Stderr, "%s└%s┘\033[0m\033[K\r\n", boxColor, strings.Repeat("─", state.boxWidth-2))
// Draw hint (may be multiple lines)
for i, line := range hintLines {
if i == len(hintLines)-1 {
// Last line - no newline
fmt.Fprintf(os.Stderr, "\033[90m%s\033[0m\033[K", line)
} else {
fmt.Fprintf(os.Stderr, "\033[90m%s\033[0m\033[K\r\n", line)
}
}
}
// updateSelectorOptions updates just the options portion of the selector
func updateSelectorOptions(state *selectorState) {
hintLines := getHintLines(state)
// Use red for warning (outside cwd), cyan for normal
boxColor := "\033[36m" // cyan
if state.isWarning {
boxColor = "\033[91m" // bright red
}
// Move up to the first option line
// Cursor is at end of last hint line, need to go up:
// (hint lines - 1) + 1 (bottom border) + numOptions
linesToMove := len(hintLines) - 1 + 1 + len(optionLabels)
fmt.Fprintf(os.Stderr, "\033[%dA\r", linesToMove)
// Redraw options (Deny option includes reason input)
for i, label := range optionLabels {
if i == 2 { // Deny option
denyLabel := "3. Deny: "
availableWidth := state.innerWidth - 2 - len(denyLabel)
if availableWidth < 5 {
availableWidth = 5
}
inputDisplay := state.denyReason
if len(inputDisplay) > availableWidth {
inputDisplay = inputDisplay[len(inputDisplay)-availableWidth:]
}
if i == state.selected {
fmt.Fprintf(os.Stderr, "%s│\033[0m \033[1;32m> %s\033[0m%-*s %s│\033[0m\033[K\r\n", boxColor, denyLabel, availableWidth, inputDisplay, boxColor)
} else {
fmt.Fprintf(os.Stderr, "%s│\033[0m \033[90m%s\033[0m%-*s %s│\033[0m\033[K\r\n", boxColor, denyLabel, availableWidth, inputDisplay, boxColor)
}
} else {
displayLabel := label
if len(displayLabel) > state.innerWidth-2 {
displayLabel = displayLabel[:state.innerWidth-5] + "..."
}
if i == state.selected {
fmt.Fprintf(os.Stderr, "%s│\033[0m \033[1;32m> %-*s\033[0m %s│\033[0m\033[K\r\n", boxColor, state.innerWidth-2, displayLabel, boxColor)
} else {
fmt.Fprintf(os.Stderr, "%s│\033[0m %-*s %s│\033[0m\033[K\r\n", boxColor, state.innerWidth-2, displayLabel, boxColor)
}
}
}
// Redraw bottom and hint
fmt.Fprintf(os.Stderr, "%s└%s┘\033[0m\033[K\r\n", boxColor, strings.Repeat("─", state.boxWidth-2))
for i, line := range hintLines {
if i == len(hintLines)-1 {
fmt.Fprintf(os.Stderr, "\033[90m%s\033[0m\033[K", line)
} else {
fmt.Fprintf(os.Stderr, "\033[90m%s\033[0m\033[K\r\n", line)
}
}
}
// updateReasonInput updates just the Deny option line (which contains the reason input)
func updateReasonInput(state *selectorState) {
hintLines := getHintLines(state)
// Use red for warning (outside cwd), cyan for normal
boxColor := "\033[36m" // cyan
if state.isWarning {
boxColor = "\033[91m" // bright red
}
// Move up to the Deny line (3rd option, index 2)
// Cursor is at end of last hint line, need to go up:
// (hint lines - 1) + 1 (bottom border) + 1 (Deny is last option)
linesToMove := len(hintLines) - 1 + 1 + 1
fmt.Fprintf(os.Stderr, "\033[%dA\r", linesToMove)
// Redraw Deny line with reason
denyLabel := "3. Deny: "
availableWidth := state.innerWidth - 2 - len(denyLabel)
if availableWidth < 5 {
availableWidth = 5
}
inputDisplay := state.denyReason
if len(inputDisplay) > availableWidth {
inputDisplay = inputDisplay[len(inputDisplay)-availableWidth:]
}
if state.selected == 2 {
fmt.Fprintf(os.Stderr, "%s│\033[0m \033[1;32m> %s\033[0m%-*s %s│\033[0m\033[K\r\n", boxColor, denyLabel, availableWidth, inputDisplay, boxColor)
} else {
fmt.Fprintf(os.Stderr, "%s│\033[0m \033[90m%s\033[0m%-*s %s│\033[0m\033[K\r\n", boxColor, denyLabel, availableWidth, inputDisplay, boxColor)
}
// Redraw bottom and hint
fmt.Fprintf(os.Stderr, "%s└%s┘\033[0m\033[K\r\n", boxColor, strings.Repeat("─", state.boxWidth-2))
for i, line := range hintLines {
if i == len(hintLines)-1 {
fmt.Fprintf(os.Stderr, "\033[90m%s\033[0m\033[K", line)
} else {
fmt.Fprintf(os.Stderr, "\033[90m%s\033[0m\033[K\r\n", line)
}
}
}
// clearSelectorBox clears the selector from screen
func clearSelectorBox(state *selectorState) {
// Clear the current line (hint line) first
fmt.Fprint(os.Stderr, "\r\033[K")
// Move up and clear each remaining line
for range state.totalLines - 1 {
fmt.Fprint(os.Stderr, "\033[A\033[K")
}
fmt.Fprint(os.Stderr, "\r")
}
// fallbackApproval handles approval when terminal control isn't available.
func (a *ApprovalManager) fallbackApproval(toolDisplay string) (ApprovalResult, error) {
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
fmt.Fprintln(os.Stderr, toolDisplay)
fmt.Fprintln(os.Stderr, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
fmt.Fprintln(os.Stderr, "[1] Execute once [2] Always allow [3] Deny")
fmt.Fprint(os.Stderr, "Choice: ")
var input string
fmt.Scanln(&input)
switch input {
case "1":
return ApprovalResult{Decision: ApprovalOnce}, nil
case "2":
return ApprovalResult{Decision: ApprovalAlways}, nil
default:
fmt.Fprint(os.Stderr, "Reason (optional): ")
var reason string
fmt.Scanln(&reason)
return ApprovalResult{Decision: ApprovalDeny, DenyReason: reason}, nil
}
}
// Reset clears the session allowlist.
func (a *ApprovalManager) Reset() {
a.mu.Lock()
defer a.mu.Unlock()
a.allowlist = make(map[string]bool)
a.prefixes = make(map[string]bool)
}
// AllowedTools returns a list of tools and prefixes in the allowlist.
func (a *ApprovalManager) AllowedTools() []string {
a.mu.RLock()
defer a.mu.RUnlock()
tools := make([]string, 0, len(a.allowlist)+len(a.prefixes))
for tool := range a.allowlist {
tools = append(tools, tool)
}
for prefix := range a.prefixes {
tools = append(tools, prefix+"*")
}
return tools
}
// FormatApprovalResult returns a formatted string showing the approval result.
func FormatApprovalResult(toolName string, args map[string]any, result ApprovalResult) string {
var status string
var icon string
switch result.Decision {
case ApprovalOnce:
status = "Approved"
icon = "\033[32m✓\033[0m"
case ApprovalAlways:
status = "Always allowed"
icon = "\033[32m✓\033[0m"
case ApprovalDeny:
status = "Denied"
icon = "\033[31m✗\033[0m"
}
// Format based on tool type
if toolName == "bash" {
if cmd, ok := args["command"].(string); ok {
// Truncate long commands
if len(cmd) > 40 {
cmd = cmd[:37] + "..."
}
return fmt.Sprintf("▶ bash: %s [%s] %s", cmd, status, icon)
}
}
if toolName == "web_search" {
if query, ok := args["query"].(string); ok {
// Truncate long queries
if len(query) > 40 {
query = query[:37] + "..."
}
return fmt.Sprintf("▶ web_search: %s [%s] %s", query, status, icon)
}
}
return fmt.Sprintf("▶ %s [%s] %s", toolName, status, icon)
}
// FormatDenyResult returns the tool result message when a tool is denied.
func FormatDenyResult(toolName string, reason string) string {
if reason != "" {
return fmt.Sprintf("User denied execution of %s. Reason: %s", toolName, reason)
}
return fmt.Sprintf("User denied execution of %s.", toolName)
}