blob: b3b8b0349392b8fb94406ecefa51d1e3f0e064fc [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
16 "sketch.dev/ant"
17 "sketch.dev/claudetool/bashkit"
18)
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
30func NewBashTool(checkPermission PermissionCallback) *ant.Tool {
31 tool := &BashTool{
32 CheckPermission: checkPermission,
33 }
34
35 return &ant.Tool{
36 Name: bashName,
37 Description: strings.TrimSpace(bashDescription),
38 InputSchema: ant.MustSchema(bashInputSchema),
39 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
170 var output bytes.Buffer
171 cmd.Stdin = nil
172 cmd.Stdout = &output
173 cmd.Stderr = &output
174 if err := cmd.Start(); err != nil {
175 return "", fmt.Errorf("command failed: %w", err)
176 }
177 proc := cmd.Process
178 done := make(chan struct{})
179 go func() {
180 select {
181 case <-execCtx.Done():
182 if execCtx.Err() == context.DeadlineExceeded && proc != nil {
183 // Kill the entire process group.
184 syscall.Kill(-proc.Pid, syscall.SIGKILL)
185 }
186 case <-done:
187 }
188 }()
189
190 err := cmd.Wait()
191 close(done)
192
193 if execCtx.Err() == context.DeadlineExceeded {
194 return "", fmt.Errorf("command timed out after %s", req.timeout())
195 }
196 longOutput := output.Len() > maxBashOutputLength
197 var outstr string
198 if longOutput {
199 outstr = fmt.Sprintf("output too long: got %v, max is %v\ninitial bytes of output:\n%s",
200 humanizeBytes(output.Len()), humanizeBytes(maxBashOutputLength),
201 output.Bytes()[:1024],
202 )
203 } else {
204 outstr = output.String()
205 }
206
207 if err != nil {
208 return "", fmt.Errorf("command failed: %w\n%s", err, outstr)
209 }
210
211 if longOutput {
212 return "", fmt.Errorf("%s", outstr)
213 }
214
215 return output.String(), nil
216}
217
218func humanizeBytes(bytes int) string {
219 switch {
220 case bytes < 4*1024:
221 return fmt.Sprintf("%dB", bytes)
222 case bytes < 1024*1024:
223 kb := int(math.Round(float64(bytes) / 1024.0))
224 return fmt.Sprintf("%dkB", kb)
225 case bytes < 1024*1024*1024:
226 mb := int(math.Round(float64(bytes) / (1024.0 * 1024.0)))
227 return fmt.Sprintf("%dMB", mb)
228 }
229 return "more than 1GB"
230}
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000231
232// executeBackgroundBash executes a command in the background and returns the pid and output file locations
233func executeBackgroundBash(ctx context.Context, req bashInput) (*BackgroundResult, error) {
234 // Create temporary directory for output files
235 tmpDir, err := os.MkdirTemp("", "sketch-bg-")
236 if err != nil {
237 return nil, fmt.Errorf("failed to create temp directory: %w", err)
238 }
239
240 // Create temp files for stdout and stderr
241 stdoutFile := filepath.Join(tmpDir, "stdout")
242 stderrFile := filepath.Join(tmpDir, "stderr")
243
244 // Prepare the command
245 cmd := exec.Command("bash", "-c", req.Command)
246 cmd.Dir = WorkingDir(ctx)
247 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
248
249 // 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
309func BashRun(ctx context.Context, m json.RawMessage) (string, error) {
310 // Use the default Bash tool which has no permission callback
311 return Bash.Run(ctx, m)
312}