blob: 4684d760d53fd11c49c8b4edd9b6f1222634a134 [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 Zeyligerb60f0f22025-04-23 18:19:32 +000066 "description": "Timeout as a Go duration string, defaults to 1m if background is false; 10m if background is true"
67 },
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 {
Earl Lee2e463fb2025-04-17 11:22:22 -0700101 return 1 * time.Minute
102 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700103}
104
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000105func (b *BashTool) Run(ctx context.Context, m json.RawMessage) (string, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700106 var req bashInput
107 if err := json.Unmarshal(m, &req); err != nil {
108 return "", fmt.Errorf("failed to unmarshal bash command input: %w", err)
109 }
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 {
114 return "", err
115 }
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 {
120 return "", err
121 }
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 {
128 return "", err
129 }
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 {
134 return "", fmt.Errorf("failed to marshal background result: %w", err)
135 }
136 return string(output), nil
137 }
138
139 // For foreground commands, use executeBash
Earl Lee2e463fb2025-04-17 11:22:22 -0700140 out, execErr := executeBash(ctx, req)
141 if execErr == nil {
142 return out, nil
143 }
144 return "", execErr
145}
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
184 if execCtx.Err() == context.DeadlineExceeded {
185 return "", fmt.Errorf("command timed out after %s", req.timeout())
186 }
187 longOutput := output.Len() > maxBashOutputLength
188 var outstr string
189 if longOutput {
190 outstr = fmt.Sprintf("output too long: got %v, max is %v\ninitial bytes of output:\n%s",
191 humanizeBytes(output.Len()), humanizeBytes(maxBashOutputLength),
192 output.Bytes()[:1024],
193 )
194 } else {
195 outstr = output.String()
196 }
197
198 if err != nil {
199 return "", fmt.Errorf("command failed: %w\n%s", err, outstr)
200 }
201
202 if longOutput {
203 return "", fmt.Errorf("%s", outstr)
204 }
205
206 return output.String(), nil
207}
208
209func humanizeBytes(bytes int) string {
210 switch {
211 case bytes < 4*1024:
212 return fmt.Sprintf("%dB", bytes)
213 case bytes < 1024*1024:
214 kb := int(math.Round(float64(bytes) / 1024.0))
215 return fmt.Sprintf("%dkB", kb)
216 case bytes < 1024*1024*1024:
217 mb := int(math.Round(float64(bytes) / (1024.0 * 1024.0)))
218 return fmt.Sprintf("%dMB", mb)
219 }
220 return "more than 1GB"
221}
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000222
223// executeBackgroundBash executes a command in the background and returns the pid and output file locations
224func executeBackgroundBash(ctx context.Context, req bashInput) (*BackgroundResult, error) {
225 // Create temporary directory for output files
226 tmpDir, err := os.MkdirTemp("", "sketch-bg-")
227 if err != nil {
228 return nil, fmt.Errorf("failed to create temp directory: %w", err)
229 }
230
231 // Create temp files for stdout and stderr
232 stdoutFile := filepath.Join(tmpDir, "stdout")
233 stderrFile := filepath.Join(tmpDir, "stderr")
234
235 // Prepare the command
236 cmd := exec.Command("bash", "-c", req.Command)
237 cmd.Dir = WorkingDir(ctx)
238 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
239
Pokey Ruleb6d6d382025-05-07 10:29:03 +0100240 // Set environment with SKETCH=1
241 cmd.Env = append(os.Environ(), "SKETCH=1")
242
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000243 // Open output files
244 stdout, err := os.Create(stdoutFile)
245 if err != nil {
246 return nil, fmt.Errorf("failed to create stdout file: %w", err)
247 }
248 defer stdout.Close()
249
250 stderr, err := os.Create(stderrFile)
251 if err != nil {
252 return nil, fmt.Errorf("failed to create stderr file: %w", err)
253 }
254 defer stderr.Close()
255
256 // Configure command to use the files
257 cmd.Stdin = nil
258 cmd.Stdout = stdout
259 cmd.Stderr = stderr
260
261 // Start the command
262 if err := cmd.Start(); err != nil {
263 return nil, fmt.Errorf("failed to start background command: %w", err)
264 }
265
266 // Start a goroutine to reap the process when it finishes
267 go func() {
268 cmd.Wait()
269 // Process has been reaped
270 }()
271
272 // Set up timeout handling if a timeout was specified
273 pid := cmd.Process.Pid
274 timeout := req.timeout()
275 if timeout > 0 {
276 // Launch a goroutine that will kill the process after the timeout
277 go func() {
Josh Bleecher Snyder56ac6052025-04-24 10:40:55 -0700278 // TODO(josh): this should use a context instead of a sleep, like executeBash above,
279 // to avoid goroutine leaks. Possibly should be partially unified with executeBash.
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000280 // Sleep for the timeout duration
281 time.Sleep(timeout)
282
283 // TODO(philip): Should we do SIGQUIT and then SIGKILL in 5s?
284
285 // Try to kill the process group
286 killErr := syscall.Kill(-pid, syscall.SIGKILL)
287 if killErr != nil {
288 // If killing the process group fails, try to kill just the process
289 syscall.Kill(pid, syscall.SIGKILL)
290 }
291 }()
292 }
293
294 // Return the process ID and file paths
295 return &BackgroundResult{
296 PID: cmd.Process.Pid,
297 StdoutFile: stdoutFile,
298 StderrFile: stderrFile,
299 }, nil
300}
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000301
302// BashRun is the legacy function for testing compatibility
303func BashRun(ctx context.Context, m json.RawMessage) (string, error) {
304 // Use the default Bash tool which has no permission callback
305 return Bash.Run(ctx, m)
306}