Files
clawgo/pkg/tools/shell.go
2026-02-14 13:56:45 +08:00

318 lines
7.7 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`))
}
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] + "..."
}