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}