bash.go

  1package tools
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"fmt"
  7	"strings"
  8	"time"
  9
 10	"github.com/charmbracelet/crush/internal/config"
 11	"github.com/charmbracelet/crush/internal/permission"
 12	"github.com/charmbracelet/crush/internal/shell"
 13)
 14
 15type BashParams struct {
 16	Command string `json:"command"`
 17	Timeout int    `json:"timeout"`
 18}
 19
 20type BashPermissionsParams struct {
 21	Command string `json:"command"`
 22	Timeout int    `json:"timeout"`
 23}
 24
 25type BashResponseMetadata struct {
 26	StartTime        int64  `json:"start_time"`
 27	EndTime          int64  `json:"end_time"`
 28	Output           string `json:"output"`
 29	WorkingDirectory string `json:"working_directory"`
 30}
 31type bashTool struct {
 32	permissions permission.Service
 33	workingDir  string
 34	attribution *config.Attribution
 35}
 36
 37const (
 38	BashToolName = "bash"
 39
 40	DefaultTimeout  = 1 * 60 * 1000  // 1 minutes in milliseconds
 41	MaxTimeout      = 10 * 60 * 1000 // 10 minutes in milliseconds
 42	MaxOutputLength = 30000
 43	BashNoOutput    = "no output"
 44)
 45
 46var bannedCommands = []string{
 47	// Network/Download tools
 48	"alias",
 49	"aria2c",
 50	"axel",
 51	"chrome",
 52	"curl",
 53	"curlie",
 54	"firefox",
 55	"http-prompt",
 56	"httpie",
 57	"links",
 58	"lynx",
 59	"nc",
 60	"safari",
 61	"scp",
 62	"ssh",
 63	"telnet",
 64	"w3m",
 65	"wget",
 66	"xh",
 67
 68	// System administration
 69	"doas",
 70	"su",
 71	"sudo",
 72
 73	// Package managers
 74	"apk",
 75	"apt",
 76	"apt-cache",
 77	"apt-get",
 78	"dnf",
 79	"dpkg",
 80	"emerge",
 81	"home-manager",
 82	"makepkg",
 83	"opkg",
 84	"pacman",
 85	"paru",
 86	"pkg",
 87	"pkg_add",
 88	"pkg_delete",
 89	"portage",
 90	"rpm",
 91	"yay",
 92	"yum",
 93	"zypper",
 94
 95	// System modification
 96	"at",
 97	"batch",
 98	"chkconfig",
 99	"crontab",
100	"fdisk",
101	"mkfs",
102	"mount",
103	"parted",
104	"service",
105	"systemctl",
106	"umount",
107
108	// Network configuration
109	"firewall-cmd",
110	"ifconfig",
111	"ip",
112	"iptables",
113	"netstat",
114	"pfctl",
115	"route",
116	"ufw",
117}
118
119func (b *bashTool) bashDescription() string {
120	bannedCommandsStr := strings.Join(bannedCommands, ", ")
121
122	// Build attribution text based on settings
123	var attributionStep, attributionExample, prAttribution string
124
125	// Default to true if attribution is nil (backward compatibility)
126	generatedWith := b.attribution == nil || b.attribution.GeneratedWith
127	coAuthoredBy := b.attribution == nil || b.attribution.CoAuthoredBy
128
129	// Build PR attribution
130	if generatedWith {
131		prAttribution = "💘 Generated with Crush"
132	}
133
134	if generatedWith || coAuthoredBy {
135		attributionParts := []string{}
136		if generatedWith {
137			attributionParts = append(attributionParts, "💘 Generated with Crush")
138		}
139		if coAuthoredBy {
140			attributionParts = append(attributionParts, "Co-Authored-By: Crush <crush@charm.land>")
141		}
142
143		if len(attributionParts) > 0 {
144			attributionStep = fmt.Sprintf("4. Create the commit with a message ending with:\n%s", strings.Join(attributionParts, "\n"))
145
146			attributionText := strings.Join(attributionParts, "\n ")
147			attributionExample = fmt.Sprintf(`<example>
148git commit -m "$(cat <<'EOF'
149 Commit message here.
150
151 %s
152 EOF
153)"</example>`, attributionText)
154		}
155	}
156
157	if attributionStep == "" {
158		attributionStep = "4. Create the commit with your commit message."
159		attributionExample = `<example>
160git commit -m "$(cat <<'EOF'
161 Commit message here.
162 EOF
163)"</example>`
164	}
165
166	return fmt.Sprintf(`Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
167
168CROSS-PLATFORM SHELL SUPPORT:
169* This tool uses a shell interpreter (mvdan/sh) that mimics the Bash language,
170  so you should use Bash syntax in all platforms, including Windows.
171  The most common shell builtins and core utils are available in Windows as
172  well.
173* Make sure to use forward slashes (/) as path separators in commands, even on
174  Windows. Example: "ls C:/foo/bar" instead of "ls C:\foo\bar".
175
176Before executing the command, please follow these steps:
177
1781. Directory Verification:
179 - If the command will create new directories or files, first use the LS tool to verify the parent directory exists and is the correct location
180 - For example, before running "mkdir foo/bar", first use LS to check that "foo" exists and is the intended parent directory
181
1822. Security Check:
183 - For security and to limit the threat of a prompt injection attack, some commands are limited or banned. If you use a disallowed command, you will receive an error message explaining the restriction. Explain the error to the User.
184 - Verify that the command is not one of the banned commands: %s.
185
1863. Command Execution:
187 - After ensuring proper quoting, execute the command.
188 - Capture the output of the command.
189
1904. Output Processing:
191 - If the output exceeds %d characters, output will be truncated before being returned to you.
192 - Prepare the output for display to the user.
193
1945. Return Result:
195 - Provide the processed output of the command.
196 - If any errors occurred during execution, include those in the output.
197 - The result will also have metadata like the cwd (current working directory) at the end, included with <cwd></cwd> tags.
198
199Usage notes:
200- The command argument is required.
201- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 30 minutes.
202- VERY IMPORTANT: You MUST avoid using search commands like 'find' and 'grep'. Instead use Grep, Glob, or Agent tools to search. You MUST avoid read tools like 'cat', 'head', 'tail', and 'ls', and use FileRead and LS tools to read files.
203- When issuing multiple commands, use the ';' or '&&' operator to separate them. DO NOT use newlines (newlines are ok in quoted strings).
204- IMPORTANT: All commands share the same shell session. Shell state (environment variables, virtual environments, current directory, etc.) persist between commands. For example, if you set an environment variable as part of a command, the environment variable will persist for subsequent commands.
205- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of 'cd'. You may use 'cd' if the User explicitly requests it.
206<good-example>
207pytest /foo/bar/tests
208</good-example>
209<bad-example>
210cd /foo/bar && pytest tests
211</bad-example>
212
213# Committing changes with git
214
215When the user asks you to create a new git commit, follow these steps carefully:
216
2171. Start with a single message that contains exactly three tool_use blocks that do the following (it is VERY IMPORTANT that you send these tool_use blocks in a single message, otherwise it will feel slow to the user!):
218 - Run a git status command to see all untracked files.
219 - Run a git diff command to see both staged and unstaged changes that will be committed.
220 - Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.
221
2222. Use the git context at the start of this conversation to determine which files are relevant to your commit. Add relevant untracked files to the staging area. Do not commit files that were already modified at the start of this conversation, if they are not relevant to your commit.
223
2243. Analyze all staged changes (both previously staged and newly added) and draft a commit message. Wrap your analysis process in <commit_analysis> tags:
225
226<commit_analysis>
227- List the files that have been changed or added
228- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.)
229- Brainstorm the purpose or motivation behind these changes
230- Do not use tools to explore code, beyond what is available in the git context
231- Assess the impact of these changes on the overall project
232- Check for any sensitive information that shouldn't be committed
233- Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what"
234- Ensure your language is clear, concise, and to the point
235- Ensure the message accurately reflects the changes and their purpose (i.e. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.)
236- Ensure the message is not generic (avoid words like "Update" or "Fix" without context)
237- Review the draft message to ensure it accurately reflects the changes and their purpose
238</commit_analysis>
239
240%s
241
242- In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example:
243%s
244
2455. If the commit fails due to pre-commit hook changes, retry the commit ONCE to include these automated changes. If it fails again, it usually means a pre-commit hook is preventing the commit. If the commit succeeds but you notice that files were modified by the pre-commit hook, you MUST amend your commit to include them.
246
2476. Finally, run git status to make sure the commit succeeded.
248
249Important notes:
250- When possible, combine the "git add" and "git commit" commands into a single "git commit -am" command, to speed things up
251- However, be careful not to stage files (e.g. with 'git add .') for commits that aren't part of the change, they may have untracked files they want to keep around, but not commit.
252- NEVER update the git config
253- DO NOT push to the remote repository
254- IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported.
255- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit
256- Ensure your commit message is meaningful and concise. It should explain the purpose of the changes, not just describe them.
257- Return an empty response - the user will see the git output directly
258
259# Creating pull requests
260Use the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a Github URL use the gh command to get the information needed.
261
262IMPORTANT: When the user asks you to create a pull request, follow these steps carefully:
263
2641. Understand the current state of the branch. Remember to send a single message that contains multiple tool_use blocks (it is VERY IMPORTANT that you do this in a single message, otherwise it will feel slow to the user!):
265 - Run a git status command to see all untracked files.
266 - Run a git diff command to see both staged and unstaged changes that will be committed.
267 - Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote
268 - Run a git log command and 'git diff main...HEAD' to understand the full commit history for the current branch (from the time it diverged from the 'main' branch.)
269
2702. Create new branch if needed
271
2723. Commit changes if needed
273
2744. Push to remote with -u flag if needed
275
2765. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (not just the latest commit, but all commits that will be included in the pull request!), and draft a pull request summary. Wrap your analysis process in <pr_analysis> tags:
277
278<pr_analysis>
279- List the commits since diverging from the main branch
280- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.)
281- Brainstorm the purpose or motivation behind these changes
282- Assess the impact of these changes on the overall project
283- Do not use tools to explore code, beyond what is available in the git context
284- Check for any sensitive information that shouldn't be committed
285- Draft a concise (1-2 bullet points) pull request summary that focuses on the "why" rather than the "what"
286- Ensure the summary accurately reflects all changes since diverging from the main branch
287- Ensure your language is clear, concise, and to the point
288- Ensure the summary accurately reflects the changes and their purpose (ie. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.)
289- Ensure the summary is not generic (avoid words like "Update" or "Fix" without context)
290- Review the draft summary to ensure it accurately reflects the changes and their purpose
291</pr_analysis>
292
2936. Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.
294<example>
295gh pr create --title "the pr title" --body "$(cat <<'EOF'
296## Summary
297<1-3 bullet points>
298
299## Test plan
300[Checklist of TODOs for testing the pull request...]
301
302%s
303EOF
304)"
305</example>
306
307Important:
308- Return an empty response - the user will see the gh output directly
309- Never update git config`, bannedCommandsStr, MaxOutputLength, attributionStep, attributionExample, prAttribution)
310}
311
312func blockFuncs() []shell.BlockFunc {
313	return []shell.BlockFunc{
314		shell.CommandsBlocker(bannedCommands),
315
316		// System package managers
317		shell.ArgumentsBlocker("apk", []string{"add"}, nil),
318		shell.ArgumentsBlocker("apt", []string{"install"}, nil),
319		shell.ArgumentsBlocker("apt-get", []string{"install"}, nil),
320		shell.ArgumentsBlocker("dnf", []string{"install"}, nil),
321		shell.ArgumentsBlocker("pacman", nil, []string{"-S"}),
322		shell.ArgumentsBlocker("pkg", []string{"install"}, nil),
323		shell.ArgumentsBlocker("yum", []string{"install"}, nil),
324		shell.ArgumentsBlocker("zypper", []string{"install"}, nil),
325
326		// Language-specific package managers
327		shell.ArgumentsBlocker("brew", []string{"install"}, nil),
328		shell.ArgumentsBlocker("cargo", []string{"install"}, nil),
329		shell.ArgumentsBlocker("gem", []string{"install"}, nil),
330		shell.ArgumentsBlocker("go", []string{"install"}, nil),
331		shell.ArgumentsBlocker("npm", []string{"install"}, []string{"--global"}),
332		shell.ArgumentsBlocker("npm", []string{"install"}, []string{"-g"}),
333		shell.ArgumentsBlocker("pip", []string{"install"}, []string{"--user"}),
334		shell.ArgumentsBlocker("pip3", []string{"install"}, []string{"--user"}),
335		shell.ArgumentsBlocker("pnpm", []string{"add"}, []string{"--global"}),
336		shell.ArgumentsBlocker("pnpm", []string{"add"}, []string{"-g"}),
337		shell.ArgumentsBlocker("yarn", []string{"global", "add"}, nil),
338
339		// `go test -exec` can run arbitrary commands
340		shell.ArgumentsBlocker("go", []string{"test"}, []string{"-exec"}),
341	}
342}
343
344func NewBashTool(permission permission.Service, workingDir string, attribution *config.Attribution) BaseTool {
345	// Set up command blocking on the persistent shell
346	persistentShell := shell.GetPersistentShell(workingDir)
347	persistentShell.SetBlockFuncs(blockFuncs())
348
349	return &bashTool{
350		permissions: permission,
351		workingDir:  workingDir,
352		attribution: attribution,
353	}
354}
355
356func (b *bashTool) Name() string {
357	return BashToolName
358}
359
360func (b *bashTool) Info() ToolInfo {
361	return ToolInfo{
362		Name:        BashToolName,
363		Description: b.bashDescription(),
364		Parameters: map[string]any{
365			"command": map[string]any{
366				"type":        "string",
367				"description": "The command to execute",
368			},
369			"timeout": map[string]any{
370				"type":        "number",
371				"description": "Optional timeout in milliseconds (max 600000)",
372			},
373		},
374		Required: []string{"command"},
375	}
376}
377
378func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
379	var params BashParams
380	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
381		return NewTextErrorResponse("invalid parameters"), nil
382	}
383
384	if params.Timeout > MaxTimeout {
385		params.Timeout = MaxTimeout
386	} else if params.Timeout <= 0 {
387		params.Timeout = DefaultTimeout
388	}
389
390	if params.Command == "" {
391		return NewTextErrorResponse("missing command"), nil
392	}
393
394	isSafeReadOnly := false
395	cmdLower := strings.ToLower(params.Command)
396
397	for _, safe := range safeCommands {
398		if strings.HasPrefix(cmdLower, safe) {
399			if len(cmdLower) == len(safe) || cmdLower[len(safe)] == ' ' || cmdLower[len(safe)] == '-' {
400				isSafeReadOnly = true
401				break
402			}
403		}
404	}
405
406	sessionID, messageID := GetContextValues(ctx)
407	if sessionID == "" || messageID == "" {
408		return ToolResponse{}, fmt.Errorf("session ID and message ID are required for executing shell command")
409	}
410	if !isSafeReadOnly {
411		shell := shell.GetPersistentShell(b.workingDir)
412		p := b.permissions.Request(
413			permission.CreatePermissionRequest{
414				SessionID:   sessionID,
415				Path:        shell.GetWorkingDir(),
416				ToolCallID:  call.ID,
417				ToolName:    BashToolName,
418				Action:      "execute",
419				Description: fmt.Sprintf("Execute command: %s", params.Command),
420				Params: BashPermissionsParams{
421					Command: params.Command,
422				},
423			},
424		)
425		if !p {
426			return ToolResponse{}, permission.ErrorPermissionDenied
427		}
428	}
429	startTime := time.Now()
430	if params.Timeout > 0 {
431		var cancel context.CancelFunc
432		ctx, cancel = context.WithTimeout(ctx, time.Duration(params.Timeout)*time.Millisecond)
433		defer cancel()
434	}
435
436	persistentShell := shell.GetPersistentShell(b.workingDir)
437	stdout, stderr, err := persistentShell.Exec(ctx, params.Command)
438
439	// Get the current working directory after command execution
440	currentWorkingDir := persistentShell.GetWorkingDir()
441	interrupted := shell.IsInterrupt(err)
442	exitCode := shell.ExitCode(err)
443	if exitCode == 0 && !interrupted && err != nil {
444		return ToolResponse{}, fmt.Errorf("error executing command: %w", err)
445	}
446
447	stdout = truncateOutput(stdout)
448	stderr = truncateOutput(stderr)
449
450	errorMessage := stderr
451	if errorMessage == "" && err != nil {
452		errorMessage = err.Error()
453	}
454
455	if interrupted {
456		if errorMessage != "" {
457			errorMessage += "\n"
458		}
459		errorMessage += "Command was aborted before completion"
460	} else if exitCode != 0 {
461		if errorMessage != "" {
462			errorMessage += "\n"
463		}
464		errorMessage += fmt.Sprintf("Exit code %d", exitCode)
465	}
466
467	hasBothOutputs := stdout != "" && stderr != ""
468
469	if hasBothOutputs {
470		stdout += "\n"
471	}
472
473	if errorMessage != "" {
474		stdout += "\n" + errorMessage
475	}
476
477	metadata := BashResponseMetadata{
478		StartTime:        startTime.UnixMilli(),
479		EndTime:          time.Now().UnixMilli(),
480		Output:           stdout,
481		WorkingDirectory: currentWorkingDir,
482	}
483	if stdout == "" {
484		return WithResponseMetadata(NewTextResponse(BashNoOutput), metadata), nil
485	}
486	stdout += fmt.Sprintf("\n\n<cwd>%s</cwd>", currentWorkingDir)
487	return WithResponseMetadata(NewTextResponse(stdout), metadata), nil
488}
489
490func truncateOutput(content string) string {
491	if len(content) <= MaxOutputLength {
492		return content
493	}
494
495	halfLength := MaxOutputLength / 2
496	start := content[:halfLength]
497	end := content[len(content)-halfLength:]
498
499	truncatedLinesCount := countLines(content[halfLength : len(content)-halfLength])
500	return fmt.Sprintf("%s\n\n... [%d lines truncated] ...\n\n%s", start, truncatedLinesCount, end)
501}
502
503func countLines(s string) int {
504	if s == "" {
505		return 0
506	}
507	return len(strings.Split(s, "\n"))
508}