| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439 |
- package tools
- import (
- "bytes"
- "cmp"
- "context"
- _ "embed"
- "fmt"
- "html/template"
- "os"
- "path/filepath"
- "runtime"
- "strings"
- "time"
- "charm.land/fantasy"
- "github.com/charmbracelet/crush/internal/config"
- "github.com/charmbracelet/crush/internal/permission"
- "github.com/charmbracelet/crush/internal/shell"
- )
- type BashParams struct {
- Description string `json:"description" description:"A brief description of what the command does, try to keep it under 30 characters or so"`
- Command string `json:"command" description:"The command to execute"`
- WorkingDir string `json:"working_dir,omitempty" description:"The working directory to execute the command in (defaults to current directory)"`
- 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."`
- }
- type BashPermissionsParams struct {
- Description string `json:"description"`
- Command string `json:"command"`
- WorkingDir string `json:"working_dir"`
- RunInBackground bool `json:"run_in_background"`
- }
- type BashResponseMetadata struct {
- StartTime int64 `json:"start_time"`
- EndTime int64 `json:"end_time"`
- Output string `json:"output"`
- Description string `json:"description"`
- WorkingDirectory string `json:"working_directory"`
- Background bool `json:"background,omitempty"`
- ShellID string `json:"shell_id,omitempty"`
- }
- const (
- BashToolName = "bash"
- AutoBackgroundThreshold = 1 * time.Minute // Commands taking longer automatically become background jobs
- MaxOutputLength = 30000
- BashNoOutput = "no output"
- )
- //go:embed bash.tpl
- var bashDescriptionTmpl []byte
- var bashDescriptionTpl = template.Must(
- template.New("bashDescription").
- Parse(string(bashDescriptionTmpl)),
- )
- type bashDescriptionData struct {
- BannedCommands string
- MaxOutputLength int
- Attribution config.Attribution
- ModelName string
- }
- var bannedCommands = []string{
- // Network/Download tools
- "alias",
- "aria2c",
- "axel",
- "chrome",
- "curl",
- "curlie",
- "firefox",
- "http-prompt",
- "httpie",
- "links",
- "lynx",
- "nc",
- "safari",
- "scp",
- "ssh",
- "telnet",
- "w3m",
- "wget",
- "xh",
- // System administration
- "doas",
- "su",
- "sudo",
- // Package managers
- "apk",
- "apt",
- "apt-cache",
- "apt-get",
- "dnf",
- "dpkg",
- "emerge",
- "home-manager",
- "makepkg",
- "opkg",
- "pacman",
- "paru",
- "pkg",
- "pkg_add",
- "pkg_delete",
- "portage",
- "rpm",
- "yay",
- "yum",
- "zypper",
- // System modification
- "at",
- "batch",
- "chkconfig",
- "crontab",
- "fdisk",
- "mkfs",
- "mount",
- "parted",
- "service",
- "systemctl",
- "umount",
- // Network configuration
- "firewall-cmd",
- "ifconfig",
- "ip",
- "iptables",
- "netstat",
- "pfctl",
- "route",
- "ufw",
- }
- func bashDescription(attribution *config.Attribution, modelName string) string {
- bannedCommandsStr := strings.Join(bannedCommands, ", ")
- var out bytes.Buffer
- if err := bashDescriptionTpl.Execute(&out, bashDescriptionData{
- BannedCommands: bannedCommandsStr,
- MaxOutputLength: MaxOutputLength,
- Attribution: *attribution,
- ModelName: modelName,
- }); err != nil {
- // this should never happen.
- panic("failed to execute bash description template: " + err.Error())
- }
- return out.String()
- }
- func blockFuncs() []shell.BlockFunc {
- return []shell.BlockFunc{
- shell.CommandsBlocker(bannedCommands),
- // System package managers
- shell.ArgumentsBlocker("apk", []string{"add"}, nil),
- shell.ArgumentsBlocker("apt", []string{"install"}, nil),
- shell.ArgumentsBlocker("apt-get", []string{"install"}, nil),
- shell.ArgumentsBlocker("dnf", []string{"install"}, nil),
- shell.ArgumentsBlocker("pacman", nil, []string{"-S"}),
- shell.ArgumentsBlocker("pkg", []string{"install"}, nil),
- shell.ArgumentsBlocker("yum", []string{"install"}, nil),
- shell.ArgumentsBlocker("zypper", []string{"install"}, nil),
- // Language-specific package managers
- shell.ArgumentsBlocker("brew", []string{"install"}, nil),
- shell.ArgumentsBlocker("cargo", []string{"install"}, nil),
- shell.ArgumentsBlocker("gem", []string{"install"}, nil),
- shell.ArgumentsBlocker("go", []string{"install"}, nil),
- shell.ArgumentsBlocker("npm", []string{"install"}, []string{"--global"}),
- shell.ArgumentsBlocker("npm", []string{"install"}, []string{"-g"}),
- shell.ArgumentsBlocker("pip", []string{"install"}, []string{"--user"}),
- shell.ArgumentsBlocker("pip3", []string{"install"}, []string{"--user"}),
- shell.ArgumentsBlocker("pnpm", []string{"add"}, []string{"--global"}),
- shell.ArgumentsBlocker("pnpm", []string{"add"}, []string{"-g"}),
- shell.ArgumentsBlocker("yarn", []string{"global", "add"}, nil),
- // `go test -exec` can run arbitrary commands
- shell.ArgumentsBlocker("go", []string{"test"}, []string{"-exec"}),
- }
- }
- func NewBashTool(permissions permission.Service, workingDir string, attribution *config.Attribution, modelName string) fantasy.AgentTool {
- return fantasy.NewAgentTool(
- BashToolName,
- string(bashDescription(attribution, modelName)),
- func(ctx context.Context, params BashParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
- if params.Command == "" {
- return fantasy.NewTextErrorResponse("missing command"), nil
- }
- // Determine working directory
- execWorkingDir := cmp.Or(params.WorkingDir, workingDir)
- isSafeReadOnly := false
- cmdLower := strings.ToLower(params.Command)
- for _, safe := range safeCommands {
- if strings.HasPrefix(cmdLower, safe) {
- if len(cmdLower) == len(safe) || cmdLower[len(safe)] == ' ' || cmdLower[len(safe)] == '-' {
- isSafeReadOnly = true
- break
- }
- }
- }
- sessionID := GetSessionFromContext(ctx)
- if sessionID == "" {
- return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for executing shell command")
- }
- if !isSafeReadOnly {
- p := permissions.Request(
- permission.CreatePermissionRequest{
- SessionID: sessionID,
- Path: execWorkingDir,
- ToolCallID: call.ID,
- ToolName: BashToolName,
- Action: "execute",
- Description: fmt.Sprintf("Execute command: %s", params.Command),
- Params: BashPermissionsParams(params),
- },
- )
- if !p {
- return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
- }
- }
- // If explicitly requested as background, start immediately with detached context
- if params.RunInBackground {
- startTime := time.Now()
- bgManager := shell.GetBackgroundShellManager()
- bgManager.Cleanup()
- // Use background context so it continues after tool returns
- bgShell, err := bgManager.Start(context.Background(), execWorkingDir, blockFuncs(), params.Command, params.Description)
- if err != nil {
- return fantasy.ToolResponse{}, fmt.Errorf("error starting background shell: %w", err)
- }
- // Wait a short time to detect fast failures (blocked commands, syntax errors, etc.)
- time.Sleep(1 * time.Second)
- stdout, stderr, done, execErr := bgShell.GetOutput()
- if done {
- // Command failed or completed very quickly
- bgManager.Remove(bgShell.ID)
- interrupted := shell.IsInterrupt(execErr)
- exitCode := shell.ExitCode(execErr)
- if exitCode == 0 && !interrupted && execErr != nil {
- return fantasy.ToolResponse{}, fmt.Errorf("[Job %s] error executing command: %w", bgShell.ID, execErr)
- }
- stdout = formatOutput(stdout, stderr, execErr)
- metadata := BashResponseMetadata{
- StartTime: startTime.UnixMilli(),
- EndTime: time.Now().UnixMilli(),
- Output: stdout,
- Description: params.Description,
- Background: params.RunInBackground,
- WorkingDirectory: bgShell.WorkingDir,
- }
- if stdout == "" {
- return fantasy.WithResponseMetadata(fantasy.NewTextResponse(BashNoOutput), metadata), nil
- }
- stdout += fmt.Sprintf("\n\n<cwd>%s</cwd>", normalizeWorkingDir(bgShell.WorkingDir))
- return fantasy.WithResponseMetadata(fantasy.NewTextResponse(stdout), metadata), nil
- }
- // Still running after fast-failure check - return as background job
- metadata := BashResponseMetadata{
- StartTime: startTime.UnixMilli(),
- EndTime: time.Now().UnixMilli(),
- Description: params.Description,
- WorkingDirectory: bgShell.WorkingDir,
- Background: true,
- ShellID: bgShell.ID,
- }
- response := fmt.Sprintf("Background shell started with ID: %s\n\nUse job_output tool to view output or job_kill to terminate.", bgShell.ID)
- return fantasy.WithResponseMetadata(fantasy.NewTextResponse(response), metadata), nil
- }
- // Start synchronous execution with auto-background support
- startTime := time.Now()
- // Start with detached context so it can survive if moved to background
- bgManager := shell.GetBackgroundShellManager()
- bgManager.Cleanup()
- bgShell, err := bgManager.Start(context.Background(), execWorkingDir, blockFuncs(), params.Command, params.Description)
- if err != nil {
- return fantasy.ToolResponse{}, fmt.Errorf("error starting shell: %w", err)
- }
- // Wait for either completion, auto-background threshold, or context cancellation
- ticker := time.NewTicker(100 * time.Millisecond)
- defer ticker.Stop()
- timeout := time.After(AutoBackgroundThreshold)
- var stdout, stderr string
- var done bool
- var execErr error
- waitLoop:
- for {
- select {
- case <-ticker.C:
- stdout, stderr, done, execErr = bgShell.GetOutput()
- if done {
- break waitLoop
- }
- case <-timeout:
- stdout, stderr, done, execErr = bgShell.GetOutput()
- break waitLoop
- case <-ctx.Done():
- // Incoming context was cancelled before we moved to background
- // Kill the shell and return error
- bgManager.Kill(bgShell.ID)
- return fantasy.ToolResponse{}, ctx.Err()
- }
- }
- if done {
- // Command completed within threshold - return synchronously
- // Remove from background manager since we're returning directly
- // Don't call Kill() as it cancels the context and corrupts the exit code
- bgManager.Remove(bgShell.ID)
- interrupted := shell.IsInterrupt(execErr)
- exitCode := shell.ExitCode(execErr)
- if exitCode == 0 && !interrupted && execErr != nil {
- return fantasy.ToolResponse{}, fmt.Errorf("[Job %s] error executing command: %w", bgShell.ID, execErr)
- }
- stdout = formatOutput(stdout, stderr, execErr)
- metadata := BashResponseMetadata{
- StartTime: startTime.UnixMilli(),
- EndTime: time.Now().UnixMilli(),
- Output: stdout,
- Description: params.Description,
- Background: params.RunInBackground,
- WorkingDirectory: bgShell.WorkingDir,
- }
- if stdout == "" {
- return fantasy.WithResponseMetadata(fantasy.NewTextResponse(BashNoOutput), metadata), nil
- }
- stdout += fmt.Sprintf("\n\n<cwd>%s</cwd>", normalizeWorkingDir(bgShell.WorkingDir))
- return fantasy.WithResponseMetadata(fantasy.NewTextResponse(stdout), metadata), nil
- }
- // Still running - keep as background job
- metadata := BashResponseMetadata{
- StartTime: startTime.UnixMilli(),
- EndTime: time.Now().UnixMilli(),
- Description: params.Description,
- WorkingDirectory: bgShell.WorkingDir,
- Background: true,
- ShellID: bgShell.ID,
- }
- 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)
- return fantasy.WithResponseMetadata(fantasy.NewTextResponse(response), metadata), nil
- })
- }
- // formatOutput formats the output of a completed command with error handling
- func formatOutput(stdout, stderr string, execErr error) string {
- interrupted := shell.IsInterrupt(execErr)
- exitCode := shell.ExitCode(execErr)
- stdout = truncateOutput(stdout)
- stderr = truncateOutput(stderr)
- errorMessage := stderr
- if errorMessage == "" && execErr != nil {
- errorMessage = execErr.Error()
- }
- if interrupted {
- if errorMessage != "" {
- errorMessage += "\n"
- }
- errorMessage += "Command was aborted before completion"
- } else if exitCode != 0 {
- if errorMessage != "" {
- errorMessage += "\n"
- }
- errorMessage += fmt.Sprintf("Exit code %d", exitCode)
- }
- hasBothOutputs := stdout != "" && stderr != ""
- if hasBothOutputs {
- stdout += "\n"
- }
- if errorMessage != "" {
- stdout += "\n" + errorMessage
- }
- return stdout
- }
- func truncateOutput(content string) string {
- if len(content) <= MaxOutputLength {
- return content
- }
- halfLength := MaxOutputLength / 2
- start := content[:halfLength]
- end := content[len(content)-halfLength:]
- truncatedLinesCount := countLines(content[halfLength : len(content)-halfLength])
- return fmt.Sprintf("%s\n\n... [%d lines truncated] ...\n\n%s", start, truncatedLinesCount, end)
- }
- func countLines(s string) int {
- if s == "" {
- return 0
- }
- return len(strings.Split(s, "\n"))
- }
- func normalizeWorkingDir(path string) string {
- if runtime.GOOS == "windows" {
- cwd, err := os.Getwd()
- if err != nil {
- cwd = "C:"
- }
- path = strings.ReplaceAll(path, filepath.VolumeName(cwd), "")
- }
- return filepath.ToSlash(path)
- }
|