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}