Merge pull request #288 from charmbracelet/feat/whitelist-commands

Kujtim Hoxha created

add command allowlist

Change summary

README.md                              | 25 +++++++
internal/app/app.go                    |  8 +
internal/cmd/root.go                   |  5 +
internal/config/config.go              | 20 +++--
internal/permission/permission.go      | 10 ++
internal/permission/permission_test.go | 92 ++++++++++++++++++++++++++++
6 files changed, 149 insertions(+), 11 deletions(-)

Detailed changes

README.md 🔗

@@ -161,6 +161,31 @@ Add to your `crush.json` config file:
 }
 ```
 
+### Configurable Default Permissions
+
+Crush includes a permission system to control which tools can be executed without prompting. You can configure allowed tools in your `crush.json` config file:
+
+```json
+{
+  "permissions": {
+    "allowed_tools": [
+      "view",
+      "ls",
+      "grep",
+      "edit:write",
+      "mcp_context7_get-library-doc"
+    ]
+  }
+}
+```
+
+The `allowed_tools` array accepts:
+
+- Tool names (e.g., `"view"`) - allows all actions for that tool
+- Tool:action combinations (e.g., `"edit:write"`) - allows only specific actions
+
+You can also skip all permission prompts entirely by running Crush with the `--yolo` flag.
+
 ### OpenAI-Compatible APIs
 
 Crush supports all OpenAI-compatible APIs. Here's an example configuration for Deepseek, which uses an OpenAI-compatible API. Don't forget to set `DEEPSEEK_API_KEY` in your environment.

internal/app/app.go 🔗

@@ -59,13 +59,17 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
 	sessions := session.NewService(q)
 	messages := message.NewService(q)
 	files := history.NewService(q, conn)
-	skipPermissionsRequests := cfg.Options != nil && cfg.Options.SkipPermissionsRequests
+	skipPermissionsRequests := cfg.Permissions != nil && cfg.Permissions.SkipRequests
+	allowedTools := []string{}
+	if cfg.Permissions != nil && cfg.Permissions.AllowedTools != nil {
+		allowedTools = cfg.Permissions.AllowedTools
+	}
 
 	app := &App{
 		Sessions:    sessions,
 		Messages:    messages,
 		History:     files,
-		Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests),
+		Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedTools),
 		LSPClients:  make(map[string]*lsp.Client),
 
 		globalCtx: ctx,

internal/cmd/root.go 🔗

@@ -73,7 +73,10 @@ to assist developers in writing, debugging, and understanding code directly from
 		if err != nil {
 			return err
 		}
-		cfg.Options.SkipPermissionsRequests = yolo
+		if cfg.Permissions == nil {
+			cfg.Permissions = &config.Permissions{}
+		}
+		cfg.Permissions.SkipRequests = yolo
 
 		ctx := cmd.Context()
 

internal/config/config.go 🔗

@@ -120,14 +120,18 @@ type TUIOptions struct {
 	// Here we can add themes later or any TUI related options
 }
 
+type Permissions struct {
+	AllowedTools []string `json:"allowed_tools,omitempty"` // Tools that don't require permission prompts
+	SkipRequests bool     `json:"-"`                       // Automatically accept all permissions (YOLO mode)
+}
+
 type Options struct {
-	ContextPaths            []string    `json:"context_paths,omitempty"`
-	TUI                     *TUIOptions `json:"tui,omitempty"`
-	Debug                   bool        `json:"debug,omitempty"`
-	DebugLSP                bool        `json:"debug_lsp,omitempty"`
-	DisableAutoSummarize    bool        `json:"disable_auto_summarize,omitempty"`
-	DataDirectory           string      `json:"data_directory,omitempty"` // Relative to the cwd
-	SkipPermissionsRequests bool        `json:"-"`                        // Automatically accept all permissions (YOLO mode)
+	ContextPaths         []string    `json:"context_paths,omitempty"`
+	TUI                  *TUIOptions `json:"tui,omitempty"`
+	Debug                bool        `json:"debug,omitempty"`
+	DebugLSP             bool        `json:"debug_lsp,omitempty"`
+	DisableAutoSummarize bool        `json:"disable_auto_summarize,omitempty"`
+	DataDirectory        string      `json:"data_directory,omitempty"` // Relative to the cwd
 }
 
 type MCPs map[string]MCPConfig
@@ -244,6 +248,8 @@ type Config struct {
 
 	Options *Options `json:"options,omitempty"`
 
+	Permissions *Permissions `json:"permissions,omitempty"`
+
 	// Internal
 	workingDir string `json:"-"`
 	// TODO: most likely remove this concept when I come back to it

internal/permission/permission.go 🔗

@@ -50,6 +50,7 @@ type permissionService struct {
 	autoApproveSessions   []string
 	autoApproveSessionsMu sync.RWMutex
 	skip                  bool
+	allowedTools          []string
 }
 
 func (s *permissionService) GrantPersistent(permission PermissionRequest) {
@@ -82,6 +83,12 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
 		return true
 	}
 
+	// Check if the tool/action combination is in the allowlist
+	commandKey := opts.ToolName + ":" + opts.Action
+	if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) {
+		return true
+	}
+
 	s.autoApproveSessionsMu.RLock()
 	autoApprove := slices.Contains(s.autoApproveSessions, opts.SessionID)
 	s.autoApproveSessionsMu.RUnlock()
@@ -130,11 +137,12 @@ func (s *permissionService) AutoApproveSession(sessionID string) {
 	s.autoApproveSessionsMu.Unlock()
 }
 
-func NewPermissionService(workingDir string, skip bool) Service {
+func NewPermissionService(workingDir string, skip bool, allowedTools []string) Service {
 	return &permissionService{
 		Broker:             pubsub.NewBroker[PermissionRequest](),
 		workingDir:         workingDir,
 		sessionPermissions: make([]PermissionRequest, 0),
 		skip:               skip,
+		allowedTools:       allowedTools,
 	}
 }

internal/permission/permission_test.go 🔗

@@ -0,0 +1,92 @@
+package permission
+
+import (
+	"testing"
+)
+
+func TestPermissionService_AllowedCommands(t *testing.T) {
+	tests := []struct {
+		name         string
+		allowedTools []string
+		toolName     string
+		action       string
+		expected     bool
+	}{
+		{
+			name:         "tool in allowlist",
+			allowedTools: []string{"bash", "view"},
+			toolName:     "bash",
+			action:       "execute",
+			expected:     true,
+		},
+		{
+			name:         "tool:action in allowlist",
+			allowedTools: []string{"bash:execute", "edit:create"},
+			toolName:     "bash",
+			action:       "execute",
+			expected:     true,
+		},
+		{
+			name:         "tool not in allowlist",
+			allowedTools: []string{"view", "ls"},
+			toolName:     "bash",
+			action:       "execute",
+			expected:     false,
+		},
+		{
+			name:         "tool:action not in allowlist",
+			allowedTools: []string{"bash:read", "edit:create"},
+			toolName:     "bash",
+			action:       "execute",
+			expected:     false,
+		},
+		{
+			name:         "empty allowlist",
+			allowedTools: []string{},
+			toolName:     "bash",
+			action:       "execute",
+			expected:     false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			service := NewPermissionService("/tmp", false, tt.allowedTools)
+
+			// Create a channel to capture the permission request
+			// Since we're testing the allowlist logic, we need to simulate the request
+			ps := service.(*permissionService)
+
+			// Test the allowlist logic directly
+			commandKey := tt.toolName + ":" + tt.action
+			allowed := false
+			for _, cmd := range ps.allowedTools {
+				if cmd == commandKey || cmd == tt.toolName {
+					allowed = true
+					break
+				}
+			}
+
+			if allowed != tt.expected {
+				t.Errorf("expected %v, got %v for tool %s action %s with allowlist %v",
+					tt.expected, allowed, tt.toolName, tt.action, tt.allowedTools)
+			}
+		})
+	}
+}
+
+func TestPermissionService_SkipMode(t *testing.T) {
+	service := NewPermissionService("/tmp", true, []string{})
+
+	result := service.Request(CreatePermissionRequest{
+		SessionID:   "test-session",
+		ToolName:    "bash",
+		Action:      "execute",
+		Description: "test command",
+		Path:        "/tmp",
+	})
+
+	if !result {
+		t.Error("expected permission to be granted in skip mode")
+	}
+}