fix safety

This commit is contained in:
lpf
2026-02-19 21:53:38 +08:00
parent b3d7774d9c
commit d9f989765d
9 changed files with 56 additions and 419 deletions

View File

@@ -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 <chat_id> -m "ping"
适用于拆解复杂任务、跨角色协作和共享状态推进。
## 🛡️ 风险防护与稳定性
## 🛡️ 稳定性
- **Proxy/Model fallback**:先在当前代理中按 `models` 顺序切换,全部失败后再按 `proxy_fallbacks` 切换代理。
- **HTTP 兼容处理**:可识别非 JSON 错页并给出响应预览;兼容从 `<function_call>` 文本块提取工具调用。
- **Shell Risk Gate**:高风险命令默认阻断,支持 dry-run 与 force 策略。
- **Sentinel**:周期巡检配置/内存/日志目录,支持自动修复与告警转发。
Sentinel 配置示例:

View File

@@ -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 `<function_call>` 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:

View File

@@ -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 {

View File

@@ -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",

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -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
}
}

View File

@@ -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] + "..."
}

View File

@@ -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)
}
}