1package tools
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "strings"
8
9 "github.com/kujtimiihoxha/termai/internal/config"
10 "github.com/kujtimiihoxha/termai/internal/llm/tools/shell"
11 "github.com/kujtimiihoxha/termai/internal/permission"
12)
13
14type bashTool struct{}
15
16const (
17 BashToolName = "bash"
18
19 DefaultTimeout = 1 * 60 * 1000 // 1 minutes in milliseconds
20 MaxTimeout = 10 * 60 * 1000 // 10 minutes in milliseconds
21 MaxOutputLength = 30000
22)
23
24type BashParams struct {
25 Command string `json:"command"`
26 Timeout int `json:"timeout"`
27}
28
29type BashPermissionsParams struct {
30 Command string `json:"command"`
31 Timeout int `json:"timeout"`
32}
33
34var BannedCommands = []string{
35 "alias", "curl", "curlie", "wget", "axel", "aria2c",
36 "nc", "telnet", "lynx", "w3m", "links", "httpie", "xh",
37 "http-prompt", "chrome", "firefox", "safari",
38}
39
40var SafeReadOnlyCommands = []string{
41 // Basic shell commands
42 "ls", "echo", "pwd", "date", "cal", "uptime", "whoami", "id", "groups", "env", "printenv", "set", "unset", "which", "type", "whereis",
43 "whatis", "uname", "hostname", "df", "du", "free", "top", "ps", "kill", "killall", "nice", "nohup", "time", "timeout",
44
45 // Git read-only commands
46 "git status", "git log", "git diff", "git show", "git branch", "git tag", "git remote", "git ls-files", "git ls-remote",
47 "git rev-parse", "git config --get", "git config --list", "git describe", "git blame", "git grep", "git shortlog",
48
49 // Go commands
50 "go version", "go list", "go env", "go doc", "go vet", "go fmt", "go mod", "go test", "go build", "go run", "go install", "go clean",
51
52 // Node.js commands
53 "node", "npm", "npx", "yarn", "pnpm",
54
55 // Python commands
56 "python", "python3", "pip", "pip3", "pytest", "pylint", "mypy", "black", "isort", "flake8", "ruff",
57
58 // Docker commands
59 "docker ps", "docker images", "docker volume", "docker network", "docker info", "docker version",
60 "docker-compose ps", "docker-compose config",
61
62 // Kubernetes commands
63 "kubectl get", "kubectl describe", "kubectl logs", "kubectl version", "kubectl config",
64
65 // Rust commands
66 "cargo", "rustc", "rustup",
67
68 // Java commands
69 "java", "javac", "mvn", "gradle",
70
71 // Misc development tools
72 "make", "cmake", "bazel", "terraform plan", "terraform validate", "ansible",
73}
74
75func (b *bashTool) Info() ToolInfo {
76 return ToolInfo{
77 Name: BashToolName,
78 Description: bashDescription(),
79 Parameters: map[string]any{
80 "command": map[string]any{
81 "type": "string",
82 "description": "The command to execute",
83 },
84 "timeout": map[string]any{
85 "type": "number",
86 "desription": "Optional timeout in milliseconds (max 600000)",
87 },
88 },
89 Required: []string{"command"},
90 }
91}
92
93// Handle implements Tool.
94func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
95 var params BashParams
96 if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
97 return NewTextErrorResponse("invalid parameters"), nil
98 }
99
100 if params.Timeout > MaxTimeout {
101 params.Timeout = MaxTimeout
102 } else if params.Timeout <= 0 {
103 params.Timeout = DefaultTimeout
104 }
105
106 if params.Command == "" {
107 return NewTextErrorResponse("missing command"), nil
108 }
109
110 // Check for banned commands (first word only)
111 baseCmd := strings.Fields(params.Command)[0]
112 for _, banned := range BannedCommands {
113 if strings.EqualFold(baseCmd, banned) {
114 return NewTextErrorResponse(fmt.Sprintf("command '%s' is not allowed", baseCmd)), nil
115 }
116 }
117
118 // Check for safe commands (can be multi-word)
119 isSafeReadOnly := false
120 cmdLower := strings.ToLower(params.Command)
121
122 for _, safe := range SafeReadOnlyCommands {
123 // Check if command starts with the safe command pattern
124 if strings.HasPrefix(cmdLower, strings.ToLower(safe)) {
125 // Make sure it's either an exact match or followed by a space or flag
126 if len(cmdLower) == len(safe) || cmdLower[len(safe)] == ' ' || cmdLower[len(safe)] == '-' {
127 isSafeReadOnly = true
128 break
129 }
130 }
131 }
132 if !isSafeReadOnly {
133 p := permission.Default.Request(
134 permission.CreatePermissionRequest{
135 Path: config.WorkingDirectory(),
136 ToolName: BashToolName,
137 Action: "execute",
138 Description: fmt.Sprintf("Execute command: %s", params.Command),
139 Params: BashPermissionsParams{
140 Command: params.Command,
141 },
142 },
143 )
144 if !p {
145 return NewTextErrorResponse("permission denied"), nil
146 }
147 }
148 shell := shell.GetPersistentShell(config.WorkingDirectory())
149 stdout, stderr, exitCode, interrupted, err := shell.Exec(ctx, params.Command, params.Timeout)
150 if err != nil {
151 return NewTextErrorResponse(fmt.Sprintf("error executing command: %s", err)), nil
152 }
153
154 stdout = truncateOutput(stdout)
155 stderr = truncateOutput(stderr)
156
157 errorMessage := stderr
158 if interrupted {
159 if errorMessage != "" {
160 errorMessage += "\n"
161 }
162 errorMessage += "Command was aborted before completion"
163 } else if exitCode != 0 {
164 if errorMessage != "" {
165 errorMessage += "\n"
166 }
167 errorMessage += fmt.Sprintf("Exit code %d", exitCode)
168 }
169
170 hasBothOutputs := stdout != "" && stderr != ""
171
172 if hasBothOutputs {
173 stdout += "\n"
174 }
175
176 if errorMessage != "" {
177 stdout += "\n" + errorMessage
178 }
179
180 if stdout == "" {
181 return NewTextResponse("no output"), nil
182 }
183 return NewTextResponse(stdout), nil
184}
185
186func truncateOutput(content string) string {
187 if len(content) <= MaxOutputLength {
188 return content
189 }
190
191 halfLength := MaxOutputLength / 2
192 start := content[:halfLength]
193 end := content[len(content)-halfLength:]
194
195 truncatedLinesCount := countLines(content[halfLength : len(content)-halfLength])
196 return fmt.Sprintf("%s\n\n... [%d lines truncated] ...\n\n%s", start, truncatedLinesCount, end)
197}
198
199func countLines(s string) int {
200 if s == "" {
201 return 0
202 }
203 return len(strings.Split(s, "\n"))
204}
205
206func bashDescription() string {
207 bannedCommandsStr := strings.Join(BannedCommands, ", ")
208 return fmt.Sprintf(`Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
209
210Before executing the command, please follow these steps:
211
2121. Directory Verification:
213 - 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
214 - For example, before running "mkdir foo/bar", first use LS to check that "foo" exists and is the intended parent directory
215
2162. Security Check:
217 - 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.
218 - Verify that the command is not one of the banned commands: %s.
219
2203. Command Execution:
221 - After ensuring proper quoting, execute the command.
222 - Capture the output of the command.
223
2244. Output Processing:
225 - If the output exceeds %d characters, output will be truncated before being returned to you.
226 - Prepare the output for display to the user.
227
2285. Return Result:
229 - Provide the processed output of the command.
230 - If any errors occurred during execution, include those in the output.
231
232Usage notes:
233- The command argument is required.
234- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 30 minutes.
235- 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.
236- When issuing multiple commands, use the ';' or '&&' operator to separate them. DO NOT use newlines (newlines are ok in quoted strings).
237- 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.
238- 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.
239<good-example>
240pytest /foo/bar/tests
241</good-example>
242<bad-example>
243cd /foo/bar && pytest tests
244</bad-example>
245
246# Committing changes with git
247
248When the user asks you to create a new git commit, follow these steps carefully:
249
2501. 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!):
251 - Run a git status command to see all untracked files.
252 - Run a git diff command to see both staged and unstaged changes that will be committed.
253 - Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.
254
2552. 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.
256
2573. Analyze all staged changes (both previously staged and newly added) and draft a commit message. Wrap your analysis process in <commit_analysis> tags:
258
259<commit_analysis>
260- List the files that have been changed or added
261- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.)
262- Brainstorm the purpose or motivation behind these changes
263- Do not use tools to explore code, beyond what is available in the git context
264- Assess the impact of these changes on the overall project
265- Check for any sensitive information that shouldn't be committed
266- Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what"
267- Ensure your language is clear, concise, and to the point
268- 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.)
269- Ensure the message is not generic (avoid words like "Update" or "Fix" without context)
270- Review the draft message to ensure it accurately reflects the changes and their purpose
271</commit_analysis>
272
2734. Create the commit with a message ending with:
274🤖 Generated with termai
275Co-Authored-By: termai <noreply@termai.io>
276
277- In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example:
278<example>
279git commit -m "$(cat <<'EOF'
280 Commit message here.
281
282 🤖 Generated with termai
283 Co-Authored-By: termai <noreply@termai.io>
284 EOF
285 )"
286</example>
287
2885. 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.
289
2906. Finally, run git status to make sure the commit succeeded.
291
292Important notes:
293- When possible, combine the "git add" and "git commit" commands into a single "git commit -am" command, to speed things up
294- 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.
295- NEVER update the git config
296- DO NOT push to the remote repository
297- 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.
298- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit
299- Ensure your commit message is meaningful and concise. It should explain the purpose of the changes, not just describe them.
300- Return an empty response - the user will see the git output directly
301
302# Creating pull requests
303Use 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.
304
305IMPORTANT: When the user asks you to create a pull request, follow these steps carefully:
306
3071. 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!):
308 - Run a git status command to see all untracked files.
309 - Run a git diff command to see both staged and unstaged changes that will be committed.
310 - 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
311 - 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.)
312
3132. Create new branch if needed
314
3153. Commit changes if needed
316
3174. Push to remote with -u flag if needed
318
3195. 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:
320
321<pr_analysis>
322- List the commits since diverging from the main branch
323- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.)
324- Brainstorm the purpose or motivation behind these changes
325- Assess the impact of these changes on the overall project
326- Do not use tools to explore code, beyond what is available in the git context
327- Check for any sensitive information that shouldn't be committed
328- Draft a concise (1-2 bullet points) pull request summary that focuses on the "why" rather than the "what"
329- Ensure the summary accurately reflects all changes since diverging from the main branch
330- Ensure your language is clear, concise, and to the point
331- 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.)
332- Ensure the summary is not generic (avoid words like "Update" or "Fix" without context)
333- Review the draft summary to ensure it accurately reflects the changes and their purpose
334</pr_analysis>
335
3366. Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.
337<example>
338gh pr create --title "the pr title" --body "$(cat <<'EOF'
339## Summary
340<1-3 bullet points>
341
342## Test plan
343[Checklist of TODOs for testing the pull request...]
344
345🤖 Generated with termai
346EOF
347)"
348</example>
349
350Important:
351- Return an empty response - the user will see the gh output directly
352- Never update git config`, bannedCommandsStr, MaxOutputLength)
353}
354
355func NewBashTool() BaseTool {
356 return &bashTool{}
357}