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