bash.go 13 KB

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