diff --git a/README.md b/README.md index b801b9d..ce4cd2a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [English](./README_EN.md) -**ClawGo** 是一个面向 Linux 服务器的 Go 原生 AI Agent。它提供单二进制部署、多通道接入、可热更新配置与可控风险执行,适合长期在线自动化任务。 +**ClawGo** 是一个面向 Linux 服务器的 Go 原生 AI Agent。它提供单二进制部署、多通道接入与可热更新配置,适合长期在线自动化任务。 ## 🚀 功能总览 @@ -12,7 +12,7 @@ - **多智能体编排**:支持 Pipeline 协议(`role + goal + depends_on + shared_state`)。 - **记忆与上下文治理**:支持分层记忆、`memory_search` 与自动上下文压缩。 - **可靠性增强**:支持代理内模型切换与跨代理切换(`proxy_fallbacks`),覆盖配额、路由、网关瞬时错误等场景。 -- **安全防护**:Shell Risk Gate、Sentinel 巡检与自动修复能力。 +- **稳定性保障**:Sentinel 巡检与自动修复能力。 - **技能扩展**:支持内置技能与 GitHub 技能安装,支持原子脚本执行。 ## 🏁 快速开始 @@ -202,11 +202,10 @@ clawgo channel test --channel telegram --to -m "ping" 适用于拆解复杂任务、跨角色协作和共享状态推进。 -## 🛡️ 风险防护与稳定性 +## 🛡️ 稳定性 - **Proxy/Model fallback**:先在当前代理中按 `models` 顺序切换,全部失败后再按 `proxy_fallbacks` 切换代理。 - **HTTP 兼容处理**:可识别非 JSON 错页并给出响应预览;兼容从 `` 文本块提取工具调用。 -- **Shell Risk Gate**:高风险命令默认阻断,支持 dry-run 与 force 策略。 - **Sentinel**:周期巡检配置/内存/日志目录,支持自动修复与告警转发。 Sentinel 配置示例: diff --git a/README_EN.md b/README_EN.md index de67a79..2497d14 100644 --- a/README_EN.md +++ b/README_EN.md @@ -2,7 +2,7 @@ [中文](./README.md) -**ClawGo** is a Go-native AI agent for Linux servers. It provides single-binary deployment, multi-channel integration, hot-reloadable config, and controllable execution risk for long-running autonomous workflows. +**ClawGo** is a Go-native AI agent for Linux servers. It provides single-binary deployment, multi-channel integration, and hot-reloadable config for long-running autonomous workflows. ## 🚀 Feature Overview @@ -12,7 +12,7 @@ - **Multi-agent orchestration**: built-in Pipeline protocol (`role + goal + depends_on + shared_state`). - **Memory and context governance**: layered memory, `memory_search`, and automatic context compaction. - **Reliability enhancements**: in-proxy model switching and cross-proxy fallback (`proxy_fallbacks`) for quota, routing, and transient gateway failures. -- **Safety controls**: Shell Risk Gate, Sentinel inspection, and auto-heal support. +- **Stability controls**: Sentinel inspection and auto-heal support. - **Skill extensibility**: built-in skills plus GitHub skill installation and atomic script execution. ## 🏁 Quick Start @@ -202,11 +202,10 @@ Built-in orchestration tools: Useful for complex task decomposition, role-based execution, and shared state workflows. -## 🛡️ Safety and Reliability +## 🛡️ Reliability - **Proxy/model fallback**: retries models in the current proxy first, then switches proxies in `proxy_fallbacks` when all models fail. - **HTTP compatibility handling**: detects non-JSON error pages with body preview; parses tool calls from `` blocks. -- **Shell Risk Gate**: blocks destructive operations by default; supports dry-run and force policies. - **Sentinel**: periodic checks for config/memory/log resources with optional auto-heal and notifications. Sentinel config example: diff --git a/cmd/clawgo/main.go b/cmd/clawgo/main.go index fb902b4..7a51844 100644 --- a/cmd/clawgo/main.go +++ b/cmd/clawgo/main.go @@ -1048,12 +1048,8 @@ func isInteractiveStdin() bool { } func applyMaximumPermissionPolicy(cfg *config.Config) { - cfg.Tools.Shell.RestrictPath = false - cfg.Tools.Shell.DeniedCmds = []string{"rm -rf /"} - cfg.Tools.Shell.Risk.Enabled = false - cfg.Tools.Shell.Risk.AllowDestructive = true - cfg.Tools.Shell.Risk.RequireDryRun = false - cfg.Tools.Shell.Risk.RequireForceFlag = false + cfg.Tools.Shell.Enabled = true + cfg.Tools.Shell.Sandbox.Enabled = false } func gatewayInstallServiceCmd() error { diff --git a/config.example.json b/config.example.json index f3fe38a..6cb3b82 100644 --- a/config.example.json +++ b/config.example.json @@ -100,6 +100,16 @@ } }, "tools": { + "filesystem": {}, + "shell": { + "enabled": true, + "working_dir": "", + "timeout": 60000000000, + "sandbox": { + "enabled": false, + "image": "golang:alpine" + } + }, "web": { "search": { "api_key": "YOUR_BRAVE_API_KEY", diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 200da33..6b5f1e6 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -78,8 +78,8 @@ Your workspace is at: %s %s Always be helpful, accurate, and concise. When using tools, explain what you're doing. -For operational tasks (for example configuring git remotes/credentials), prefer executing tools directly instead of only giving manual steps. -If user provides credentials/tokens for a requested operation, you may use them to execute the task, but never expose full secrets in visible output. +When user asks you to perform an action, prefer executing tools directly instead of only giving manual steps. +Never expose full secrets in visible output. When remembering something, write to %s/memory/MEMORY.md`, now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, workspacePath) } diff --git a/pkg/config/config.go b/pkg/config/config.go index d96c1b9..3035b83 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -171,21 +171,10 @@ type WebToolsConfig struct { } type ShellConfig struct { - Enabled bool `json:"enabled" env:"CLAWGO_TOOLS_SHELL_ENABLED"` - WorkingDir string `json:"working_dir" env:"CLAWGO_TOOLS_SHELL_WORKING_DIR"` - Timeout time.Duration `json:"timeout" env:"CLAWGO_TOOLS_SHELL_TIMEOUT"` - DeniedCmds []string `json:"denied_cmds" env:"CLAWGO_TOOLS_SHELL_DENIED_CMDS"` - AllowedCmds []string `json:"allowed_cmds" env:"CLAWGO_TOOLS_SHELL_ALLOWED_CMDS"` - Sandbox SandboxConfig `json:"sandbox"` - Risk RiskConfig `json:"risk"` - RestrictPath bool `json:"restrict_path" env:"CLAWGO_TOOLS_SHELL_RESTRICT_PATH"` -} - -type RiskConfig struct { - Enabled bool `json:"enabled" env:"CLAWGO_TOOLS_SHELL_RISK_ENABLED"` - AllowDestructive bool `json:"allow_destructive" env:"CLAWGO_TOOLS_SHELL_RISK_ALLOW_DESTRUCTIVE"` - RequireDryRun bool `json:"require_dry_run" env:"CLAWGO_TOOLS_SHELL_RISK_REQUIRE_DRY_RUN"` - RequireForceFlag bool `json:"require_force_flag" env:"CLAWGO_TOOLS_SHELL_RISK_REQUIRE_FORCE_FLAG"` + Enabled bool `json:"enabled" env:"CLAWGO_TOOLS_SHELL_ENABLED"` + WorkingDir string `json:"working_dir" env:"CLAWGO_TOOLS_SHELL_WORKING_DIR"` + Timeout time.Duration `json:"timeout" env:"CLAWGO_TOOLS_SHELL_TIMEOUT"` + Sandbox SandboxConfig `json:"sandbox"` } type SandboxConfig struct { @@ -193,10 +182,7 @@ type SandboxConfig struct { Image string `json:"image" env:"CLAWGO_TOOLS_SHELL_SANDBOX_IMAGE"` } -type FilesystemConfig struct { - AllowedPaths []string `json:"allowed_paths" env:"CLAWGO_TOOLS_FILESYSTEM_ALLOWED_PATHS"` - DeniedPaths []string `json:"denied_paths" env:"CLAWGO_TOOLS_FILESYSTEM_DENIED_PATHS"` -} +type FilesystemConfig struct{} type ToolsConfig struct { Web WebToolsConfig `json:"web"` @@ -373,24 +359,12 @@ func DefaultConfig() *Config { Shell: ShellConfig{ Enabled: true, Timeout: 60 * time.Second, - DeniedCmds: []string{ - "rm -rf /", "dd if=", "mkfs", "shutdown", "reboot", - }, Sandbox: SandboxConfig{ Enabled: false, Image: "golang:alpine", }, - Risk: RiskConfig{ - Enabled: true, - AllowDestructive: false, - RequireDryRun: true, - RequireForceFlag: true, - }, - }, - Filesystem: FilesystemConfig{ - AllowedPaths: []string{}, - DeniedPaths: []string{"/etc/shadow", "/etc/passwd"}, }, + Filesystem: FilesystemConfig{}, }, Logging: LoggingConfig{ Enabled: true, diff --git a/pkg/tools/risk.go b/pkg/tools/risk.go deleted file mode 100644 index 4ceee87..0000000 --- a/pkg/tools/risk.go +++ /dev/null @@ -1,93 +0,0 @@ -package tools - -import ( - "regexp" - "strings" -) - -type RiskLevel string - -const ( - RiskSafe RiskLevel = "safe" - RiskModerate RiskLevel = "moderate" - RiskDestructive RiskLevel = "destructive" -) - -type RiskAssessment struct { - Level RiskLevel - Reasons []string -} - -var destructivePatterns = []*regexp.Regexp{ - regexp.MustCompile(`\brm\s+-rf\b`), - regexp.MustCompile(`\bmkfs(\.| )`), - regexp.MustCompile(`\bdd\s+if=`), - regexp.MustCompile(`\bshutdown\b`), - regexp.MustCompile(`\breboot\b`), - regexp.MustCompile(`\buserdel\b`), - regexp.MustCompile(`\bchown\b.+\s+/`), - regexp.MustCompile(`\bclawgo\s+uninstall\b`), - regexp.MustCompile(`\bdbt\s+drop\b`), - regexp.MustCompile(`\bgit\s+clean\b`), -} - -var moderatePatterns = []*regexp.Regexp{ - regexp.MustCompile(`\bgit\s+reset\s+--hard\b`), - regexp.MustCompile(`\bdocker\s+system\s+prune\b`), - regexp.MustCompile(`\bapt(-get)?\s+install\b`), - regexp.MustCompile(`\byum\s+install\b`), - regexp.MustCompile(`\bpip\s+install\b`), -} - -func assessCommandRisk(command string) RiskAssessment { - cmd := strings.ToLower(strings.TrimSpace(command)) - out := RiskAssessment{Level: RiskSafe, Reasons: []string{}} - - for _, re := range destructivePatterns { - if re.MatchString(cmd) { - out.Level = RiskDestructive - out.Reasons = append(out.Reasons, "destructive pattern: "+re.String()) - } - } - if out.Level == RiskDestructive { - return out - } - - for _, re := range moderatePatterns { - if re.MatchString(cmd) { - out.Level = RiskModerate - out.Reasons = append(out.Reasons, "moderate pattern: "+re.String()) - } - } - return out -} - -func buildDryRunCommand(command string) (string, bool) { - trimmed := strings.TrimSpace(command) - lower := strings.ToLower(trimmed) - - switch { - case strings.HasPrefix(lower, "apt ") || strings.HasPrefix(lower, "apt-get "): - if strings.Contains(lower, "--dry-run") || strings.Contains(lower, "-s ") { - return trimmed, true - } - return trimmed + " --dry-run", true - case strings.HasPrefix(lower, "yum "): - if strings.Contains(lower, "--assumeno") { - return trimmed, true - } - return trimmed + " --assumeno", true - case strings.HasPrefix(lower, "dnf "): - if strings.Contains(lower, "--assumeno") { - return trimmed, true - } - return trimmed + " --assumeno", true - case strings.HasPrefix(lower, "git clean"): - if strings.Contains(lower, "-n") || strings.Contains(lower, "--dry-run") { - return trimmed, true - } - return trimmed + " -n", true - default: - return "", false - } -} diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index 304faaa..a5875c1 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -7,47 +7,24 @@ import ( "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 + workingDir string + timeout time.Duration + sandboxEnabled bool + sandboxImage string } 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, + workingDir: workspace, + timeout: cfg.Timeout, + sandboxEnabled: cfg.Sandbox.Enabled, + sandboxImage: cfg.Sandbox.Image, } } @@ -71,10 +48,6 @@ func (t *ExecTool) Parameters() 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"}, } @@ -98,19 +71,6 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) (st } } - 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) } @@ -150,125 +110,10 @@ func (t *ExecTool) executeInSandbox(ctx context.Context, command, cwd string) (s 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() @@ -305,13 +150,3 @@ func (t *ExecTool) executeCommand(ctx context.Context, command, cwd string) (str } 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] + "..." -} diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go index c67dd47..bb0c5e3 100644 --- a/pkg/tools/shell_test.go +++ b/pkg/tools/shell_test.go @@ -1,119 +1,36 @@ package tools import ( + "context" + "strings" "testing" + "time" "clawgo/pkg/config" ) -func TestApplyRiskGate_DryRunCanBeBypassedWithForce(t *testing.T) { - tool := &ExecTool{riskCfg: config.RiskConfig{ - Enabled: true, - AllowDestructive: true, - RequireDryRun: true, - RequireForceFlag: false, - }} - - msg, dryRun := tool.applyRiskGate("git clean -fd", true) - if msg != "" || dryRun != "" { - t.Fatalf("expected force=true to allow execution after dry-run step, got msg=%q dryRun=%q", msg, dryRun) +func TestExecToolExecuteBasicCommand(t *testing.T) { + tool := NewExecTool(config.ShellConfig{Timeout: 2 * time.Second}, ".") + out, err := tool.Execute(context.Background(), map[string]interface{}{ + "command": "echo hello", + }) + if err != nil { + t.Fatalf("execute failed: %v", err) + } + if !strings.Contains(out, "hello") { + t.Fatalf("expected output to contain hello, got %q", out) } } -func TestApplyRiskGate_RequiresDryRunWithoutForce(t *testing.T) { - tool := &ExecTool{riskCfg: config.RiskConfig{ - Enabled: true, - AllowDestructive: true, - RequireDryRun: true, - RequireForceFlag: false, - }} - - msg, dryRun := tool.applyRiskGate("git clean -fd", false) - if msg == "" { - t.Fatal("expected dry-run block message") +func TestExecToolExecuteTimeout(t *testing.T) { + tool := NewExecTool(config.ShellConfig{Timeout: 20 * time.Millisecond}, ".") + out, err := tool.Execute(context.Background(), map[string]interface{}{ + "command": "sleep 1", + }) + if err != nil { + t.Fatalf("execute failed: %v", err) } - if dryRun == "" { - t.Fatal("expected dry-run command") - } -} - -func TestAssessCommandRisk_GitCleanIsDestructive(t *testing.T) { - assessment := assessCommandRisk("git clean -fd") - if assessment.Level != RiskDestructive { - t.Fatalf("expected git clean to be destructive, got %s", assessment.Level) - } -} - -func TestNewExecTool_LoadsAllowedCmdsIntoAllowPatterns(t *testing.T) { - tool := NewExecTool(config.ShellConfig{AllowedCmds: []string{"echo"}}, ".") - if len(tool.allowPatterns) != 1 { - t.Fatalf("expected one allow pattern, got %d", len(tool.allowPatterns)) - } -} - -func TestGuardCommand_BlocksCommandNotInAllowlist(t *testing.T) { - tool := NewExecTool(config.ShellConfig{AllowedCmds: []string{"echo"}}, ".") - if msg := tool.guardCommand("ls -la", "."); msg == "" { - t.Fatal("expected allowlist to block command not in allowed_cmds") - } - - if msg := tool.guardCommand("echo hi", "."); msg != "" { - t.Fatalf("expected allowed command to pass guard, got %q", msg) - } -} - -func TestGuardCommand_AllowlistIsCaseInsensitive(t *testing.T) { - tool := NewExecTool(config.ShellConfig{AllowedCmds: []string{"ECHO"}}, ".") - if msg := tool.guardCommand("echo hi", "."); msg != "" { - t.Fatalf("expected case-insensitive allowlist match, got %q", msg) - } -} - -func TestGuardCommand_DenylistIsCaseInsensitive(t *testing.T) { - tool := NewExecTool(config.ShellConfig{DeniedCmds: []string{"RM"}}, ".") - if msg := tool.guardCommand("rm -f tmp.txt", "."); msg == "" { - t.Fatal("expected case-insensitive denylist match to block command") - } -} - -func TestApplyRiskGate_RequireDryRunWithoutStrategyStillBlocks(t *testing.T) { - tool := &ExecTool{riskCfg: config.RiskConfig{ - Enabled: true, - AllowDestructive: true, - RequireDryRun: true, - RequireForceFlag: false, - }} - - msg, dryRun := tool.applyRiskGate("rm -rf tmp", false) - if msg == "" { - t.Fatal("expected destructive command without dry-run strategy to be blocked") - } - if dryRun != "" { - t.Fatalf("expected no dry-run command for rm -rf, got %q", dryRun) - } -} - -func TestSetAllowPatterns_IsCaseInsensitive(t *testing.T) { - tool := &ExecTool{} - if err := tool.SetAllowPatterns([]string{`^ECHO\b`}); err != nil { - t.Fatalf("SetAllowPatterns returned error: %v", err) - } - - if msg := tool.guardCommand("echo hi", "."); msg != "" { - t.Fatalf("expected case-insensitive allow pattern to match, got %q", msg) - } -} - -func TestGuardCommand_BlocksRootWipeVariants(t *testing.T) { - tool := &ExecTool{} - cases := []string{ - "rm -rf /", - "rm -fr /", - "rm --no-preserve-root -rf /", - } - for _, c := range cases { - if msg := tool.guardCommand(c, "."); msg == "" { - t.Fatalf("expected root wipe variant to be blocked: %s", c) - } + if !strings.Contains(out, "timed out") { + t.Fatalf("expected timeout message, got %q", out) } }