bash.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  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 := permissions.Request(
  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 !p {
  205. return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
  206. }
  207. }
  208. // If explicitly requested as background, start immediately with detached context
  209. if params.RunInBackground {
  210. startTime := time.Now()
  211. bgManager := shell.GetBackgroundShellManager()
  212. bgManager.Cleanup()
  213. // Use background context so it continues after tool returns
  214. bgShell, err := bgManager.Start(context.Background(), execWorkingDir, blockFuncs(), params.Command, params.Description)
  215. if err != nil {
  216. return fantasy.ToolResponse{}, fmt.Errorf("error starting background shell: %w", err)
  217. }
  218. // Wait a short time to detect fast failures (blocked commands, syntax errors, etc.)
  219. time.Sleep(1 * time.Second)
  220. stdout, stderr, done, execErr := bgShell.GetOutput()
  221. if done {
  222. // Command failed or completed very quickly
  223. bgManager.Remove(bgShell.ID)
  224. interrupted := shell.IsInterrupt(execErr)
  225. exitCode := shell.ExitCode(execErr)
  226. if exitCode == 0 && !interrupted && execErr != nil {
  227. return fantasy.ToolResponse{}, fmt.Errorf("[Job %s] error executing command: %w", bgShell.ID, execErr)
  228. }
  229. stdout = formatOutput(stdout, stderr, execErr)
  230. metadata := BashResponseMetadata{
  231. StartTime: startTime.UnixMilli(),
  232. EndTime: time.Now().UnixMilli(),
  233. Output: stdout,
  234. Description: params.Description,
  235. Background: params.RunInBackground,
  236. WorkingDirectory: bgShell.WorkingDir,
  237. }
  238. if stdout == "" {
  239. return fantasy.WithResponseMetadata(fantasy.NewTextResponse(BashNoOutput), metadata), nil
  240. }
  241. stdout += fmt.Sprintf("\n\n<cwd>%s</cwd>", normalizeWorkingDir(bgShell.WorkingDir))
  242. return fantasy.WithResponseMetadata(fantasy.NewTextResponse(stdout), metadata), nil
  243. }
  244. // Still running after fast-failure check - return as background job
  245. metadata := BashResponseMetadata{
  246. StartTime: startTime.UnixMilli(),
  247. EndTime: time.Now().UnixMilli(),
  248. Description: params.Description,
  249. WorkingDirectory: bgShell.WorkingDir,
  250. Background: true,
  251. ShellID: bgShell.ID,
  252. }
  253. response := fmt.Sprintf("Background shell started with ID: %s\n\nUse job_output tool to view output or job_kill to terminate.", bgShell.ID)
  254. return fantasy.WithResponseMetadata(fantasy.NewTextResponse(response), metadata), nil
  255. }
  256. // Start synchronous execution with auto-background support
  257. startTime := time.Now()
  258. // Start with detached context so it can survive if moved to background
  259. bgManager := shell.GetBackgroundShellManager()
  260. bgManager.Cleanup()
  261. bgShell, err := bgManager.Start(context.Background(), execWorkingDir, blockFuncs(), params.Command, params.Description)
  262. if err != nil {
  263. return fantasy.ToolResponse{}, fmt.Errorf("error starting shell: %w", err)
  264. }
  265. // Wait for either completion, auto-background threshold, or context cancellation
  266. ticker := time.NewTicker(100 * time.Millisecond)
  267. defer ticker.Stop()
  268. timeout := time.After(AutoBackgroundThreshold)
  269. var stdout, stderr string
  270. var done bool
  271. var execErr error
  272. waitLoop:
  273. for {
  274. select {
  275. case <-ticker.C:
  276. stdout, stderr, done, execErr = bgShell.GetOutput()
  277. if done {
  278. break waitLoop
  279. }
  280. case <-timeout:
  281. stdout, stderr, done, execErr = bgShell.GetOutput()
  282. break waitLoop
  283. case <-ctx.Done():
  284. // Incoming context was cancelled before we moved to background
  285. // Kill the shell and return error
  286. bgManager.Kill(bgShell.ID)
  287. return fantasy.ToolResponse{}, ctx.Err()
  288. }
  289. }
  290. if done {
  291. // Command completed within threshold - return synchronously
  292. // Remove from background manager since we're returning directly
  293. // Don't call Kill() as it cancels the context and corrupts the exit code
  294. bgManager.Remove(bgShell.ID)
  295. interrupted := shell.IsInterrupt(execErr)
  296. exitCode := shell.ExitCode(execErr)
  297. if exitCode == 0 && !interrupted && execErr != nil {
  298. return fantasy.ToolResponse{}, fmt.Errorf("[Job %s] error executing command: %w", bgShell.ID, execErr)
  299. }
  300. stdout = formatOutput(stdout, stderr, execErr)
  301. metadata := BashResponseMetadata{
  302. StartTime: startTime.UnixMilli(),
  303. EndTime: time.Now().UnixMilli(),
  304. Output: stdout,
  305. Description: params.Description,
  306. Background: params.RunInBackground,
  307. WorkingDirectory: bgShell.WorkingDir,
  308. }
  309. if stdout == "" {
  310. return fantasy.WithResponseMetadata(fantasy.NewTextResponse(BashNoOutput), metadata), nil
  311. }
  312. stdout += fmt.Sprintf("\n\n<cwd>%s</cwd>", normalizeWorkingDir(bgShell.WorkingDir))
  313. return fantasy.WithResponseMetadata(fantasy.NewTextResponse(stdout), metadata), nil
  314. }
  315. // Still running - keep as background job
  316. metadata := BashResponseMetadata{
  317. StartTime: startTime.UnixMilli(),
  318. EndTime: time.Now().UnixMilli(),
  319. Description: params.Description,
  320. WorkingDirectory: bgShell.WorkingDir,
  321. Background: true,
  322. ShellID: bgShell.ID,
  323. }
  324. 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)
  325. return fantasy.WithResponseMetadata(fantasy.NewTextResponse(response), metadata), nil
  326. })
  327. }
  328. // formatOutput formats the output of a completed command with error handling
  329. func formatOutput(stdout, stderr string, execErr error) string {
  330. interrupted := shell.IsInterrupt(execErr)
  331. exitCode := shell.ExitCode(execErr)
  332. stdout = truncateOutput(stdout)
  333. stderr = truncateOutput(stderr)
  334. errorMessage := stderr
  335. if errorMessage == "" && execErr != nil {
  336. errorMessage = execErr.Error()
  337. }
  338. if interrupted {
  339. if errorMessage != "" {
  340. errorMessage += "\n"
  341. }
  342. errorMessage += "Command was aborted before completion"
  343. } else if exitCode != 0 {
  344. if errorMessage != "" {
  345. errorMessage += "\n"
  346. }
  347. errorMessage += fmt.Sprintf("Exit code %d", exitCode)
  348. }
  349. hasBothOutputs := stdout != "" && stderr != ""
  350. if hasBothOutputs {
  351. stdout += "\n"
  352. }
  353. if errorMessage != "" {
  354. stdout += "\n" + errorMessage
  355. }
  356. return stdout
  357. }
  358. func truncateOutput(content string) string {
  359. if len(content) <= MaxOutputLength {
  360. return content
  361. }
  362. halfLength := MaxOutputLength / 2
  363. start := content[:halfLength]
  364. end := content[len(content)-halfLength:]
  365. truncatedLinesCount := countLines(content[halfLength : len(content)-halfLength])
  366. return fmt.Sprintf("%s\n\n... [%d lines truncated] ...\n\n%s", start, truncatedLinesCount, end)
  367. }
  368. func countLines(s string) int {
  369. if s == "" {
  370. return 0
  371. }
  372. return len(strings.Split(s, "\n"))
  373. }
  374. func normalizeWorkingDir(path string) string {
  375. if runtime.GOOS == "windows" {
  376. cwd, err := os.Getwd()
  377. if err != nil {
  378. cwd = "C:"
  379. }
  380. path = strings.ReplaceAll(path, filepath.VolumeName(cwd), "")
  381. }
  382. return filepath.ToSlash(path)
  383. }