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:
parent
d17233e195
commit
1f2802906f
4 changed files with 116 additions and 61 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.ResolvedRoute, *AgentInstance, error) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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, "../") {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue