1package tools
2
3import (
4 "bytes"
5 "context"
6 "crypto/rand"
7 "encoding/hex"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "io"
12 "os"
13 "os/exec"
14 "path/filepath"
15 "runtime"
16 "strings"
17 "time"
18
19 "github.com/bnema/zut/packages/core"
20 "github.com/bnema/zut/packages/provider"
21)
22
23const (
24 maxBashLines = 2000
25 maxBashBytes = 50 * 1024
26
27 // maxBashTimeoutSeconds is the largest whole-second timeout that can be
28 // converted to a time.Duration without overflowing it.
29 maxBashTimeoutSeconds int64 = int64(time.Duration(1<<63-1) / time.Second)
30)
31
32// BashTool runs a shell command in the agent's cwd.
33type BashTool struct {
34 CWD string
35 Sandbox *Sandbox
36}
37
38type bashArgs struct {
39 Command string `json:"command"`
40 Timeout *int64 `json:"timeout"`
41}
42
43var bashSchema = fmt.Sprintf(`{"type":"object","properties":{"command":{"type":"string"},"timeout":{"type":"integer","minimum":1,"maximum":%d,"description":"Maximum command runtime in seconds."}},"required":["command","timeout"]}`, maxBashTimeoutSeconds)
44
45func (t *BashTool) Name() string { return "bash" }
46func (t *BashTool) Description() string { return shellDescription(currentShell()) }
47func (t *BashTool) Schema() json.RawMessage { return json.RawMessage(bashSchema) }
48
49func bashTimeoutDuration(seconds int64) (time.Duration, error) {
50 if seconds < 1 || seconds > maxBashTimeoutSeconds {
51 return 0, fmt.Errorf("timeout must be between 1 and %d seconds", maxBashTimeoutSeconds)
52 }
53 return time.Duration(seconds) * time.Second, nil
54}
55
56func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
57 var a bashArgs
58 if err := json.Unmarshal(raw, &a); err != nil {
59 return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
60 }
61 if strings.TrimSpace(a.Command) == "" {
62 return core.ToolResult{}, fmt.Errorf("command is required")
63 }
64 if a.Timeout == nil {
65 return core.ToolResult{}, fmt.Errorf("timeout is required")
66 }
67 timeout, err := bashTimeoutDuration(*a.Timeout)
68 if err != nil {
69 return core.ToolResult{}, err
70 }
71 if err := t.Sandbox.CheckCommand(a.Command); err != nil {
72 return core.ToolResult{}, err
73 }
74 if err := t.Sandbox.CheckBashPermission(a.Command); err != nil {
75 return core.ToolResult{}, err
76 }
77 cwd := t.CWD
78 if cwd == "" {
79 cwd, _ = os.Getwd()
80 }
81
82 runCtx, cancel := context.WithTimeout(ctx, timeout)
83 defer cancel()
84
85 cmd := newShellCmd(runCtx, a.Command)
86 cmd.Dir = cwd
87 cmd.Env = os.Environ()
88
89 // Capture merged stdout+stderr with line-by-line streaming.
90 pr, pw := io.Pipe()
91 cmd.Stdout = pw
92 cmd.Stderr = pw
93 closeOutput := configureBashProcess(cmd, pw)
94
95 if err := cmd.Start(); err != nil {
96 closeOutput()
97 return core.ToolResult{}, fmt.Errorf("start: %w", err)
98 }
99
100 // Writer to both the buffer (trimmed) and progress callback.
101 captured := &bytes.Buffer{}
102 readerDone := make(chan struct{})
103
104 go func() {
105 defer close(readerDone)
106 buf := make([]byte, 4096)
107 for {
108 n, err := pr.Read(buf)
109 if n > 0 {
110 chunk := buf[:n]
111 if captured.Len() < maxBashBytes {
112 room := maxBashBytes - captured.Len()
113 if n > room {
114 captured.Write(chunk[:room])
115 } else {
116 captured.Write(chunk)
117 }
118 }
119 if progress != nil {
120 progress(string(chunk))
121 }
122 }
123 if err != nil {
124 return
125 }
126 }
127 }()
128
129 waitErr := cmd.Wait()
130 closeOutput()
131 <-readerDone
132
133 output := captured.String()
134 truncBytes := captured.Len() >= maxBashBytes
135 lines := strings.Split(output, "\n")
136 truncLines := false
137 if len(lines) > maxBashLines {
138 lines = lines[:maxBashLines]
139 truncLines = true
140 }
141 trimmed := strings.Join(lines, "\n")
142
143 exitCode := 0
144 if waitErr != nil {
145 if ee, ok := waitErr.(*exec.ExitError); ok {
146 exitCode = ee.ExitCode()
147 } else {
148 exitCode = -1
149 }
150 }
151
152 // Terminal-log style: echo the command on the first line with
153 // a shell-prompt prefix, a blank line, the captured output, and
154 // a footer showing the exit code. Invocation timing is added once
155 // by the core tool boundary for the model-only projection.
156 var sb strings.Builder
157 fmt.Fprintf(&sb, "$ %s\n", a.Command)
158 if trimmed != "" {
159 sb.WriteString("\n")
160 sb.WriteString(trimmed)
161 if !strings.HasSuffix(trimmed, "\n") {
162 sb.WriteString("\n")
163 }
164 }
165 if truncLines {
166 fmt.Fprintf(&sb, "... [truncated at %d lines]\n", maxBashLines)
167 }
168 if truncBytes {
169 fmt.Fprintf(&sb, "... [truncated at %d bytes]\n", maxBashBytes)
170 }
171 sb.WriteString("\n")
172 if errors.Is(runCtx.Err(), context.DeadlineExceeded) && ctx.Err() == nil {
173 fmt.Fprintf(&sb, "[timed out after %d second", *a.Timeout)
174 if *a.Timeout != 1 {
175 sb.WriteByte('s')
176 }
177 sb.WriteByte(']')
178 } else if exitCode == 0 {
179 fmt.Fprintf(&sb, "[exit 0]")
180 } else {
181 fmt.Fprintf(&sb, "[exit %d]", exitCode)
182 }
183
184 var fullPath string
185 if truncBytes || truncLines {
186 fullPath = writeFullOutput(output)
187 if fullPath != "" {
188 fmt.Fprintf(&sb, " (full output: %s)", fullPath)
189 }
190 }
191
192 isErr := exitCode != 0 || runCtx.Err() != nil
193 return core.ToolResult{
194 Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
195 IsError: isErr,
196 Details: map[string]any{
197 "exit_code": exitCode,
198 "full_output_path": fullPath,
199 "lines_truncated": truncLines,
200 "bytes_truncated": truncBytes,
201 },
202 }, nil
203}
204
205func writeFullOutput(s string) string {
206 b := make([]byte, 6)
207 _, _ = rand.Read(b)
208 name := filepath.Join(os.TempDir(), "zut-bash-"+hex.EncodeToString(b)+".log")
209 if err := os.WriteFile(name, []byte(s), 0o600); err != nil {
210 return ""
211 }
212 return name
213}
214
215type shellCommand struct {
216 path string
217 flag string
218 isBash bool
219}
220
221func currentShell() shellCommand {
222 return resolveShell(runtime.GOOS, isExecutableFile, exec.LookPath)
223}
224
225func resolveShell(goos string, executable func(string) bool, lookPath func(string) (string, error)) shellCommand {
226 if goos == "windows" {
227 return shellCommand{path: "cmd", flag: "/C"}
228 }
229 if executable("/bin/bash") {
230 return shellCommand{path: "/bin/bash", flag: "-c", isBash: true}
231 }
232 if path, err := lookPath("bash"); err == nil {
233 return shellCommand{path: path, flag: "-c", isBash: true}
234 }
235 return shellCommand{path: "/bin/sh", flag: "-c"}
236}
237
238func isExecutableFile(path string) bool {
239 info, err := os.Stat(path)
240 return err == nil && !info.IsDir() && info.Mode().Perm()&0o111 != 0
241}
242
243func shellDescription(shell shellCommand) string {
244 if shell.flag == "/C" {
245 return "Run a Windows Command Prompt command via cmd /C. stdout+stderr merged."
246 }
247 if shell.isBash {
248 return fmt.Sprintf("Run a Bash command via %s -c. stdout+stderr merged.", shell.path)
249 }
250 return "Bash is unavailable; run a POSIX sh command via /bin/sh -c. stdout+stderr merged."
251}
252
253func newShellCmd(ctx context.Context, command string) *exec.Cmd {
254 shell := currentShell()
255 return exec.CommandContext(ctx, shell.path, shell.flag, command)
256}