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