bash.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442
  1. package tools
  2. import (
  3. "bytes"
  4. "cmp"
  5. "context"
  6. _ "embed"
  7. "fmt"
  8. "html/template"
  9. "path/filepath"
  10. "runtime"
  11. "strings"
  12. "time"
  13. "charm.land/fantasy"
  14. "github.com/charmbracelet/crush/internal/config"
  15. "github.com/charmbracelet/crush/internal/fsext"
  16. "github.com/charmbracelet/crush/internal/permission"
  17. "github.com/charmbracelet/crush/internal/shell"
  18. )
  19. type BashParams struct {
  20. Description string `json:"description" description:"A brief description of what the command does, try to keep it under 30 characters or so"`
  21. Command string `json:"command" description:"The command to execute"`
  22. WorkingDir string `json:"working_dir,omitempty" description:"The working directory to execute the command in (defaults to current directory)"`
  23. 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."`
  24. AutoBackgroundAfter int `json:"auto_background_after,omitempty" description:"Seconds to wait before automatically moving the command to a background job (default: 60)"`
  25. }
  26. type BashPermissionsParams struct {
  27. Description string `json:"description"`
  28. Command string `json:"command"`
  29. WorkingDir string `json:"working_dir"`
  30. RunInBackground bool `json:"run_in_background"`
  31. AutoBackgroundAfter int `json:"auto_background_after"`
  32. }
  33. type BashResponseMetadata struct {
  34. StartTime int64 `json:"start_time"`
  35. EndTime int64 `json:"end_time"`
  36. Output string `json:"output"`
  37. Description string `json:"description"`
  38. WorkingDirectory string `json:"working_directory"`
  39. Background bool `json:"background,omitempty"`
  40. ShellID string `json:"shell_id,omitempty"`
  41. }
  42. const (
  43. BashToolName = "bash"
  44. DefaultAutoBackgroundAfter = 60 // Commands taking longer automatically become background jobs
  45. MaxOutputLength = 30000
  46. BashNoOutput = "no output"
  47. )
  48. //go:embed bash.tpl
  49. var bashDescriptionTmpl []byte
  50. var bashDescriptionTpl = template.Must(
  51. template.New("bashDescription").
  52. Parse(string(bashDescriptionTmpl)),
  53. )
  54. type bashDescriptionData struct {
  55. BannedCommands string
  56. MaxOutputLength int
  57. Attribution config.Attribution
  58. ModelName string
  59. }
  60. var bannedCommands = []string{
  61. // Network/Download tools
  62. "alias",
  63. "aria2c",
  64. "axel",
  65. "chrome",
  66. "curl",
  67. "curlie",
  68. "firefox",
  69. "http-prompt",
  70. "httpie",
  71. "links",
  72. "lynx",
  73. "nc",
  74. "safari",
  75. "scp",
  76. "ssh",
  77. "telnet",
  78. "w3m",
  79. "wget",
  80. "xh",
  81. // System administration
  82. "doas",
  83. "su",
  84. "sudo",
  85. // Package managers
  86. "apk",
  87. "apt",
  88. "apt-cache",
  89. "apt-get",
  90. "dnf",
  91. "dpkg",
  92. "emerge",
  93. "home-manager",
  94. "makepkg",
  95. "opkg",
  96. "pacman",
  97. "paru",
  98. "pkg",
  99. "pkg_add",
  100. "pkg_delete",
  101. "portage",
  102. "rpm",
  103. "yay",
  104. "yum",
  105. "zypper",
  106. // System modification
  107. "at",
  108. "batch",
  109. "chkconfig",
  110. "crontab",
  111. "fdisk",
  112. "mkfs",
  113. "mount",
  114. "parted",
  115. "service",
  116. "systemctl",
  117. "umount",
  118. // Network configuration
  119. "firewall-cmd",
  120. "ifconfig",
  121. "ip",
  122. "iptables",
  123. "netstat",
  124. "pfctl",
  125. "route",
  126. "ufw",
  127. }
  128. func bashDescription(attribution *config.Attribution, modelName string) string {
  129. bannedCommandsStr := strings.Join(bannedCommands, ", ")
  130. var out bytes.Buffer
  131. if err := bashDescriptionTpl.Execute(&out, bashDescriptionData{
  132. BannedCommands: bannedCommandsStr,
  133. MaxOutputLength: MaxOutputLength,
  134. Attribution: *attribution,
  135. ModelName: modelName,
  136. }); err != nil {
  137. // this should never happen.
  138. panic("failed to execute bash description template: " + err.Error())
  139. }
  140. return out.String()
  141. }
  142. func blockFuncs() []shell.BlockFunc {
  143. return []shell.BlockFunc{
  144. shell.CommandsBlocker(bannedCommands),
  145. // System package managers
  146. shell.ArgumentsBlocker("apk", []string{"add"}, nil),
  147. shell.ArgumentsBlocker("apt", []string{"install"}, nil),
  148. shell.ArgumentsBlocker("apt-get", []string{"install"}, nil),
  149. shell.ArgumentsBlocker("dnf", []string{"install"}, nil),
  150. shell.ArgumentsBlocker("pacman", nil, []string{"-S"}),
  151. shell.ArgumentsBlocker("pkg", []string{"install"}, nil),
  152. shell.ArgumentsBlocker("yum", []string{"install"}, nil),
  153. shell.ArgumentsBlocker("zypper", []string{"install"}, nil),
  154. // Language-specific package managers
  155. shell.ArgumentsBlocker("brew", []string{"install"}, nil),
  156. shell.ArgumentsBlocker("cargo", []string{"install"}, nil),
  157. shell.ArgumentsBlocker("gem", []string{"install"}, nil),
  158. shell.ArgumentsBlocker("go", []string{"install"}, nil),
  159. shell.ArgumentsBlocker("npm", []string{"install"}, []string{"--global"}),
  160. shell.ArgumentsBlocker("npm", []string{"install"}, []string{"-g"}),
  161. shell.ArgumentsBlocker("pip", []string{"install"}, []string{"--user"}),
  162. shell.ArgumentsBlocker("pip3", []string{"install"}, []string{"--user"}),
  163. shell.ArgumentsBlocker("pnpm", []string{"add"}, []string{"--global"}),
  164. shell.ArgumentsBlocker("pnpm", []string{"add"}, []string{"-g"}),
  165. shell.ArgumentsBlocker("yarn", []string{"global", "add"}, nil),
  166. // `go test -exec` can run arbitrary commands
  167. shell.ArgumentsBlocker("go", []string{"test"}, []string{"-exec"}),
  168. }
  169. }
  170. func NewBashTool(permissions permission.Service, workingDir string, attribution *config.Attribution, modelName string) fantasy.AgentTool {
  171. return fantasy.NewAgentTool(
  172. BashToolName,
  173. string(bashDescription(attribution, modelName)),
  174. func(ctx context.Context, params BashParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
  175. if params.Command == "" {
  176. return fantasy.NewTextErrorResponse("missing command"), nil
  177. }
  178. // Determine working directory
  179. execWorkingDir := cmp.Or(params.WorkingDir, workingDir)
  180. isSafeReadOnly := false
  181. cmdLower := strings.ToLower(params.Command)
  182. for _, safe := range safeCommands {
  183. if strings.HasPrefix(cmdLower, safe) {
  184. if len(cmdLower) == len(safe) || cmdLower[len(safe)] == ' ' || cmdLower[len(safe)] == '-' {
  185. isSafeReadOnly = true
  186. break
  187. }
  188. }
  189. }
  190. sessionID := GetSessionFromContext(ctx)
  191. if sessionID == "" {
  192. return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for executing shell command")
  193. }
  194. if !isSafeReadOnly {
  195. p, err := permissions.Request(ctx,
  196. permission.CreatePermissionRequest{
  197. SessionID: sessionID,
  198. Path: execWorkingDir,
  199. ToolCallID: call.ID,
  200. ToolName: BashToolName,
  201. Action: "execute",
  202. Description: fmt.Sprintf("Execute command: %s", params.Command),
  203. Params: BashPermissionsParams(params),
  204. },
  205. )
  206. if err != nil {
  207. return fantasy.ToolResponse{}, err
  208. }
  209. if !p {
  210. return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
  211. }
  212. }
  213. // If explicitly requested as background, start immediately with detached context
  214. if params.RunInBackground {
  215. startTime := time.Now()
  216. bgManager := shell.GetBackgroundShellManager()
  217. bgManager.Cleanup()
  218. // Use background context so it continues after tool returns
  219. bgShell, err := bgManager.Start(context.Background(), execWorkingDir, blockFuncs(), params.Command, params.Description)
  220. if err != nil {
  221. return fantasy.ToolResponse{}, fmt.Errorf("error starting background shell: %w", err)
  222. }
  223. // Wait a short time to detect fast failures (blocked commands, syntax errors, etc.)
  224. time.Sleep(1 * time.Second)
  225. stdout, stderr, done, execErr := bgShell.GetOutput()
  226. if done {
  227. // Command failed or completed very quickly
  228. bgManager.Remove(bgShell.ID)
  229. interrupted := shell.IsInterrupt(execErr)
  230. exitCode := shell.ExitCode(execErr)
  231. if exitCode == 0 && !interrupted && execErr != nil {
  232. return fantasy.ToolResponse{}, fmt.Errorf("[Job %s] error executing command: %w", bgShell.ID, execErr)
  233. }
  234. stdout = formatOutput(stdout, stderr, execErr)
  235. metadata := BashResponseMetadata{
  236. StartTime: startTime.UnixMilli(),
  237. EndTime: time.Now().UnixMilli(),
  238. Output: stdout,
  239. Description: params.Description,
  240. Background: params.RunInBackground,
  241. WorkingDirectory: bgShell.WorkingDir,
  242. }
  243. if stdout == "" {
  244. return fantasy.WithResponseMetadata(fantasy.NewTextResponse(BashNoOutput), metadata), nil
  245. }
  246. stdout += fmt.Sprintf("\n\n<cwd>%s</cwd>", normalizeWorkingDir(bgShell.WorkingDir))
  247. return fantasy.WithResponseMetadata(fantasy.NewTextResponse(stdout), metadata), nil
  248. }
  249. // Still running after fast-failure check - return as background job
  250. metadata := BashResponseMetadata{
  251. StartTime: startTime.UnixMilli(),
  252. EndTime: time.Now().UnixMilli(),
  253. Description: params.Description,
  254. WorkingDirectory: bgShell.WorkingDir,
  255. Background: true,
  256. ShellID: bgShell.ID,
  257. }
  258. response := fmt.Sprintf("Background shell started with ID: %s\n\nUse job_output tool to view output or job_kill to terminate.", bgShell.ID)
  259. return fantasy.WithResponseMetadata(fantasy.NewTextResponse(response), metadata), nil
  260. }
  261. // Start synchronous execution with auto-background support
  262. startTime := time.Now()
  263. // Start with detached context so it can survive if moved to background
  264. bgManager := shell.GetBackgroundShellManager()
  265. bgManager.Cleanup()
  266. bgShell, err := bgManager.Start(context.Background(), execWorkingDir, blockFuncs(), params.Command, params.Description)
  267. if err != nil {
  268. return fantasy.ToolResponse{}, fmt.Errorf("error starting shell: %w", err)
  269. }
  270. // Wait for either completion, auto-background threshold, or context cancellation
  271. ticker := time.NewTicker(100 * time.Millisecond)
  272. defer ticker.Stop()
  273. autoBackgroundAfter := cmp.Or(params.AutoBackgroundAfter, DefaultAutoBackgroundAfter)
  274. autoBackgroundThreshold := time.Duration(autoBackgroundAfter) * time.Second
  275. timeout := time.After(autoBackgroundThreshold)
  276. var stdout, stderr string
  277. var done bool
  278. var execErr error
  279. waitLoop:
  280. for {
  281. select {
  282. case <-ticker.C:
  283. stdout, stderr, done, execErr = bgShell.GetOutput()
  284. if done {
  285. break waitLoop
  286. }
  287. case <-timeout:
  288. stdout, stderr, done, execErr = bgShell.GetOutput()
  289. break waitLoop
  290. case <-ctx.Done():
  291. // Incoming context was cancelled before we moved to background
  292. // Kill the shell and return error
  293. bgManager.Kill(bgShell.ID)
  294. return fantasy.ToolResponse{}, ctx.Err()
  295. }
  296. }
  297. if done {
  298. // Command completed within threshold - return synchronously
  299. // Remove from background manager since we're returning directly
  300. // Don't call Kill() as it cancels the context and corrupts the exit code
  301. bgManager.Remove(bgShell.ID)
  302. interrupted := shell.IsInterrupt(execErr)
  303. exitCode := shell.ExitCode(execErr)
  304. if exitCode == 0 && !interrupted && execErr != nil {
  305. return fantasy.ToolResponse{}, fmt.Errorf("[Job %s] error executing command: %w", bgShell.ID, execErr)
  306. }
  307. stdout = formatOutput(stdout, stderr, execErr)
  308. metadata := BashResponseMetadata{
  309. StartTime: startTime.UnixMilli(),
  310. EndTime: time.Now().UnixMilli(),
  311. Output: stdout,
  312. Description: params.Description,
  313. Background: params.RunInBackground,
  314. WorkingDirectory: bgShell.WorkingDir,
  315. }
  316. if stdout == "" {
  317. return fantasy.WithResponseMetadata(fantasy.NewTextResponse(BashNoOutput), metadata), nil
  318. }
  319. stdout += fmt.Sprintf("\n\n<cwd>%s</cwd>", normalizeWorkingDir(bgShell.WorkingDir))
  320. return fantasy.WithResponseMetadata(fantasy.NewTextResponse(stdout), metadata), nil
  321. }
  322. // Still running - keep as background job
  323. metadata := BashResponseMetadata{
  324. StartTime: startTime.UnixMilli(),
  325. EndTime: time.Now().UnixMilli(),
  326. Description: params.Description,
  327. WorkingDirectory: bgShell.WorkingDir,
  328. Background: true,
  329. ShellID: bgShell.ID,
  330. }
  331. 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)
  332. return fantasy.WithResponseMetadata(fantasy.NewTextResponse(response), metadata), nil
  333. })
  334. }
  335. // formatOutput formats the output of a completed command with error handling
  336. func formatOutput(stdout, stderr string, execErr error) string {
  337. interrupted := shell.IsInterrupt(execErr)
  338. exitCode := shell.ExitCode(execErr)
  339. stdout = truncateOutput(stdout)
  340. stderr = truncateOutput(stderr)
  341. errorMessage := stderr
  342. if errorMessage == "" && execErr != nil {
  343. errorMessage = execErr.Error()
  344. }
  345. if interrupted {
  346. if errorMessage != "" {
  347. errorMessage += "\n"
  348. }
  349. errorMessage += "Command was aborted before completion"
  350. } else if exitCode != 0 {
  351. if errorMessage != "" {
  352. errorMessage += "\n"
  353. }
  354. errorMessage += fmt.Sprintf("Exit code %d", exitCode)
  355. }
  356. hasBothOutputs := stdout != "" && stderr != ""
  357. if hasBothOutputs {
  358. stdout += "\n"
  359. }
  360. if errorMessage != "" {
  361. stdout += "\n" + errorMessage
  362. }
  363. return stdout
  364. }
  365. func truncateOutput(content string) string {
  366. if len(content) <= MaxOutputLength {
  367. return content
  368. }
  369. halfLength := MaxOutputLength / 2
  370. start := content[:halfLength]
  371. end := content[len(content)-halfLength:]
  372. truncatedLinesCount := countLines(content[halfLength : len(content)-halfLength])
  373. return fmt.Sprintf("%s\n\n... [%d lines truncated] ...\n\n%s", start, truncatedLinesCount, end)
  374. }
  375. func countLines(s string) int {
  376. if s == "" {
  377. return 0
  378. }
  379. return len(strings.Split(s, "\n"))
  380. }
  381. func normalizeWorkingDir(path string) string {
  382. if runtime.GOOS == "windows" {
  383. path = strings.ReplaceAll(path, fsext.WindowsWorkingDirDrive(), "")
  384. }
  385. return filepath.ToSlash(path)
  386. }