2026-02-21 23:01:39 +08:00
|
|
|
package shelltool
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"fmt"
|
|
|
|
|
"os/exec"
|
|
|
|
|
"path/filepath"
|
2026-02-28 17:48:33 +08:00
|
|
|
"runtime"
|
2026-02-21 23:01:39 +08:00
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"laodingbot/internal/logger"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type Tool struct {
|
|
|
|
|
allowedCommands map[string]struct{}
|
|
|
|
|
workDir string
|
|
|
|
|
timeout time.Duration
|
2026-02-28 17:48:33 +08:00
|
|
|
maxOutputChars int
|
2026-02-21 23:01:39 +08:00
|
|
|
log *logger.Logger
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-28 17:48:33 +08:00
|
|
|
func New(allowed []string, workDir string, timeout time.Duration, maxOutputChars int, log *logger.Logger) *Tool {
|
2026-02-21 23:01:39 +08:00
|
|
|
set := make(map[string]struct{}, len(allowed))
|
|
|
|
|
for _, c := range allowed {
|
|
|
|
|
cmd := strings.TrimSpace(c)
|
|
|
|
|
if cmd != "" {
|
|
|
|
|
set[cmd] = struct{}{}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
absDir, err := filepath.Abs(workDir)
|
|
|
|
|
if err != nil {
|
|
|
|
|
absDir = workDir
|
|
|
|
|
}
|
|
|
|
|
if timeout <= 0 {
|
|
|
|
|
timeout = 15 * time.Second
|
|
|
|
|
}
|
2026-02-28 17:48:33 +08:00
|
|
|
if maxOutputChars <= 0 {
|
|
|
|
|
maxOutputChars = 4000
|
|
|
|
|
}
|
2026-02-21 23:01:39 +08:00
|
|
|
if log != nil {
|
2026-02-28 17:48:33 +08:00
|
|
|
log.Infof("shell tool initialized allowed_commands=%d work_dir=%s timeout=%s max_output_chars=%d", len(set), absDir, timeout, maxOutputChars)
|
2026-02-21 23:01:39 +08:00
|
|
|
}
|
2026-02-28 17:48:33 +08:00
|
|
|
return &Tool{allowedCommands: set, workDir: absDir, timeout: timeout, maxOutputChars: maxOutputChars, log: log}
|
2026-02-21 23:01:39 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (t *Tool) Name() string { return "shell" }
|
|
|
|
|
|
|
|
|
|
func (t *Tool) Description() string {
|
|
|
|
|
return "Execute allowlisted shell commands in Linux"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (t *Tool) Call(ctx context.Context, input string) (string, error) {
|
|
|
|
|
trimmed := strings.TrimSpace(input)
|
|
|
|
|
if trimmed == "" {
|
|
|
|
|
if t.log != nil {
|
|
|
|
|
t.log.Warnf("shell tool rejected empty command")
|
|
|
|
|
}
|
|
|
|
|
return "", fmt.Errorf("empty command")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
parts := strings.Fields(trimmed)
|
|
|
|
|
base := parts[0]
|
|
|
|
|
if _, ok := t.allowedCommands[base]; !ok {
|
|
|
|
|
if t.log != nil {
|
2026-02-21 23:29:27 +08:00
|
|
|
t.log.Warnf("shell command denied command=%s full_command=%q", base, trimmed)
|
2026-02-21 23:01:39 +08:00
|
|
|
}
|
|
|
|
|
return "", fmt.Errorf("command not allowed: %s", base)
|
|
|
|
|
}
|
|
|
|
|
if t.log != nil {
|
2026-02-21 23:29:27 +08:00
|
|
|
t.log.Infof("shell command start command=%s args=%d full_command=%q", base, len(parts)-1, trimmed)
|
2026-02-21 23:01:39 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
runCtx, cancel := context.WithTimeout(ctx, t.timeout)
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
|
|
|
|
cmd := exec.CommandContext(runCtx, base, parts[1:]...)
|
|
|
|
|
cmd.Dir = t.workDir
|
|
|
|
|
out, err := cmd.CombinedOutput()
|
2026-02-28 17:48:33 +08:00
|
|
|
outText := string(out)
|
|
|
|
|
if len(outText) > t.maxOutputChars {
|
|
|
|
|
outText = outText[:t.maxOutputChars]
|
|
|
|
|
}
|
2026-02-21 23:01:39 +08:00
|
|
|
if err != nil {
|
|
|
|
|
if t.log != nil {
|
2026-02-28 17:48:33 +08:00
|
|
|
t.log.Errorf("shell command failed command=%s full_command=%q err=%v output_bytes=%d output=%q", base, trimmed, err, len(out), outText)
|
|
|
|
|
}
|
|
|
|
|
if runtime.GOOS == "windows" && strings.Contains(strings.ToLower(err.Error()), "executable file not found") {
|
|
|
|
|
return outText, fmt.Errorf("command not executable in current windows environment: %s", base)
|
2026-02-21 23:01:39 +08:00
|
|
|
}
|
2026-02-28 17:48:33 +08:00
|
|
|
return outText, err
|
2026-02-21 23:01:39 +08:00
|
|
|
}
|
|
|
|
|
if t.log != nil {
|
2026-02-28 17:48:33 +08:00
|
|
|
t.log.Infof("shell command success command=%s full_command=%q output_bytes=%d output=%q", base, trimmed, len(out), outText)
|
2026-02-21 23:01:39 +08:00
|
|
|
}
|
2026-02-28 17:48:33 +08:00
|
|
|
return outText, nil
|
2026-02-21 23:01:39 +08:00
|
|
|
}
|