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