blob: 827235aad108c7c740b49086b32fc382f1ae57f3 [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 Zeyliger72252cb2025-05-10 17:00:08 -0700141 if execErr != nil {
142 return nil, execErr
Earl Lee2e463fb2025-04-17 11:22:22 -0700143 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700144 return llm.TextContent(out), nil
Earl Lee2e463fb2025-04-17 11:22:22 -0700145}
146
147const maxBashOutputLength = 131072
148
149func executeBash(ctx context.Context, req bashInput) (string, error) {
150 execCtx, cancel := context.WithTimeout(ctx, req.timeout())
151 defer cancel()
152
153 // Can't do the simple thing and call CombinedOutput because of the need to kill the process group.
154 cmd := exec.CommandContext(execCtx, "bash", "-c", req.Command)
155 cmd.Dir = WorkingDir(ctx)
156 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
157
Pokey Ruleb6d6d382025-05-07 10:29:03 +0100158 // Set environment with SKETCH=1
159 cmd.Env = append(os.Environ(), "SKETCH=1")
160
Earl Lee2e463fb2025-04-17 11:22:22 -0700161 var output bytes.Buffer
162 cmd.Stdin = nil
163 cmd.Stdout = &output
164 cmd.Stderr = &output
165 if err := cmd.Start(); err != nil {
166 return "", fmt.Errorf("command failed: %w", err)
167 }
168 proc := cmd.Process
169 done := make(chan struct{})
170 go func() {
171 select {
172 case <-execCtx.Done():
173 if execCtx.Err() == context.DeadlineExceeded && proc != nil {
174 // Kill the entire process group.
175 syscall.Kill(-proc.Pid, syscall.SIGKILL)
176 }
177 case <-done:
178 }
179 }()
180
181 err := cmd.Wait()
182 close(done)
183
Earl Lee2e463fb2025-04-17 11:22:22 -0700184 longOutput := output.Len() > maxBashOutputLength
185 var outstr string
186 if longOutput {
187 outstr = fmt.Sprintf("output too long: got %v, max is %v\ninitial bytes of output:\n%s",
188 humanizeBytes(output.Len()), humanizeBytes(maxBashOutputLength),
189 output.Bytes()[:1024],
190 )
191 } else {
192 outstr = output.String()
193 }
194
Philip Zeyliger8a1b89a2025-05-13 17:58:41 -0700195 if execCtx.Err() == context.DeadlineExceeded {
196 // Get the partial output that was captured before the timeout
197 partialOutput := output.String()
198 // Truncate if the output is too large
199 if len(partialOutput) > maxBashOutputLength {
200 partialOutput = partialOutput[:maxBashOutputLength] + "\n[output truncated due to size]\n"
201 }
202 return "", fmt.Errorf("command timed out after %s\nCommand output (until it timed out):\n%s", req.timeout(), outstr)
203 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700204 if err != nil {
205 return "", fmt.Errorf("command failed: %w\n%s", err, outstr)
206 }
207
208 if longOutput {
209 return "", fmt.Errorf("%s", outstr)
210 }
211
212 return output.String(), nil
213}
214
215func humanizeBytes(bytes int) string {
216 switch {
217 case bytes < 4*1024:
218 return fmt.Sprintf("%dB", bytes)
219 case bytes < 1024*1024:
220 kb := int(math.Round(float64(bytes) / 1024.0))
221 return fmt.Sprintf("%dkB", kb)
222 case bytes < 1024*1024*1024:
223 mb := int(math.Round(float64(bytes) / (1024.0 * 1024.0)))
224 return fmt.Sprintf("%dMB", mb)
225 }
226 return "more than 1GB"
227}
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000228
229// executeBackgroundBash executes a command in the background and returns the pid and output file locations
230func executeBackgroundBash(ctx context.Context, req bashInput) (*BackgroundResult, error) {
231 // Create temporary directory for output files
232 tmpDir, err := os.MkdirTemp("", "sketch-bg-")
233 if err != nil {
234 return nil, fmt.Errorf("failed to create temp directory: %w", err)
235 }
236
237 // Create temp files for stdout and stderr
238 stdoutFile := filepath.Join(tmpDir, "stdout")
239 stderrFile := filepath.Join(tmpDir, "stderr")
240
241 // Prepare the command
242 cmd := exec.Command("bash", "-c", req.Command)
243 cmd.Dir = WorkingDir(ctx)
244 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
245
Pokey Ruleb6d6d382025-05-07 10:29:03 +0100246 // Set environment with SKETCH=1
247 cmd.Env = append(os.Environ(), "SKETCH=1")
248
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000249 // Open output files
250 stdout, err := os.Create(stdoutFile)
251 if err != nil {
252 return nil, fmt.Errorf("failed to create stdout file: %w", err)
253 }
254 defer stdout.Close()
255
256 stderr, err := os.Create(stderrFile)
257 if err != nil {
258 return nil, fmt.Errorf("failed to create stderr file: %w", err)
259 }
260 defer stderr.Close()
261
262 // Configure command to use the files
263 cmd.Stdin = nil
264 cmd.Stdout = stdout
265 cmd.Stderr = stderr
266
267 // Start the command
268 if err := cmd.Start(); err != nil {
269 return nil, fmt.Errorf("failed to start background command: %w", err)
270 }
271
272 // Start a goroutine to reap the process when it finishes
273 go func() {
274 cmd.Wait()
275 // Process has been reaped
276 }()
277
278 // Set up timeout handling if a timeout was specified
279 pid := cmd.Process.Pid
280 timeout := req.timeout()
281 if timeout > 0 {
282 // Launch a goroutine that will kill the process after the timeout
283 go func() {
Josh Bleecher Snyder56ac6052025-04-24 10:40:55 -0700284 // TODO(josh): this should use a context instead of a sleep, like executeBash above,
285 // to avoid goroutine leaks. Possibly should be partially unified with executeBash.
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000286 // Sleep for the timeout duration
287 time.Sleep(timeout)
288
289 // TODO(philip): Should we do SIGQUIT and then SIGKILL in 5s?
290
291 // Try to kill the process group
292 killErr := syscall.Kill(-pid, syscall.SIGKILL)
293 if killErr != nil {
294 // If killing the process group fails, try to kill just the process
295 syscall.Kill(pid, syscall.SIGKILL)
296 }
297 }()
298 }
299
300 // Return the process ID and file paths
301 return &BackgroundResult{
302 PID: cmd.Process.Pid,
303 StdoutFile: stdoutFile,
304 StderrFile: stderrFile,
305 }, nil
306}
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000307
308// BashRun is the legacy function for testing compatibility
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700309func BashRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000310 // Use the default Bash tool which has no permission callback
311 return Bash.Run(ctx, m)
312}