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), ¶ms); 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}