blob: d76d7f164cd013feb385739be2f81be9de9ab5b5 [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"
9 "os/exec"
10 "strings"
11 "syscall"
12 "time"
13
14 "sketch.dev/ant"
15 "sketch.dev/claudetool/bashkit"
16)
17
18// The Bash tool executes shell commands with bash -c and optional timeout
19var Bash = &ant.Tool{
20 Name: bashName,
21 Description: strings.TrimSpace(bashDescription),
22 InputSchema: ant.MustSchema(bashInputSchema),
23 Run: BashRun,
24}
25
26const (
27 bashName = "bash"
28 bashDescription = `
29Executes a shell command using bash -c with an optional timeout, returning combined stdout and stderr.
30
31Executables pre-installed in this environment include:
32- standard unix tools
33- go
34- git
35- rg
36- jq
37- gopls
38- sqlite
39- fzf
40- gh
41- python3
42`
43 // If you modify this, update the termui template for prettier rendering.
44 bashInputSchema = `
45{
46 "type": "object",
47 "required": ["command"],
48 "properties": {
49 "command": {
50 "type": "string",
51 "description": "Shell script to execute"
52 },
53 "timeout": {
54 "type": "string",
55 "description": "Timeout as a Go duration string, defaults to '1m'"
56 }
57 }
58}
59`
60)
61
62type bashInput struct {
63 Command string `json:"command"`
64 Timeout string `json:"timeout,omitempty"`
65}
66
67func (i *bashInput) timeout() time.Duration {
68 dur, err := time.ParseDuration(i.Timeout)
69 if err != nil {
70 return 1 * time.Minute
71 }
72 return dur
73}
74
75func BashRun(ctx context.Context, m json.RawMessage) (string, error) {
76 var req bashInput
77 if err := json.Unmarshal(m, &req); err != nil {
78 return "", fmt.Errorf("failed to unmarshal bash command input: %w", err)
79 }
80 // do a quick permissions check (NOT a security barrier)
81 err := bashkit.Check(req.Command)
82 if err != nil {
83 return "", err
84 }
85 out, execErr := executeBash(ctx, req)
86 if execErr == nil {
87 return out, nil
88 }
89 return "", execErr
90}
91
92const maxBashOutputLength = 131072
93
94func executeBash(ctx context.Context, req bashInput) (string, error) {
95 execCtx, cancel := context.WithTimeout(ctx, req.timeout())
96 defer cancel()
97
98 // Can't do the simple thing and call CombinedOutput because of the need to kill the process group.
99 cmd := exec.CommandContext(execCtx, "bash", "-c", req.Command)
100 cmd.Dir = WorkingDir(ctx)
101 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
102
103 var output bytes.Buffer
104 cmd.Stdin = nil
105 cmd.Stdout = &output
106 cmd.Stderr = &output
107 if err := cmd.Start(); err != nil {
108 return "", fmt.Errorf("command failed: %w", err)
109 }
110 proc := cmd.Process
111 done := make(chan struct{})
112 go func() {
113 select {
114 case <-execCtx.Done():
115 if execCtx.Err() == context.DeadlineExceeded && proc != nil {
116 // Kill the entire process group.
117 syscall.Kill(-proc.Pid, syscall.SIGKILL)
118 }
119 case <-done:
120 }
121 }()
122
123 err := cmd.Wait()
124 close(done)
125
126 if execCtx.Err() == context.DeadlineExceeded {
127 return "", fmt.Errorf("command timed out after %s", req.timeout())
128 }
129 longOutput := output.Len() > maxBashOutputLength
130 var outstr string
131 if longOutput {
132 outstr = fmt.Sprintf("output too long: got %v, max is %v\ninitial bytes of output:\n%s",
133 humanizeBytes(output.Len()), humanizeBytes(maxBashOutputLength),
134 output.Bytes()[:1024],
135 )
136 } else {
137 outstr = output.String()
138 }
139
140 if err != nil {
141 return "", fmt.Errorf("command failed: %w\n%s", err, outstr)
142 }
143
144 if longOutput {
145 return "", fmt.Errorf("%s", outstr)
146 }
147
148 return output.String(), nil
149}
150
151func humanizeBytes(bytes int) string {
152 switch {
153 case bytes < 4*1024:
154 return fmt.Sprintf("%dB", bytes)
155 case bytes < 1024*1024:
156 kb := int(math.Round(float64(bytes) / 1024.0))
157 return fmt.Sprintf("%dkB", kb)
158 case bytes < 1024*1024*1024:
159 mb := int(math.Round(float64(bytes) / (1024.0 * 1024.0)))
160 return fmt.Sprintf("%dMB", mb)
161 }
162 return "more than 1GB"
163}