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 }