Download
761 lines 25.9 KiBGoModified 2026-10-31 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761
// Package tools are the tools the agent may call: Read, Write, Patch,
// Safe_Bash and Unsafe_Bash. Every one runs inside the sandbox, so the
// kernel checks each access; the policy decides beforehand which mode to
// use and whether to ask the user first.
package tools

import (
	"bytes"
	"context"
	"crypto/sha256"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"slices"
	"strings"
	"sync"
	"time"
	"unicode"
	"unicode/utf8"

	"blinker/internal/diff"
	"blinker/internal/llm"
	"blinker/internal/policy"
	"blinker/internal/sandbox"
	"blinker/internal/tooldef"
)

// Question is something only the user can decide.
type Question struct {
	Tool  string
	Title string   // one line: what wants to happen
	Body  string   // the details, shown in full: a script, a message
	Diff  string   // a unified diff, shown in full after Body
	About string   // what they mean in plain words, shown in full after them
	Opts  []Option // the answers
	// Edits are the changes Diff shows, for an option that lets the user
	// edit them.
	Edits []Edit
	// Pager shows Body and Diff in a scrolling pane instead of the
	// scrollback, for ones that may be long.
	Pager bool
}

// Edit is a change to a file's contents.
type Edit struct {
	Name     string // the file's name, for the editor
	Old, New string
}

// Option is one answer to a Question.
type Option struct {
	Label string
	Allow bool
	// Remember allows the same thing again without asking, for the rest
	// of the session.
	Remember bool
	// Explain asks the user for a note to pass to the model.
	Explain bool
	// Edit has the user edit each of Question.Edits' New first.
	Edit bool
	// Preview runs it first with its writes kept aside, to show them.
	Preview bool
}

// Answer is the user's answer.
type Answer struct {
	Option Option
	Note   string
	Edited []string // with Option.Edit, the contents the user saved, one per edit
}

// writeOpts are the answers to a write that shows a diff.
var writeOpts = []Option{
	{Label: "Allow", Allow: true},
	{Label: "Allow writes of this file for the session", Allow: true, Remember: true},
	{Label: "Edit it in vimdiff, then write it", Allow: true, Edit: true},
	{Label: "Deny"},
	{Label: "Deny, and tell the model what to change", Explain: true},
}

// Asker asks the user a question and waits for the answer. It returns a
// denying answer if the context ends or no one can answer.
type Asker func(ctx context.Context, q Question) Answer

// Result is what a tool call produced.
type Result struct {
	// Output goes back to the model.
	Output string
	// Summary is one line for the user, such as "Read main.go (120 lines)".
	Summary string
	// Detail is shown to the user under the summary: a diff, command
	// output.
	Detail string
	// Diff marks Detail as a unified diff.
	Diff bool
	// Failed marks a call that did not do what was asked.
	Failed bool
}

// Toolbox runs the tools in one sandbox.
type Toolbox struct {
	SB             *sandbox.Sandbox
	Ask            Asker
	CommandTimeout time.Duration
	// Model is the model's name, for the trailer Commit adds and commands'
	// $AI_MODEL. Set it only while no tool runs.
	Model string

	mu       sync.Mutex
	approved map[string]bool // "read:/path" or "write:/path", allowed for the session
	// seen is each file's contents as the model last read or wrote them,
	// hashed, by real path, so Write cannot replace changes it never saw.
	seen map[string][sha256.Size]byte
}

// New makes a toolbox.
func New(sb *sandbox.Sandbox, ask Asker, commandTimeout time.Duration) *Toolbox {
	return &Toolbox{SB: sb, Ask: ask, CommandTimeout: commandTimeout, approved: map[string]bool{}, seen: map[string][sha256.Size]byte{}}
}

// Forget forgets which files the model has read, for when the
// conversation no longer holds what it read.
func (t *Toolbox) Forget() {
	t.mu.Lock()
	clear(t.seen)
	t.mu.Unlock()
}

// saw records that the model has seen the file at real with these
// contents.
func (t *Toolbox) saw(real string, content []byte) {
	t.mu.Lock()
	t.seen[real] = sha256.Sum256(content)
	t.mu.Unlock()
}

