package approval
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"sync"
)
// Decision is the outcome of an approval prompt.
type Decision int
const (
Deny Decision = iota
Once
AlwaysThisCommand
AlwaysThisTool
)
// Request is one tool call awaiting a decision.
type Request struct {
Tool string
// Summary is a one-line description of what will happen.
Summary string
// Detail is the argument the user most needs to see: the command, the
// path, or the replacement text.
Detail string
// Rememberable is false when "always allow this exact command" would be a
// lie — an opaque shell command does not determine what it runs.
Rememberable bool
// Agent is the subagent that wants this, empty for the session's own
// agent. Several agents share one prompt box, so a request that does not
// say who is asking cannot be decided on.
Agent string
}
// Prompt asks a human. Returning an error aborts the turn.
type Prompt func(context.Context, Request) (Decision, error)
// readOnly tools cannot change anything, so they never prompt.
var readOnly = map[string]bool{
"read_file": true,
"glob": true,
"grep": true,
// skill reads one file from a directory found at startup, and cannot
// reach anything read_file could not already reach.
"skill": true,
}
// Policy decides whether a tool call proceeds, remembering what it is told.
type Policy struct {
mu sync.Mutex
tools map[string]bool // tool name -> always allowed
commands map[string]bool // exact detail string -> always allowed
// Prompt is called when a decision is needed. A nil Prompt denies every
// mutating call, which is the safe default for a headless run.
Prompt Prompt
// YOLO skips every prompt. Set it only where the blast radius is
// contained: a throwaway clone, a container, or the eval runner.
YOLO bool
}
// New builds a policy.
func New(prompt Prompt) *Policy {
return &Policy{tools: map[string]bool{}, commands: map[string]bool{}, Prompt: prompt}
}
// Approve implements the agent's Approver interface. The returned error is
// text the model reads, so a denial tells it to change course rather than
// looking like a tool that broke.
func (p *Policy) Approve(ctx context.Context, tool string, input json.RawMessage) error {
return p.ApproveAs(ctx, "", tool, input)
}
// ApproveAs is Approve on behalf of a named subagent. What is remembered is
// remembered for the whole session, not per agent: "always allow bash" is a
// statement about this working tree, and an agent id is not a trust boundary.
func (p *Policy) ApproveAs(ctx context.Context, agent, tool string, input json.RawMessage) error {
if p == nil || p.YOLO || readOnly[tool] {
return nil
}
req := describe(tool, input)
req.Agent = agent
p.mu.Lock()
allowed := p.tools[tool] || (req.Rememberable && p.commands[memoryKey(tool, req.Detail)])
p.mu.Unlock()
if allowed {
return nil
}
if p.Prompt == nil {
return fmt.Errorf("%s was not run: this session has no way to ask for approval, and %s can modify the working tree. Explain what you would do instead", tool, tool)
}
decision, err := p.Prompt(ctx, req)
if err != nil {
return err
}
switch decision {
case AlwaysThisTool:
p.mu.Lock()
p.tools[tool] = true
p.mu.Unlock()
return nil
case AlwaysThisCommand:
if req.Rememberable {
p.mu.Lock()
p.commands[memoryKey(tool, req.Detail)] = true
p.mu.Unlock()
}
return nil
case Once:
return nil
default:
return fmt.Errorf("the user declined to run %s. Do not retry it; either find another way or explain what you need", tool)
}
}
// Remembered lists what the session has been told to always allow, for showing
// the user what they have agreed to.
func (p *Policy) Remembered() []string {
p.mu.Lock()
defer p.mu.Unlock()
var out []string
for t := range p.tools {
out = append(out, "tool "+t)
}
for c := range p.commands {
out = append(out, c)
}
sort.Strings(out)
return out
}
func memoryKey(tool, detail string) string { return tool + ": " + detail }
// article picks "a" or "an" for a word, so the approval prompt does not ask
// about "a explore agent".
func article(word string) string {
if word == "" {
return "a"
}
switch word[0] {
case 'a', 'e', 'i', 'o', 'u', 'A', 'E', 'I', 'O', 'U':
return "an"
}
return "a"
}
// describe turns a tool call into something a human can decide on in one read.
func describe(tool string, input json.RawMessage) Request {
var args struct {
Command string `json:"command"`
Path string `json:"path"`
OldStr string `json:"old_str"`
NewStr string `json:"new_str"`
Content string `json:"content"`
Preset string `json:"preset"`
Task string `json:"task"`
Model string `json:"model"`
Effort string `json:"effort"`
}
json.Unmarshal(input, &args)
switch tool {
case "bash":
info := Analyze(args.Command)
return Request{
Tool: tool,
Summary: "run a shell command — " + info.Describe(),
Detail: args.Command,
Rememberable: !info.Opaque && info.ParseError == nil,
}
case "edit_file":
return Request{
Tool: tool,
Summary: fmt.Sprintf("edit %s (%d bytes in, %d out)", args.Path, len(args.OldStr), len(args.NewStr)),
Detail: args.Path,
Rememberable: true,
}
case "spawn_agent":
summary := "start " + article(args.Preset) + " " + args.Preset + " agent"
if args.Model != "" {
summary += " on " + args.Model
}
if args.Effort != "" {
summary += " at " + args.Effort + " effort"
}
return Request{
Tool: tool,
Summary: summary,
Detail: args.Task,
// The task is different every time, so remembering one exact
// spawn would allow nothing. "Always this tool" still applies.
Rememberable: false,
}
case "write_file":
return Request{
Tool: tool,
Summary: fmt.Sprintf("write %s (%d bytes, replacing whatever is there)", args.Path, len(args.Content)),
Detail: args.Path,
Rememberable: true,
}
default:
detail := strings.TrimSpace(string(input))
return Request{Tool: tool, Summary: "run " + tool, Detail: detail, Rememberable: true}
}
}