blob: ce27ec7386ef07b99aabd320706d92c45bb238fa [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package claudetool
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +00008 "log/slog"
Earl Lee2e463fb2025-04-17 11:22:22 -07009 "math"
Philip Zeyligerb60f0f22025-04-23 18:19:32 +000010 "os"
Earl Lee2e463fb2025-04-17 11:22:22 -070011 "os/exec"
Philip Zeyligerb60f0f22025-04-23 18:19:32 +000012 "path/filepath"
Earl Lee2e463fb2025-04-17 11:22:22 -070013 "strings"
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +000014 "sync"
Earl Lee2e463fb2025-04-17 11:22:22 -070015 "syscall"
16 "time"
17
Earl Lee2e463fb2025-04-17 11:22:22 -070018 "sketch.dev/claudetool/bashkit"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070019 "sketch.dev/llm"
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +000020 "sketch.dev/llm/conversation"
Earl Lee2e463fb2025-04-17 11:22:22 -070021)
22
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000023// PermissionCallback is a function type for checking if a command is allowed to run
24type PermissionCallback func(command string) error
25
26// BashTool is a struct for executing shell commands with bash -c and optional timeout
27type BashTool struct {
28 // CheckPermission is called before running any command, if set
29 CheckPermission PermissionCallback
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +000030 // EnableJITInstall enables just-in-time tool installation for missing commands
31 EnableJITInstall bool
Earl Lee2e463fb2025-04-17 11:22:22 -070032}
33
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +000034const (
35 EnableBashToolJITInstall = true
36 NoBashToolJITInstall = false
37)
38
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000039// NewBashTool creates a new Bash tool with optional permission callback
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +000040func NewBashTool(checkPermission PermissionCallback, enableJITInstall bool) *llm.Tool {
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000041 tool := &BashTool{
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +000042 CheckPermission: checkPermission,
43 EnableJITInstall: enableJITInstall,
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000044 }
45
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070046 return &llm.Tool{
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000047 Name: bashName,
48 Description: strings.TrimSpace(bashDescription),
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070049 InputSchema: llm.MustSchema(bashInputSchema),
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000050 Run: tool.Run,
51 }
52}
53
54// The Bash tool executes shell commands with bash -c and optional timeout
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +000055var Bash = NewBashTool(nil, NoBashToolJITInstall)
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000056
Earl Lee2e463fb2025-04-17 11:22:22 -070057const (
58 bashName = "bash"
59 bashDescription = `
60Executes a shell command using bash -c with an optional timeout, returning combined stdout and stderr.
Philip Zeyligerb60f0f22025-04-23 18:19:32 +000061When run with background flag, the process may keep running after the tool call returns, and
62the agent can inspect the output by reading the output files. Use the background task when, for example,
63starting a server to test something. Be sure to kill the process group when done.
Earl Lee2e463fb2025-04-17 11:22:22 -070064`
65 // If you modify this, update the termui template for prettier rendering.
66 bashInputSchema = `
67{
68 "type": "object",
69 "required": ["command"],
70 "properties": {
71 "command": {
72 "type": "string",
73 "description": "Shell script to execute"
74 },
75 "timeout": {
76 "type": "string",
Philip Zeyliger208938f2025-05-13 17:17:18 -070077 "description": "Timeout as a Go duration string, defaults to 10s if background is false; 10m if background is true"
Philip Zeyligerb60f0f22025-04-23 18:19:32 +000078 },
79 "background": {
80 "type": "boolean",
81 "description": "If true, executes the command in the background without waiting for completion"
Earl Lee2e463fb2025-04-17 11:22:22 -070082 }
83 }
84}
85`
86)
87
88type bashInput struct {
Philip Zeyligerb60f0f22025-04-23 18:19:32 +000089 Command string `json:"command"`
90 Timeout string `json:"timeout,omitempty"`
91 Background bool `json:"background,omitempty"`
92}
93
94type BackgroundResult struct {
95 PID int `json:"pid"`
96 StdoutFile string `json:"stdout_file"`
97 StderrFile string `json:"stderr_file"`
Earl Lee2e463fb2025-04-17 11:22:22 -070098}
99
100func (i *bashInput) timeout() time.Duration {
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000101 if i.Timeout != "" {
102 dur, err := time.ParseDuration(i.Timeout)
103 if err == nil {
104 return dur
105 }
106 }
107
108 // Otherwise, use different defaults based on background mode
109 if i.Background {
110 return 10 * time.Minute
111 } else {
Philip Zeyliger208938f2025-05-13 17:17:18 -0700112 return 10 * time.Second
Earl Lee2e463fb2025-04-17 11:22:22 -0700113 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700114}
115
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700116func (b *BashTool) Run(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700117 var req bashInput
118 if err := json.Unmarshal(m, &req); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700119 return nil, fmt.Errorf("failed to unmarshal bash command input: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700120 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000121
Earl Lee2e463fb2025-04-17 11:22:22 -0700122 // do a quick permissions check (NOT a security barrier)
123 err := bashkit.Check(req.Command)
124 if err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700125 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -0700126 }
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000127
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000128 // Custom permission callback if set
129 if b.CheckPermission != nil {
130 if err := b.CheckPermission(req.Command); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700131 return nil, err
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000132 }
133 }
134
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +0000135 // Check for missing tools and try to install them if needed, best effort only
136 if b.EnableJITInstall {
137 err := b.checkAndInstallMissingTools(ctx, req.Command)
138 if err != nil {
139 slog.DebugContext(ctx, "failed to auto-install missing tools", "error", err)
140 }
141 }
142
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000143 // If Background is set to true, use executeBackgroundBash
144 if req.Background {
145 result, err := executeBackgroundBash(ctx, req)
146 if err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700147 return nil, err
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000148 }
149 // Marshal the result to JSON
Josh Bleecher Snyder56ac6052025-04-24 10:40:55 -0700150 // TODO: emit XML(-ish) instead?
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000151 output, err := json.Marshal(result)
152 if err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700153 return nil, fmt.Errorf("failed to marshal background result: %w", err)
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000154 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700155 return llm.TextContent(string(output)), nil
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000156 }
157
158 // For foreground commands, use executeBash
Earl Lee2e463fb2025-04-17 11:22:22 -0700159 out, execErr := executeBash(ctx, req)
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700160 if execErr != nil {
161 return nil, execErr
Earl Lee2e463fb2025-04-17 11:22:22 -0700162 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700163 return llm.TextContent(out), nil
Earl Lee2e463fb2025-04-17 11:22:22 -0700164}
165
166const maxBashOutputLength = 131072
167
168func executeBash(ctx context.Context, req bashInput) (string, error) {
169 execCtx, cancel := context.WithTimeout(ctx, req.timeout())
170 defer cancel()
171
172 // Can't do the simple thing and call CombinedOutput because of the need to kill the process group.
173 cmd := exec.CommandContext(execCtx, "bash", "-c", req.Command)
174 cmd.Dir = WorkingDir(ctx)
175 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
176
Pokey Ruleb6d6d382025-05-07 10:29:03 +0100177 // Set environment with SKETCH=1
178 cmd.Env = append(os.Environ(), "SKETCH=1")
179
Earl Lee2e463fb2025-04-17 11:22:22 -0700180 var output bytes.Buffer
181 cmd.Stdin = nil
182 cmd.Stdout = &output
183 cmd.Stderr = &output
184 if err := cmd.Start(); err != nil {
185 return "", fmt.Errorf("command failed: %w", err)
186 }
187 proc := cmd.Process
188 done := make(chan struct{})
189 go func() {
190 select {
191 case <-execCtx.Done():
192 if execCtx.Err() == context.DeadlineExceeded && proc != nil {
193 // Kill the entire process group.
194 syscall.Kill(-proc.Pid, syscall.SIGKILL)
195 }
196 case <-done:
197 }
198 }()
199
200 err := cmd.Wait()
201 close(done)
202
Earl Lee2e463fb2025-04-17 11:22:22 -0700203 longOutput := output.Len() > maxBashOutputLength
204 var outstr string
205 if longOutput {
206 outstr = fmt.Sprintf("output too long: got %v, max is %v\ninitial bytes of output:\n%s",
207 humanizeBytes(output.Len()), humanizeBytes(maxBashOutputLength),
208 output.Bytes()[:1024],
209 )
210 } else {
211 outstr = output.String()
212 }
213
Philip Zeyliger8a1b89a2025-05-13 17:58:41 -0700214 if execCtx.Err() == context.DeadlineExceeded {
215 // Get the partial output that was captured before the timeout
216 partialOutput := output.String()
217 // Truncate if the output is too large
218 if len(partialOutput) > maxBashOutputLength {
219 partialOutput = partialOutput[:maxBashOutputLength] + "\n[output truncated due to size]\n"
220 }
221 return "", fmt.Errorf("command timed out after %s\nCommand output (until it timed out):\n%s", req.timeout(), outstr)
222 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700223 if err != nil {
224 return "", fmt.Errorf("command failed: %w\n%s", err, outstr)
225 }
226
227 if longOutput {
228 return "", fmt.Errorf("%s", outstr)
229 }
230
231 return output.String(), nil
232}
233
234func humanizeBytes(bytes int) string {
235 switch {
236 case bytes < 4*1024:
237 return fmt.Sprintf("%dB", bytes)
238 case bytes < 1024*1024:
239 kb := int(math.Round(float64(bytes) / 1024.0))
240 return fmt.Sprintf("%dkB", kb)
241 case bytes < 1024*1024*1024:
242 mb := int(math.Round(float64(bytes) / (1024.0 * 1024.0)))
243 return fmt.Sprintf("%dMB", mb)
244 }
245 return "more than 1GB"
246}
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000247
248// executeBackgroundBash executes a command in the background and returns the pid and output file locations
249func executeBackgroundBash(ctx context.Context, req bashInput) (*BackgroundResult, error) {
250 // Create temporary directory for output files
251 tmpDir, err := os.MkdirTemp("", "sketch-bg-")
252 if err != nil {
253 return nil, fmt.Errorf("failed to create temp directory: %w", err)
254 }
255
256 // Create temp files for stdout and stderr
257 stdoutFile := filepath.Join(tmpDir, "stdout")
258 stderrFile := filepath.Join(tmpDir, "stderr")
259
260 // Prepare the command
261 cmd := exec.Command("bash", "-c", req.Command)
262 cmd.Dir = WorkingDir(ctx)
263 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
264
Pokey Ruleb6d6d382025-05-07 10:29:03 +0100265 // Set environment with SKETCH=1
266 cmd.Env = append(os.Environ(), "SKETCH=1")
267
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000268 // Open output files
269 stdout, err := os.Create(stdoutFile)
270 if err != nil {
271 return nil, fmt.Errorf("failed to create stdout file: %w", err)
272 }
273 defer stdout.Close()
274
275 stderr, err := os.Create(stderrFile)
276 if err != nil {
277 return nil, fmt.Errorf("failed to create stderr file: %w", err)
278 }
279 defer stderr.Close()
280
281 // Configure command to use the files
282 cmd.Stdin = nil
283 cmd.Stdout = stdout
284 cmd.Stderr = stderr
285
286 // Start the command
287 if err := cmd.Start(); err != nil {
288 return nil, fmt.Errorf("failed to start background command: %w", err)
289 }
290
291 // Start a goroutine to reap the process when it finishes
292 go func() {
293 cmd.Wait()
294 // Process has been reaped
295 }()
296
297 // Set up timeout handling if a timeout was specified
298 pid := cmd.Process.Pid
299 timeout := req.timeout()
300 if timeout > 0 {
301 // Launch a goroutine that will kill the process after the timeout
302 go func() {
Josh Bleecher Snyder56ac6052025-04-24 10:40:55 -0700303 // TODO(josh): this should use a context instead of a sleep, like executeBash above,
304 // to avoid goroutine leaks. Possibly should be partially unified with executeBash.
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000305 // Sleep for the timeout duration
306 time.Sleep(timeout)
307
308 // TODO(philip): Should we do SIGQUIT and then SIGKILL in 5s?
309
310 // Try to kill the process group
311 killErr := syscall.Kill(-pid, syscall.SIGKILL)
312 if killErr != nil {
313 // If killing the process group fails, try to kill just the process
314 syscall.Kill(pid, syscall.SIGKILL)
315 }
316 }()
317 }
318
319 // Return the process ID and file paths
320 return &BackgroundResult{
321 PID: cmd.Process.Pid,
322 StdoutFile: stdoutFile,
323 StderrFile: stderrFile,
324 }, nil
325}
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000326
327// BashRun is the legacy function for testing compatibility
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700328func BashRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000329 // Use the default Bash tool which has no permission callback
330 return Bash.Run(ctx, m)
331}
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +0000332
333// checkAndInstallMissingTools analyzes a bash command and attempts to automatically install any missing tools.
334func (b *BashTool) checkAndInstallMissingTools(ctx context.Context, command string) error {
335 commands, err := bashkit.ExtractCommands(command)
336 if err != nil {
337 return err
338 }
339
340 autoInstallMu.Lock()
341 defer autoInstallMu.Unlock()
342
343 var missing []string
344 for _, cmd := range commands {
345 if doNotAttemptToolInstall[cmd] {
346 continue
347 }
348 _, err := exec.LookPath(cmd)
349 if err == nil {
350 doNotAttemptToolInstall[cmd] = true // spare future LookPath calls
351 continue
352 }
353 missing = append(missing, cmd)
354 }
355
Josh Bleecher Snyder855afff2025-05-30 08:47:47 -0700356 if len(missing) == 0 {
357 return nil
358 }
359
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +0000360 err = b.installTools(ctx, missing)
361 if err != nil {
362 return err
363 }
364 for _, cmd := range missing {
365 doNotAttemptToolInstall[cmd] = true // either it's installed or it's not--either way, we're done with it
366 }
367 return nil
368}
369
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +0000370// Command safety check cache to avoid repeated LLM calls
371var (
372 autoInstallMu sync.Mutex
373 doNotAttemptToolInstall = make(map[string]bool) // set to true if the tool should not be auto-installed
374)
375
376// installTools installs missing tools.
377func (b *BashTool) installTools(ctx context.Context, missing []string) error {
378 slog.InfoContext(ctx, "installTools subconvo", "tools", missing)
379
380 info := conversation.ToolCallInfoFromContext(ctx)
381 if info.Convo == nil {
382 return fmt.Errorf("no conversation context available for tool installation")
383 }
384 subConvo := info.Convo.SubConvo()
385 subConvo.Hidden = true
386 subBash := NewBashTool(nil, NoBashToolJITInstall)
387
388 done := false
389 doneTool := &llm.Tool{
390 Name: "done",
391 Description: "Call this tool once when finished processing all commands, providing the installation status for each.",
392 InputSchema: json.RawMessage(`{
393 "type": "object",
394 "properties": {
395 "results": {
396 "type": "array",
397 "items": {
398 "type": "object",
399 "properties": {
400 "command_name": {
401 "type": "string",
402 "description": "The name of the command"
403 },
404 "installed": {
405 "type": "boolean",
406 "description": "Whether the command was installed"
407 }
408 },
409 "required": ["command_name", "installed"]
410 }
411 }
412 },
413 "required": ["results"]
414}`),
415 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
416 type InstallResult struct {
417 CommandName string `json:"command_name"`
418 Installed bool `json:"installed"`
419 }
420 type DoneInput struct {
421 Results []InstallResult `json:"results"`
422 }
423 var doneInput DoneInput
424 err := json.Unmarshal(input, &doneInput)
425 results := doneInput.Results
426 if err != nil {
427 slog.WarnContext(ctx, "failed to parse install results", "raw", string(input), "error", err)
428 } else {
429 slog.InfoContext(ctx, "auto-tool installation complete", "results", results)
430 }
431 done = true
432 return llm.TextContent(""), nil
433 },
434 }
435
436 subConvo.Tools = []*llm.Tool{
437 subBash,
438 doneTool,
439 }
440
441 const autoinstallSystemPrompt = `The assistant powers an entirely automated auto-installer tool.
442
443The user will provide a list of commands that were not found on the system.
444
445The assistant's task:
446
447First, decide whether each command is mainstream and safe for automatic installation in a development environment. Skip any commands that could cause security issues, legal problems, or consume excessive resources.
448
449For each appropriate command:
450
4511. Detect the system's package manager and install the command using standard repositories only (no source builds, no curl|bash installs).
4522. Make a minimal verification attempt (package manager success is sufficient).
4533. If installation fails after reasonable attempts, mark as failed and move on.
454
455Once all commands have been processed, call the "done" tool with the status of each command.
456`
457
458 subConvo.SystemPrompt = autoinstallSystemPrompt
459
460 cmds := new(strings.Builder)
461 cmds.WriteString("<commands>\n")
462 for _, cmd := range missing {
463 cmds.WriteString("<command>")
464 cmds.WriteString(cmd)
465 cmds.WriteString("</command>\n")
466 }
467 cmds.WriteString("</commands>\n")
468
469 resp, err := subConvo.SendUserTextMessage(cmds.String())
470 if err != nil {
471 return err
472 }
473
474 for !done {
475 if resp.StopReason != llm.StopReasonToolUse {
476 return fmt.Errorf("subagent finished without calling tool")
477 }
478
479 ctxWithWorkDir := WithWorkingDir(ctx, WorkingDir(ctx))
480 results, _, err := subConvo.ToolResultContents(ctxWithWorkDir, resp)
481 if err != nil {
482 return err
483 }
484
485 resp, err = subConvo.SendMessage(llm.Message{
486 Role: llm.MessageRoleUser,
487 Content: results,
488 })
489 if err != nil {
490 return err
491 }
492 }
493
494 return nil
495}