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