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}