feat: block exec commands from writing to protected dirs via denyWritePaths

This commit is contained in:
stevef 2026-04-17 20:32:09 +02:00
parent 8a4420ec6e
commit f259a04aa9
3 changed files with 96 additions and 9 deletions

View file

@ -96,11 +96,13 @@ func NewAgentInstance(
toolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths, denyReadPaths)) toolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths, denyReadPaths))
} }
if cfg.Tools.IsToolEnabled("exec") { if cfg.Tools.IsToolEnabled("exec") {
execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg, allowReadPaths) execTool, err := tools.NewExecToolWithDenyPaths(workspace, restrict, [][]*regexp.Regexp{allowReadPaths}, denyWritePaths, cfg)
if err != nil { if err != nil {
logger.ErrorCF("agent", "Failed to initialize exec tool; continuing without exec", logger.ErrorCF("agent", "Failed to initialize exec tool; continuing without exec",
map[string]any{"error": err.Error()}) map[string]any{"error": err.Error()})
} else { execTool = nil
}
if execTool != nil {
toolsRegistry.Register(execTool) toolsRegistry.Register(execTool)
} }
} }

View file

@ -40,6 +40,7 @@ type ExecTool struct {
allowPatterns []*regexp.Regexp allowPatterns []*regexp.Regexp
customAllowPatterns []*regexp.Regexp customAllowPatterns []*regexp.Regexp
allowedPathPatterns []*regexp.Regexp allowedPathPatterns []*regexp.Regexp
denyWritePaths []*regexp.Regexp
restrictToWorkspace bool restrictToWorkspace bool
allowRemote bool allowRemote bool
sessionManager *SessionManager sessionManager *SessionManager
@ -114,14 +115,24 @@ var (
) )
func NewExecTool(workingDir string, restrict bool, allowPaths ...[]*regexp.Regexp) (*ExecTool, error) { func NewExecTool(workingDir string, restrict bool, allowPaths ...[]*regexp.Regexp) (*ExecTool, error) {
return NewExecToolWithConfig(workingDir, restrict, nil, allowPaths...) return NewExecToolWithDenyPaths(workingDir, restrict, allowPaths, nil, nil)
} }
func NewExecToolWithConfig( func NewExecToolWithConfig(
workingDir string, workingDir string,
restrict bool, restrict bool,
config *config.Config, cfg *config.Config,
allowPaths ...[]*regexp.Regexp, allowPaths ...[]*regexp.Regexp,
) (*ExecTool, error) {
return NewExecToolWithDenyPaths(workingDir, restrict, allowPaths, nil, cfg)
}
func NewExecToolWithDenyPaths(
workingDir string,
restrict bool,
allowPaths [][]*regexp.Regexp,
denyWritePaths []*regexp.Regexp,
cfg *config.Config,
) (*ExecTool, error) { ) (*ExecTool, error) {
denyPatterns := make([]*regexp.Regexp, 0) denyPatterns := make([]*regexp.Regexp, 0)
customAllowPatterns := make([]*regexp.Regexp, 0) customAllowPatterns := make([]*regexp.Regexp, 0)
@ -131,8 +142,8 @@ func NewExecToolWithConfig(
allowedPathPatterns = allowPaths[0] allowedPathPatterns = allowPaths[0]
} }
if config != nil { if cfg != nil {
execConfig := config.Tools.Exec execConfig := cfg.Tools.Exec
enableDenyPatterns := execConfig.EnableDenyPatterns enableDenyPatterns := execConfig.EnableDenyPatterns
allowRemote = execConfig.AllowRemote allowRemote = execConfig.AllowRemote
if enableDenyPatterns { if enableDenyPatterns {
@ -148,7 +159,6 @@ func NewExecToolWithConfig(
} }
} }
} else { } 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.") fmt.Println("Warning: deny patterns are disabled. All commands will be allowed.")
} }
for _, pattern := range execConfig.CustomAllowPatterns { for _, pattern := range execConfig.CustomAllowPatterns {
@ -163,8 +173,8 @@ func NewExecToolWithConfig(
} }
var timeout time.Duration var timeout time.Duration
if config != nil && config.Tools.Exec.TimeoutSeconds > 0 { if cfg != nil && cfg.Tools.Exec.TimeoutSeconds > 0 {
timeout = time.Duration(config.Tools.Exec.TimeoutSeconds) * time.Second timeout = time.Duration(cfg.Tools.Exec.TimeoutSeconds) * time.Second
} }
return &ExecTool{ return &ExecTool{
@ -174,6 +184,7 @@ func NewExecToolWithConfig(
allowPatterns: nil, allowPatterns: nil,
customAllowPatterns: customAllowPatterns, customAllowPatterns: customAllowPatterns,
allowedPathPatterns: allowedPathPatterns, allowedPathPatterns: allowedPathPatterns,
denyWritePaths: denyWritePaths,
restrictToWorkspace: restrict, restrictToWorkspace: restrict,
allowRemote: allowRemote, allowRemote: allowRemote,
sessionManager: getSessionManager(), sessionManager: getSessionManager(),
@ -1033,6 +1044,28 @@ func (t *ExecTool) guardCommand(command, cwd string) string {
return "Command blocked by safety guard (dangerous pattern detected)" return "Command blocked by safety guard (dangerous pattern detected)"
} }
} }
// Check deny write paths - block commands that write to protected directories
if len(t.denyWritePaths) > 0 {
words := strings.Fields(cmd)
for i, word := range words {
for _, pattern := range t.denyWritePaths {
if pattern.MatchString(word) {
return fmt.Sprintf("Command blocked: cannot write to %s (access denied)", word)
}
// Also check path components like "skills" in "mkdir -p skills/my_skill"
if i >= 0 && (word == "-p" || word == "-rf" || word == "-r") {
continue
}
pathParts := strings.Split(word, "/")
for _, part := range pathParts {
if pattern.MatchString(part) {
return fmt.Sprintf("Command blocked: cannot write to %s (access denied)", part)
}
}
}
}
}
} }
if len(t.allowPatterns) > 0 { if len(t.allowPatterns) > 0 {

View file

@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"runtime" "runtime"
"strings" "strings"
"testing" "testing"
@ -1613,3 +1614,54 @@ func TestEncodeKeyTokenWithPtyKeyMode(t *testing.T) {
}) })
} }
} }
func TestShellTool_DenyWritePaths(t *testing.T) {
tests := []struct {
name string
command string
denyPaths []*regexp.Regexp
expectBlock bool
}{
{
name: "mkdir blocked",
command: "mkdir -p skills",
denyPaths: []*regexp.Regexp{regexp.MustCompile(`^skills(/.*)?$`)},
expectBlock: true,
},
{
name: "mkdir -p blocked",
command: "mkdir -p skills/my_skill",
denyPaths: []*regexp.Regexp{regexp.MustCompile(`^skills(/.*)?$`)},
expectBlock: true,
},
{
name: "mkdir allowed",
command: "mkdir -p workspace/data",
denyPaths: []*regexp.Regexp{regexp.MustCompile(`^skills(/.*)?$`)},
expectBlock: false,
},
{
name: "touch skills file blocked",
command: "touch skills/test.txt",
denyPaths: []*regexp.Regexp{regexp.MustCompile(`^skills(/.*)?$`)},
expectBlock: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tool, err := NewExecToolWithDenyPaths("", false, nil, tt.denyPaths, nil)
require.NoError(t, err)
result := tool.Execute(context.Background(), map[string]any{
"action": "run",
"command": tt.command,
})
if tt.expectBlock {
require.True(t, result.IsError, "expected block for command: %s", tt.command)
require.Contains(t, result.ForLLM, "access denied")
} else {
require.False(t, result.IsError, "expected allow for command: %s, got: %s", tt.command, result.ForLLM)
}
})
}
}