blob: b24e7000fbb675a009b834ce6ab301cb2e094efd [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package claudetool
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "math"
Philip Zeyligerb60f0f22025-04-23 18:19:32 +00009 "os"
Earl Lee2e463fb2025-04-17 11:22:22 -070010 "os/exec"
Philip Zeyligerb60f0f22025-04-23 18:19:32 +000011 "path/filepath"
Earl Lee2e463fb2025-04-17 11:22:22 -070012 "strings"
13 "syscall"
14 "time"
15
Earl Lee2e463fb2025-04-17 11:22:22 -070016 "sketch.dev/claudetool/bashkit"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070017 "sketch.dev/llm"
Earl Lee2e463fb2025-04-17 11:22:22 -070018)
19
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000020// PermissionCallback is a function type for checking if a command is allowed to run
21type PermissionCallback func(command string) error
22
23// BashTool is a struct for executing shell commands with bash -c and optional timeout
24type BashTool struct {
25 // CheckPermission is called before running any command, if set
26 CheckPermission PermissionCallback
Earl Lee2e463fb2025-04-17 11:22:22 -070027}
28
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000029// NewBashTool creates a new Bash tool with optional permission callback
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070030func NewBashTool(checkPermission PermissionCallback) *llm.Tool {
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000031 tool := &BashTool{
32 CheckPermission: checkPermission,
33 }
34
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070035 return &llm.Tool{
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000036 Name: bashName,
37 Description: strings.TrimSpace(bashDescription),
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070038 InputSchema: llm.MustSchema(bashInputSchema),
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000039 Run: tool.Run,
40 }
41}
42
43// The Bash tool executes shell commands with bash -c and optional timeout
44var Bash = NewBashTool(nil)
45
Earl Lee2e463fb2025-04-17 11:22:22 -070046const (
47 bashName = "bash"
48 bashDescription = `
49Executes a shell command using bash -c with an optional timeout, returning combined stdout and stderr.
Philip Zeyligerb60f0f22025-04-23 18:19:32 +000050When run with background flag, the process may keep running after the tool call returns, and
51the agent can inspect the output by reading the output files. Use the background task when, for example,
52starting a server to test something. Be sure to kill the process group when done.
Earl Lee2e463fb2025-04-17 11:22:22 -070053
54Executables pre-installed in this environment include:
55- standard unix tools
56- go
57- git
58- rg
59- jq
60- gopls
61- sqlite
62- fzf
63- gh
64- python3
65`
66 // If you modify this, update the termui template for prettier rendering.
67 bashInputSchema = `
68{
69 "type": "object",
70 "required": ["command"],
71 "properties": {
72 "command": {
73 "type": "string",
74 "description": "Shell script to execute"
75 },
76 "timeout": {
77 "type": "string",
Philip Zeyligerb60f0f22025-04-23 18:19:32 +000078 "description": "Timeout as a Go duration string, defaults to 1m if background is false; 10m if background is true"
79 },
80 "background": {
81 "type": "boolean",
82 "description": "If true, executes the command in the background without waiting for completion"
Earl Lee2e463fb2025-04-17 11:22:22 -070083 }
84 }
85}
86`
87)
88
89type bashInput struct {
Philip Zeyligerb60f0f22025-04-23 18:19:32 +000090 Command string `json:"command"`
91 Timeout string `json:"timeout,omitempty"`
92 Background bool `json:"background,omitempty"`
93}
94
95type BackgroundResult struct {
96 PID int `json:"pid"`
97 StdoutFile string `json:"stdout_file"`
98 StderrFile string `json:"stderr_file"`
Earl Lee2e463fb2025-04-17 11:22:22 -070099}
100
101func (i *bashInput) timeout() time.Duration {
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000102 if i.Timeout != "" {
103 dur, err := time.ParseDuration(i.Timeout)
104 if err == nil {
105 return dur
106 }
107 }
108
109 // Otherwise, use different defaults based on background mode
110 if i.Background {
111 return 10 * time.Minute
112 } else {
Earl Lee2e463fb2025-04-17 11:22:22 -0700113 return 1 * time.Minute
114 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700115}
116
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000117func (b *BashTool) Run(ctx context.Context, m json.RawMessage) (string, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700118 var req bashInput
119 if err := json.Unmarshal(m, &req); err != nil {
120 return "", fmt.Errorf("failed to unmarshal bash command input: %w", err)
121 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000122
Earl Lee2e463fb2025-04-17 11:22:22 -0700123 // do a quick permissions check (NOT a security barrier)
124 err := bashkit.Check(req.Command)
125 if err != nil {
126 return "", err
127 }
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000128
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000129 // Custom permission callback if set
130 if b.CheckPermission != nil {
131 if err := b.CheckPermission(req.Command); err != nil {
132 return "", err
133 }
134 }
135
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000136 // If Background is set to true, use executeBackgroundBash
137 if req.Background {
138 result, err := executeBackgroundBash(ctx, req)
139 if err != nil {
140 return "", err
141 }
142 // Marshal the result to JSON
Josh Bleecher Snyder56ac6052025-04-24 10:40:55 -0700143 // TODO: emit XML(-ish) instead?
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000144 output, err := json.Marshal(result)
145 if err != nil {
146 return "", fmt.Errorf("failed to marshal background result: %w", err)
147 }
148 return string(output), nil
149 }
150
151 // For foreground commands, use executeBash
Earl Lee2e463fb2025-04-17 11:22:22 -0700152 out, execErr := executeBash(ctx, req)
153 if execErr == nil {
154 return out, nil
155 }
156 return "", execErr
157}
158
159const maxBashOutputLength = 131072
160
161func executeBash(ctx context.Context, req bashInput) (string, error) {
162 execCtx, cancel := context.WithTimeout(ctx, req.timeout())
163 defer cancel()
164
165 // Can't do the simple thing and call CombinedOutput because of the need to kill the process group.
166 cmd := exec.CommandContext(execCtx, "bash", "-c", req.Command)
167 cmd.Dir = WorkingDir(ctx)
168 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
169
Pokey Ruleb6d6d382025-05-07 10:29:03 +0100170 // Set environment with SKETCH=1
171 cmd.Env = append(os.Environ(), "SKETCH=1")
172
Earl Lee2e463fb2025-04-17 11:22:22 -0700173 var output bytes.Buffer
174 cmd.Stdin = nil
175 cmd.Stdout = &output
176 cmd.Stderr = &output
177 if err := cmd.Start(); err != nil {
178 return "", fmt.Errorf("command failed: %w", err)
179 }
180 proc := cmd.Process
181 done := make(chan struct{})
182 go func() {
183 select {
184 case <-execCtx.Done():
185 if execCtx.Err() == context.DeadlineExceeded && proc != nil {
186 // Kill the entire process group.
187 syscall.Kill(-proc.Pid, syscall.SIGKILL)
188 }
189 case <-done:
190 }
191 }()
192
193 err := cmd.Wait()
194 close(done)
195
196 if execCtx.Err() == context.DeadlineExceeded {
197 return "", fmt.Errorf("command timed out after %s", req.timeout())
198 }
199 longOutput := output.Len() > maxBashOutputLength
200 var outstr string
201 if longOutput {
202 outstr = fmt.Sprintf("output too long: got %v, max is %v\ninitial bytes of output:\n%s",
203 humanizeBytes(output.Len()), humanizeBytes(maxBashOutputLength),
204 output.Bytes()[:1024],
205 )
206 } else {
207 outstr = output.String()
208 }
209
210 if err != nil {
211 return "", fmt.Errorf("command failed: %w\n%s", err, outstr)
212 }
213
214 if longOutput {
215 return "", fmt.Errorf("%s", outstr)
216 }
217
218 return output.String(), nil
219}
220
221func humanizeBytes(bytes int) string {
222 switch {
223 case bytes < 4*1024:
224 return fmt.Sprintf("%dB", bytes)
225 case bytes < 1024*1024:
226 kb := int(math.Round(float64(bytes) / 1024.0))
227 return fmt.Sprintf("%dkB", kb)
228 case bytes < 1024*1024*1024:
229 mb := int(math.Round(float64(bytes) / (1024.0 * 1024.0)))
230 return fmt.Sprintf("%dMB", mb)
231 }
232 return "more than 1GB"
233}
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000234
235// executeBackgroundBash executes a command in the background and returns the pid and output file locations
236func executeBackgroundBash(ctx context.Context, req bashInput) (*BackgroundResult, error) {
237 // Create temporary directory for output files
238 tmpDir, err := os.MkdirTemp("", "sketch-bg-")
239 if err != nil {
240 return nil, fmt.Errorf("failed to create temp directory: %w", err)
241 }
242
243 // Create temp files for stdout and stderr
244 stdoutFile := filepath.Join(tmpDir, "stdout")
245 stderrFile := filepath.Join(tmpDir, "stderr")
246
247 // Prepare the command
248 cmd := exec.Command("bash", "-c", req.Command)
249 cmd.Dir = WorkingDir(ctx)
250 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
251
Pokey Ruleb6d6d382025-05-07 10:29:03 +0100252 // Set environment with SKETCH=1
253 cmd.Env = append(os.Environ(), "SKETCH=1")
254
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000255 // Open output files
256 stdout, err := os.Create(stdoutFile)
257 if err != nil {
258 return nil, fmt.Errorf("failed to create stdout file: %w", err)
259 }
260 defer stdout.Close()
261
262 stderr, err := os.Create(stderrFile)
263 if err != nil {
264 return nil, fmt.Errorf("failed to create stderr file: %w", err)
265 }
266 defer stderr.Close()
267
268 // Configure command to use the files
269 cmd.Stdin = nil
270 cmd.Stdout = stdout
271 cmd.Stderr = stderr
272
273 // Start the command
274 if err := cmd.Start(); err != nil {
275 return nil, fmt.Errorf("failed to start background command: %w", err)
276 }
277
278 // Start a goroutine to reap the process when it finishes
279 go func() {
280 cmd.Wait()
281 // Process has been reaped
282 }()
283
284 // Set up timeout handling if a timeout was specified
285 pid := cmd.Process.Pid
286 timeout := req.timeout()
287 if timeout > 0 {
288 // Launch a goroutine that will kill the process after the timeout
289 go func() {
Josh Bleecher Snyder56ac6052025-04-24 10:40:55 -0700290 // TODO(josh): this should use a context instead of a sleep, like executeBash above,
291 // to avoid goroutine leaks. Possibly should be partially unified with executeBash.
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000292 // Sleep for the timeout duration
293 time.Sleep(timeout)
294
295 // TODO(philip): Should we do SIGQUIT and then SIGKILL in 5s?
296
297 // Try to kill the process group
298 killErr := syscall.Kill(-pid, syscall.SIGKILL)
299 if killErr != nil {
300 // If killing the process group fails, try to kill just the process
301 syscall.Kill(pid, syscall.SIGKILL)
302 }
303 }()
304 }
305
306 // Return the process ID and file paths
307 return &BackgroundResult{
308 PID: cmd.Process.Pid,
309 StdoutFile: stdoutFile,
310 StderrFile: stderrFile,
311 }, nil
312}
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000313
314// BashRun is the legacy function for testing compatibility
315func BashRun(ctx context.Context, m json.RawMessage) (string, error) {
316 // Use the default Bash tool which has no permission callback
317 return Bash.Run(ctx, m)
318}