feat(exec): add dev mode, command whitelist, and unify cmd dispatch

- shell.go: remove deny patterns that block build workflows ($(), heredoc,
  chmod, git push); add DevMode (unrestricted) and AllowedCommands
  (whitelist-only mode) to ExecTool and guardCommand
- config.go: add DevMode and AllowedCommands fields to ExecConfig
- loop.go: rewrite bare text as /exec in cmd mode before handleCommand,
  eliminating the parallel switch/executeCmdMode dispatch path
- config.example.json: document new exec fields with a default whitelist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
seagochen 2026-03-11 14:25:51 +08:00
parent d17233e195
commit 1f2802906f
4 changed files with 116 additions and 61 deletions

View file

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

View file

@ -724,21 +724,21 @@ 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)
}
}
func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.ResolvedRoute, *AgentInstance, error) {
route := al.registry.ResolveRoute(routing.RouteInput{

View file

@ -671,6 +671,13 @@ type ExecConfig struct {
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)
// 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 {

View file

@ -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,9 +94,14 @@ 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
devMode = execConfig.DevMode
if !devMode {
enableDenyPatterns := execConfig.EnableDenyPatterns
if enableDenyPatterns {
denyPatterns = append(denyPatterns, defaultDenyPatterns...)
@ -121,7 +116,6 @@ func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Conf
}
}
} 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 {
@ -131,6 +125,14 @@ func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Conf
}
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)
}
}
} 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,10 +311,29 @@ 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.
// Whitelist mode: AllowedCommands is the sole access control; deny patterns are bypassed.
if len(t.allowedCommands) > 0 {
allowed := false
for _, pattern := range t.allowedCommands {
if pattern.MatchString(lower) {
allowed = true
break
}
}
if !allowed {
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) {
@ -339,6 +362,7 @@ func (t *ExecTool) guardCommand(command, cwd string) string {
return "Command blocked by safety guard (not in allowlist)"
}
}
}
if t.restrictToWorkspace {
if strings.Contains(cmd, "..\\") || strings.Contains(cmd, "../") {