// unseen says why the model has not seen the file at real as it is now,
// or "" if it has.
func (t *Toolbox) unseen(shown, real, current string) string {
	t.mu.Lock()
	h, ok := t.seen[real]
	t.mu.Unlock()
	switch {
	case !ok:
		return fmt.Sprintf("%s exists and you have not read it; Read it before replacing it with Write, or change part of it with Patch", shown)
	case h != sha256.Sum256([]byte(current)):
		return fmt.Sprintf("%s has changed since you last read it (the user or a command changed it); Read it again before replacing it with Write", shown)
	}
	return ""
}

const (
	maxReadBytes   = 10 << 20
	defaultLimit   = 2000
	maxLineLength  = 2000
	maxOutputBytes = 30_000
)

func schema(props map[string]any, required ...string) map[string]any {
	return map[string]any{"type": "object", "properties": props, "required": required}
}

func prop(typ, desc string) map[string]any { return map[string]any{"type": typ, "description": desc} }

// Specs describes the tools for the model, with the current policy.
func (t *Toolbox) Specs() []llm.Tool {
	pol, _ := t.SB.Policy()
	safeNet := "No network access."
	if d := pol.Safe.AllowedHosts(); len(d) > 0 {
		safeNet = "Network access only to: " + strings.Join(d, ", ") + "."
	}
	policyNote := "The access policy is in the system prompt."
	timeout := fmt.Sprintf("Optional time limit in seconds (default %d).", int(t.CommandTimeout.Seconds()))
	bashParams := schema(map[string]any{
		"script":          prop("string", "The bash script. It runs in the project directory; do not cd into it."),
		"timeout_seconds": prop("integer", timeout),
	}, "script")
	specs := []llm.Tool{
		{
			Name: "Read",
			Description: "Read a text file. Returns its lines numbered like `cat -n` (the number and a tab come before each line's text; " +
				"they are not part of the file). Reads up to 2000 lines; use offset and limit for more. " +
				"Files the policy marks confirm need the user's approval; forbidden ones cannot be read. " + policyNote,
			Parameters: schema(map[string]any{
				"path":   prop("string", "Path to the file, absolute or relative to the project directory."),
				"offset": prop("integer", "Line to start at, 1-based (default 1)."),
				"limit":  prop("integer", "Number of lines to read (default 2000)."),
			}, "path"),
		},
		{
			Name: "Write",
			Description: "Create a file, or replace a file's whole contents. Creates missing directories. " +
				"To replace an existing file, first Read all of its current contents; prefer Patch for changing part of a file. Writes the policy marks confirm need the user's approval. " + policyNote,
			Parameters: schema(map[string]any{
				"path":    prop("string", "Path to the file, absolute or relative to the project directory."),
				"content": prop("string", "The complete new contents of the file."),
			}, "path", "content"),
		},
		{
			Name: "Patch",
			Description: "Change part of a file by applying a unified diff to it. Each hunk starts with an @@ line and holds context lines " +
				"(starting with a space), removed lines ('-') and added lines ('+'). Hunks are placed by matching their context and " +
				"removed lines against the file, so copy those exactly from a recent Read (without the line numbers), with 2 or 3 " +
				"unchanged lines around each change, and enough that each hunk matches only one place; the @@ line numbers are ignored. Writes the policy marks confirm need the user's approval.",
			Parameters: schema(map[string]any{
				"path": prop("string", "Path to the file, absolute or relative to the project directory."),
				"diff": prop("string", "The unified diff to apply. ---/+++ header lines are optional."),
			}, "path", "diff"),
		},
		{
			Name: "Safe_Bash",
			Description: fmt.Sprintf("Run a bash script in the project directory, immediately and without asking the user, in a "+
				"kernel-enforced sandbox (%s) limited to the policy's safe access: by default reading and writing the project, "+
				"reading toolchains and dotfiles, and writing $TMPDIR (%s). %s Anything else fails with a permission error, which "+
				"is harmless to try. If a script fails because the sandbox denied something, do not retry it with Safe_Bash: "+
				"use Unsafe_Bash, which asks the user. Use Safe_Bash whenever its access suffices.", t.SB.Backend(), t.SB.TempDir(), safeNet),
			Parameters: bashParams,
		},
		{
			Name: "Unsafe_Bash",
			Description: "Run a bash script after the user approves it, in the sandbox with the policy's wider confirm access " +
				"(by default also writing .git and toolchain caches). Network access goes through a proxy that asks the user about " +
				"each new domain. The user sees the whole script before deciding. Use it only for what Safe_Bash cannot do, and say why.",
			Parameters: bashParams,
		},
	}
	for _, tool := range t.userTools() {
		specs = append(specs, userSpec(tool))
	}
	return specs
}

