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}