ollama/agent/approval.go
2026-06-26 17:26:38 -07:00

905 lines
22 KiB
Go

package agent
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"sync"
"unicode"
)
type ApprovalDecision string
const (
ApprovalAllowOnce ApprovalDecision = "allow_once"
ApprovalAllowSession ApprovalDecision = "allow_session"
ApprovalDeny ApprovalDecision = "deny"
)
type ApprovalRisk string
const (
ApprovalRiskLow ApprovalRisk = "low"
ApprovalRiskMedium ApprovalRisk = "medium"
ApprovalRiskHigh ApprovalRisk = "high"
)
type ApprovalRequest struct {
ToolCallID string
ToolName string
Args map[string]any
WorkingDir string
ToolApprovalRequired bool
Summary string
Risk ApprovalRisk
Reasons []string
}
type ApprovalResult struct {
Decision ApprovalDecision
Reason string
}
type ApprovalHandler interface {
RequiresApproval(context.Context, Tool, ApprovalRequest) bool
Approve(context.Context, ApprovalRequest) (ApprovalResult, error)
}
type ApprovalPrompter interface {
PromptApproval(context.Context, ApprovalRequest) (ApprovalResult, error)
}
type ApprovalPolicy interface {
EvaluateApproval(context.Context, ApprovalRequest) ApprovalEvaluation
}
type ApprovalEvaluation struct {
Decision ApprovalDecision
RequirePrompt bool
Risk ApprovalRisk
Summary string
Reasons []string
SessionKey string
}
type AutoAllowApproval struct{}
func (AutoAllowApproval) RequiresApproval(context.Context, Tool, ApprovalRequest) bool {
return false
}
func (AutoAllowApproval) Approve(context.Context, ApprovalRequest) (ApprovalResult, error) {
return ApprovalResult{Decision: ApprovalAllowOnce}, nil
}
type ApprovalManagerOptions struct {
Policy ApprovalPolicy
Prompter ApprovalPrompter
}
type ApprovalManager struct {
policy ApprovalPolicy
prompter ApprovalPrompter
mu *sync.Mutex
sessionAllowed map[string]struct{}
}
func NewApprovalManager(opts ApprovalManagerOptions) *ApprovalManager {
policy := opts.Policy
if policy == nil {
policy = DefaultApprovalPolicy{}
}
return &ApprovalManager{
policy: policy,
prompter: opts.Prompter,
mu: &sync.Mutex{},
sessionAllowed: make(map[string]struct{}),
}
}
func (m *ApprovalManager) WithPrompter(prompter ApprovalPrompter) *ApprovalManager {
if m == nil {
return NewApprovalManager(ApprovalManagerOptions{Prompter: prompter})
}
return &ApprovalManager{
policy: m.policy,
prompter: prompter,
mu: m.mu,
sessionAllowed: m.sessionAllowed,
}
}
func (m *ApprovalManager) RequiresApproval(ctx context.Context, tool Tool, req ApprovalRequest) bool {
if m == nil {
return false
}
req.ToolApprovalRequired = req.ToolApprovalRequired || ToolRequiresApproval(tool, req.Args)
evaluation := applyToolApprovalRequirement(req, m.evaluate(ctx, req))
if evaluation.Decision == ApprovalDeny {
return true
}
if evaluation.RequirePrompt {
return !m.sessionAllowedFor(evaluation.SessionKey)
}
return false
}
func (m *ApprovalManager) Approve(ctx context.Context, req ApprovalRequest) (ApprovalResult, error) {
if m == nil {
return ApprovalResult{Decision: ApprovalAllowOnce}, nil
}
evaluation := applyToolApprovalRequirement(req, m.evaluate(ctx, req))
req = approvalRequestWithEvaluation(req, evaluation)
if evaluation.Decision == ApprovalDeny {
reason := strings.Join(evaluation.Reasons, "; ")
if reason == "" {
reason = "Tool execution denied."
}
return ApprovalResult{Decision: ApprovalDeny, Reason: reason}, nil
}
if !evaluation.RequirePrompt || m.sessionAllowedFor(evaluation.SessionKey) {
return ApprovalResult{Decision: ApprovalAllowOnce}, nil
}
if m.prompter == nil {
return ApprovalResult{
Decision: ApprovalDeny,
Reason: "Tool execution requires approval, but no approval prompter is available. Re-run with --auto-approve-tools or --yolo to allow tool execution.",
}, nil
}
result, err := m.prompter.PromptApproval(ctx, req)
if err != nil {
return ApprovalResult{}, err
}
if result.Decision == "" {
result.Decision = ApprovalDeny
}
if result.Decision == ApprovalAllowSession {
m.allowSession(evaluation.SessionKey)
}
return result, nil
}
func (m *ApprovalManager) evaluate(ctx context.Context, req ApprovalRequest) ApprovalEvaluation {
if m == nil || m.policy == nil {
return DefaultApprovalPolicy{}.EvaluateApproval(ctx, req)
}
evaluation := m.policy.EvaluateApproval(ctx, req)
if evaluation.Risk == "" {
evaluation.Risk = ApprovalRiskLow
}
if evaluation.SessionKey == "" {
evaluation.SessionKey = approvalSessionKey(req)
}
return evaluation
}
func applyToolApprovalRequirement(req ApprovalRequest, evaluation ApprovalEvaluation) ApprovalEvaluation {
if !req.ToolApprovalRequired || evaluation.Decision == ApprovalDeny {
return evaluation
}
evaluation.RequirePrompt = true
if evaluation.Summary == "" {
evaluation.Summary = fmt.Sprintf("%s wants to run", ToolDisplayName(req.ToolName))
}
if len(evaluation.Reasons) == 0 {
evaluation.Reasons = []string{"tool requires approval"}
}
return evaluation
}
func (m *ApprovalManager) sessionAllowedFor(key string) bool {
if m == nil || key == "" {
return false
}
m.mu.Lock()
defer m.mu.Unlock()
_, ok := m.sessionAllowed[key]
return ok
}
func (m *ApprovalManager) allowSession(key string) {
if m == nil || key == "" {
return
}
m.mu.Lock()
defer m.mu.Unlock()
m.sessionAllowed[key] = struct{}{}
}
func approvalRequestWithEvaluation(req ApprovalRequest, evaluation ApprovalEvaluation) ApprovalRequest {
req.Summary = evaluation.Summary
req.Risk = evaluation.Risk
req.Reasons = slices.Clone(evaluation.Reasons)
return req
}
func approvalSessionKey(req ApprovalRequest) string {
if IsShellToolName(req.ToolName) {
if command, ok := stringApprovalArg(req.Args, "command"); ok {
return req.ToolName + ":" + command
}
}
switch req.ToolName {
case "edit":
if path, ok := stringApprovalArg(req.Args, "path"); ok {
return "edit:" + path
}
case "web_search":
if query, ok := stringApprovalArg(req.Args, "query"); ok {
return "web_search:" + query
}
case "web_fetch":
if targetURL, ok := stringApprovalArg(req.Args, "url"); ok {
return "web_fetch:" + targetURL
}
}
return req.ToolName + ":" + stableApprovalArgs(req.Args)
}
type DefaultApprovalPolicy struct{}
func (DefaultApprovalPolicy) EvaluateApproval(_ context.Context, req ApprovalRequest) ApprovalEvaluation {
switch req.ToolName {
case "read":
if path, ok := stringApprovalArg(req.Args, "path"); ok {
if reason := approvalPathEscapeReason(req.WorkingDir, path); reason != "" {
return denyApproval(req.ToolName, ApprovalRiskHigh, reason)
}
}
return ApprovalEvaluation{Decision: ApprovalAllowOnce, Risk: ApprovalRiskLow, Summary: fmt.Sprintf("%s can run without approval", ToolDisplayName(req.ToolName))}
case "web_search", "web_fetch":
return evaluateWebApproval(req)
case "edit":
return evaluateEditApproval(req)
case "bash", "powershell":
return evaluateShellApproval(req)
default:
return ApprovalEvaluation{
RequirePrompt: true,
Risk: ApprovalRiskMedium,
Summary: fmt.Sprintf("%s wants to run", ToolDisplayName(req.ToolName)),
Reasons: []string{"unknown tool effects"},
SessionKey: approvalSessionKey(req),
}
}
}
func evaluateEditApproval(req ApprovalRequest) ApprovalEvaluation {
path, ok := stringApprovalArg(req.Args, "path")
if !ok {
return denyApproval("edit", ApprovalRiskHigh, "missing path argument")
}
if reason := approvalPathEscapeReason(req.WorkingDir, path); reason != "" {
return denyApproval("edit", ApprovalRiskHigh, reason)
}
reasons := []string{"writes to a file"}
if replaceAll, _ := req.Args["replace_all"].(bool); replaceAll {
reasons = append(reasons, "may replace multiple matches")
}
return ApprovalEvaluation{
RequirePrompt: true,
Risk: ApprovalRiskMedium,
Summary: fmt.Sprintf("Edit wants to modify %s", sanitizeApprovalDisplay(path)),
Reasons: reasons,
SessionKey: "edit:" + path,
}
}
func evaluateWebApproval(req ApprovalRequest) ApprovalEvaluation {
switch req.ToolName {
case "web_search":
query, ok := stringApprovalArg(req.Args, "query")
if !ok {
return denyApproval("web_search", ApprovalRiskHigh, "missing query argument")
}
return ApprovalEvaluation{
RequirePrompt: true,
Risk: ApprovalRiskMedium,
Summary: fmt.Sprintf("Web Search wants to search for %q", sanitizeApprovalDisplay(query)),
Reasons: []string{"searches the web"},
SessionKey: "web_search:" + query,
}
case "web_fetch":
targetURL, ok := stringApprovalArg(req.Args, "url")
if !ok {
return denyApproval("web_fetch", ApprovalRiskHigh, "missing url argument")
}
return ApprovalEvaluation{
RequirePrompt: true,
Risk: ApprovalRiskMedium,
Summary: fmt.Sprintf("Web Fetch wants to fetch %s", sanitizeApprovalDisplay(targetURL)),
Reasons: []string{"fetches web content"},
SessionKey: "web_fetch:" + targetURL,
}
}
return ApprovalEvaluation{
RequirePrompt: true,
Risk: ApprovalRiskMedium,
Summary: fmt.Sprintf("%s wants to run", ToolDisplayName(req.ToolName)),
Reasons: []string{"accesses the web"},
SessionKey: approvalSessionKey(req),
}
}
func sanitizeApprovalDisplay(value string) string {
value = approvalANSIEscapePattern.ReplaceAllString(value, "")
value = strings.Map(func(r rune) rune {
switch r {
case '\n', '\r', '\t':
return ' '
}
if unicode.IsControl(r) {
return -1
}
return r
}, value)
value = strings.Join(strings.Fields(value), " ")
if value == "" {
return "(empty)"
}
return value
}
func evaluateShellApproval(req ApprovalRequest) ApprovalEvaluation {
command, ok := stringApprovalArg(req.Args, "command")
if !ok || strings.TrimSpace(command) == "" {
return denyApproval(req.ToolName, ApprovalRiskHigh, "missing command argument")
}
risk, reasons := classifyBashCommand(command)
if len(reasons) == 0 {
reasons = append(reasons, "runs shell commands")
}
return ApprovalEvaluation{
RequirePrompt: true,
Risk: risk,
Summary: fmt.Sprintf("%s wants to run a command", ToolDisplayName(req.ToolName)),
Reasons: reasons,
SessionKey: req.ToolName + ":" + command,
}
}
func denyApproval(toolName string, risk ApprovalRisk, reason string) ApprovalEvaluation {
return ApprovalEvaluation{
Decision: ApprovalDeny,
Risk: risk,
Summary: fmt.Sprintf("%s cannot run", ToolDisplayName(toolName)),
Reasons: []string{reason},
SessionKey: approvalSessionKey(ApprovalRequest{ToolName: toolName}),
}
}
// 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}
tokens, scannerReasons, scannerHigh := scanBashTokens(command)
for _, reason := range scannerReasons {
classifier.addReason(reason)
}
if scannerHigh {
classifier.high = true
}
if bashFunctionDeclPattern.MatchString(command) {
classifier.addReason("defines shell functions")
classifier.high = true
}
if bashSubshellPattern.MatchString(command) {
classifier.addReason("uses a subshell")
classifier.high = true
}
for _, token := range tokens {
if token.kind != bashTokenOperator {
continue
}
switch token.value {
case "&&", "||", "|":
classifier.addReason("uses shell control operator " + token.value)
classifier.high = true
case ";", "\n":
classifier.addReason("uses shell statement separator")
classifier.high = true
case "&":
classifier.addReason("runs a command in the background")
classifier.high = true
case "$(", "`":
classifier.addReason("uses command substitution")
classifier.high = true
case "<(", ">(":
classifier.addReason("uses process substitution")
classifier.high = true
case ">", ">>", ">|", ">&", "&>":
classifier.addReason("writes or redirects files")
classifier.high = true
case "<", "<<", "<&":
classifier.addReason("uses shell redirection")
}
}
for _, args := range bashCommandCalls(tokens) {
classifier.addCall(args)
}
if classifier.high {
classifier.risk = ApprovalRiskHigh
}
return classifier.risk, classifier.reasons
}
type bashClassifier struct {
risk ApprovalRisk
high bool
reasons []string
}
func (c *bashClassifier) addReason(reason string) {
if reason == "" || slices.Contains(c.reasons, reason) {
return
}
c.reasons = append(c.reasons, reason)
}
func (c *bashClassifier) addCall(args []string) {
args = shellCommandArgs(args)
if len(args) == 0 {
return
}
if isDynamicCommandName(args[0]) {
c.addReason("uses dynamic command name")
c.high = true
}
name := shellCommandBase(args[0])
switch name {
case "cd":
c.addReason("changes directory")
c.high = true
case "eval":
c.addReason("evaluates shell code")
c.high = true
case "source", ".":
c.addReason("sources shell code")
c.high = true
case "exec":
c.addReason("replaces the shell process")
c.high = true
case "sudo":
c.addReason("runs with elevated privileges")
c.high = true
case "rm":
if hasAnyFlag(args[1:], "r", "R", "recursive") || hasAnyFlag(args[1:], "f", "force") {
c.addReason("removes files destructively")
c.high = true
}
case "git":
if isGitResetHard(args) {
c.addReason("runs destructive git reset")
c.high = true
}
if isGitCleanDestructive(args) {
c.addReason("runs destructive git clean")
c.high = true
}
case "find":
c.addFindReasons(args)
case "chmod", "chown":
if hasAnyFlag(args[1:], "R", "recursive") {
c.addReason("changes permissions or ownership recursively")
c.high = true
}
case "dd", "diskutil":
c.addReason("can write directly to disks")
c.high = true
case "mkfs", "newfs":
c.addReason("formats filesystems")
c.high = true
case "curl", "wget":
c.addReason("downloads remote content")
case "sh", "bash", "zsh":
if len(args) > 1 {
c.addReason("runs a shell interpreter")
c.high = true
}
}
}
func (c *bashClassifier) addFindReasons(args []string) {
for i := 1; i < len(args); i++ {
switch args[i] {
case "-delete":
c.addReason("deletes files via find")
c.high = true
case "-exec", "-execdir":
c.addReason("executes commands via find")
c.high = true
end := i + 1
for end < len(args) && args[end] != ";" && args[end] != `\;` && args[end] != "+" {
end++
}
if end > i+1 {
c.addCall(args[i+1 : end])
}
i = end
}
}
}
func hasAnyFlag(args []string, flags ...string) bool {
for _, arg := range args {
if arg == "--" {
return false
}
if !strings.HasPrefix(arg, "-") {
continue
}
for _, flag := range flags {
short := len(flag) == 1
if short && strings.HasPrefix(arg, "-") && !strings.HasPrefix(arg, "--") && strings.Contains(arg[1:], flag) {
return true
}
if arg == "--"+flag {
return true
}
}
}
return false
}
func isGitResetHard(args []string) bool {
subcommand := gitSubcommandArgs(args)
return len(subcommand) >= 2 && subcommand[0] == "reset" && slices.Contains(subcommand[1:], "--hard")
}
func isGitCleanDestructive(args []string) bool {
subcommand := gitSubcommandArgs(args)
if len(subcommand) < 1 || subcommand[0] != "clean" {
return false
}
return hasAnyFlag(subcommand[1:], "f", "force") && (hasAnyFlag(subcommand[1:], "d") || hasAnyFlag(subcommand[1:], "x", "X"))
}
func gitSubcommandArgs(args []string) []string {
for i := 1; i < len(args); i++ {
arg := args[i]
if arg == "--" {
return args[i+1:]
}
switch {
case arg == "-C", arg == "-c", arg == "--git-dir", arg == "--work-tree":
i++
continue
case strings.HasPrefix(arg, "-C"), strings.HasPrefix(arg, "-c"):
continue
case strings.HasPrefix(arg, "--git-dir="), strings.HasPrefix(arg, "--work-tree="):
continue
case strings.HasPrefix(arg, "-"):
continue
default:
return args[i:]
}
}
return nil
}
type bashTokenKind int
const (
bashTokenWord bashTokenKind = iota
bashTokenOperator
)
type bashToken struct {
kind bashTokenKind
value string
}
var (
approvalANSIEscapePattern = regexp.MustCompile(`\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))`)
bashFunctionDeclPattern = regexp.MustCompile(`(?m)(^|[;&|[:space:]])(?:function[[:space:]]+)?[A-Za-z_][A-Za-z0-9_]*[[:space:]]*(?:\(\)[[:space:]]*)?\{`)
bashSubshellPattern = regexp.MustCompile(`(?m)(^|[;&|[:space:]])\(`)
)
func scanBashTokens(command string) ([]bashToken, []string, bool) {
var tokens []bashToken
var reasons []string
var word strings.Builder
var quote byte
escaped := false
high := false
flushWord := func() {
if word.Len() == 0 {
return
}
tokens = append(tokens, bashToken{kind: bashTokenWord, value: word.String()})
word.Reset()
}
addOperator := func(op string) {
tokens = append(tokens, bashToken{kind: bashTokenOperator, value: op})
}
for i := 0; i < len(command); i++ {
ch := command[i]
if escaped {
word.WriteByte(ch)
escaped = false
continue
}
if ch == '\\' && quote != '\'' {
escaped = true
continue
}
if quote == '\'' {
if ch == '\'' {
quote = 0
} else {
word.WriteByte(ch)
}
continue
}
if quote == '"' {
switch {
case ch == '"':
quote = 0
case ch == '`':
addOperator("`")
word.WriteByte(ch)
case ch == '$' && i+1 < len(command) && command[i+1] == '(':
addOperator("$(")
word.WriteString("$(")
i++
default:
word.WriteByte(ch)
}
continue
}
if ch == '\'' || ch == '"' {
quote = ch
continue
}
if ch == '`' {
addOperator("`")
word.WriteByte(ch)
continue
}
if ch == '$' && i+1 < len(command) && command[i+1] == '(' {
addOperator("$(")
word.WriteString("$(")
i++
continue
}
if (ch == '<' || ch == '>') && i+1 < len(command) && command[i+1] == '(' {
flushWord()
addOperator(string([]byte{ch, '('}))
i++
continue
}
if ch == '\n' {
flushWord()
addOperator("\n")
continue
}
if ch == ' ' || ch == '\t' || ch == '\r' {
flushWord()
continue
}
switch ch {
case '&':
flushWord()
switch {
case i+1 < len(command) && command[i+1] == '&':
addOperator("&&")
i++
case i+1 < len(command) && command[i+1] == '>':
addOperator("&>")
i++
default:
addOperator("&")
}
case '|':
flushWord()
if i+1 < len(command) && command[i+1] == '|' {
addOperator("||")
i++
} else {
addOperator("|")
}
case ';':
flushWord()
addOperator(";")
case '<':
flushWord()
if i+1 < len(command) && command[i+1] == '<' {
addOperator("<<")
i++
} else if i+1 < len(command) && command[i+1] == '&' {
addOperator("<&")
i++
} else {
addOperator("<")
}
case '>':
flushWord()
if i+1 < len(command) && command[i+1] == '>' {
addOperator(">>")
i++
} else if i+1 < len(command) && command[i+1] == '|' {
addOperator(">|")
i++
} else if i+1 < len(command) && command[i+1] == '&' {
addOperator(">&")
i++
} else {
addOperator(">")
}
default:
word.WriteByte(ch)
}
}
if escaped || quote != 0 {
reasons = append(reasons, "could not parse shell command")
high = true
}
flushWord()
return tokens, reasons, high
}
func bashCommandCalls(tokens []bashToken) [][]string {
var calls [][]string
var current []string
flush := func() {
if len(current) == 0 {
return
}
calls = append(calls, current)
current = nil
}
for _, token := range tokens {
if token.kind == bashTokenWord {
current = append(current, token.value)
continue
}
if isBashCommandBoundary(token.value) {
flush()
}
}
flush()
return calls
}
func isBashCommandBoundary(op string) bool {
switch op {
case "&&", "||", "|", ";", "&", "\n":
return true
default:
return false
}
}
func shellCommandArgs(args []string) []string {
for len(args) > 0 && isShellAssignment(args[0]) {
args = args[1:]
}
return args
}
func isShellAssignment(word string) bool {
name, _, ok := strings.Cut(word, "=")
if !ok || name == "" {
return false
}
for i := range len(name) {
ch := name[i]
if i == 0 {
if !((ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || ch == '_') {
return false
}
continue
}
if !((ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_') {
return false
}
}
return true
}
func isDynamicCommandName(name string) bool {
return strings.HasPrefix(name, "$") || strings.HasPrefix(name, "`") || strings.Contains(name, "$(") || strings.Contains(name, "`")
}
func shellCommandBase(name string) string {
name = strings.TrimSpace(name)
if i := strings.LastIndexAny(name, `/\`); i >= 0 && i+1 < len(name) {
name = name[i+1:]
}
return name
}
func approvalPathEscapeReason(workingDir, path string) string {
if strings.TrimSpace(path) == "" {
return ""
}
if filepath.IsAbs(path) {
return "absolute paths are not allowed"
}
base := workingDir
if base == "" {
var err error
base, err = os.Getwd()
if err != nil {
return "could not determine working directory"
}
}
base, err := canonicalApprovalPath(base)
if err != nil {
return "could not resolve working directory"
}
resolved := filepath.Clean(filepath.Join(base, path))
resolvedForCheck := resolved
if canonical, err := canonicalApprovalPath(resolved); err == nil {
resolvedForCheck = canonical
}
rel, err := filepath.Rel(base, resolvedForCheck)
if err != nil {
return "could not resolve path"
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return "path escapes working directory"
}
return ""
}
func canonicalApprovalPath(path string) (string, error) {
abs, err := filepath.Abs(path)
if err != nil {
return "", err
}
resolved, err := filepath.EvalSymlinks(abs)
if err == nil {
return resolved, nil
}
return abs, nil
}
func stringApprovalArg(args map[string]any, key string) (string, bool) {
value, ok := args[key].(string)
return value, ok && strings.TrimSpace(value) != ""
}
func stableApprovalArgs(args map[string]any) string {
if len(args) == 0 {
return ""
}
keys := make([]string, 0, len(args))
for key := range args {
keys = append(keys, key)
}
slices.Sort(keys)
var b bytes.Buffer
for _, key := range keys {
if b.Len() > 0 {
b.WriteByte(',')
}
fmt.Fprintf(&b, "%s=%v", key, args[key])
}
return b.String()
}