mirror of
https://github.com/docker/docker-agent.git
synced 2026-11-04 10:02:15 +00:00
Tool approval today is a coarse two-option choice: approve a call once,
or approve everything for the rest of the session. Prompt fatigue
pushes people to the blanket option without them realising it also
covers destructive commands. There is no middle ground, and no obvious
way back. The shell classifier (safer: true) was also entangled with
the approval verdict, which made its judgement impossible to reuse.
Introduce three explicit safety modes and split the classifier into a
pure labeller so the mode is what actually gates the call:
safe destructive unknown
strict ask ask ask
balanced ALLOW ask ask
autonomous ALLOW ALLOW ALLOW
Every call gets a safe / destructive / unknown label from a new
dependency-free pkg/safety package (shell commands via the pattern
taxonomy, other tools via their MCP annotations), and the pipeline
becomes: preempt hooks -> custom rules -> mode x label -> default
hooks -> ask user. Custom rules always win over the mode; session
ask rules always prompt while agent-author ask rules yield to a
user-chosen auto-approving mode.
Escalation is guided and reversible: confirmation prompts offer the
next rung, a new --safety flag and the PATCH safety-policy endpoint
set or undo the mode at any time, and downgrading genuinely revokes
the blanket approval (the yolo toggle restores the pre-escalation
mode, surviving branch and fork).
Compatibility: legacy policy values (unsafe / safer / safe-auto) and
resume verbs are accepted and normalized; safer maps to balanced,
which is deliberately narrower (unknown commands now prompt). Sessions
that never choose a mode keep the historical default. The shell
toolset's safer: flag stays parseable but is ignored; the safer_shell
builtin degrades to a metadata-only labeller and warns at config build
time. Confirmation events and the on_tool_approval_decision hook now
carry safety_label. Resuming a session stored as autonomous keeps
autonomous without re-passing --yolo; an explicit --safety on resume
overrides.
366 lines
10 KiB
Go
366 lines
10 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/fatih/color"
|
|
"github.com/mattn/go-isatty"
|
|
orderedmap "github.com/wk8/go-ordered-map/v2"
|
|
"golang.org/x/term"
|
|
|
|
"github.com/docker/docker-agent/pkg/input"
|
|
"github.com/docker/docker-agent/pkg/tools"
|
|
)
|
|
|
|
// ConfirmationResult represents the result of a user confirmation prompt
|
|
type ConfirmationResult string
|
|
|
|
const (
|
|
ConfirmationApprove ConfirmationResult = "approve"
|
|
// ConfirmationApproveBalanced switches the session to the Balanced
|
|
// safety mode (auto-approve classifier-safe calls).
|
|
ConfirmationApproveBalanced ConfirmationResult = "approve_balanced"
|
|
// ConfirmationApproveSession switches the session to the
|
|
// Autonomous safety mode (approve everything).
|
|
ConfirmationApproveSession ConfirmationResult = "approve_session"
|
|
ConfirmationReject ConfirmationResult = "reject"
|
|
ConfirmationAbort ConfirmationResult = "abort"
|
|
)
|
|
|
|
var bold = color.New(color.Bold).SprintfFunc()
|
|
|
|
type Printer struct {
|
|
out io.Writer
|
|
isTTYOut bool
|
|
}
|
|
|
|
func NewPrinter(out io.Writer) *Printer {
|
|
isTTY := false
|
|
if f, ok := out.(*os.File); ok {
|
|
isTTY = isatty.IsTerminal(f.Fd())
|
|
}
|
|
return &Printer{
|
|
out: out,
|
|
isTTYOut: isTTY,
|
|
}
|
|
}
|
|
|
|
func (p *Printer) Println(a ...any) {
|
|
fmt.Fprintln(p.out, a...)
|
|
}
|
|
|
|
func (p *Printer) Print(a ...any) {
|
|
fmt.Fprint(p.out, a...)
|
|
}
|
|
|
|
func (p *Printer) Printf(format string, a ...any) {
|
|
fmt.Fprintf(p.out, format, a...)
|
|
}
|
|
|
|
// PrintWelcomeMessage prints the welcome message
|
|
func (p *Printer) PrintWelcomeMessage(appName string) {
|
|
p.Printf("\n------- Welcome to %s! -------\n(Ctrl+C to stop the agent and exit)\n\n", bold(appName))
|
|
}
|
|
|
|
// PrintWarning prints a warning message on its own line. Used for non-fatal
|
|
// notices such as a turn that produced only reasoning or an empty response,
|
|
// which would otherwise be invisible in non-TUI runs.
|
|
func (p *Printer) PrintWarning(message string) {
|
|
p.Printf("\n⚠️ %s\n", message)
|
|
}
|
|
|
|
// PrintAgentName prints the agent name header
|
|
func (p *Printer) PrintAgentName(agentName string) {
|
|
if !p.isTTYOut {
|
|
return
|
|
}
|
|
p.Printf("\n--- Agent: %s ---\n", bold(agentName))
|
|
}
|
|
|
|
// PrintToolCall prints a tool call
|
|
func (p *Printer) PrintToolCall(toolCall tools.ToolCall) {
|
|
p.Printf("\nCalling %s%s\n", bold(toolCall.Function.Name), formatToolCallArguments(toolCall.Function.Arguments))
|
|
}
|
|
|
|
// PrintToolCallWithConfirmation prints a tool call and prompts for confirmation
|
|
func (p *Printer) PrintToolCallWithConfirmation(ctx context.Context, toolCall tools.ToolCall, rd io.Reader) ConfirmationResult {
|
|
p.Printf("\n%s\n", bold("🛠️ Tool call requires confirmation 🛠️"))
|
|
p.PrintToolCall(toolCall)
|
|
p.Printf("\n%s", bold("Can I run this tool? ([y]es/[b]alanced/[a]ll/[n]o): "))
|
|
|
|
if !isatty.IsTerminal(os.Stdout.Fd()) {
|
|
return ConfirmationReject
|
|
}
|
|
|
|
// Try single-character input from stdin in raw mode (no Enter required)
|
|
fd := int(os.Stdin.Fd())
|
|
if oldState, err := term.MakeRaw(fd); err == nil {
|
|
defer func() {
|
|
if err := term.Restore(fd, oldState); err != nil {
|
|
p.Printf("\nFailed to restore terminal state: %v\n", err)
|
|
}
|
|
}()
|
|
buf := make([]byte, 1)
|
|
for {
|
|
if _, err := os.Stdin.Read(buf); err != nil {
|
|
break
|
|
}
|
|
switch buf[0] {
|
|
case 'y', 'Y':
|
|
p.Print(bold("Yes 👍"))
|
|
return ConfirmationApprove
|
|
case 'b', 'B':
|
|
p.Print(bold("Yes, auto-approve safe commands 👍"))
|
|
return ConfirmationApproveBalanced
|
|
case 'a', 'A':
|
|
p.Print(bold("Yes to all 👍"))
|
|
return ConfirmationApproveSession
|
|
case 'n', 'N':
|
|
p.Print(bold("No 👎"))
|
|
return ConfirmationReject
|
|
case 3: // Ctrl+C
|
|
return ConfirmationAbort
|
|
case '\r', '\n':
|
|
// ignore
|
|
default:
|
|
// ignore other keys
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback: line-based scanner (requires Enter)
|
|
text, err := input.ReadLine(ctx, rd)
|
|
if err != nil {
|
|
return ConfirmationReject
|
|
}
|
|
|
|
switch text {
|
|
case "y":
|
|
return ConfirmationApprove
|
|
case "b":
|
|
return ConfirmationApproveBalanced
|
|
case "a":
|
|
return ConfirmationApproveSession
|
|
case "n":
|
|
return ConfirmationReject
|
|
default:
|
|
// Default to reject for invalid input
|
|
return ConfirmationReject
|
|
}
|
|
}
|
|
|
|
// PrintToolCallResponse prints a tool call response
|
|
func (p *Printer) PrintToolCallResponse(name, response string) {
|
|
p.Printf("\n%s response%s\n", bold(name), formatToolCallResponse(response))
|
|
}
|
|
|
|
// PromptMaxIterationsContinue prompts the user to continue after max iterations
|
|
func (p *Printer) PromptMaxIterationsContinue(ctx context.Context, maxIterations int) ConfirmationResult {
|
|
p.Printf("\n⚠️ Maximum iterations (%d) reached. The agent may be stuck in a loop.\n", maxIterations)
|
|
p.Println("This can happen with smaller or less capable models.")
|
|
p.Println("\nDo you want to continue for 10 more iterations? (y/n):")
|
|
|
|
response, err := input.ReadLine(ctx, os.Stdin)
|
|
if err != nil {
|
|
p.Println("\nFailed to read input, exiting...")
|
|
return ConfirmationAbort
|
|
}
|
|
|
|
response = strings.TrimSpace(strings.ToLower(response))
|
|
if response == "y" || response == "yes" {
|
|
p.Print("✓ Continuing...\n\n")
|
|
return ConfirmationApprove
|
|
} else {
|
|
p.Print("Exiting...\n\n")
|
|
return ConfirmationReject
|
|
}
|
|
}
|
|
|
|
// PromptOAuthAuthorization prompts the user for OAuth authorization
|
|
func (p *Printer) PromptOAuthAuthorization(ctx context.Context, serverURL string) ConfirmationResult {
|
|
p.Println("\n🔐 OAuth Authorization Required")
|
|
p.Println("Server:", serverURL, "(remote)")
|
|
p.Println("This server requires OAuth authentication to access its tools.")
|
|
p.Println("Your browser will open automatically to complete the authorization.")
|
|
p.Printf("\n%s (y/n): ", "Do you want to authorize access?")
|
|
|
|
response, err := input.ReadLine(ctx, os.Stdin)
|
|
if err != nil {
|
|
p.Println("\nFailed to read input, aborting authorization...")
|
|
return ConfirmationAbort
|
|
}
|
|
|
|
response = strings.TrimSpace(strings.ToLower(response))
|
|
if response == "y" || response == "yes" {
|
|
p.Println("✓ Starting OAuth authorization...")
|
|
p.Println("Please complete the authorization in your browser.")
|
|
p.Print("Once completed, the agent will continue automatically.\n\n")
|
|
return ConfirmationApprove
|
|
} else {
|
|
p.Print("Authorization declined. Exiting...\n\n")
|
|
return ConfirmationReject
|
|
}
|
|
}
|
|
|
|
func formatToolCallArguments(arguments string) string {
|
|
if arguments == "" {
|
|
return "()"
|
|
}
|
|
|
|
// Is is a map?
|
|
kv := orderedmap.New[string, any]()
|
|
if err := json.Unmarshal([]byte(arguments), &kv); err == nil {
|
|
if kv.Len() == 0 {
|
|
return "()"
|
|
}
|
|
|
|
var (
|
|
parts []string
|
|
multiline bool
|
|
)
|
|
|
|
for key, value := range kv.FromOldest() {
|
|
formatted := formatJSONValue(key, value)
|
|
parts = append(parts, formatted)
|
|
|
|
multiline = multiline || strings.Contains(formatted, "\n")
|
|
}
|
|
|
|
if len(parts) == 1 && !multiline {
|
|
return fmt.Sprintf("(%s)", parts[0])
|
|
}
|
|
|
|
return fmt.Sprintf("(\n %s\n)", strings.Join(parts, "\n "))
|
|
}
|
|
|
|
// Maybe some other JSON type?
|
|
var parsed any
|
|
if err := json.Unmarshal([]byte(arguments), &parsed); err == nil {
|
|
formatted, _ := json.MarshalIndent(parsed, "", " ")
|
|
return fmt.Sprintf("(%s)", string(formatted))
|
|
}
|
|
|
|
// JSON parsing failed
|
|
return fmt.Sprintf("(%s)", arguments)
|
|
}
|
|
|
|
func formatToolCallResponse(response string) string {
|
|
if response == "" {
|
|
return " → ()"
|
|
}
|
|
|
|
// For responses, we want to show them as readable text, not JSON
|
|
// Check if it looks like JSON first
|
|
var parsed any
|
|
if err := json.Unmarshal([]byte(response), &parsed); err == nil {
|
|
// It's valid JSON, format it nicely
|
|
return " → " + formatParsedJSON(parsed)
|
|
}
|
|
|
|
// It's plain text, handle multiline content
|
|
if strings.Contains(response, "\n") {
|
|
// Trim whitespace and split into lines
|
|
trimmed := strings.TrimSpace(response)
|
|
lines := strings.Split(trimmed, "\n")
|
|
|
|
if len(lines) <= 3 {
|
|
// Short multiline, show inline
|
|
return fmt.Sprintf(" → %q", response)
|
|
}
|
|
|
|
// Long multiline, format with line breaks
|
|
// Process each line individually and collapse consecutive empty lines
|
|
var formatted []string
|
|
lastWasEmpty := false
|
|
|
|
for _, line := range lines {
|
|
trimmedLine := strings.TrimSpace(line)
|
|
if trimmedLine == "" {
|
|
// Empty line - only add one if the last line wasn't empty
|
|
if !lastWasEmpty {
|
|
formatted = append(formatted, "")
|
|
lastWasEmpty = true
|
|
}
|
|
} else {
|
|
formatted = append(formatted, line)
|
|
lastWasEmpty = false
|
|
}
|
|
}
|
|
return fmt.Sprintf(" → (\n%s\n)", strings.Join(formatted, "\n"))
|
|
}
|
|
|
|
// Single line text response
|
|
return fmt.Sprintf(" → %q", response)
|
|
}
|
|
|
|
func formatParsedJSON(data any) string {
|
|
switch v := data.(type) {
|
|
case map[string]any:
|
|
if len(v) == 0 {
|
|
return "()"
|
|
}
|
|
|
|
parts := make([]string, 0, len(v))
|
|
hasMultilineContent := false
|
|
|
|
for key, value := range v {
|
|
formatted := formatJSONValue(key, value)
|
|
parts = append(parts, formatted)
|
|
if strings.Contains(formatted, "\n") {
|
|
hasMultilineContent = true
|
|
}
|
|
}
|
|
|
|
if len(parts) == 1 && !hasMultilineContent {
|
|
return fmt.Sprintf("(%s)", parts[0])
|
|
}
|
|
|
|
return fmt.Sprintf("(\n %s\n)", strings.Join(parts, "\n "))
|
|
|
|
default:
|
|
// For non-object types, use standard JSON formatting
|
|
formatted, _ := json.MarshalIndent(data, "", " ")
|
|
return fmt.Sprintf("(%s)", string(formatted))
|
|
}
|
|
}
|
|
|
|
func formatJSONValue(key string, value any) string {
|
|
switch v := value.(type) {
|
|
case string:
|
|
// Handle multiline strings by displaying with actual newlines
|
|
if strings.Contains(v, "\n") {
|
|
// Format as: key: "content with
|
|
// actual line breaks"
|
|
return fmt.Sprintf("%s: %q", bold(key), v)
|
|
}
|
|
// Regular string with proper escaping
|
|
return fmt.Sprintf("%s: %q", bold(key), v)
|
|
|
|
case []any:
|
|
if len(v) == 0 {
|
|
return bold(key) + ": []"
|
|
}
|
|
// Single item arrays are rendered on a single line
|
|
if len(v) == 1 {
|
|
jsonBytes, _ := json.Marshal(v)
|
|
return fmt.Sprintf("%s: %s", bold(key), string(jsonBytes))
|
|
}
|
|
// Show full array contents
|
|
jsonBytes, _ := json.MarshalIndent(v, "", " ")
|
|
return fmt.Sprintf("%s: %s", bold(key), string(jsonBytes))
|
|
|
|
case map[string]any:
|
|
jsonBytes, _ := json.MarshalIndent(v, "", " ")
|
|
return fmt.Sprintf("%s: %s", bold(key), string(jsonBytes))
|
|
|
|
default:
|
|
jsonBytes, _ := json.Marshal(v)
|
|
return fmt.Sprintf("%s: %s", bold(key), string(jsonBytes))
|
|
}
|
|
}
|