diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index fd7ec484a..d56e89e26 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -563,7 +563,7 @@ func gatewayCmd() { // Setup cron tool and service execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute - cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.Agents.Defaults.RestrictToWorkspace, execTimeout) + cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.Agents.Defaults.RestrictToWorkspace, execTimeout, cfg.Agents.Defaults.AllowPatterns) heartbeatService := heartbeat.NewHeartbeatService( cfg.WorkspacePath(), @@ -988,14 +988,14 @@ func getConfigPath() string { return filepath.Join(home, ".picoclaw", "config.json") } -func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration) *cron.CronService { +func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, allowPatterns []string) *cron.CronService { cronStorePath := filepath.Join(workspace, "cron", "jobs.json") // Create cron service cronService := cron.NewCronService(cronStorePath, nil) // Create and register CronTool - cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout) + cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, allowPatterns...) agentLoop.RegisterTool(cronTool) // Set the onJob handler diff --git a/config/config.example.json b/config/config.example.json index 7cd0ab8c6..00e196ece 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -3,6 +3,8 @@ "defaults": { "workspace": "~/.picoclaw/workspace", "restrict_to_workspace": true, + "provider": "", + "allow_patterns": [], "model": "glm-4.7", "max_tokens": 8192, "temperature": 0.7, @@ -122,6 +124,10 @@ "api_key": "YOUR_BRAVE_API_KEY", "max_results": 5 }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + }, "perplexity": { "enabled": false, "api_key": "pplx-xxx", diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index d3afa298e..d6c0192cf 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -71,7 +71,7 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg registry.Register(tools.NewAppendFileTool(workspace, restrict)) // Shell execution - registry.Register(tools.NewExecTool(workspace, restrict)) + registry.Register(tools.NewExecTool(workspace, restrict, cfg.Agents.Defaults.AllowPatterns...)) if searchTool := tools.NewWebSearchTool(tools.WebSearchToolOptions{ BraveAPIKey: cfg.Tools.Web.Brave.APIKey, diff --git a/pkg/config/config.go b/pkg/config/config.go index 1d34f56f3..e00243525 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -61,6 +61,7 @@ type AgentsConfig struct { type AgentDefaults struct { Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` + AllowPatterns []string `json:"allow_patterns" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_PATTERNS" envSeparator:","` Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` @@ -233,6 +234,7 @@ func DefaultConfig() *Config { Defaults: AgentDefaults{ Workspace: "~/.picoclaw/workspace", RestrictToWorkspace: true, + AllowPatterns: []string{}, Provider: "", Model: "glm-4.7", MaxTokens: 8192, diff --git a/pkg/migrate/config.go b/pkg/migrate/config.go index 9c1e36359..194d4d1bf 100644 --- a/pkg/migrate/config.go +++ b/pkg/migrate/config.go @@ -84,6 +84,9 @@ func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error if v, ok := getString(defaults, "workspace"); ok { cfg.Agents.Defaults.Workspace = rewriteWorkspacePath(v) } + if v := getStringSlice(defaults, "allow_patterns"); len(v) > 0 { + cfg.Agents.Defaults.AllowPatterns = v + } } } @@ -280,6 +283,10 @@ func MergeConfig(existing, incoming *config.Config) *config.Config { existing.Tools.Web.Brave = incoming.Tools.Web.Brave } + if len(existing.Agents.Defaults.AllowPatterns) == 0 && len(incoming.Agents.Defaults.AllowPatterns) > 0 { + existing.Agents.Defaults.AllowPatterns = incoming.Agents.Defaults.AllowPatterns + } + return existing } diff --git a/pkg/migrate/migrate_test.go b/pkg/migrate/migrate_test.go index be2360aac..4ce317df4 100644 --- a/pkg/migrate/migrate_test.go +++ b/pkg/migrate/migrate_test.go @@ -261,6 +261,7 @@ func TestConvertConfig(t *testing.T) { "temperature": 0.5, "max_tool_iterations": float64(10), "workspace": "~/.openclaw/workspace", + "allow_patterns": []interface{}{"^ls(\\s|$)", "^pwd$"}, }, }, } @@ -281,6 +282,15 @@ func TestConvertConfig(t *testing.T) { if cfg.Agents.Defaults.Workspace != "~/.picoclaw/workspace" { t.Errorf("Workspace = %q, want %q", cfg.Agents.Defaults.Workspace, "~/.picoclaw/workspace") } + if len(cfg.Agents.Defaults.AllowPatterns) != 2 { + t.Fatalf("AllowPatterns length = %d, want 2", len(cfg.Agents.Defaults.AllowPatterns)) + } + if cfg.Agents.Defaults.AllowPatterns[0] != "^ls(\\s|$)" { + t.Errorf("AllowPatterns[0] = %q, want %q", cfg.Agents.Defaults.AllowPatterns[0], "^ls(\\s|$)") + } + if cfg.Agents.Defaults.AllowPatterns[1] != "^pwd$" { + t.Errorf("AllowPatterns[1] = %q, want %q", cfg.Agents.Defaults.AllowPatterns[1], "^pwd$") + } }) t.Run("empty config", func(t *testing.T) { @@ -361,6 +371,36 @@ func TestMergeConfig(t *testing.T) { t.Errorf("Telegram.Token should be preserved, got %q", result.Channels.Telegram.Token) } }) + + t.Run("fills empty allow patterns", func(t *testing.T) { + existing := config.DefaultConfig() + incoming := config.DefaultConfig() + incoming.Agents.Defaults.AllowPatterns = []string{"^ls(\\s|$)", "^pwd$"} + + result := MergeConfig(existing, incoming) + if len(result.Agents.Defaults.AllowPatterns) != 2 { + t.Fatalf("AllowPatterns length = %d, want 2", len(result.Agents.Defaults.AllowPatterns)) + } + if result.Agents.Defaults.AllowPatterns[0] != "^ls(\\s|$)" { + t.Errorf("AllowPatterns[0] = %q, want %q", result.Agents.Defaults.AllowPatterns[0], "^ls(\\s|$)") + } + }) + + t.Run("preserves existing allow patterns", func(t *testing.T) { + existing := config.DefaultConfig() + existing.Agents.Defaults.AllowPatterns = []string{"^cat\\s+README\\.md$"} + + incoming := config.DefaultConfig() + incoming.Agents.Defaults.AllowPatterns = []string{"^ls(\\s|$)"} + + result := MergeConfig(existing, incoming) + if len(result.Agents.Defaults.AllowPatterns) != 1 { + t.Fatalf("AllowPatterns length = %d, want 1", len(result.Agents.Defaults.AllowPatterns)) + } + if result.Agents.Defaults.AllowPatterns[0] != "^cat\\s+README\\.md$" { + t.Errorf("AllowPatterns[0] should be preserved, got %q", result.Agents.Defaults.AllowPatterns[0]) + } + }) } func TestPlanWorkspaceMigration(t *testing.T) { diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go index 21bee42ef..7607d1b3d 100644 --- a/pkg/tools/cron.go +++ b/pkg/tools/cron.go @@ -29,8 +29,8 @@ type CronTool struct { // NewCronTool creates a new CronTool // execTimeout: 0 means no timeout, >0 sets the timeout duration -func NewCronTool(cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration) *CronTool { - execTool := NewExecTool(workspace, restrict) +func NewCronTool(cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, allowPatterns ...string) *CronTool { + execTool := NewExecTool(workspace, restrict, allowPatterns...) execTool.SetTimeout(execTimeout) // 0 means no timeout return &CronTool{ cronService: cronService, diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index 713850f97..c2f0f0a2d 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -21,7 +21,7 @@ type ExecTool struct { restrictToWorkspace bool } -func NewExecTool(workingDir string, restrict bool) *ExecTool { +func NewExecTool(workingDir string, restrict bool, allowPatterns ...string) *ExecTool { denyPatterns := []*regexp.Regexp{ regexp.MustCompile(`\brm\s+-[rf]{1,2}\b`), regexp.MustCompile(`\bdel\s+/[fq]\b`), @@ -33,11 +33,19 @@ func NewExecTool(workingDir string, restrict bool) *ExecTool { regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`), } + var allowRegex []*regexp.Regexp + if len(allowPatterns) > 0 { + allowRegex = make([]*regexp.Regexp, 0, len(allowPatterns)) + for _, p := range allowPatterns { + allowRegex = append(allowRegex, regexp.MustCompile(p)) + } + } + return &ExecTool{ workingDir: workingDir, timeout: 60 * time.Second, denyPatterns: denyPatterns, - allowPatterns: nil, + allowPatterns: allowRegex, restrictToWorkspace: restrict, } }