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	"git.secluded.site/crush/internal/config"
 17	"git.secluded.site/crush/internal/fsext"
 18	"git.secluded.site/crush/internal/permission"
 19	"git.secluded.site/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			if !containsCommandChaining(params.Command) {
209				for _, safe := range safeCommands {
210					if strings.HasPrefix(cmdLower, safe) {
211						if len(cmdLower) == len(safe) || cmdLower[len(safe)] == ' ' || cmdLower[len(safe)] == '-' {
212							isSafeReadOnly = true
213							break
214						}
215					}
216				}
217			}
218
219			sessionID := GetSessionFromContext(ctx)
220			if sessionID == "" {
221				return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for executing shell command")
222			}
223			if !isSafeReadOnly {
224				p, err := permissions.Request(
225					ctx,
226					permission.CreatePermissionRequest{
227						SessionID:   sessionID,
228						Path:        execWorkingDir,
229						ToolCallID:  call.ID,
230						ToolName:    BashToolName,
231						Action:      "execute",
232						Description: fmt.Sprintf("Execute command: %s", params.Command),
233						Params:      BashPermissionsParams(params),
234					},
235				)
236				if err != nil {
237					return fantasy.ToolResponse{}, err
238				}
239				if !p {
240					return NewPermissionDeniedResponse(), nil
241				}
242			}
243
244			// If explicitly requested as background, start immediately with detached context
245			if params.RunInBackground {
246				startTime := time.Now()
247				bgManager := shell.GetBackgroundShellManager()
248				bgManager.Cleanup()
249				// Use background context so it continues after tool returns
250				bgShell, err := bgManager.Start(context.Background(), execWorkingDir, blockFuncs(), params.Command, params.Description)
251				if err != nil {
252					return fantasy.ToolResponse{}, fmt.Errorf("error starting background shell: %w", err)
253				}
254
255				// Wait a short time to detect fast failures (blocked commands, syntax errors, etc.)
256				time.Sleep(1 * time.Second)
257				stdout, stderr, done, execErr := bgShell.GetOutput()
258
259				if done {
260					// Command failed or completed very quickly
261					bgManager.Remove(bgShell.ID)
262
263					interrupted := shell.IsInterrupt(execErr)
264					exitCode := shell.ExitCode(execErr)
265					if exitCode == 0 && !interrupted && execErr != nil {
266						return fantasy.ToolResponse{}, fmt.Errorf("[Job %s] error executing command: %w", bgShell.ID, execErr)
267					}
268
269					stdout = formatOutput(stdout, stderr, execErr)
270
271					metadata := BashResponseMetadata{
272						StartTime:        startTime.UnixMilli(),
273						EndTime:          time.Now().UnixMilli(),
274						Output:           stdout,
275						Description:      params.Description,
276						Background:       params.RunInBackground,
277						WorkingDirectory: bgShell.WorkingDir,
278					}
279					if stdout == "" {
280						return fantasy.WithResponseMetadata(fantasy.NewTextResponse(BashNoOutput), metadata), nil
281					}
282					stdout += fmt.Sprintf("\n\n<cwd>%s</cwd>", normalizeWorkingDir(bgShell.WorkingDir))
283					return fantasy.WithResponseMetadata(fantasy.NewTextResponse(stdout), metadata), nil
284				}
285
286				// Still running after fast-failure check - return as background job
287				metadata := BashResponseMetadata{
288					StartTime:        startTime.UnixMilli(),
289					EndTime:          time.Now().UnixMilli(),
290					Description:      params.Description,
291					WorkingDirectory: bgShell.WorkingDir,
292					Background:       true,
293					ShellID:          bgShell.ID,
294				}
295				response := fmt.Sprintf("Background shell started with ID: %s\n\nUse job_output tool to view output or job_kill to terminate.", bgShell.ID)
296				return fantasy.WithResponseMetadata(fantasy.NewTextResponse(response), metadata), nil
297			}
298
299			// Start synchronous execution with auto-background support
300			startTime := time.Now()
301
302			// Start with detached context so it can survive if moved to background
303			bgManager := shell.GetBackgroundShellManager()
304			bgManager.Cleanup()
305			bgShell, err := bgManager.Start(context.Background(), execWorkingDir, blockFuncs(), params.Command, params.Description)
306			if err != nil {
307				return fantasy.ToolResponse{}, fmt.Errorf("error starting shell: %w", err)
308			}
309
310			// Wait for either completion, auto-background threshold, or context cancellation
311			ticker := time.NewTicker(100 * time.Millisecond)
312			defer ticker.Stop()
313
314			autoBackgroundAfter := cmp.Or(params.AutoBackgroundAfter, DefaultAutoBackgroundAfter)
315			autoBackgroundThreshold := time.Duration(autoBackgroundAfter) * time.Second
316			timeout := time.After(autoBackgroundThreshold)
317
318			var stdout, stderr string
319			var done bool
320			var execErr error
321
322		waitLoop:
323			for {
324				select {
325				case <-ticker.C:
326					stdout, stderr, done, execErr = bgShell.GetOutput()
327					if done {
328						break waitLoop
329					}
330				case <-timeout:
331					stdout, stderr, done, execErr = bgShell.GetOutput()
332					break waitLoop
333				case <-ctx.Done():
334					// Incoming context was cancelled before we moved to background
335					// Kill the shell and return error
336					bgManager.Kill(bgShell.ID)
337					return fantasy.ToolResponse{}, ctx.Err()
338				}
339			}
340
341			if done {
342				// Command completed within threshold - return synchronously
343				// Remove from background manager since we're returning directly
344				// Don't call Kill() as it cancels the context and corrupts the exit code
345				bgManager.Remove(bgShell.ID)
346
347				interrupted := shell.IsInterrupt(execErr)
348				exitCode := shell.ExitCode(execErr)
349				if exitCode == 0 && !interrupted && execErr != nil {
350					return fantasy.ToolResponse{}, fmt.Errorf("[Job %s] error executing command: %w", bgShell.ID, execErr)
351				}
352
353				stdout = formatOutput(stdout, stderr, execErr)
354
355				metadata := BashResponseMetadata{
356					StartTime:        startTime.UnixMilli(),
357					EndTime:          time.Now().UnixMilli(),
358					Output:           stdout,
359					Description:      params.Description,
360					Background:       params.RunInBackground,
361					WorkingDirectory: bgShell.WorkingDir,
362				}
363				if stdout == "" {
364					return fantasy.WithResponseMetadata(fantasy.NewTextResponse(BashNoOutput), metadata), nil
365				}
366				stdout += fmt.Sprintf("\n\n<cwd>%s</cwd>", normalizeWorkingDir(bgShell.WorkingDir))
367				return fantasy.WithResponseMetadata(fantasy.NewTextResponse(stdout), metadata), nil
368			}
369
370			// Still running - keep as background job
371			metadata := BashResponseMetadata{
372				StartTime:        startTime.UnixMilli(),
373				EndTime:          time.Now().UnixMilli(),
374				Description:      params.Description,
375				WorkingDirectory: bgShell.WorkingDir,
376				Background:       true,
377				ShellID:          bgShell.ID,
378			}
379			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)
380			return fantasy.WithResponseMetadata(fantasy.NewTextResponse(response), metadata), nil
381		},
382	)
383}
384
385// formatOutput formats the output of a completed command with error handling
386func formatOutput(stdout, stderr string, execErr error) string {
387	interrupted := shell.IsInterrupt(execErr)
388	exitCode := shell.ExitCode(execErr)
389
390	stdout = truncateOutput(stdout)
391	stderr = truncateOutput(stderr)
392
393	errorMessage := stderr
394	if errorMessage == "" && execErr != nil {
395		errorMessage = execErr.Error()
396	}
397
398	if interrupted {
399		if errorMessage != "" {
400			errorMessage += "\n"
401		}
402		errorMessage += "Command was aborted before completion"
403	} else if exitCode != 0 {
404		if errorMessage != "" {
405			errorMessage += "\n"
406		}
407		errorMessage += fmt.Sprintf("Exit code %d", exitCode)
408	}
409
410	hasBothOutputs := stdout != "" && stderr != ""
411
412	if hasBothOutputs {
413		stdout += "\n"
414	}
415
416	if errorMessage != "" {
417		stdout += "\n" + errorMessage
418	}
419
420	return stdout
421}
422
423func TruncateOutput(content string) string {
424	if len(content) <= MaxOutputLength {
425		return content
426	}
427
428	halfLength := MaxOutputLength / 2
429	start := content[:halfLength]
430	end := content[len(content)-halfLength:]
431
432	truncatedLinesCount := countLines(content[halfLength : len(content)-halfLength])
433	return fmt.Sprintf("%s\n\n... [%d lines truncated] ...\n\n%s", start, truncatedLinesCount, end)
434}
435
436func truncateOutput(content string) string {
437	return TruncateOutput(content)
438}
439
440func countLines(s string) int {
441	if s == "" {
442		return 0
443	}
444	return len(strings.Split(s, "\n"))
445}
446
447func normalizeWorkingDir(path string) string {
448	if runtime.GOOS == "windows" {
449		path = strings.ReplaceAll(path, fsext.WindowsWorkingDirDrive(), "")
450	}
451	return filepath.ToSlash(path)
452}