blob: adbd12c4e107217a41ef931b6dbcdf7c1683af80 [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`
54 // If you modify this, update the termui template for prettier rendering.
55 bashInputSchema = `
56{
57 "type": "object",
58 "required": ["command"],
59 "properties": {
60 "command": {
61 "type": "string",
62 "description": "Shell script to execute"
63 },
64 "timeout": {
65 "type": "string",
Philip Zeyliger208938f2025-05-13 17:17:18 -070066 "description": "Timeout as a Go duration string, defaults to 10s if background is false; 10m if background is true"
Philip Zeyligerb60f0f22025-04-23 18:19:32 +000067 },
68 "background": {
69 "type": "boolean",
70 "description": "If true, executes the command in the background without waiting for completion"
Earl Lee2e463fb2025-04-17 11:22:22 -070071 }
72 }
73}
74`
75)
76
77type bashInput struct {
Philip Zeyligerb60f0f22025-04-23 18:19:32 +000078 Command string `json:"command"`
79 Timeout string `json:"timeout,omitempty"`
80 Background bool `json:"background,omitempty"`
81}
82
83type BackgroundResult struct {
84 PID int `json:"pid"`
85 StdoutFile string `json:"stdout_file"`
86 StderrFile string `json:"stderr_file"`
Earl Lee2e463fb2025-04-17 11:22:22 -070087}
88
89func (i *bashInput) timeout() time.Duration {
Philip Zeyligerb60f0f22025-04-23 18:19:32 +000090 if i.Timeout != "" {
91 dur, err := time.ParseDuration(i.Timeout)
92 if err == nil {
93 return dur
94 }
95 }
96
97 // Otherwise, use different defaults based on background mode
98 if i.Background {
99 return 10 * time.Minute
100 } else {
Philip Zeyliger208938f2025-05-13 17:17:18 -0700101 return 10 * time.Second
Earl Lee2e463fb2025-04-17 11:22:22 -0700102 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700103}
104
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700105func (b *BashTool) Run(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700106 var req bashInput
107 if err := json.Unmarshal(m, &req); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700108 return nil, fmt.Errorf("failed to unmarshal bash command input: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700109 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000110
Earl Lee2e463fb2025-04-17 11:22:22 -0700111 // do a quick permissions check (NOT a security barrier)
112 err := bashkit.Check(req.Command)
113 if err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700114 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -0700115 }
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000116
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000117 // Custom permission callback if set
118 if b.CheckPermission != nil {
119 if err := b.CheckPermission(req.Command); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700120 return nil, err
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000121 }
122 }
123
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000124 // If Background is set to true, use executeBackgroundBash
125 if req.Background {
126 result, err := executeBackgroundBash(ctx, req)
127 if err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700128 return nil, err
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000129 }
130 // Marshal the result to JSON
Josh Bleecher Snyder56ac6052025-04-24 10:40:55 -0700131 // TODO: emit XML(-ish) instead?
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000132 output, err := json.Marshal(result)
133 if err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700134 return nil, fmt.Errorf("failed to marshal background result: %w", err)
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000135 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700136 return llm.TextContent(string(output)), nil
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000137 }
138
139 // For foreground commands, use executeBash
Earl Lee2e463fb2025-04-17 11:22:22 -0700140 out, execErr := executeBash(ctx, req)
Philip Zeyliger064f8ae2025-05-14 00:47:41 +0000141 // If there's a timeout error, we still want to return the partial output
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700142 if execErr != nil {
Philip Zeyliger064f8ae2025-05-14 00:47:41 +0000143 if out != "" && strings.Contains(execErr.Error(), "timed out") {
144 // Return both the partial output and the error
145 return llm.TextContent(out), execErr
146 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700147 return nil, execErr
Earl Lee2e463fb2025-04-17 11:22:22 -0700148 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700149 return llm.TextContent(out), nil
Earl Lee2e463fb2025-04-17 11:22:22 -0700150}
151
152const maxBashOutputLength = 131072
153
154func executeBash(ctx context.Context, req bashInput) (string, error) {
155 execCtx, cancel := context.WithTimeout(ctx, req.timeout())
156 defer cancel()
157
158 // Can't do the simple thing and call CombinedOutput because of the need to kill the process group.
159 cmd := exec.CommandContext(execCtx, "bash", "-c", req.Command)
160 cmd.Dir = WorkingDir(ctx)
161 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
162
Pokey Ruleb6d6d382025-05-07 10:29:03 +0100163 // Set environment with SKETCH=1
164 cmd.Env = append(os.Environ(), "SKETCH=1")
165
Earl Lee2e463fb2025-04-17 11:22:22 -0700166 var output bytes.Buffer
167 cmd.Stdin = nil
168 cmd.Stdout = &output
169 cmd.Stderr = &output
170 if err := cmd.Start(); err != nil {
171 return "", fmt.Errorf("command failed: %w", err)
172 }
173 proc := cmd.Process
174 done := make(chan struct{})
175 go func() {
176 select {
177 case <-execCtx.Done():
178 if execCtx.Err() == context.DeadlineExceeded && proc != nil {
179 // Kill the entire process group.
180 syscall.Kill(-proc.Pid, syscall.SIGKILL)
181 }
182 case <-done:
183 }
184 }()
185
186 err := cmd.Wait()
187 close(done)
188
189 if execCtx.Err() == context.DeadlineExceeded {
Philip Zeyliger064f8ae2025-05-14 00:47:41 +0000190 // Get the partial output that was captured before the timeout
191 partialOutput := output.String()
192 // Truncate if the output is too large
193 if len(partialOutput) > maxBashOutputLength {
194 partialOutput = partialOutput[:maxBashOutputLength] + "\n[output truncated due to size]\n"
195 }
196 return partialOutput, fmt.Errorf("command timed out after %s - partial output included", req.timeout())
Earl Lee2e463fb2025-04-17 11:22:22 -0700197 }
198 longOutput := output.Len() > maxBashOutputLength
199 var outstr string
200 if longOutput {
201 outstr = fmt.Sprintf("output too long: got %v, max is %v\ninitial bytes of output:\n%s",
202 humanizeBytes(output.Len()), humanizeBytes(maxBashOutputLength),
203 output.Bytes()[:1024],
204 )
205 } else {
206 outstr = output.String()
207 }
208
209 if err != nil {
210 return "", fmt.Errorf("command failed: %w\n%s", err, outstr)
211 }
212
213 if longOutput {
214 return "", fmt.Errorf("%s", outstr)
215 }
216
217 return output.String(), nil
218}
219
220func humanizeBytes(bytes int) string {
221 switch {
222 case bytes < 4*1024:
223 return fmt.Sprintf("%dB", bytes)
224 case bytes < 1024*1024:
225 kb := int(math.Round(float64(bytes) / 1024.0))
226 return fmt.Sprintf("%dkB", kb)
227 case bytes < 1024*1024*1024:
228 mb := int(math.Round(float64(bytes) / (1024.0 * 1024.0)))
229 return fmt.Sprintf("%dMB", mb)
230 }
231 return "more than 1GB"
232}
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000233
234// executeBackgroundBash executes a command in the background and returns the pid and output file locations
235func executeBackgroundBash(ctx context.Context, req bashInput) (*BackgroundResult, error) {
236 // Create temporary directory for output files
237 tmpDir, err := os.MkdirTemp("", "sketch-bg-")
238 if err != nil {
239 return nil, fmt.Errorf("failed to create temp directory: %w", err)
240 }
241
242 // Create temp files for stdout and stderr
243 stdoutFile := filepath.Join(tmpDir, "stdout")
244 stderrFile := filepath.Join(tmpDir, "stderr")
245
246 // Prepare the command
247 cmd := exec.Command("bash", "-c", req.Command)
248 cmd.Dir = WorkingDir(ctx)
249 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
250
Pokey Ruleb6d6d382025-05-07 10:29:03 +0100251 // Set environment with SKETCH=1
252 cmd.Env = append(os.Environ(), "SKETCH=1")
253
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000254 // Open output files
255 stdout, err := os.Create(stdoutFile)
256 if err != nil {
257 return nil, fmt.Errorf("failed to create stdout file: %w", err)
258 }
259 defer stdout.Close()
260
261 stderr, err := os.Create(stderrFile)
262 if err != nil {
263 return nil, fmt.Errorf("failed to create stderr file: %w", err)
264 }
265 defer stderr.Close()
266
267 // Configure command to use the files
268 cmd.Stdin = nil
269 cmd.Stdout = stdout
270 cmd.Stderr = stderr
271
272 // Start the command
273 if err := cmd.Start(); err != nil {
274 return nil, fmt.Errorf("failed to start background command: %w", err)
275 }
276
277 // Start a goroutine to reap the process when it finishes
278 go func() {
279 cmd.Wait()
280 // Process has been reaped
281 }()
282
283 // Set up timeout handling if a timeout was specified
284 pid := cmd.Process.Pid
285 timeout := req.timeout()
286 if timeout > 0 {
287 // Launch a goroutine that will kill the process after the timeout
288 go func() {
Josh Bleecher Snyder56ac6052025-04-24 10:40:55 -0700289 // TODO(josh): this should use a context instead of a sleep, like executeBash above,
290 // to avoid goroutine leaks. Possibly should be partially unified with executeBash.
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000291 // Sleep for the timeout duration
292 time.Sleep(timeout)
293
294 // TODO(philip): Should we do SIGQUIT and then SIGKILL in 5s?
295
296 // Try to kill the process group
297 killErr := syscall.Kill(-pid, syscall.SIGKILL)
298 if killErr != nil {
299 // If killing the process group fails, try to kill just the process
300 syscall.Kill(pid, syscall.SIGKILL)
301 }
302 }()
303 }
304
305 // Return the process ID and file paths
306 return &BackgroundResult{
307 PID: cmd.Process.Pid,
308 StdoutFile: stdoutFile,
309 StderrFile: stderrFile,
310 }, nil
311}
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000312
313// BashRun is the legacy function for testing compatibility
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700314func BashRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000315 // Use the default Bash tool which has no permission callback
316 return Bash.Run(ctx, m)
317}