bash.go

  1package tools
  2
  3import (
  4	"bytes"
  5	"cmp"
  6	"context"
  7	_ "embed"
  8	"fmt"
  9	"html/template"
 10	"path/filepath"
 11	"runtime"
 12	"strings"
 13	"time"
 14
 15	"charm.land/fantasy"
 16	"github.com/charmbracelet/crush/internal/config"
 17	"github.com/charmbracelet/crush/internal/fsext"
 18	"github.com/charmbracelet/crush/internal/permission"
 19	"github.com/charmbracelet/crush/internal/shell"
 20)
 21
 22type BashParams struct {
 23	Description         string `json:"description" description:"A brief description of what the command does, try to keep it under 30 characters or so"`
 24	Command             string `json:"command" description:"The command to execute"`
 25	WorkingDir          string `json:"working_dir,omitempty" description:"The working directory to execute the command in (defaults to current directory)"`
 26	RunInBackground     bool   `json:"run_in_background,omitempty" description:"Set to true (boolean) to run this command in the background. Use job_output to read the output later."`
 27	AutoBackgroundAfter int    `json:"auto_background_after,omitempty" description:"Seconds to wait before automatically moving the command to a background job (default: 60)"`
 28}
 29
 30type BashPermissionsParams struct {
 31	Description         string `json:"description"`
 32	Command             string `json:"command"`
 33	WorkingDir          string `json:"working_dir"`
 34	RunInBackground     bool   `json:"run_in_background"`
 35	AutoBackgroundAfter int    `json:"auto_background_after"`
 36}
 37
 38type BashResponseMetadata struct {
 39	StartTime        int64  `json:"start_time"`
 40	EndTime          int64  `json:"end_time"`
 41	Output           string `json:"output"`
 42	Description      string `json:"description"`
 43	WorkingDirectory string `json:"working_directory"`
 44	Background       bool   `json:"background,omitempty"`
 45	ShellID          string `json:"shell_id,omitempty"`
 46}
 47
 48const (
 49	BashToolName = "bash"
 50
 51	DefaultAutoBackgroundAfter = 60 // Commands taking longer automatically become background jobs
 52	MaxOutputLength            = 30000
 53	BashNoOutput               = "no output"
 54)
 55
 56//go:embed bash.md.tpl
 57var bashDescriptionTmpl []byte
 58
 59var bashDescriptionTpl = template.Must(
 60	template.New("bashDescription").
 61		Parse(string(bashDescriptionTmpl)),
 62)
 63
 64type bashDescriptionData struct {
 65	BannedCommands  string
 66	MaxOutputLength int
 67	Attribution     config.Attribution
 68	ModelID         string
 69	RgAvailable     bool
 70	GhAvailable     bool
 71}
 72
 73var bannedCommands = []string{
 74	// Network/Download tools
 75	"alias",
 76	"aria2c",
 77	"axel",
 78	"chrome",
 79	"curl",
 80	"curlie",
 81	"firefox",
 82	"http-prompt",
 83	"httpie",
 84	"links",
 85	"lynx",
 86	"nc",
 87	"safari",
 88	"scp",
 89	"ssh",
 90	"telnet",
 91	"w3m",
 92	"wget",
 93	"xh",
 94
 95	// System administration
 96	"doas",
 97	"su",
 98	"sudo",
 99
100	// Package managers
101	"apk",
102	"apt",
103	"apt-cache",
104	"apt-get",
105	"dnf",
106	"dpkg",
107	"emerge",
108	"home-manager",
109	"makepkg",
110	"opkg",
111	"pacman",
112	"paru",
113	"pkg",
114	"pkg_add",
115	"pkg_delete",
116	"portage",
117	"rpm",
118	"yay",
119	"yum",
120	"zypper",
121
122	// System modification
123	"at",
124	"batch",
125	"chkconfig",
126	"crontab",
127	"fdisk",
128	"mkfs",
129	"mount",
130	"parted",
131	"service",
132	"systemctl",
133	"umount",
134
135	// Network configuration
136	"firewall-cmd",
137	"ifconfig",
138	"ip",
139	"iptables",
140	"netstat",
141	"pfctl",
142	"route",
143	"ufw",
144}
145
146func bashDescription(attribution *config.Attribution, modelID string) string {
147	bannedCommandsStr := strings.Join(bannedCommands, ", ")
148	var out bytes.Buffer
149	if err := bashDescriptionTpl.Execute(&out, bashDescriptionData{
150		BannedCommands:  bannedCommandsStr,
151		MaxOutputLength: MaxOutputLength,
152		Attribution:     *attribution,
153		ModelID:         modelID,
154		RgAvailable:     getRg() != "",
155		GhAvailable:     ghAvailable,
156	}); err != nil {
157		// this should never happen.
158		panic("failed to execute bash description template: " + err.Error())
159	}
160	return out.String()
161}
162
163func blockFuncs() []shell.BlockFunc {
164	return []shell.BlockFunc{
165		shell.CommandsBlocker(bannedCommands),
166
167		// System package managers
168		shell.ArgumentsBlocker("apk", []string{"add"}, nil),
169		shell.ArgumentsBlocker("apt", []string{"install"}, nil),
170		shell.ArgumentsBlocker("apt-get", []string{"install"}, nil),
171		shell.ArgumentsBlocker("dnf", []string{"install"}, nil),
172		shell.ArgumentsBlocker("pacman", nil, []string{"-S"}),
173		shell.ArgumentsBlocker("pkg", []string{"install"}, nil),
174		shell.ArgumentsBlocker("yum", []string{"install"}, nil),
175		shell.ArgumentsBlocker("zypper", []string{"install"}, nil),
176
177		// Language-specific package managers
178		shell.ArgumentsBlocker("brew", []string{"install"}, nil),
179		shell.ArgumentsBlocker("cargo", []string{"install"}, nil),
180		shell.ArgumentsBlocker("gem", []string{"install"}, nil),
181		shell.ArgumentsBlocker("go", []string{"install"}, nil),
182		shell.ArgumentsBlocker("npm", []string{"install"}, []string{"--global"}),
183		shell.ArgumentsBlocker("npm", []string{"install"}, []string{"-g"}),
184		shell.ArgumentsBlocker("pip", []string{"install"}, []string{"--user"}),
185		shell.ArgumentsBlocker("pip3", []string{"install"}, []string{"--user"}),
186		shell.ArgumentsBlocker("pnpm", []string{"add"}, []string{"--global"}),
187		shell.ArgumentsBlocker("pnpm", []string{"add"}, []string{"-g"}),
188		shell.ArgumentsBlocker("yarn", []string{"global", "add"}, nil),
189
190		// `go test -exec` can run arbitrary commands
191		shell.ArgumentsBlocker("go", []string{"test"}, []string{"-exec"}),
192	}
193}
194
195func NewBashTool(permissions permission.Service, workingDir string, attribution *config.Attribution, modelID string) fantasy.AgentTool {
196	return fantasy.NewAgentTool(
197		BashToolName,
198		string(bashDescription(attribution, modelID)),
199		func(ctx context.Context, params BashParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
200			if params.Command == "" {
201				return fantasy.NewTextErrorResponse("missing command"), nil
202			}
203
204			// Determine working directory
205			execWorkingDir := cmp.Or(params.WorkingDir, workingDir)
206
207			isSafeReadOnly := false
208			cmdLower := strings.ToLower(params.Command)
209
210			if !containsCommandChaining(params.Command) {
211				for _, safe := range safeCommands {
212					if strings.HasPrefix(cmdLower, safe) {
213						if len(cmdLower) == len(safe) || cmdLower[len(safe)] == ' ' || cmdLower[len(safe)] == '-' {
214							isSafeReadOnly = true
215							break
216						}
217					}
218				}
219			}
220
221			sessionID := GetSessionFromContext(ctx)
222			if sessionID == "" {
223				return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for executing shell command")
224			}
225			if !isSafeReadOnly {
226				p, err := permissions.Request(
227					ctx,
228					permission.CreatePermissionRequest{
229						SessionID:   sessionID,
230						Path:        execWorkingDir,
231						ToolCallID:  call.ID,
232						ToolName:    BashToolName,
233						Action:      "execute",
234						Description: fmt.Sprintf("Execute command: %s", params.Command),
235						Params:      BashPermissionsParams(params),
236					},
237				)
238				if err != nil {
239					return fantasy.ToolResponse{}, err
240				}
241				if !p {
242					return NewPermissionDeniedResponse(), nil
243				}
244			}
245
246			// If explicitly requested as background, start immediately with detached context
247			if params.RunInBackground {
248				startTime := time.Now()
249				bgManager := shell.GetBackgroundShellManager()
250				bgManager.Cleanup()
251				// Use background context so it continues after tool returns
252				bgShell, err := bgManager.Start(context.Background(), execWorkingDir, blockFuncs(), params.Command, params.Description)
253				if err != nil {
254					return fantasy.ToolResponse{}, fmt.Errorf("error starting background shell: %w", err)
255				}
256
257				// Wait a short time to detect fast failures (blocked commands, syntax errors, etc.)
258				time.Sleep(1 * time.Second)
259				stdout, stderr, done, execErr := bgShell.GetOutput()
260
261				if done {
262					// Command failed or completed very quickly
263					bgManager.Remove(bgShell.ID)
264
265					interrupted := shell.IsInterrupt(execErr)
266					exitCode := shell.ExitCode(execErr)
267					if exitCode == 0 && !interrupted && execErr != nil {
268						return fantasy.ToolResponse{}, fmt.Errorf("[Job %s] error executing command: %w", bgShell.ID, execErr)
269					}
270
271					stdout = formatOutput(stdout, stderr, execErr)
272
273					metadata := BashResponseMetadata{
274						StartTime:        startTime.UnixMilli(),
275						EndTime:          time.Now().UnixMilli(),
276						Output:           stdout,
277						Description:      params.Description,
278						Background:       params.RunInBackground,
279						WorkingDirectory: bgShell.WorkingDir,
280					}
281					if stdout == "" {
282						return fantasy.WithResponseMetadata(fantasy.NewTextResponse(BashNoOutput), metadata), nil
283					}
284					stdout += fmt.Sprintf("\n\n<cwd>%s</cwd>", normalizeWorkingDir(bgShell.WorkingDir))
285					return fantasy.WithResponseMetadata(fantasy.NewTextResponse(stdout), metadata), nil
286				}
287
288				// Still running after fast-failure check - return as background job
289				metadata := BashResponseMetadata{
290					StartTime:        startTime.UnixMilli(),
291					EndTime:          time.Now().UnixMilli(),
292					Description:      params.Description,
293					WorkingDirectory: bgShell.WorkingDir,
294					Background:       true,
295					ShellID:          bgShell.ID,
296				}
297				response := fmt.Sprintf("Background shell started with ID: %s\n\nUse job_output tool to view output or job_kill to terminate.", bgShell.ID)
298				return fantasy.WithResponseMetadata(fantasy.NewTextResponse(response), metadata), nil
299			}
300
301			// Start synchronous execution with auto-background support
302			startTime := time.Now()
303
304			// Start with detached context so it can survive if moved to background
305			bgManager := shell.GetBackgroundShellManager()
306			bgManager.Cleanup()
307			bgShell, err := bgManager.Start(context.Background(), execWorkingDir, blockFuncs(), params.Command, params.Description)
308			if err != nil {
309				return fantasy.ToolResponse{}, fmt.Errorf("error starting shell: %w", err)
310			}
311
312			// Wait for either completion, auto-background threshold, or context cancellation
313			ticker := time.NewTicker(100 * time.Millisecond)
314			defer ticker.Stop()
315
316			autoBackgroundAfter := cmp.Or(params.AutoBackgroundAfter, DefaultAutoBackgroundAfter)
317			autoBackgroundThreshold := time.Duration(autoBackgroundAfter) * time.Second
318			timeout := time.After(autoBackgroundThreshold)
319
320			var stdout, stderr string
321			var done bool
322			var execErr error
323
324		waitLoop:
325			for {
326				select {
327				case <-ticker.C:
328					stdout, stderr, done, execErr = bgShell.GetOutput()
329					if done {
330						break waitLoop
331					}
332				case <-timeout:
333					stdout, stderr, done, execErr = bgShell.GetOutput()
334					break waitLoop
335				case <-ctx.Done():
336					// Incoming context was cancelled before we moved to background
337					// Kill the shell and return error
338					bgManager.Kill(bgShell.ID)
339					return fantasy.ToolResponse{}, ctx.Err()
340				}
341			}
342
343			if done {
344				// Command completed within threshold - return synchronously
345				// Remove from background manager since we're returning directly
346				// Don't call Kill() as it cancels the context and corrupts the exit code
347				bgManager.Remove(bgShell.ID)
348
349				interrupted := shell.IsInterrupt(execErr)
350				exitCode := shell.ExitCode(execErr)
351				if exitCode == 0 && !interrupted && execErr != nil {
352					return fantasy.ToolResponse{}, fmt.Errorf("[Job %s] error executing command: %w", bgShell.ID, execErr)
353				}
354
355				stdout = formatOutput(stdout, stderr, execErr)
356
357				metadata := BashResponseMetadata{
358					StartTime:        startTime.UnixMilli(),
359					EndTime:          time.Now().UnixMilli(),
360					Output:           stdout,
361					Description:      params.Description,
362					Background:       params.RunInBackground,
363					WorkingDirectory: bgShell.WorkingDir,
364				}
365				if stdout == "" {
366					return fantasy.WithResponseMetadata(fantasy.NewTextResponse(BashNoOutput), metadata), nil
367				}
368				stdout += fmt.Sprintf("\n\n<cwd>%s</cwd>", normalizeWorkingDir(bgShell.WorkingDir))
369				return fantasy.WithResponseMetadata(fantasy.NewTextResponse(stdout), metadata), nil
370			}
371
372			// Still running - keep as background job
373			metadata := BashResponseMetadata{
374				StartTime:        startTime.UnixMilli(),
375				EndTime:          time.Now().UnixMilli(),
376				Description:      params.Description,
377				WorkingDirectory: bgShell.WorkingDir,
378				Background:       true,
379				ShellID:          bgShell.ID,
380			}
381			response := fmt.Sprintf("Command is taking longer than expected and has been moved to background.\n\nBackground shell ID: %s\n\nUse job_output tool to view output or job_kill to terminate.", bgShell.ID)
382			return fantasy.WithResponseMetadata(fantasy.NewTextResponse(response), metadata), nil
383		},
384	)
385}
386
387// formatOutput formats the output of a completed command with error handling
388func formatOutput(stdout, stderr string, execErr error) string {
389	interrupted := shell.IsInterrupt(execErr)
390	exitCode := shell.ExitCode(execErr)
391
392	stdout = truncateOutput(stdout)
393	stderr = truncateOutput(stderr)
394
395	errorMessage := stderr
396	if errorMessage == "" && execErr != nil {
397		errorMessage = execErr.Error()
398	}
399
400	if interrupted {
401		if errorMessage != "" {
402			errorMessage += "\n"
403		}
404		errorMessage += "Command was aborted before completion"
405	} else if exitCode != 0 {
406		if errorMessage != "" {
407			errorMessage += "\n"
408		}
409		errorMessage += fmt.Sprintf("Exit code %d", exitCode)
410	}
411
412	hasBothOutputs := stdout != "" && stderr != ""
413
414	if hasBothOutputs {
415		stdout += "\n"
416	}
417
418	if errorMessage != "" {
419		stdout += "\n" + errorMessage
420	}
421
422	return stdout
423}
424
425func TruncateOutput(content string) string {
426	if len(content) <= MaxOutputLength {
427		return content
428	}
429
430	halfLength := MaxOutputLength / 2
431	start := content[:halfLength]
432	end := content[len(content)-halfLength:]
433
434	truncatedLinesCount := countLines(content[halfLength : len(content)-halfLength])
435	return fmt.Sprintf("%s\n\n... [%d lines truncated] ...\n\n%s", start, truncatedLinesCount, end)
436}
437
438func truncateOutput(content string) string {
439	return TruncateOutput(content)
440}
441
442func countLines(s string) int {
443	if s == "" {
444		return 0
445	}
446	return len(strings.Split(s, "\n"))
447}
448
449func normalizeWorkingDir(path string) string {
450	if runtime.GOOS == "windows" {
451		path = strings.ReplaceAll(path, fsext.WindowsWorkingDirDrive(), "")
452	}
453	return filepath.ToSlash(path)
454}