mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-13 03:17:28 +08:00
fix safety
This commit is contained in:
@@ -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 配置示例:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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] + "..."
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user