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