// Run runs a tool call.
func (t *Toolbox) Run(ctx context.Context, name, args string) (r Result) {
	defer func() {
		// A bug in a tool fails that call, rather than ending blinker with
		// the terminal left raw. Nothing was half done outside the sandbox:
		// every tool's I/O happens in sandboxed commands.
		if p := recover(); p != nil {
			r = Result{Output: fmt.Sprintf("Error: blinker failed internally: %v", p), Summary: fmt.Sprintf("%s: internal error: %v", name, p), Failed: true}
		}
	}()
	var err error
	switch name {
	case "Read":
		var a struct {
			Path   string `json:"path"`
			Offset int    `json:"offset"`
			Limit  int    `json:"limit"`
		}
		if err = json.Unmarshal([]byte(args), &a); err == nil {
			r, err = t.read(ctx, a.Path, a.Offset, a.Limit)
		}
	case "Write":
		var a struct {
			Path    string `json:"path"`
			Content string `json:"content"`
		}
		if err = json.Unmarshal([]byte(args), &a); err == nil {
			r, err = t.write(ctx, name, a.Path, func(string, bool) (string, error) { return a.Content, nil })
		}
	case "Patch":
		var a struct {
			Path string `json:"path"`
			Diff string `json:"diff"`
		}
		if err = json.Unmarshal([]byte(args), &a); err == nil {
			var brief string
			r, err = t.write(ctx, name, a.Path, func(old string, exists bool) (string, error) {
				if !exists && !strings.Contains(a.Diff, "@@ -0,0") {
					return "", errors.New("the file does not exist; use Write to create it")
				}
				out, err := diff.Apply(old, a.Diff)
				if err != nil {
					// The model gets the whole error; the user only its
					// first line, not the failed hunk again.
					brief, _, _ = strings.Cut(err.Error(), "\n")
				}
				return out, err
			})
			if err != nil && brief != "" {
				r.Detail = brief
			}
		}
	case "Safe_Bash", "Unsafe_Bash":
		var a struct {
			Script  string  `json:"script"`
			Timeout float64 `json:"timeout_seconds"`
		}
		if err = json.Unmarshal([]byte(args), &a); err == nil {
			mode := policy.SafeMode
			if name == "Unsafe_Bash" {
				mode = policy.UnsafeMode
			}
			r, err = t.bash(ctx, name, mode, a.Script, time.Duration(a.Timeout*float64(time.Second)))
		}
	default:
		tool, ok := t.userTool(name)
		if !ok {
			return Result{Output: fmt.Sprintf("There is no tool named %q.", name), Summary: name + ": no such tool", Failed: true}
		}
		// The built-in Commit runs here, for its review, and Fetch, to turn
		// pages into Markdown; a user's own Commit.sh or Fetch.sh runs as
		// any other tool.
		switch {
		case tool.Source == tooldef.BuiltinSource && name == "Commit":
			r, err = t.commit(ctx, tool, args)
		case tool.Source == tooldef.BuiltinSource && name == "Fetch":
			r, err = t.fetch(ctx, tool, args)
		default:
			r, err = t.runUserTool(ctx, tool, args)
		}
	}
	if err != nil {
		var se *json.SyntaxError
		var te *json.UnmarshalTypeError
		if errors.As(err, &se) || errors.As(err, &te) {
			err = fmt.Errorf("the arguments are not valid JSON for %s: %v", name, err)
		}
		if r.Summary == "" {
			r.Summary = name + ": " + err.Error()
		}
		r.Output, r.Failed = "Error: "+err.Error(), true
		if errors.Is(err, context.Canceled) {
			r.Output = "The user interrupted this tool call."
		}
	}
	return r
}

// resolve turns a tool's path argument into the absolute path the
// operation lands on, symlinks resolved, for the policy to judge.
func (t *Toolbox) resolve(path string) (shown, real string, err error) {
	path = strings.TrimSpace(path)
	if path == "" {
		return "", "", errors.New("no path given")
	}
	// A newline or an invisible character would make the path in an
	// approval prompt read as something else (a newline can forge a line
	// of the diff below it).
	for _, c := range path {
		if c == 0 || unicode.IsControl(c) || unicode.Is(unicode.Cf, c) {
			return "", "", fmt.Errorf("the path contains the character %U, which blinker does not allow in paths", c)
		}
	}
	switch {
	case path == "~":
		path = t.SB.Home()
	case strings.HasPrefix(path, "~/"):
		path = filepath.Join(t.SB.Home(), path[2:])
	case !filepath.IsAbs(path):
		path = filepath.Join(t.SB.Project(), path)
	}
	path = filepath.Clean(path)
	shown = path
	path = t.SB.HostPath(path) // the model gives paths as commands see them
	if rel, err := filepath.Rel(t.SB.Project(), path); err == nil && filepath.IsLocal(rel) {
		shown = rel
	}
	return shown, policy.RealPath(path), nil
}

// permit decides how to access a path: the mode to use, after asking the
// user if the policy says to confirm. An error explains a refusal. A write
// with an edit shows its diff, and the answer says whether the user edited
// it.
func (t *Toolbox) permit(ctx context.Context, tool, shown, path string, write bool, edit *Edit) (policy.Mode, Answer, error) {
	pol, _ := t.SB.Policy()
	verb := "read"
	if write {
		verb = "write"
	}
	switch pol.Decide(path, write) {
	case policy.Safe:
		return policy.SafeMode, Answer{}, nil
	case policy.Forbid:
		return 0, Answer{}, fmt.Errorf("the access policy does not allow %s access to %s (%s). Only the user can change that, by editing the policy (/policy)",
			verb, shown, pol.Explain(path, write))
	}
	key := verb + ":" + path
	t.mu.Lock()
	ok := t.approved[key]
	t.mu.Unlock()
	if ok {
		return policy.UnsafeMode, Answer{}, nil
	}
	title := fmt.Sprintf("%s wants to %s %s", tool, verb, shown)
	if path != filepath.Join(t.SB.Project(), shown) && path != shown {
		title += " (" + path + ")"
	}
	q := Question{
		Tool: tool, Title: title,
		Opts: []Option{
			{Label: "Allow", Allow: true},
			{Label: "Allow " + verb + "s of this file for the session", Allow: true, Remember: true},
			{Label: "Deny"},
			{Label: "Deny, and tell the model why", Explain: true},
		},
	}
	if edit != nil {
		q.Diff, q.Edits, q.Opts = diff.Unified(shown, edit.Old, edit.New, 3), []Edit{*edit}, writeOpts
	}
	a := t.Ask(ctx, q)
	if err := ctx.Err(); err != nil {
		return 0, Answer{}, err
	}
	if !a.Option.Allow {
		msg := "the user denied this " + verb
		if a.Note != "" {
			msg += ": " + a.Note
		}
		return 0, Answer{}, errors.New(msg)
	}
	if a.Option.Remember {
		t.mu.Lock()
		t.approved[key] = true
		t.mu.Unlock()
	}
	return policy.UnsafeMode, a, nil
}

func (t *Toolbox) read(ctx context.Context, path string, offset, limit int) (Result, error) {
	shown, real, err := t.resolve(path)
	if err != nil {
		return Result{}, err
	}
	r := Result{Summary: "Read " + shown}
	mode, _, err := t.permit(ctx, "Read", shown, real, false, nil)
	if err != nil {
		return r, err
	}
	data, err := t.SB.ReadFile(ctx, mode, real, maxReadBytes)
	if errors.Is(err, sandbox.ErrTooLarge) {
		return r, fmt.Errorf("%s is over %d MB; read parts of it with Safe_Bash (head, sed -n)", shown, maxReadBytes>>20)
	}
	if err != nil {
		return r, err
	}
	if bytes.IndexByte(data, 0) >= 0 || !utf8.Valid(data) {
		return r, fmt.Errorf("%s is not a UTF-8 text file (%d bytes)", shown, len(data))
	}
	t.saw(real, data)
	lines := diff.SplitLines(string(data))
	if offset < 1 {
		offset = 1
	}
	if limit <= 0 {
		limit = defaultLimit
	}
	var b strings.Builder
	end := len(lines)
	if offset-1 < len(lines) && limit < len(lines)-(offset-1) {
		end = offset - 1 + limit
	}
	for i := offset - 1; i < end; i++ {
		line := lines[i]
		if len(line) > maxLineLength {
			line = strings.ToValidUTF8(line[:maxLineLength], "") + "… (line truncated)"
		}
		fmt.Fprintf(&b, "%6d\t%s\n", i+1, line)
	}
	switch {
	case len(lines) == 0:
		b.WriteString("(the file is empty)")
	case offset > len(lines):
		fmt.Fprintf(&b, "(the file has only %d lines)", len(lines))
	case end < len(lines):
		fmt.Fprintf(&b, "(%d more lines; read on with offset %d)", len(lines)-end, end+1)
	}
	r.Output = b.String()
	if offset > 1 || end < len(lines) {
		r.Summary = fmt.Sprintf("Read %s (lines %d-%d of %d)", shown, offset, end, len(lines))
	} else {
		r.Summary = fmt.Sprintf("Read %s (%d lines)", shown, len(lines))
	}
	return r, nil
}

// write writes the contents edit makes from the file's current contents
// (and whether it exists).
func (t *Toolbox) write(ctx context.Context, tool, path string, edit func(old string, exists bool) (string, error)) (Result, error) {
	shown, real, err := t.resolve(path)
	if err != nil {
		return Result{}, err
	}
	r := Result{Summary: tool + " " + shown}
	pol, _ := t.SB.Policy()
	writeDecision := pol.Decide(real, true)
	if writeDecision == policy.Forbid {
		_, _, err := t.permit(ctx, tool, shown, real, true, nil)
		return r, err
	}
	if pol.Decide(real, false) != policy.Safe {
		// What happens before the write's question (whether the file
		// exists, "No change", a hunk that does not match) tells the model
		// about the file. If it may not read the file by itself, the user
		// decides that first.
		if _, _, err := t.permit(ctx, tool, shown, real, false, nil); err != nil {
			return r, err
		}
	}
	// Read the current contents with the access the write will have.
	readMode := policy.SafeMode
	if writeDecision == policy.Confirm {
		readMode = policy.UnsafeMode
	}
	old, exists := "", false
	if fi, err := os.Stat(real); err == nil {
		if fi.IsDir() {
			return r, fmt.Errorf("%s is a directory", shown)
		}
		data, err := t.SB.ReadFile(ctx, readMode, real, maxReadBytes)
		if err != nil {
			return r, err
		}
		old, exists = string(data), true
	}
	// Patch's hunks are checked against the file as it is, and keep the
	// rest of it, so only Write needs the model to have seen it.
	if exists && tool == "Write" {
		if why := t.unseen(shown, real, old); why != "" {
			return r, errors.New(why)
		}
	}
	content, err := edit(old, exists)
	if err != nil {
		return r, err
	}
	added, removed := diff.Stat(old, content)
	if !exists {
		r.Summary = fmt.Sprintf("%s %s (new file, %d lines)", tool, shown, added)
	} else {
		r.Summary = fmt.Sprintf("%s %s (+%d -%d)", tool, shown, added, removed)
	}
	if exists && old == content {
		r.Output = "No change: the file already has these contents."
		return r, nil
	}
	mode, a, err := t.permit(ctx, tool, shown, real, true, &Edit{Name: filepath.Base(shown), Old: old, New: content})
	if err != nil {
		return r, err
	}
	if !a.Option.Allow {
		// No question showed the user the diff, so the result does.
		r.Detail, r.Diff = diff.Unified(shown, old, content, 3), true
	}
	// The user's edit, if any, is what gets written, and the model hears
	// how it differs from what it proposed.
	edited := ""
	if a.Option.Edit && len(a.Edited) != 1 {
		return r, errors.New("the editor returned no contents; nothing was written")
	}
	if a.Option.Edit && a.Edited[0] != content {
		edited = diff.Unified(shown, content, a.Edited[0], 3)
		content = a.Edited[0]
		r.Detail, r.Diff = diff.Unified(shown, old, content, 3), true
		added, removed = diff.Stat(old, content)
		r.Summary = fmt.Sprintf("%s %s (+%d -%d, edited by the user)", tool, shown, added, removed)
	}
	// The file may have changed while the user decided, as they edited
	// it themselves. (Allow means a question was asked.)
	if a.Option.Allow && exists {
		now, err := t.SB.ReadFile(ctx, readMode, real, maxReadBytes)
		if err != nil {
			return r, err
		}
		if string(now) != old {
			return r, fmt.Errorf("%s changed while the user decided, so nothing was written; Read it again, then make the change again", shown)
		}
	}
	if a.Option.Allow && !exists {
		if _, err := os.Stat(real); err == nil {
			return r, fmt.Errorf("%s was created while the user decided, so nothing was written; Read it, then make the change again", shown)
		}
	}
	if err := t.SB.WriteFile(ctx, mode, real, []byte(content)); err != nil {
		return r, err
	}
	t.saw(real, []byte(content))
	if exists {
		r.Output = fmt.Sprintf("Updated %s (+%d -%d lines).", shown, added, removed)
	} else {
		r.Output = fmt.Sprintf("Created %s (%d lines).", shown, added)
	}
	if edited != "" {
		r.Output += " The user edited your change before it was written; the file differs from what you proposed by:\n" + edited
	}
	return r, nil
}

func (t *Toolbox) bash(ctx context.Context, tool string, mode policy.Mode, script string, timeout time.Duration) (Result, error) {
	r := Result{Summary: tool + ": " + firstLine(script)}
	if strings.TrimSpace(script) == "" {
		return r, errors.New("empty script")
	}
	if timeout <= 0 {
		timeout = t.CommandTimeout
	}
	if mode == policy.UnsafeMode {
		opts := runOpts
		if t.SB.CanPreview() == nil {
			opts = slices.Insert(slices.Clone(runOpts), 1, Option{Label: "Preview the files it changes", Allow: true, Preview: true})
		}
		q := Question{Tool: tool, Title: "Unsafe_Bash wants to run this script", Body: script, Opts: opts}
		a, ok, err := t.ask(ctx, q, &r, "The user denied this script; it did not run.")
		if !ok {
			if err == nil && a.Note == "" {
				r.Output += " Find another way, or ask the user before trying again."
			}
			return r, err
		}
		if a.Option.Preview {
			return t.preview(ctx, r, tool, script, timeout)
		}
	}
	return t.run(ctx, r, mode, []string{t.SB.Shell(), "-c", script}, timeout, nil)
}

// runOpts are the answers to whether to run something.
var runOpts = []Option{
	{Label: "Run", Allow: true},
	{Label: "Deny"},
	{Label: "Deny, and tell the model why", Explain: true},
}

// ask asks the user q. If they deny it, or ctx ends, it returns false; a
// denial sets r to tell the model, with denied and what they said.
func (t *Toolbox) ask(ctx context.Context, q Question, r *Result, denied string) (Answer, bool, error) {
	a := t.Ask(ctx, q)
	if err := ctx.Err(); err != nil {
		return a, false, err
	}
	if !a.Option.Allow {
		r.Output, r.Failed, r.Detail = denied, true, "denied"
		if a.Note != "" {
			r.Output += " They said: " + a.Note
		}
		return a, false, nil
	}
	return a, true, nil
}

// run runs a command in the sandbox and reports its output, exit code, and
// which hosts the proxy denied it. pol, if set, replaces the session's
// policy (for a tool's own rules).
func (t *Toolbox) run(ctx context.Context, r Result, mode policy.Mode, argv []string, timeout time.Duration, pol *policy.Compiled) (Result, error) {
	r, _, err := t.runCmd(ctx, r, sandbox.Cmd{Mode: mode, Argv: argv, Timeout: timeout, Network: true, Policy: pol})
	return r, err
}

