bash.go

  1package tools
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"fmt"
  7	"log/slog"
  8	"strings"
  9	"time"
 10
 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}
 29type bashTool struct {
 30	permissions permission.Service
 31	workingDir  string
 32}
 33
 34const (
 35	BashToolName = "bash"
 36
 37	DefaultTimeout  = 1 * 60 * 1000  // 1 minutes in milliseconds
 38	MaxTimeout      = 10 * 60 * 1000 // 10 minutes in milliseconds
 39	MaxOutputLength = 30000
 40	BashNoOutput    = "no output"
 41)
 42
 43var bannedCommands = []string{
 44	// Network/Download tools
 45	"alias",
 46	"aria2c",
 47	"axel",
 48	"chrome",
 49	"curl",
 50	"curlie",
 51	"firefox",
 52	"http-prompt",
 53	"httpie",
 54	"links",
 55	"lynx",
 56	"nc",
 57	"safari",
 58	"telnet",
 59	"w3m",
 60	"wget",
 61	"xh",
 62
 63	// System administration
 64	"doas",
 65	"su",
 66	"sudo",
 67
 68	// Package managers
 69	"apk",
 70	"apt",
 71	"apt-cache",
 72	"apt-get",
 73	"dnf",
 74	"dpkg",
 75	"emerge",
 76	"home-manager",
 77	"makepkg",
 78	"opkg",
 79	"pacman",
 80	"paru",
 81	"pkg",
 82	"pkg_add",
 83	"pkg_delete",
 84	"portage",
 85	"rpm",
 86	"yay",
 87	"yum",
 88	"zypper",
 89
 90	// System modification
 91	"at",
 92	"batch",
 93	"chkconfig",
 94	"crontab",
 95	"fdisk",
 96	"mkfs",
 97	"mount",
 98	"parted",
 99	"service",
100	"systemctl",
101	"umount",
102
103	// Network configuration
104	"firewall-cmd",
105	"ifconfig",
106	"ip",
107	"iptables",
108	"netstat",
109	"pfctl",
110	"route",
111	"ufw",
112}
113
114func bashDescription() string {
115	bannedCommandsStr := strings.Join(bannedCommands, ", ")
116	return fmt.Sprintf(`Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
117
118CROSS-PLATFORM SHELL SUPPORT:
119* This tool uses a shell interpreter (mvdan/sh) that mimics the Bash language,
120  so you should use Bash syntax in all platforms, including Windows.
121  The most common shell builtins and core utils are available in Windows as
122  well.
123* Make sure to use forward slashes (/) as path separators in commands, even on
124  Windows. Example: "ls C:/foo/bar" instead of "ls C:\foo\bar".
125
126Before executing the command, please follow these steps:
127
1281. Directory Verification:
129 - 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
130 - For example, before running "mkdir foo/bar", first use LS to check that "foo" exists and is the intended parent directory
131
1322. Security Check:
133 - 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.
134 - Verify that the command is not one of the banned commands: %s.
135
1363. Command Execution:
137 - After ensuring proper quoting, execute the command.
138 - Capture the output of the command.
139
1404. Output Processing:
141 - If the output exceeds %d characters, output will be truncated before being returned to you.
142 - Prepare the output for display to the user.
143
1445. Return Result:
145 - Provide the processed output of the command.
146 - If any errors occurred during execution, include those in the output.
147
148Usage notes:
149- The command argument is required.
150- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 30 minutes.
151- 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.
152- When issuing multiple commands, use the ';' or '&&' operator to separate them. DO NOT use newlines (newlines are ok in quoted strings).
153- 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.
154- 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.
155<good-example>
156pytest /foo/bar/tests
157</good-example>
158<bad-example>
159cd /foo/bar && pytest tests
160</bad-example>
161
162# Committing changes with git
163
164When the user asks you to create a new git commit, follow these steps carefully:
165
1661. 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!):
167 - Run a git status command to see all untracked files.
168 - Run a git diff command to see both staged and unstaged changes that will be committed.
169 - Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.
170
1712. 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.
172
1733. Analyze all staged changes (both previously staged and newly added) and draft a commit message. Wrap your analysis process in <commit_analysis> tags:
174
175<commit_analysis>
176- List the files that have been changed or added
177- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.)
178- Brainstorm the purpose or motivation behind these changes
179- Do not use tools to explore code, beyond what is available in the git context
180- Assess the impact of these changes on the overall project
181- Check for any sensitive information that shouldn't be committed
182- Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what"
183- Ensure your language is clear, concise, and to the point
184- 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.)
185- Ensure the message is not generic (avoid words like "Update" or "Fix" without context)
186- Review the draft message to ensure it accurately reflects the changes and their purpose
187</commit_analysis>
188
1894. Create the commit with a message ending with:
190💘 Generated with Crush
191Co-Authored-By: Crush <crush@charm.land>
192
193- In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example:
194<example>
195git commit -m "$(cat <<'EOF'
196 Commit message here.
197
198 💘 Generated with Crush
199 Co-Authored-By: 💘 Crush <crush@charm.land>
200 EOF
201 )"
202</example>
203
2045. 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.
205
2066. Finally, run git status to make sure the commit succeeded.
207
208Important notes:
209- When possible, combine the "git add" and "git commit" commands into a single "git commit -am" command, to speed things up
210- 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.
211- NEVER update the git config
212- DO NOT push to the remote repository
213- 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.
214- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit
215- Ensure your commit message is meaningful and concise. It should explain the purpose of the changes, not just describe them.
216- Return an empty response - the user will see the git output directly
217
218# Creating pull requests
219Use 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.
220
221IMPORTANT: When the user asks you to create a pull request, follow these steps carefully:
222
2231. 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!):
224 - Run a git status command to see all untracked files.
225 - Run a git diff command to see both staged and unstaged changes that will be committed.
226 - 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
227 - 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.)
228
2292. Create new branch if needed
230
2313. Commit changes if needed
232
2334. Push to remote with -u flag if needed
234
2355. 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:
236
237<pr_analysis>
238- List the commits since diverging from the main branch
239- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.)
240- Brainstorm the purpose or motivation behind these changes
241- Assess the impact of these changes on the overall project
242- Do not use tools to explore code, beyond what is available in the git context
243- Check for any sensitive information that shouldn't be committed
244- Draft a concise (1-2 bullet points) pull request summary that focuses on the "why" rather than the "what"
245- Ensure the summary accurately reflects all changes since diverging from the main branch
246- Ensure your language is clear, concise, and to the point
247- 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.)
248- Ensure the summary is not generic (avoid words like "Update" or "Fix" without context)
249- Review the draft summary to ensure it accurately reflects the changes and their purpose
250</pr_analysis>
251
2526. Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.
253<example>
254gh pr create --title "the pr title" --body "$(cat <<'EOF'
255## Summary
256<1-3 bullet points>
257
258## Test plan
259[Checklist of TODOs for testing the pull request...]
260
261💘 Generated with Crush
262EOF
263)"
264</example>
265
266Important:
267- Return an empty response - the user will see the gh output directly
268- Never update git config`, bannedCommandsStr, MaxOutputLength)
269}
270
271func blockFuncs() []shell.BlockFunc {
272	return []shell.BlockFunc{
273		shell.CommandsBlocker(bannedCommands),
274		shell.ArgumentsBlocker([][]string{
275			// System package managers
276			{"apk", "add"},
277			{"apt", "install"},
278			{"apt-get", "install"},
279			{"dnf", "install"},
280			{"emerge"},
281			{"pacman", "-S"},
282			{"pkg", "install"},
283			{"yum", "install"},
284			{"zypper", "install"},
285
286			// Language-specific package managers
287			{"brew", "install"},
288			{"cargo", "install"},
289			{"gem", "install"},
290			{"go", "install"},
291			{"npm", "install", "-g"},
292			{"npm", "install", "--global"},
293			{"pip", "install", "--user"},
294			{"pip3", "install", "--user"},
295			{"pnpm", "add", "-g"},
296			{"pnpm", "add", "--global"},
297			{"yarn", "global", "add"},
298		}),
299	}
300}
301
302func NewBashTool(permission permission.Service, workingDir string) BaseTool {
303	// Set up command blocking on the persistent shell
304	persistentShell := shell.GetPersistentShell(workingDir)
305	persistentShell.SetBlockFuncs(blockFuncs())
306
307	return &bashTool{
308		permissions: permission,
309		workingDir:  workingDir,
310	}
311}
312
313func (b *bashTool) Name() string {
314	return BashToolName
315}
316
317func (b *bashTool) Info() ToolInfo {
318	return ToolInfo{
319		Name:        BashToolName,
320		Description: bashDescription(),
321		Parameters: map[string]any{
322			"command": map[string]any{
323				"type":        "string",
324				"description": "The command to execute",
325			},
326			"timeout": map[string]any{
327				"type":        "number",
328				"description": "Optional timeout in milliseconds (max 600000)",
329			},
330		},
331		Required: []string{"command"},
332	}
333}
334
335func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
336	var params BashParams
337	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
338		return NewTextErrorResponse("invalid parameters"), nil
339	}
340
341	if params.Timeout > MaxTimeout {
342		params.Timeout = MaxTimeout
343	} else if params.Timeout <= 0 {
344		params.Timeout = DefaultTimeout
345	}
346
347	if params.Command == "" {
348		return NewTextErrorResponse("missing command"), nil
349	}
350
351	isSafeReadOnly := false
352	cmdLower := strings.ToLower(params.Command)
353
354	for _, safe := range safeCommands {
355		if strings.HasPrefix(cmdLower, safe) {
356			if len(cmdLower) == len(safe) || cmdLower[len(safe)] == ' ' || cmdLower[len(safe)] == '-' {
357				isSafeReadOnly = true
358				break
359			}
360		}
361	}
362
363	sessionID, messageID := GetContextValues(ctx)
364	if sessionID == "" || messageID == "" {
365		return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
366	}
367	if !isSafeReadOnly {
368		p := b.permissions.Request(
369			permission.CreatePermissionRequest{
370				SessionID:   sessionID,
371				Path:        b.workingDir,
372				ToolName:    BashToolName,
373				Action:      "execute",
374				Description: fmt.Sprintf("Execute command: %s", params.Command),
375				Params: BashPermissionsParams{
376					Command: params.Command,
377				},
378			},
379		)
380		if !p {
381			return ToolResponse{}, permission.ErrorPermissionDenied
382		}
383	}
384	startTime := time.Now()
385	if params.Timeout > 0 {
386		var cancel context.CancelFunc
387		ctx, cancel = context.WithTimeout(ctx, time.Duration(params.Timeout)*time.Millisecond)
388		defer cancel()
389	}
390	stdout, stderr, err := shell.
391		GetPersistentShell(b.workingDir).
392		Exec(ctx, params.Command)
393	interrupted := shell.IsInterrupt(err)
394	exitCode := shell.ExitCode(err)
395	if exitCode == 0 && !interrupted && err != nil {
396		return ToolResponse{}, fmt.Errorf("error executing command: %w", err)
397	}
398
399	stdout = truncateOutput(stdout)
400	stderr = truncateOutput(stderr)
401
402	slog.Info("Bash command executed",
403		"command", params.Command,
404		"stdout", stdout,
405		"stderr", stderr,
406		"exit_code", exitCode,
407		"interrupted", interrupted,
408		"err", err,
409	)
410
411	errorMessage := stderr
412	if errorMessage == "" && err != nil {
413		errorMessage = err.Error()
414	}
415
416	if interrupted {
417		if errorMessage != "" {
418			errorMessage += "\n"
419		}
420		errorMessage += "Command was aborted before completion"
421	} else if exitCode != 0 {
422		if errorMessage != "" {
423			errorMessage += "\n"
424		}
425		errorMessage += fmt.Sprintf("Exit code %d", exitCode)
426	}
427
428	hasBothOutputs := stdout != "" && stderr != ""
429
430	if hasBothOutputs {
431		stdout += "\n"
432	}
433
434	if errorMessage != "" {
435		stdout += "\n" + errorMessage
436	}
437
438	metadata := BashResponseMetadata{
439		StartTime: startTime.UnixMilli(),
440		EndTime:   time.Now().UnixMilli(),
441	}
442	if stdout == "" {
443		return WithResponseMetadata(NewTextResponse(BashNoOutput), metadata), nil
444	}
445	return WithResponseMetadata(NewTextResponse(stdout), metadata), nil
446}
447
448func truncateOutput(content string) string {
449	if len(content) <= MaxOutputLength {
450		return content
451	}
452
453	halfLength := MaxOutputLength / 2
454	start := content[:halfLength]
455	end := content[len(content)-halfLength:]
456
457	truncatedLinesCount := countLines(content[halfLength : len(content)-halfLength])
458	return fmt.Sprintf("%s\n\n... [%d lines truncated] ...\n\n%s", start, truncatedLinesCount, end)
459}
460
461func countLines(s string) int {
462	if s == "" {
463		return 0
464	}
465	return len(strings.Split(s, "\n"))
466}