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
}
|