// runCmd is run for a Cmd, given its input, output and model here, which
// may be a preview, returned for the caller to discard.
func (t *Toolbox) runCmd(ctx context.Context, r Result, c sandbox.Cmd) (Result, *sandbox.Preview, error) {
	mode, timeout := c.Mode, c.Timeout
	// One writer for both, so exec.Cmd writes to it from one goroutine at a
	// time.
	w := &capWriter{}
	c.Stdin, c.Stdout, c.Stderr, c.Model = bytes.NewReader(nil), w, w, t.Model
	res, err := t.SB.Run(ctx, c)
	if err != nil {
		return r, nil, err
	}
	text := clip(w.buf.String(), w.dropped)
	var b strings.Builder
	b.WriteString(text)
	if text != "" && !strings.HasSuffix(text, "\n") {
		b.WriteByte('\n')
	}
	switch {
	case res.TimedOut:
		fmt.Fprintf(&b, "[timed out after %s; raise timeout_seconds if it needs longer]\n", timeout)
	case res.ExitCode != 0:
		fmt.Fprintf(&b, "[exit code %d]\n", res.ExitCode)
	case text == "":
		b.WriteString("[no output; exit code 0]\n")
	}
	// Only what the sandbox itself recorded is reported as its doing: the
	// command's output is untrusted, and the tool descriptions already say
	// what to do about a denied file.
	if hosts := slices.Compact(slices.Sorted(slices.Values(res.DeniedHosts))); len(hosts) > 0 {
		fmt.Fprintf(&b, "[sandbox] The network proxy denied: %s.\n", strings.Join(hosts, ", "))
		if mode == policy.SafeMode {
			b.WriteString("[sandbox] Retry with Unsafe_Bash, which asks the user about each new domain.\n")
		} else {
			b.WriteString("[sandbox] The user denied the domain, or no one could be asked. Do not retry without asking the user.\n")
		}
	}
	if res.ExitCode != 0 || res.TimedOut {
		r.Failed = true
	}
	r.Output = strings.TrimSuffix(b.String(), "\n")
	r.Detail = r.Output
	return r, res.Preview, nil
}

func firstLine(s string) string {
	s = strings.TrimSpace(s)
	line, _, more := strings.Cut(s, "\n")
	if utf8.RuneCountInString(line) > 80 {
		line = string([]rune(line)[:80]) + "…"
		more = true
	}
	if more && !strings.HasSuffix(line, "…") {
		line += " …"
	}
	return line
}

// clip keeps the start and end of long output, of which dropped bytes in
// the middle are already gone.
func clip(s string, dropped int) string {
	if len(s) <= maxOutputBytes && dropped == 0 {
		return s
	}
	half := min(len(s)/2, maxOutputBytes/2)
	head, tail := s[:half], s[len(s)-half:]
	return strings.ToValidUTF8(head, "") + fmt.Sprintf("\n[... %d bytes omitted ...]\n", len(s)-2*half+dropped) + strings.ToValidUTF8(tail, "")
}

// limitWriter keeps up to max bytes, and fails a write past them, which
// stops the copy. The buffer is a field, not embedded, so it has no
// ReadFrom: io.Copy, which os/exec uses, would call that instead of Write
// and skip the limit.
type limitWriter struct {
	buf  bytes.Buffer
	max  int
	over bool
}

func (w *limitWriter) Write(p []byte) (int, error) {
	if w.buf.Len()+len(p) > w.max {
		w.over = true
		return 0, errors.New("too much output")
	}
	return w.buf.Write(p)
}

// capWriter keeps at most a few MB of a command's output: the start and
// what came last. Its buffer is a field, as limitWriter's is.
type capWriter struct {
	buf     bytes.Buffer
	dropped int
}

const captureLimit = 4 << 20

func (w *capWriter) Write(p []byte) (int, error) {
	n := len(p)
	w.buf.Write(p)
	if w.buf.Len() > captureLimit {
		data := w.buf.Bytes()
		keep := append([]byte{}, data[:captureLimit/2]...)
		keep = append(keep, data[len(data)-captureLimit/4:]...)
		w.dropped += len(data) - len(keep)
		w.buf.Reset()
		w.buf.Write(keep)
	}
	return n, nil
}