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,
|
"enabled": true,
|
||||||
"enable_deny_patterns": true,
|
"enable_deny_patterns": true,
|
||||||
"custom_deny_patterns": null,
|
"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": {
|
"skills": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|
|
||||||
|
|
@ -724,20 +724,20 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
WorkingDir: msg.Metadata["work_dir"],
|
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
|
// context-dependent commands check their own Runtime fields and report
|
||||||
// "unavailable" when the required capability is nil.
|
// "unavailable" when the required capability is nil.
|
||||||
if response, handled := al.handleCommand(ctx, msg, agent, &opts); handled {
|
if response, handled := al.handleCommand(ctx, msg, agent, &opts); handled {
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dispatch based on current session mode
|
return al.runAgentLoop(ctx, agent, opts)
|
||||||
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) {
|
func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.ResolvedRoute, *AgentInstance, error) {
|
||||||
|
|
|
||||||
|
|
@ -670,7 +670,14 @@ type ExecConfig struct {
|
||||||
EnableDenyPatterns bool ` env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS" json:"enable_deny_patterns"`
|
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"`
|
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"`
|
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 {
|
type SkillsToolsConfig struct {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ type ExecTool struct {
|
||||||
allowPatterns []*regexp.Regexp
|
allowPatterns []*regexp.Regexp
|
||||||
customAllowPatterns []*regexp.Regexp
|
customAllowPatterns []*regexp.Regexp
|
||||||
restrictToWorkspace bool
|
restrictToWorkspace bool
|
||||||
|
devMode bool
|
||||||
|
allowedCommands []*regexp.Regexp
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -42,24 +44,15 @@ var (
|
||||||
),
|
),
|
||||||
regexp.MustCompile(`\b(shutdown|reboot|poweroff)\b`),
|
regexp.MustCompile(`\b(shutdown|reboot|poweroff)\b`),
|
||||||
regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`),
|
regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`),
|
||||||
regexp.MustCompile(`\$\([^)]+\)`),
|
|
||||||
regexp.MustCompile(`\$\{[^}]+\}`),
|
|
||||||
regexp.MustCompile("`[^`]+`"),
|
|
||||||
regexp.MustCompile(`\|\s*sh\b`),
|
regexp.MustCompile(`\|\s*sh\b`),
|
||||||
regexp.MustCompile(`\|\s*bash\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*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(`\bsudo\b`),
|
||||||
regexp.MustCompile(`\bsu\b`),
|
regexp.MustCompile(`\bsu\b`),
|
||||||
regexp.MustCompile(`\bdoas\b`),
|
regexp.MustCompile(`\bdoas\b`),
|
||||||
regexp.MustCompile(`\bpkexec\b`),
|
regexp.MustCompile(`\bpkexec\b`),
|
||||||
regexp.MustCompile(`\bchmod\s+[0-7]{3,4}\b`),
|
|
||||||
regexp.MustCompile(`\bchown\b`),
|
regexp.MustCompile(`\bchown\b`),
|
||||||
regexp.MustCompile(`\bpkill\b`),
|
regexp.MustCompile(`\bpkill\b`),
|
||||||
regexp.MustCompile(`\bkillall\b`),
|
regexp.MustCompile(`\bkillall\b`),
|
||||||
|
|
@ -73,11 +66,8 @@ var (
|
||||||
regexp.MustCompile(`\bdnf\s+(install|remove)\b`),
|
regexp.MustCompile(`\bdnf\s+(install|remove)\b`),
|
||||||
regexp.MustCompile(`\bdocker\s+run\b`),
|
regexp.MustCompile(`\bdocker\s+run\b`),
|
||||||
regexp.MustCompile(`\bdocker\s+exec\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(`\bssh\b.*@`),
|
||||||
regexp.MustCompile(`\beval\b`),
|
regexp.MustCompile(`\beval\b`),
|
||||||
regexp.MustCompile(`\bsource\s+.*\.sh\b`),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// absolutePathPattern matches absolute file paths in commands (Unix and Windows).
|
// 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) {
|
func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Config) (*ExecTool, error) {
|
||||||
denyPatterns := make([]*regexp.Regexp, 0)
|
denyPatterns := make([]*regexp.Regexp, 0)
|
||||||
customAllowPatterns := make([]*regexp.Regexp, 0)
|
customAllowPatterns := make([]*regexp.Regexp, 0)
|
||||||
|
allowedCommands := make([]*regexp.Regexp, 0)
|
||||||
|
devMode := false
|
||||||
|
|
||||||
if config != nil {
|
if config != nil {
|
||||||
execConfig := config.Tools.Exec
|
execConfig := config.Tools.Exec
|
||||||
enableDenyPatterns := execConfig.EnableDenyPatterns
|
devMode = execConfig.DevMode
|
||||||
if enableDenyPatterns {
|
|
||||||
denyPatterns = append(denyPatterns, defaultDenyPatterns...)
|
if !devMode {
|
||||||
if len(execConfig.CustomDenyPatterns) > 0 {
|
enableDenyPatterns := execConfig.EnableDenyPatterns
|
||||||
fmt.Printf("Using custom deny patterns: %v\n", execConfig.CustomDenyPatterns)
|
if enableDenyPatterns {
|
||||||
for _, pattern := range execConfig.CustomDenyPatterns {
|
denyPatterns = append(denyPatterns, defaultDenyPatterns...)
|
||||||
re, err := regexp.Compile(pattern)
|
if len(execConfig.CustomDenyPatterns) > 0 {
|
||||||
if err != nil {
|
fmt.Printf("Using custom deny patterns: %v\n", execConfig.CustomDenyPatterns)
|
||||||
return nil, fmt.Errorf("invalid custom deny pattern %q: %w", pattern, err)
|
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 {
|
for _, pattern := range execConfig.CustomAllowPatterns {
|
||||||
// If deny patterns are disabled, we won't add any patterns, allowing all commands.
|
re, err := regexp.Compile(pattern)
|
||||||
fmt.Println("Warning: deny patterns are disabled. All commands will be allowed.")
|
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)
|
customAllowPatterns = append(customAllowPatterns, re)
|
||||||
if err != nil {
|
}
|
||||||
return nil, fmt.Errorf("invalid custom allow pattern %q: %w", pattern, err)
|
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 {
|
} else {
|
||||||
denyPatterns = append(denyPatterns, defaultDenyPatterns...)
|
denyPatterns = append(denyPatterns, defaultDenyPatterns...)
|
||||||
|
|
@ -147,6 +149,8 @@ func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Conf
|
||||||
allowPatterns: nil,
|
allowPatterns: nil,
|
||||||
customAllowPatterns: customAllowPatterns,
|
customAllowPatterns: customAllowPatterns,
|
||||||
restrictToWorkspace: restrict,
|
restrictToWorkspace: restrict,
|
||||||
|
devMode: devMode,
|
||||||
|
allowedCommands: allowedCommands,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -307,36 +311,56 @@ func sanitizeCommand(cmd string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *ExecTool) guardCommand(command, cwd 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))
|
cmd := sanitizeCommand(strings.TrimSpace(command))
|
||||||
lower := strings.ToLower(cmd)
|
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.
|
||||||
explicitlyAllowed := false
|
if len(t.allowedCommands) > 0 {
|
||||||
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
|
allowed := false
|
||||||
for _, pattern := range t.allowPatterns {
|
for _, pattern := range t.allowedCommands {
|
||||||
if pattern.MatchString(lower) {
|
if pattern.MatchString(lower) {
|
||||||
allowed = true
|
allowed = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !allowed {
|
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)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue