github.com/anthropics/anthropic-sdk-go@v1.70.1/tools/agenttoolset/bash.go (about)

     1  package agenttoolset
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"crypto/rand"
     7  	"encoding/hex"
     8  	"encoding/json"
     9  	"errors"
    10  	"fmt"
    11  	"io"
    12  	"log/slog"
    13  	"os"
    14  	"os/exec"
    15  	"regexp"
    16  	"strings"
    17  	"sync"
    18  	"time"
    19  
    20  	anthropic "github.com/anthropics/anthropic-sdk-go"
    21  	"github.com/creack/pty"
    22  )
    23  
    24  const (
    25  	bashOutputLimit = 100 * 1024
    26  	bashDefaultTO   = 120 * time.Second
    27  )
    28  
    29  // credentialEnvPrefixes are the environment-variable namespaces stripped from
    30  // the bash tool's spawned shell. The runner is typically started with
    31  // ANTHROPIC_* credentials in its environment (API key, auth token, the
    32  // environment key the SDK and the environment worker read); none of those
    33  // belong in a model-controlled shell, so the whole namespace is dropped.
    34  // PATH, HOME, locale, etc. are kept so the shell still behaves normally.
    35  var credentialEnvPrefixes = []string{"ANTHROPIC_"}
    36  
    37  // scrubbedEnviron returns os.Environ() with every credential-bearing variable
    38  // (see credentialEnvPrefixes) removed, so the bash tool cannot leak the
    39  // runner's credentials into a command's environment.
    40  func scrubbedEnviron() []string {
    41  	src := os.Environ()
    42  	out := make([]string, 0, len(src))
    43  	for _, kv := range src {
    44  		name := kv
    45  		if i := strings.IndexByte(kv, '='); i >= 0 {
    46  			name = kv[:i]
    47  		}
    48  		scrub := false
    49  		for _, prefix := range credentialEnvPrefixes {
    50  			if strings.HasPrefix(name, prefix) {
    51  				scrub = true
    52  				break
    53  			}
    54  		}
    55  		if !scrub {
    56  			out = append(out, kv)
    57  		}
    58  	}
    59  	return out
    60  }
    61  
    62  var ansi = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`)
    63  
    64  // BashSession is a persistent /bin/bash process attached to a PTY. State
    65  // (cwd, env, background jobs) survives across Exec calls.
    66  type BashSession struct {
    67  	mu        sync.Mutex
    68  	pty       *os.File
    69  	cmd       *exec.Cmd
    70  	buf       bytes.Buffer
    71  	truncated bool
    72  	closed    bool
    73  	done      chan struct{}
    74  	// notify carries a wakeup whenever the drain goroutine appends output, so
    75  	// exec waits on an event instead of polling the buffer on a timer. It is
    76  	// buffered (cap 1) and drained on every select iteration, so a signal is
    77  	// never missed and a burst of writes collapses harmlessly into one wakeup.
    78  	notify chan struct{}
    79  }
    80  
    81  var _ io.Closer = (*BashSession)(nil)
    82  
    83  // NewBashSession starts bash in dir and begins draining the PTY into an
    84  // internal buffer.
    85  //
    86  // env selects the base environment for the spawned shell. When nil the shell
    87  // inherits the runner's environment minus its ANTHROPIC_* credentials (see
    88  // scrubbedEnviron), so a model-issued command cannot read the API key / auth
    89  // token / environment key out of its own environment. When non-nil it FULLY
    90  // REPLACES that default — the mapping is used verbatim and is NOT merged with
    91  // the scrubbed process environment. PS1/PS2/TERM are always overlaid on the
    92  // chosen base so output stays clean and parseable regardless of the base.
    93  func NewBashSession(dir string, env map[string]string) (*BashSession, error) {
    94  	cmd := exec.Command("/bin/bash", "--noprofile", "--norc")
    95  	cmd.Dir = dir
    96  	// When env is nil, spawn the shell with the runner's environment minus
    97  	// its ANTHROPIC_* credentials. When env is non-nil it FULLY REPLACES
    98  	// that default — the mapping is used verbatim, nothing is merged in.
    99  	base := scrubbedEnviron()
   100  	if env != nil {
   101  		base = make([]string, 0, len(env))
   102  		for k, v := range env {
   103  			base = append(base, k+"="+v)
   104  		}
   105  	}
   106  	cmd.Env = append(base, "PS1=", "PS2=", "TERM=dumb")
   107  
   108  	p, err := pty.Start(cmd)
   109  	if err != nil {
   110  		return nil, fmt.Errorf("start bash pty: %w", err)
   111  	}
   112  
   113  	s := &BashSession{pty: p, cmd: cmd, done: make(chan struct{}), notify: make(chan struct{}, 1)}
   114  	go s.drain()
   115  	// Disable echo and job-control noise so output is just command results.
   116  	// This call must keep the PTY as its stdin (no </dev/null redirect):
   117  	// stty needs a terminal on fd 0 to set its modes.
   118  	_, _, _ = s.exec(context.Background(), "stty -echo 2>/dev/null; set +m", 5*time.Second, false)
   119  	return s, nil
   120  }
   121  
   122  func (s *BashSession) drain() {
   123  	tmp := make([]byte, 4096)
   124  	for {
   125  		n, err := s.pty.Read(tmp)
   126  		if n > 0 {
   127  			s.mu.Lock()
   128  			s.buf.Write(tmp[:n])
   129  			// Cap the buffer during accumulation so a command that streams
   130  			// unboundedly can't OOM the runner. Keep the tail so the sentinel
   131  			// stays detectable.
   132  			if over := s.buf.Len() - bashOutputLimit; over > 0 {
   133  				s.buf.Next(over)
   134  				s.truncated = true
   135  			}
   136  			s.mu.Unlock()
   137  			// Wake any waiting exec; non-blocking so drain never stalls.
   138  			select {
   139  			case s.notify <- struct{}{}:
   140  			default:
   141  			}
   142  		}
   143  		if err != nil {
   144  			close(s.done)
   145  			return
   146  		}
   147  	}
   148  }
   149  
   150  // Exec runs cmd in the persistent shell and returns combined output and the
   151  // command's exit code embedded in the output tail.
   152  func (s *BashSession) Exec(ctx context.Context, cmd string, timeout time.Duration) (string, int, error) {
   153  	if timeout <= 0 {
   154  		timeout = bashDefaultTO
   155  	}
   156  	s.mu.Lock()
   157  	if s.closed {
   158  		s.mu.Unlock()
   159  		return "", -1, errBashClosed
   160  	}
   161  	s.buf.Reset()
   162  	s.truncated = false
   163  	s.mu.Unlock()
   164  	return s.exec(ctx, cmd, timeout, true)
   165  }
   166  
   167  // exec writes cmd wrapped with a completion sentinel and waits for it. When
   168  // redirectStdin is true the command group's stdin is /dev/null so a
   169  // stdin-reading command (`cat`, `read`) gets EOF instead of blocking on the
   170  // PTY until the timeout; the internal `stty -echo` setup call passes false
   171  // because stty needs the PTY on fd 0.
   172  func (s *BashSession) exec(ctx context.Context, cmd string, timeout time.Duration, redirectStdin bool) (string, int, error) {
   173  	// The trailing printf surfaces the exit code on its own line so we can
   174  	// strip it from the user-visible output and report is_error accurately.
   175  	// Per-call random nonce so a command that prints a fixed marker can't
   176  	// spoof the exit-code framing. The sentinel is split across two adjacent
   177  	// quoted strings so PTY echo of the input cannot contain the full
   178  	// sentinel and false-trigger the reader.
   179  	sentinel := newSentinel()
   180  	half := len(sentinel) / 2
   181  	redir := ""
   182  	if redirectStdin {
   183  		redir = " </dev/null"
   184  	}
   185  	wrapped := fmt.Sprintf("{ %s\n}%s 2>&1; printf '\\n%s''%s%%d\\n' $?\n", cmd, redir, sentinel[:half], sentinel[half:])
   186  	if _, err := io.WriteString(s.pty, wrapped); err != nil {
   187  		return "", -1, fmt.Errorf("write to pty: %w", err)
   188  	}
   189  
   190  	// Event-driven wait: the drain goroutine signals s.notify whenever it
   191  	// appends output, and the timer bounds the call. No busy polling.
   192  	timer := time.NewTimer(timeout)
   193  	defer timer.Stop()
   194  
   195  	timedOut := false
   196  	for {
   197  		select {
   198  		case <-ctx.Done():
   199  			return "", -1, ctx.Err()
   200  		case <-s.done:
   201  			return "", -1, ErrBashTerminated
   202  		case <-s.notify:
   203  		case <-timer.C:
   204  			timedOut = true
   205  		}
   206  		s.mu.Lock()
   207  		out := append([]byte(nil), s.buf.Bytes()...)
   208  		truncated := s.truncated
   209  		s.mu.Unlock()
   210  		if i := bytes.Index(out, []byte(sentinel)); i >= 0 {
   211  			body := out[:i]
   212  			tail := out[i+len(sentinel):]
   213  			var code int
   214  			// A parse miss means the framing is corrupt; report -1 instead of
   215  			// letting code default to 0.
   216  			if n, err := fmt.Fscan(bytes.NewReader(tail), &code); err != nil || n != 1 {
   217  				slog.Default().Warn("bash: failed to parse exit code from sentinel tail; treating as failure",
   218  					slog.Any("error", err), slog.Int("scanned", n))
   219  				code = -1
   220  			}
   221  			return cleanOutput(body, truncated), code, nil
   222  		}
   223  		if timedOut {
   224  			return cleanOutput(out, truncated) + "\n[timed out]", -1, ErrTimedOut
   225  		}
   226  	}
   227  }
   228  
   229  // newSentinel returns a per-call completion marker carrying a random nonce so
   230  // a command can't predict and emit it to spoof the exit-code framing.
   231  func newSentinel() string {
   232  	b := make([]byte, 8)
   233  	_, _ = rand.Read(b)
   234  	return "__ANT_CMD_" + hex.EncodeToString(b) + "_DONE__"
   235  }
   236  
   237  // ErrTimedOut signals the wrapped command did not complete before the
   238  // per-call deadline; the caller must restart the session because the
   239  // running group's sentinel will land in a future buffer otherwise.
   240  var ErrTimedOut = errors.New("bash: command timed out")
   241  
   242  // ErrBashTerminated signals the shell exited while a command was running,
   243  // so no completion sentinel will land; the caller must restart the session.
   244  var ErrBashTerminated = errors.New("bash session terminated")
   245  
   246  // errBashClosed is returned when Exec is called on a closed session.
   247  var errBashClosed = errors.New("session closed")
   248  
   249  // Close terminates the bash process group and the PTY. Safe to call multiple
   250  // times.
   251  func (s *BashSession) Close() error {
   252  	s.mu.Lock()
   253  	if s.closed {
   254  		s.mu.Unlock()
   255  		return nil
   256  	}
   257  	s.closed = true
   258  	s.mu.Unlock()
   259  
   260  	var firstErr error
   261  	if s.pty != nil {
   262  		if err := s.pty.Close(); err != nil && firstErr == nil {
   263  			firstErr = err
   264  		}
   265  	}
   266  	if s.cmd != nil && s.cmd.Process != nil {
   267  		if err := killProcessGroup(s.cmd.Process); err != nil && firstErr == nil {
   268  			firstErr = err
   269  		}
   270  		// Reap the process; ExitError after SIGKILL is expected.
   271  		var exitErr *exec.ExitError
   272  		if err := s.cmd.Wait(); err != nil && !errors.As(err, &exitErr) && firstErr == nil {
   273  			firstErr = err
   274  		}
   275  	}
   276  	return firstErr
   277  }
   278  
   279  func cleanOutput(b []byte, truncated bool) string {
   280  	out := ansi.ReplaceAll(b, nil)
   281  	out = bytes.ReplaceAll(out, []byte("\r\n"), []byte("\n"))
   282  	out = bytes.ReplaceAll(out, []byte("\r"), nil)
   283  	out = bytes.TrimLeft(out, "\n")
   284  	if truncated {
   285  		return "[output truncated]\n" + string(out)
   286  	}
   287  	return string(out)
   288  }
   289  
   290  // BetaBashTool returns an anthropic.BetaTool backed by a persistent bash
   291  // session rooted at env.Workdir. The session is created lazily on first use and
   292  // persists across calls; the returned tool implements io.Closer.
   293  //
   294  // bash is the one explicitly-unrestricted tool in the set — it runs /bin/bash
   295  // directly and ignores the AgentToolContext path policy (Workdir/AllowedRoots/
   296  // ReadOnlyRoots). Run it inside a sandbox you control.
   297  func BetaBashTool(env *AgentToolContext) anthropic.BetaTool {
   298  	return &bashTool{env: env}
   299  }
   300  
   301  type bashTool struct {
   302  	env  *AgentToolContext
   303  	mu   sync.Mutex
   304  	sess *BashSession
   305  }
   306  
   307  func (t *bashTool) Name() string { return "bash" }
   308  
   309  func (t *bashTool) Description() string {
   310  	return "Run a bash command in a persistent shell. State (cwd, env vars) persists across calls."
   311  }
   312  
   313  func (t *bashTool) InputSchema() anthropic.BetaToolInputSchemaParam {
   314  	return objectSchema(map[string]any{
   315  		"command":    prop("string", "The command to run"),
   316  		"restart":    prop("boolean", "Restart the persistent shell before running"),
   317  		"timeout_ms": prop("integer", "Per-call timeout in milliseconds"),
   318  	})
   319  }
   320  
   321  func (t *bashTool) Execute(ctx context.Context, raw json.RawMessage) ([]anthropic.BetaToolResultBlockParamContentUnion, error) {
   322  	if err := rejectUnrestrictedPaths(t.env); err != nil {
   323  		return nil, err
   324  	}
   325  	content, isErr := t.run(ctx, raw)
   326  	if isErr {
   327  		return nil, &ToolError{Content: content}
   328  	}
   329  	return textResult(content), nil
   330  }
   331  
   332  func (t *bashTool) session() (*BashSession, error) {
   333  	t.mu.Lock()
   334  	defer t.mu.Unlock()
   335  	if t.sess != nil {
   336  		return t.sess, nil
   337  	}
   338  	s, err := NewBashSession(t.env.Workdir, t.env.Env)
   339  	if err != nil {
   340  		return nil, err
   341  	}
   342  	t.sess = s
   343  	return s, nil
   344  }
   345  
   346  func (t *bashTool) restart() error {
   347  	t.mu.Lock()
   348  	defer t.mu.Unlock()
   349  	if t.sess == nil {
   350  		return nil
   351  	}
   352  	err := t.sess.Close()
   353  	t.sess = nil
   354  	return err
   355  }
   356  
   357  func (t *bashTool) Close() error {
   358  	return t.restart()
   359  }
   360  
   361  func (t *bashTool) run(ctx context.Context, raw json.RawMessage) (string, bool) {
   362  	var in anthropic.BetaManagedAgentsAgentToolset20260401BashInput
   363  	if err := json.Unmarshal(raw, &in); err != nil {
   364  		return errorf("invalid bash input: %v", err)
   365  	}
   366  	if in.Restart {
   367  		_ = t.restart()
   368  		if in.Command == "" {
   369  			return "bash session restarted", false
   370  		}
   371  	}
   372  	if in.Command == "" {
   373  		return errorf("bash: command is required")
   374  	}
   375  	sess, err := t.session()
   376  	if err != nil {
   377  		return errorf("bash: %v", err)
   378  	}
   379  	to := time.Duration(in.TimeoutMs) * time.Millisecond
   380  	out, code, err := sess.Exec(ctx, in.Command, to)
   381  	if err != nil {
   382  		// Any non-nil error from Exec leaves the persistent shell in an
   383  		// unknown state: a context-cancel returns ctx.Err() with the
   384  		// command still running on the other side of the PTY, a pty write
   385  		// error means the shell may be unusable, a "session terminated"
   386  		// means the underlying process is gone. If we kept the session,
   387  		// the still-running command's stdout plus the per-call exit-code
   388  		// sentinel would land in the next Exec's buffer and contaminate
   389  		// it. Restart unconditionally and keep the special-case timeout
   390  		// message so tool consumers see the same outcome as before.
   391  		_ = t.restart()
   392  		if errors.Is(err, ErrTimedOut) {
   393  			return out + "\nsession restarted after timeout", true
   394  		}
   395  		return errorf("bash: %v", err)
   396  	}
   397  	if code != 0 {
   398  		return fmt.Sprintf("%s\nexit code: %d", out, code), true
   399  	}
   400  	return out, false
   401  }