mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-13 00:54:53 +08:00
323 lines
7.9 KiB
Go
323 lines
7.9 KiB
Go
package tools
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"clawgo/pkg/config"
|
|
"clawgo/pkg/logger"
|
|
)
|
|
|
|
var blockedRootWipePattern = regexp.MustCompile(`(?i)(^|[;&|\n])\s*rm\b[^\n;&|]*\s(?:'/'|"/"|/)(?:\s|$)`)
|
|
|
|
type ExecTool struct {
|
|
workingDir string
|
|
timeout time.Duration
|
|
denyPatterns []*regexp.Regexp
|
|
allowPatterns []*regexp.Regexp
|
|
restrictToWorkspace bool
|
|
sandboxEnabled bool
|
|
sandboxImage string
|
|
riskCfg config.RiskConfig
|
|
}
|
|
|
|
func NewExecTool(cfg config.ShellConfig, workspace string) *ExecTool {
|
|
denyPatterns := make([]*regexp.Regexp, 0, len(cfg.DeniedCmds))
|
|
for _, p := range cfg.DeniedCmds {
|
|
denyPatterns = append(denyPatterns, regexp.MustCompile(`(?i)\b`+regexp.QuoteMeta(p)+`\b`))
|
|
}
|
|
|
|
allowPatterns := make([]*regexp.Regexp, 0, len(cfg.AllowedCmds))
|
|
for _, p := range cfg.AllowedCmds {
|
|
allowPatterns = append(allowPatterns, regexp.MustCompile(`(?i)\b`+regexp.QuoteMeta(p)+`\b`))
|
|
}
|
|
|
|
allowPatterns := make([]*regexp.Regexp, 0, len(cfg.AllowedCmds))
|
|
for _, p := range cfg.AllowedCmds {
|
|
allowPatterns = append(allowPatterns, regexp.MustCompile(`\b`+regexp.QuoteMeta(p)+`\b`))
|
|
}
|
|
|
|
return &ExecTool{
|
|
workingDir: workspace,
|
|
timeout: cfg.Timeout,
|
|
denyPatterns: denyPatterns,
|
|
allowPatterns: allowPatterns,
|
|
restrictToWorkspace: cfg.RestrictPath,
|
|
sandboxEnabled: cfg.Sandbox.Enabled,
|
|
sandboxImage: cfg.Sandbox.Image,
|
|
riskCfg: cfg.Risk,
|
|
}
|
|
}
|
|
|
|
func (t *ExecTool) Name() string {
|
|
return "exec"
|
|
}
|
|
|
|
func (t *ExecTool) Description() string {
|
|
return "Execute a shell command and return its output. Use with caution."
|
|
}
|
|
|
|
func (t *ExecTool) Parameters() map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"command": map[string]interface{}{
|
|
"type": "string",
|
|
"description": "The shell command to execute",
|
|
},
|
|
"working_dir": map[string]interface{}{
|
|
"type": "string",
|
|
"description": "Optional working directory for the command",
|
|
},
|
|
"force": map[string]interface{}{
|
|
"type": "boolean",
|
|
"description": "Bypass risk gate for destructive operations (still strongly discouraged).",
|
|
},
|
|
},
|
|
"required": []string{"command"},
|
|
}
|
|
}
|
|
|
|
func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
|
|
command, ok := args["command"].(string)
|
|
if !ok {
|
|
return "", fmt.Errorf("command is required")
|
|
}
|
|
|
|
cwd := t.workingDir
|
|
if wd, ok := args["working_dir"].(string); ok && wd != "" {
|
|
cwd = wd
|
|
}
|
|
|
|
if cwd == "" {
|
|
wd, err := os.Getwd()
|
|
if err == nil {
|
|
cwd = wd
|
|
}
|
|
}
|
|
|
|
if guardError := t.guardCommand(command, cwd); guardError != "" {
|
|
return fmt.Sprintf("Error: %s", guardError), nil
|
|
}
|
|
|
|
force, _ := args["force"].(bool)
|
|
if blockMsg, dryRunCmd := t.applyRiskGate(command, force); blockMsg != "" {
|
|
if dryRunCmd != "" {
|
|
dryRunResult, _ := t.executeCommand(ctx, dryRunCmd, cwd)
|
|
return fmt.Sprintf("%s\n\nDry-run command: %s\nDry-run output:\n%s", blockMsg, dryRunCmd, dryRunResult), nil
|
|
}
|
|
return blockMsg, nil
|
|
}
|
|
|
|
if t.sandboxEnabled {
|
|
return t.executeInSandbox(ctx, command, cwd)
|
|
}
|
|
|
|
return t.executeCommand(ctx, command, cwd)
|
|
}
|
|
|
|
func (t *ExecTool) executeInSandbox(ctx context.Context, command, cwd string) (string, error) {
|
|
// 实现 Docker 沙箱执行逻辑
|
|
absCwd, _ := filepath.Abs(cwd)
|
|
dockerArgs := []string{
|
|
"run", "--rm",
|
|
"-v", fmt.Sprintf("%s:/app", absCwd),
|
|
"-w", "/app",
|
|
t.sandboxImage,
|
|
"sh", "-c", command,
|
|
}
|
|
|
|
cmdCtx, cancel := context.WithTimeout(ctx, t.timeout)
|
|
defer cancel()
|
|
|
|
cmd := exec.CommandContext(cmdCtx, "docker", dockerArgs...)
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
err := cmd.Run()
|
|
output := stdout.String()
|
|
if stderr.Len() > 0 {
|
|
output += "\nSTDERR:\n" + stderr.String()
|
|
}
|
|
|
|
if err != nil {
|
|
output += fmt.Sprintf("\nSandbox Exit code: %v", err)
|
|
}
|
|
|
|
return output, nil
|
|
}
|
|
|
|
func (t *ExecTool) guardCommand(command, cwd string) string {
|
|
cmd := strings.TrimSpace(command)
|
|
lower := strings.ToLower(cmd)
|
|
|
|
if blockedRootWipePattern.MatchString(lower) {
|
|
return "Command blocked by safety guard (removing root path / is forbidden)"
|
|
}
|
|
|
|
for _, pattern := range t.denyPatterns {
|
|
if pattern.MatchString(cmd) {
|
|
return "Command blocked by safety guard (dangerous pattern detected)"
|
|
}
|
|
}
|
|
|
|
if len(t.allowPatterns) > 0 {
|
|
allowed := false
|
|
for _, pattern := range t.allowPatterns {
|
|
if pattern.MatchString(cmd) {
|
|
allowed = true
|
|
break
|
|
}
|
|
}
|
|
if !allowed {
|
|
return "Command blocked by safety guard (not in allowlist)"
|
|
}
|
|
}
|
|
|
|
if t.restrictToWorkspace {
|
|
if strings.Contains(cmd, "..\\") || strings.Contains(cmd, "../") {
|
|
return "Command blocked by safety guard (path traversal detected)"
|
|
}
|
|
|
|
cwdPath, err := filepath.Abs(cwd)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
pathPattern := regexp.MustCompile(`[A-Za-z]:\\[^\\\"']+|/[^\s\"']+`)
|
|
matches := pathPattern.FindAllString(cmd, -1)
|
|
|
|
for _, raw := range matches {
|
|
p, err := filepath.Abs(raw)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
rel, err := filepath.Rel(cwdPath, p)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if strings.HasPrefix(rel, "..") {
|
|
return "Command blocked by safety guard (path outside working dir)"
|
|
}
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func (t *ExecTool) SetTimeout(timeout time.Duration) {
|
|
t.timeout = timeout
|
|
}
|
|
|
|
func (t *ExecTool) SetRestrictToWorkspace(restrict bool) {
|
|
t.restrictToWorkspace = restrict
|
|
}
|
|
|
|
func (t *ExecTool) SetAllowPatterns(patterns []string) error {
|
|
t.allowPatterns = make([]*regexp.Regexp, 0, len(patterns))
|
|
for _, p := range patterns {
|
|
re, err := regexp.Compile("(?i)" + p)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid allow pattern %q: %w", p, err)
|
|
}
|
|
t.allowPatterns = append(t.allowPatterns, re)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (t *ExecTool) applyRiskGate(command string, force bool) (string, string) {
|
|
if !t.riskCfg.Enabled {
|
|
return "", ""
|
|
}
|
|
|
|
assessment := assessCommandRisk(command)
|
|
logger.InfoCF("risk", "Command risk assessed", map[string]interface{}{
|
|
"level": assessment.Level,
|
|
"command": truncateCmd(command, 200),
|
|
"reasons": assessment.Reasons,
|
|
})
|
|
|
|
if assessment.Level != RiskDestructive {
|
|
return "", ""
|
|
}
|
|
|
|
if t.riskCfg.RequireForceFlag && !force {
|
|
msg := "Error: destructive command blocked by risk gate. Re-run with force=true if intentional."
|
|
if t.riskCfg.RequireDryRun {
|
|
if dryRunCmd, ok := buildDryRunCommand(command); ok {
|
|
return msg, dryRunCmd
|
|
}
|
|
}
|
|
return msg, ""
|
|
}
|
|
|
|
if !t.riskCfg.AllowDestructive {
|
|
return "Error: destructive command is disabled by policy (tools.shell.risk.allow_destructive=false).", ""
|
|
}
|
|
|
|
if t.riskCfg.RequireDryRun && !force {
|
|
if dryRunCmd, ok := buildDryRunCommand(command); ok {
|
|
return "Risk gate: dry-run required first. Review output, then execute intentionally with force=true.", dryRunCmd
|
|
}
|
|
return "Error: destructive command requires explicit force=true because no dry-run strategy is available.", ""
|
|
}
|
|
return "", ""
|
|
}
|
|
|
|
func (t *ExecTool) executeCommand(ctx context.Context, command, cwd string) (string, error) {
|
|
cmdCtx, cancel := context.WithTimeout(ctx, t.timeout)
|
|
defer cancel()
|
|
|
|
cmd := exec.CommandContext(cmdCtx, "sh", "-c", command)
|
|
if cwd != "" {
|
|
cmd.Dir = cwd
|
|
}
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
err := cmd.Run()
|
|
output := stdout.String()
|
|
if stderr.Len() > 0 {
|
|
output += "\nSTDERR:\n" + stderr.String()
|
|
}
|
|
|
|
if err != nil {
|
|
if cmdCtx.Err() == context.DeadlineExceeded {
|
|
return fmt.Sprintf("Error: Command timed out after %v", t.timeout), nil
|
|
}
|
|
output += fmt.Sprintf("\nExit code: %v", err)
|
|
}
|
|
|
|
if output == "" {
|
|
output = "(no output)"
|
|
}
|
|
|
|
maxLen := 10000
|
|
if len(output) > maxLen {
|
|
output = output[:maxLen] + fmt.Sprintf("\n... (truncated, %d more chars)", len(output)-maxLen)
|
|
}
|
|
return output, nil
|
|
}
|
|
|
|
func truncateCmd(s string, max int) string {
|
|
if len(s) <= max {
|
|
return s
|
|
}
|
|
if max <= 3 {
|
|
return s[:max]
|
|
}
|
|
return s[:max-3] + "..."
|
|
}
|