1package claudetool
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "log/slog"
10 "math"
11 "os"
12 "os/exec"
13 "path/filepath"
14 "slices"
15 "strings"
16 "sync"
17 "syscall"
18 "time"
19
20 "shelley.exe.dev/claudetool/bashkit"
21 "shelley.exe.dev/llm"
22)
23
24// PermissionCallback is a function type for checking if a command is allowed to run
25type PermissionCallback func(command string) error
26
27// BashTool specifies an llm.Tool for executing shell commands.
28type BashTool struct {
29 // CheckPermission is called before running any command, if set
30 CheckPermission PermissionCallback
31 // EnableJITInstall enables just-in-time tool installation for missing commands
32 EnableJITInstall bool
33 // Timeouts holds the configurable timeout values (uses defaults if nil)
34 Timeouts *Timeouts
35 // WorkingDir is the shared mutable working directory.
36 WorkingDir *MutableWorkingDir
37 // LLMProvider provides access to LLM services for tool validation
38 LLMProvider LLMServiceProvider
39 // ConversationID is the ID of the conversation this tool belongs to.
40 // It is exposed to invoked commands via SHELLEY_CONVERSATION_ID.
41 ConversationID string
42}
43
44const (
45 EnableBashToolJITInstall = true
46 NoBashToolJITInstall = false
47
48 DefaultFastTimeout = 30 * time.Second
49 DefaultSlowTimeout = 15 * time.Minute
50 DefaultBackgroundTimeout = 24 * time.Hour
51)
52
53// Timeouts holds the configurable timeout values for bash commands.
54type Timeouts struct {
55 Fast time.Duration // regular commands (e.g., ls, echo, simple scripts)
56 Slow time.Duration // commands that may reasonably take longer (e.g., downloads, builds, tests)
57 Background time.Duration // background commands (e.g., servers, long-running processes)
58}
59
60// Fast returns t's fast timeout, or DefaultFastTimeout if t is nil.
61func (t *Timeouts) fast() time.Duration {
62 if t == nil {
63 return DefaultFastTimeout
64 }
65 return t.Fast
66}
67
68// Slow returns t's slow timeout, or DefaultSlowTimeout if t is nil.
69func (t *Timeouts) slow() time.Duration {
70 if t == nil {
71 return DefaultSlowTimeout
72 }
73 return t.Slow
74}
75
76// Background returns t's background timeout, or DefaultBackgroundTimeout if t is nil.
77func (t *Timeouts) background() time.Duration {
78 if t == nil {
79 return DefaultBackgroundTimeout
80 }
81 return t.Background
82}
83
84// Tool returns an llm.Tool based on b.
85func (b *BashTool) Tool() *llm.Tool {
86 return &llm.Tool{
87 Name: bashName,
88 Description: strings.TrimSpace(bashDescription),
89 InputSchema: llm.MustSchema(bashInputSchema),
90 Run: b.Run,
91 }
92}
93
94// getWorkingDir returns the current working directory.
95func (b *BashTool) getWorkingDir() string {
96 return b.WorkingDir.Get()
97}
98
99const (
100 bashName = "bash"
101 bashDescription = `Executes shell commands via bash -c, returning combined stdout/stderr.
102Bash state changes (working dir, variables, aliases) don't persist between calls.
103
104With background=true, returns immediately, with output redirected to a file.
105Use background for servers/demos that need to stay running.
106
107MUST set slow_ok=true for potentially slow commands: builds, downloads,
108installs, tests, or any other substantive operation.
109
110Avoid overly destructive cleanup commands. Commands that could delete .git
111directories, home directories, or use broad wildcards require explicit paths.
112Confirm with the user before running destructive operations.
113
114To change the working directory persistently, use the change_dir tool.
115
116IMPORTANT: Keep commands concise. The command input must be less than 60k tokens.
117For complex scripts, write them to a file first and then execute the file.
118`
119 // If you modify this, update the termui template for prettier rendering.
120 bashInputSchema = `
121{
122 "type": "object",
123 "required": ["command"],
124 "properties": {
125 "command": {
126 "type": "string",
127 "description": "Shell to execute"
128 },
129 "slow_ok": {
130 "type": "boolean",
131 "description": "Use extended timeout"
132 },
133 "background": {
134 "type": "boolean",
135 "description": "Execute in background"
136 }
137 }
138}
139`
140)
141
142type bashInput struct {
143 Command string `json:"command"`
144 SlowOK bool `json:"slow_ok,omitempty"`
145 Background bool `json:"background,omitempty"`
146}
147
148// BashDisplayData is the display data sent to the UI for bash tool results.
149type BashDisplayData struct {
150 WorkingDir string `json:"workingDir"`
151}
152
153type BackgroundResult struct {
154 PID int
155 OutFile string
156}
157
158func (r *BackgroundResult) XMLish() string {
159 return fmt.Sprintf("<pid>%d</pid>\n<output_file>%s</output_file>\n<reminder>To stop the process: `kill -9 -%d`</reminder>\n",
160 r.PID, r.OutFile, r.PID)
161}
162
163func (i *bashInput) timeout(t *Timeouts) time.Duration {
164 switch {
165 case i.Background:
166 return t.background()
167 case i.SlowOK:
168 return t.slow()
169 default:
170 return t.fast()
171 }
172}
173
174func (b *BashTool) Run(ctx context.Context, m json.RawMessage) llm.ToolOut {
175 var req bashInput
176 if err := json.Unmarshal(m, &req); err != nil {
177 return llm.ErrorfToolOut("failed to unmarshal bash command input: %w", err)
178 }
179
180 // Check that the working directory exists
181 wd := b.getWorkingDir()
182 if _, err := os.Stat(wd); err != nil {
183 if os.IsNotExist(err) {
184 return llm.ErrorfToolOut("working directory does not exist: %s (use change_dir to switch to a valid directory)", wd)
185 }
186 return llm.ErrorfToolOut("cannot access working directory %s: %w", wd, err)
187 }
188
189 // do a quick permissions check (NOT a security barrier)
190 err := bashkit.Check(req.Command)
191 if err != nil {
192 return llm.ErrorToolOut(err)
193 }
194
195 // Custom permission callback if set
196 if b.CheckPermission != nil {
197 if err := b.CheckPermission(req.Command); err != nil {
198 return llm.ErrorToolOut(err)
199 }
200 }
201
202 // Check for missing tools and try to install them if needed, best effort only
203 if b.EnableJITInstall {
204 err := b.checkAndInstallMissingTools(ctx, req.Command)
205 if err != nil {
206 slog.DebugContext(ctx, "failed to auto-install missing tools", "error", err)
207 }
208 }
209
210 // Add co-author trailer to git commits
211 req.Command = bashkit.AddCoauthorTrailer(req.Command, "Co-authored-by: Shelley <shelley@exe.dev>")
212
213 timeout := req.timeout(b.Timeouts)
214
215 display := BashDisplayData{WorkingDir: wd}
216
217 // If Background is set to true, use executeBackgroundBash
218 if req.Background {
219 result, err := b.executeBackgroundBash(ctx, req, timeout)
220 if err != nil {
221 return llm.ErrorToolOut(err)
222 }
223 return llm.ToolOut{LLMContent: llm.TextContent(result.XMLish()), Display: display}
224 }
225
226 // For foreground commands, use executeBash
227 out, execErr := b.executeBash(ctx, req, timeout)
228 if execErr != nil {
229 return llm.ErrorToolOut(execErr)
230 }
231 return llm.ToolOut{LLMContent: llm.TextContent(out), Display: display}
232}
233
234const (
235 largeOutputThreshold = 50 * 1024 // 50KB - threshold for saving to file
236 firstLinesCount = 2
237 lastLinesCount = 5
238 maxLineLength = 200 // truncate displayed lines to this length
239)
240
241func (b *BashTool) makeBashCommand(ctx context.Context, command string, out io.Writer) *exec.Cmd {
242 cmd := exec.CommandContext(ctx, "bash", "-c", command)
243 // Use shared WorkingDir if available, then context, then Pwd fallback
244 cmd.Dir = b.getWorkingDir()
245 cmd.Stdin = nil
246 cmd.Stdout = out
247 cmd.Stderr = out
248 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} // set up for killing the process group
249 cmd.Cancel = func() error {
250 if cmd.Process == nil {
251 // Process hasn't started yet.
252 // Not sure whether this is possible in practice,
253 // but it is possible in theory, and it doesn't hurt to handle it gracefully.
254 return nil
255 }
256 return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) // kill entire process group
257 }
258 cmd.WaitDelay = 15 * time.Second // prevent indefinite hangs when child processes keep pipes open
259 // Remove SKETCH_MODEL_URL, SKETCH_PUB_KEY, SKETCH_MODEL_API_KEY,
260 // and any other future SKETCH_ goodies from the environment.
261 // ...except for SKETCH_PROXY_ID, which is intentionally available.
262 env := slices.DeleteFunc(os.Environ(), func(s string) bool {
263 return strings.HasPrefix(s, "SKETCH_") && s != "SKETCH_PROXY_ID"
264 })
265 env = append(env, "SKETCH=1") // signal that this has been run by Sketch, sometimes useful for scripts
266 env = append(env, "EDITOR=/bin/false") // interactive editors won't work
267 if b.ConversationID != "" {
268 env = append(env, "SHELLEY_CONVERSATION_ID="+b.ConversationID)
269 }
270 cmd.Env = env
271 return cmd
272}
273
274func cmdWait(cmd *exec.Cmd) error {
275 err := cmd.Wait()
276 // We used to kill the process group here, but it's not clear that
277 // this is correct in the case of self-daemonizing processes,
278 // and I encountered issues where daemons that I tried to run
279 // as background tasks would mysteriously exit.
280 return err
281}
282
283func (b *BashTool) executeBash(ctx context.Context, req bashInput, timeout time.Duration) (string, error) {
284 execCtx, cancel := context.WithTimeout(ctx, timeout)
285 defer cancel()
286
287 output := new(bytes.Buffer)
288 cmd := b.makeBashCommand(execCtx, req.Command, output)
289 // TODO: maybe detect simple interactive git rebase commands and auto-background them?
290 // Would need to hint to the agent what is happening.
291 // We might also be able to do this for other simple interactive commands that use EDITOR.
292 cmd.Env = append(cmd.Env, `GIT_SEQUENCE_EDITOR=echo "To do an interactive rebase, run it as a background task and check the output file." && exit 1`)
293 if err := cmd.Start(); err != nil {
294 return "", fmt.Errorf("command failed: %w", err)
295 }
296
297 err := cmdWait(cmd)
298
299 out, formatErr := formatForegroundBashOutput(output.String())
300 if formatErr != nil {
301 return "", formatErr
302 }
303
304 if execCtx.Err() == context.DeadlineExceeded {
305 return "", fmt.Errorf("[command timed out after %s, showing output until timeout]\n%s", timeout, out)
306 }
307 if err != nil {
308 return "", fmt.Errorf("[command failed: %w]\n%s", err, out)
309 }
310
311 return out, nil
312}
313
314// formatForegroundBashOutput formats the output of a foreground bash command for display to the agent.
315// If output exceeds largeOutputThreshold, it saves to a file and returns a summary.
316func formatForegroundBashOutput(out string) (string, error) {
317 if len(out) <= largeOutputThreshold {
318 return out, nil
319 }
320
321 // Save full output to a temp file
322 tmpDir, err := os.MkdirTemp("", "shelley-output-")
323 if err != nil {
324 return "", fmt.Errorf("failed to create temp dir for large output: %w", err)
325 }
326
327 outFile := filepath.Join(tmpDir, "output")
328 if err := os.WriteFile(outFile, []byte(out), 0o644); err != nil {
329 os.RemoveAll(tmpDir)
330 return "", fmt.Errorf("failed to write large output to file: %w", err)
331 }
332
333 // Split into lines
334 lines := strings.Split(out, "\n")
335
336 // If fewer than 3 lines total, likely binary or single-line output
337 if len(lines) < 3 {
338 return fmt.Sprintf("[output too large (%s, %d lines), saved to: %s]",
339 humanizeBytes(len(out)), len(lines), outFile), nil
340 }
341
342 var result strings.Builder
343 result.WriteString(fmt.Sprintf("[output too large (%s, %d lines), saved to: %s]\n\n",
344 humanizeBytes(len(out)), len(lines), outFile))
345
346 // First N lines
347 result.WriteString("First lines:\n")
348 firstN := min(firstLinesCount, len(lines))
349 for i := 0; i < firstN; i++ {
350 result.WriteString(fmt.Sprintf("%5d: %s\n", i+1, truncateLine(lines[i])))
351 }
352
353 // Last N lines
354 result.WriteString("\n...\n\nLast lines:\n")
355 startIdx := max(0, len(lines)-lastLinesCount)
356 for i := startIdx; i < len(lines); i++ {
357 result.WriteString(fmt.Sprintf("%5d: %s\n", i+1, truncateLine(lines[i])))
358 }
359
360 return result.String(), nil
361}
362
363// truncateLine truncates a line to maxLineLength characters, appending "..." if truncated.
364func truncateLine(line string) string {
365 if len(line) <= maxLineLength {
366 return line
367 }
368 return line[:maxLineLength] + "..."
369}
370
371func humanizeBytes(bytes int) string {
372 switch {
373 case bytes < 4*1024:
374 return fmt.Sprintf("%dB", bytes)
375 case bytes < 1024*1024:
376 kb := int(math.Round(float64(bytes) / 1024.0))
377 return fmt.Sprintf("%dkB", kb)
378 case bytes < 1024*1024*1024:
379 mb := int(math.Round(float64(bytes) / (1024.0 * 1024.0)))
380 return fmt.Sprintf("%dMB", mb)
381 }
382 return "more than 1GB"
383}
384
385// executeBackgroundBash executes a command in the background and returns the pid and output file locations
386func (b *BashTool) executeBackgroundBash(ctx context.Context, req bashInput, timeout time.Duration) (*BackgroundResult, error) {
387 // Create temp output files
388 tmpDir, err := os.MkdirTemp("", "sketch-bg-")
389 if err != nil {
390 return nil, fmt.Errorf("failed to create temp directory: %w", err)
391 }
392 // We can't really clean up tempDir, because we have no idea
393 // how far into the future the agent might want to read the output.
394
395 outFile := filepath.Join(tmpDir, "output")
396 out, err := os.Create(outFile)
397 if err != nil {
398 return nil, fmt.Errorf("failed to create output file: %w", err)
399 }
400
401 execCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout) // detach from tool use context
402 cmd := b.makeBashCommand(execCtx, req.Command, out)
403 cmd.Env = append(cmd.Env, `GIT_SEQUENCE_EDITOR=python3 -c "import os, sys, signal, threading; print(f\"Send USR1 to pid {os.getpid()} after editing {sys.argv[1]}\", flush=True); signal.signal(signal.SIGUSR1, lambda *_: sys.exit(0)); threading.Event().wait()"`)
404
405 if err := cmd.Start(); err != nil {
406 cancel()
407 out.Close()
408 os.RemoveAll(tmpDir) // clean up temp dir -- didn't start means we don't need the output
409 return nil, fmt.Errorf("failed to start background command: %w", err)
410 }
411
412 // Wait for completion in the background, then do cleanup.
413 go func() {
414 err := cmdWait(cmd)
415 // Leave a note to the agent so that it knows that the process has finished.
416 if err != nil {
417 fmt.Fprintf(out, "\n\n[background process failed: %v]\n", err)
418 } else {
419 fmt.Fprintf(out, "\n\n[background process completed]\n")
420 }
421 out.Close()
422 cancel()
423 }()
424
425 return &BackgroundResult{
426 PID: cmd.Process.Pid,
427 OutFile: outFile,
428 }, nil
429}
430
431// checkAndInstallMissingTools analyzes a bash command and attempts to automatically install any missing tools.
432func (b *BashTool) checkAndInstallMissingTools(ctx context.Context, command string) error {
433 commands, err := bashkit.ExtractCommands(command)
434 if err != nil {
435 return err
436 }
437
438 autoInstallMu.Lock()
439 defer autoInstallMu.Unlock()
440
441 var missing []string
442 for _, cmd := range commands {
443 if doNotAttemptToolInstall[cmd] {
444 continue
445 }
446 _, err := exec.LookPath(cmd)
447 if err == nil {
448 doNotAttemptToolInstall[cmd] = true // spare future LookPath calls
449 continue
450 }
451 missing = append(missing, cmd)
452 }
453
454 if len(missing) == 0 {
455 return nil
456 }
457
458 for _, cmd := range missing {
459 err := b.installTool(ctx, cmd)
460 if err != nil {
461 slog.WarnContext(ctx, "failed to install tool", "tool", cmd, "error", err)
462 }
463 doNotAttemptToolInstall[cmd] = true // either it's installed or it's not--either way, we're done with it
464 }
465 return nil
466}
467
468// Command safety check cache to avoid repeated LLM calls
469var (
470 autoInstallMu sync.Mutex
471 doNotAttemptToolInstall = make(map[string]bool) // set to true if the tool should not be auto-installed
472)
473
474// autodetectPackageManager returns the first packageāmanager binary
475// found in PATH, or an empty string if none are present.
476func autodetectPackageManager() string {
477 // TODO: cache this result with a sync.OnceValue
478
479 managers := []string{
480 "apt", "apt-get", // Debian/Ubuntu
481 "brew", "port", // macOS (Homebrew / MacPorts)
482 "apk", // Alpine
483 "yum", "dnf", // RHEL/Fedora
484 "pacman", // Arch
485 "zypper", // openSUSE
486 "xbps-install", // Void
487 "emerge", // Gentoo
488 "nix-env", "guix", // NixOS / Guix
489 "pkg", // FreeBSD
490 "slackpkg", // Slackware
491 }
492
493 for _, m := range managers {
494 if _, err := exec.LookPath(m); err == nil {
495 return m
496 }
497 }
498 return ""
499}
500
501// installTool attempts to install a single missing tool using LLM validation and system package manager.
502func (b *BashTool) installTool(ctx context.Context, cmd string) error {
503 slog.InfoContext(ctx, "attempting to install tool", "tool", cmd)
504
505 packageManager := autodetectPackageManager()
506 if packageManager == "" {
507 return fmt.Errorf("no known package manager found in PATH")
508 }
509 // Use LLM to validate and get package name
510 if b.LLMProvider == nil {
511 return fmt.Errorf("no LLM provider available for tool validation")
512 }
513 llmService, err := b.selectBestLLM()
514 if err != nil {
515 return fmt.Errorf("failed to get LLM service for tool validation: %w", err)
516 }
517
518 query := fmt.Sprintf(`Do you know this command/package/tool? Is it legitimate, clearly non-harmful, and commonly used? Can it be installed with package manager %s?
519
520Command: %s
521
522- YES: Respond ONLY with the package name used to install it
523- NO or UNSURE: Respond ONLY with the word NO`, packageManager, cmd)
524
525 req := &llm.Request{
526 Messages: []llm.Message{{
527 Role: llm.MessageRoleUser,
528 Content: []llm.Content{llm.StringContent(query)},
529 }},
530 System: []llm.SystemContent{{
531 Type: "text",
532 Text: "You are an expert in software developer tools.",
533 }},
534 }
535
536 resp, err := llmService.Do(ctx, req)
537 if err != nil {
538 return fmt.Errorf("failed to validate tool with LLM: %w", err)
539 }
540
541 if len(resp.Content) == 0 {
542 return fmt.Errorf("empty response from LLM for tool validation")
543 }
544
545 response := strings.TrimSpace(resp.Content[0].Text)
546 if response == "NO" || response == "UNSURE" {
547 slog.InfoContext(ctx, "tool installation declined by LLM", "tool", cmd, "response", response)
548 return fmt.Errorf("tool %s not approved for installation", cmd)
549 }
550
551 packageName := strings.TrimSpace(response)
552 if packageName == "" {
553 return fmt.Errorf("no package name provided for tool %s", cmd)
554 }
555
556 return b.installPackage(ctx, cmd, packageName, packageManager)
557}
558
559// installPackage handles the actual package installation
560func (b *BashTool) installPackage(ctx context.Context, cmd, packageName, packageManager string) error {
561 // Install the package (with update command first if needed)
562 // TODO: these invocations create zombies when we are PID 1.
563 // We should give them the same zombie-reaping treatment as above,
564 // if/when we care enough to put in the effort. Not today.
565 var updateCmd, installCmd string
566 switch packageManager {
567 case "apt", "apt-get":
568 updateCmd = fmt.Sprintf("sudo %s update", packageManager)
569 installCmd = fmt.Sprintf("sudo %s install -y %s", packageManager, packageName)
570 case "brew":
571 // brew handles updates automatically, no explicit update needed
572 installCmd = fmt.Sprintf("brew install %s", packageName)
573 case "apk":
574 updateCmd = "sudo apk update"
575 installCmd = fmt.Sprintf("sudo apk add %s", packageName)
576 case "yum", "dnf":
577 // For yum/dnf, we don't need a separate update command as the package cache is usually fresh enough
578 // and install will fetch the latest available packages
579 installCmd = fmt.Sprintf("sudo %s install -y %s", packageManager, packageName)
580 case "pacman":
581 updateCmd = "sudo pacman -Sy"
582 installCmd = fmt.Sprintf("sudo pacman -S --noconfirm %s", packageName)
583 case "zypper":
584 updateCmd = "sudo zypper refresh"
585 installCmd = fmt.Sprintf("sudo zypper install -y %s", packageName)
586 case "xbps-install":
587 updateCmd = "sudo xbps-install -S"
588 installCmd = fmt.Sprintf("sudo xbps-install -y %s", packageName)
589 case "emerge":
590 // Note: emerge --sync is expensive, so we skip it for JIT installs
591 // Users should manually sync if needed
592 installCmd = fmt.Sprintf("sudo emerge %s", packageName)
593 case "nix-env":
594 // nix-env doesn't require explicit updates for JIT installs
595 installCmd = fmt.Sprintf("nix-env -i %s", packageName)
596 case "guix":
597 // guix doesn't require explicit updates for JIT installs
598 installCmd = fmt.Sprintf("guix install %s", packageName)
599 case "pkg":
600 updateCmd = "sudo pkg update"
601 installCmd = fmt.Sprintf("sudo pkg install -y %s", packageName)
602 case "slackpkg":
603 updateCmd = "sudo slackpkg update"
604 installCmd = fmt.Sprintf("sudo slackpkg install %s", packageName)
605 default:
606 return fmt.Errorf("unsupported package manager: %s", packageManager)
607 }
608
609 slog.InfoContext(ctx, "installing tool", "tool", cmd, "package", packageName, "update_command", updateCmd, "install_command", installCmd)
610
611 // Execute the update command first if needed
612 if updateCmd != "" {
613 slog.InfoContext(ctx, "updating package cache", "command", updateCmd)
614 updateCmdExec := exec.CommandContext(ctx, "sh", "-c", updateCmd)
615 updateOutput, err := updateCmdExec.CombinedOutput()
616 if err != nil {
617 slog.WarnContext(ctx, "package cache update failed, proceeding with install anyway", "error", err, "output", string(updateOutput))
618 }
619 }
620
621 // Execute the install command
622 cmdExec := exec.CommandContext(ctx, "sh", "-c", installCmd)
623 output, err := cmdExec.CombinedOutput()
624 if err != nil {
625 return fmt.Errorf("failed to install %s: %w\nOutput: %s", packageName, err, string(output))
626 }
627
628 slog.InfoContext(ctx, "tool installation successful", "tool", cmd, "package", packageName)
629 return nil
630}
631
632// selectBestLLM selects the best available LLM service for bash tool validation
633func (b *BashTool) selectBestLLM() (llm.Service, error) {
634 if b.LLMProvider == nil {
635 return nil, fmt.Errorf("no LLM provider available")
636 }
637
638 // Preferred models in order of preference for tool validation (fast, cheap models preferred)
639 preferredModels := []string{"qwen3-coder-fireworks", "gpt-5-thinking-mini", "gpt5-mini", "claude-sonnet-4.5", "predictable"}
640
641 for _, model := range preferredModels {
642 svc, err := b.LLMProvider.GetService(model)
643 if err == nil {
644 return svc, nil
645 }
646 }
647
648 // If no preferred model is available, try any available model
649 available := b.LLMProvider.GetAvailableModels()
650 if len(available) > 0 {
651 return b.LLMProvider.GetService(available[0])
652 }
653
654 return nil, fmt.Errorf("no LLM services available")
655}