diff --git a/config/config.example.json b/config/config.example.json index 49658b9f2..e7730f828 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -401,7 +401,31 @@ "enabled": true, "enable_deny_patterns": true, "custom_deny_patterns": null, - "custom_allow_patterns": null + "custom_allow_patterns": null, + "timeout_seconds": 0, + "dev_mode": false, + "allowed_commands": [ + "^ls\\b", + "^ll\\b", + "^pwd\\b", + "^cd\\b", + "^cat\\b", + "^head\\b", + "^tail\\b", + "^echo\\b", + "^find\\b", + "^grep\\b", + "^wc\\b", + "^cp\\b", + "^mv\\b", + "^mkdir\\b", + "^touch\\b", + "^zip\\b", + "^unzip\\b", + "^tar\\b", + "^git\\b", + "^rm\\s+(?!.*-[rR])\\S" + ] }, "skills": { "enabled": true, diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 49cb1962f..f33be48fd 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -724,20 +724,20 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) WorkingDir: msg.Metadata["work_dir"], } + // In cmd mode, rewrite bare text as /exec so all shell execution flows + // through the registry path — no second dispatch branch needed. + content := strings.TrimSpace(msg.Content) + if al.getSessionMode(sessionKey) == modeCmd && !commands.HasCommandPrefix(content) && content != "" { + msg.Content = "/exec " + content + } + // context-dependent commands check their own Runtime fields and report // "unavailable" when the required capability is nil. if response, handled := al.handleCommand(ctx, msg, agent, &opts); handled { return response, nil } - // Dispatch based on current session mode - content := strings.TrimSpace(msg.Content) - switch al.getSessionMode(sessionKey) { - case modeCmd: - return al.executeCmdMode(ctx, agent, content, sessionKey, msg.Channel, msg.ChatID) - default: // modePico - return al.runAgentLoop(ctx, agent, opts) - } + return al.runAgentLoop(ctx, agent, opts) } func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.ResolvedRoute, *AgentInstance, error) { diff --git a/pkg/config/config.go b/pkg/config/config.go index 13d5a7306..02791e740 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -670,7 +670,14 @@ type ExecConfig struct { EnableDenyPatterns bool ` env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS" json:"enable_deny_patterns"` CustomDenyPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS" json:"custom_deny_patterns"` CustomAllowPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS" json:"custom_allow_patterns"` - TimeoutSeconds int ` env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS" json:"timeout_seconds"` // 0 means use default (60s) + TimeoutSeconds int ` env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS" json:"timeout_seconds"` // 0 means use default (60s) + // DevMode disables all command guards, allowing unrestricted shell execution. + // AllowedCommands has no effect when DevMode is true. + DevMode bool `env:"PICOCLAW_TOOLS_EXEC_DEV_MODE" json:"dev_mode"` + // AllowedCommands is a whitelist of regex patterns matched against the command string. + // When non-empty and DevMode is false, only commands matching at least one pattern are permitted; + // deny patterns are bypassed — the whitelist is the sole access control. + AllowedCommands []string `env:"PICOCLAW_TOOLS_EXEC_ALLOWED_COMMANDS" json:"allowed_commands"` } type SkillsToolsConfig struct { diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index 18c9b25f0..55dd7c8f9 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -24,6 +24,8 @@ type ExecTool struct { allowPatterns []*regexp.Regexp customAllowPatterns []*regexp.Regexp restrictToWorkspace bool + devMode bool + allowedCommands []*regexp.Regexp } var ( @@ -42,24 +44,15 @@ var ( ), regexp.MustCompile(`\b(shutdown|reboot|poweroff)\b`), regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`), - regexp.MustCompile(`\$\([^)]+\)`), - regexp.MustCompile(`\$\{[^}]+\}`), - regexp.MustCompile("`[^`]+`"), regexp.MustCompile(`\|\s*sh\b`), regexp.MustCompile(`\|\s*bash\b`), regexp.MustCompile(`;\s*rm\s+-[rf]`), regexp.MustCompile(`&&\s*rm\s+-[rf]`), regexp.MustCompile(`\|\|\s*rm\s+-[rf]`), - regexp.MustCompile(`<<\s*EOF`), - regexp.MustCompile(`\$\(\s*cat\s+`), - regexp.MustCompile(`\$\(\s*curl\s+`), - regexp.MustCompile(`\$\(\s*wget\s+`), - regexp.MustCompile(`\$\(\s*which\s+`), regexp.MustCompile(`\bsudo\b`), regexp.MustCompile(`\bsu\b`), regexp.MustCompile(`\bdoas\b`), regexp.MustCompile(`\bpkexec\b`), - regexp.MustCompile(`\bchmod\s+[0-7]{3,4}\b`), regexp.MustCompile(`\bchown\b`), regexp.MustCompile(`\bpkill\b`), regexp.MustCompile(`\bkillall\b`), @@ -73,11 +66,8 @@ var ( regexp.MustCompile(`\bdnf\s+(install|remove)\b`), regexp.MustCompile(`\bdocker\s+run\b`), regexp.MustCompile(`\bdocker\s+exec\b`), - regexp.MustCompile(`\bgit\s+push\b`), - regexp.MustCompile(`\bgit\s+force\b`), regexp.MustCompile(`\bssh\b.*@`), regexp.MustCompile(`\beval\b`), - regexp.MustCompile(`\bsource\s+.*\.sh\b`), } // absolutePathPattern matches absolute file paths in commands (Unix and Windows). @@ -104,32 +94,44 @@ func NewExecTool(workingDir string, restrict bool) (*ExecTool, error) { func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Config) (*ExecTool, error) { denyPatterns := make([]*regexp.Regexp, 0) customAllowPatterns := make([]*regexp.Regexp, 0) + allowedCommands := make([]*regexp.Regexp, 0) + devMode := false if config != nil { execConfig := config.Tools.Exec - enableDenyPatterns := execConfig.EnableDenyPatterns - if enableDenyPatterns { - denyPatterns = append(denyPatterns, defaultDenyPatterns...) - if len(execConfig.CustomDenyPatterns) > 0 { - fmt.Printf("Using custom deny patterns: %v\n", execConfig.CustomDenyPatterns) - for _, pattern := range execConfig.CustomDenyPatterns { - re, err := regexp.Compile(pattern) - if err != nil { - return nil, fmt.Errorf("invalid custom deny pattern %q: %w", pattern, err) + devMode = execConfig.DevMode + + if !devMode { + enableDenyPatterns := execConfig.EnableDenyPatterns + if enableDenyPatterns { + denyPatterns = append(denyPatterns, defaultDenyPatterns...) + if len(execConfig.CustomDenyPatterns) > 0 { + fmt.Printf("Using custom deny patterns: %v\n", execConfig.CustomDenyPatterns) + for _, pattern := range execConfig.CustomDenyPatterns { + re, err := regexp.Compile(pattern) + if err != nil { + return nil, fmt.Errorf("invalid custom deny pattern %q: %w", pattern, err) + } + denyPatterns = append(denyPatterns, re) } - denyPatterns = append(denyPatterns, re) } + } else { + fmt.Println("Warning: deny patterns are disabled. All commands will be allowed.") } - } else { - // If deny patterns are disabled, we won't add any patterns, allowing all commands. - fmt.Println("Warning: deny patterns are disabled. All commands will be allowed.") - } - for _, pattern := range execConfig.CustomAllowPatterns { - re, err := regexp.Compile(pattern) - if err != nil { - return nil, fmt.Errorf("invalid custom allow pattern %q: %w", pattern, err) + for _, pattern := range execConfig.CustomAllowPatterns { + re, err := regexp.Compile(pattern) + if err != nil { + return nil, fmt.Errorf("invalid custom allow pattern %q: %w", pattern, err) + } + customAllowPatterns = append(customAllowPatterns, re) + } + for _, pattern := range execConfig.AllowedCommands { + re, err := regexp.Compile(pattern) + if err != nil { + return nil, fmt.Errorf("invalid allowed command pattern %q: %w", pattern, err) + } + allowedCommands = append(allowedCommands, re) } - customAllowPatterns = append(customAllowPatterns, re) } } else { denyPatterns = append(denyPatterns, defaultDenyPatterns...) @@ -147,6 +149,8 @@ func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Conf allowPatterns: nil, customAllowPatterns: customAllowPatterns, restrictToWorkspace: restrict, + devMode: devMode, + allowedCommands: allowedCommands, }, nil } @@ -307,36 +311,56 @@ func sanitizeCommand(cmd string) string { } func (t *ExecTool) guardCommand(command, cwd string) string { + // Dev mode: no restrictions at all. + if t.devMode { + return "" + } + cmd := sanitizeCommand(strings.TrimSpace(command)) lower := strings.ToLower(cmd) - // Custom allow patterns exempt a command from deny checks. - explicitlyAllowed := false - for _, pattern := range t.customAllowPatterns { - if pattern.MatchString(lower) { - explicitlyAllowed = true - break - } - } - - if !explicitlyAllowed { - for _, pattern := range t.denyPatterns { - if pattern.MatchString(lower) { - return "Command blocked by safety guard (dangerous pattern detected)" - } - } - } - - if len(t.allowPatterns) > 0 { + // Whitelist mode: AllowedCommands is the sole access control; deny patterns are bypassed. + if len(t.allowedCommands) > 0 { allowed := false - for _, pattern := range t.allowPatterns { + for _, pattern := range t.allowedCommands { if pattern.MatchString(lower) { allowed = true break } } if !allowed { - return "Command blocked by safety guard (not in allowlist)" + return "Command not permitted (not in allowed commands list)" + } + // Fall through to workspace restriction check below. + } else { + // Deny-list mode (default): custom allow patterns exempt a command from deny checks. + explicitlyAllowed := false + for _, pattern := range t.customAllowPatterns { + if pattern.MatchString(lower) { + explicitlyAllowed = true + break + } + } + + if !explicitlyAllowed { + for _, pattern := range t.denyPatterns { + if pattern.MatchString(lower) { + return "Command blocked by safety guard (dangerous pattern detected)" + } + } + } + + if len(t.allowPatterns) > 0 { + allowed := false + for _, pattern := range t.allowPatterns { + if pattern.MatchString(lower) { + allowed = true + break + } + } + if !allowed { + return "Command blocked by safety guard (not in allowlist)" + } } }