on main help sign in
Merge pull request #288 from charmbracelet/feat/whitelist-commands
0f3b6c6Kujtim Hoxha
README.md+25
@@ -161,6 +161,31 @@ Add to your `crush.json` config file:
161}
161}
162```
162```
163
163
164+### Configurable Default Permissions
165+
166+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:
167+
168+```json
169+{
170+ "permissions": {
171+ "allowed_tools": [
172+ "view",
173+ "ls",
174+ "grep",
175+ "edit:write",
176+ "mcp_context7_get-library-doc"
177+ ]
178+ }
179+}
180+```
181+
182+The `allowed_tools` array accepts:
183+
184+- Tool names (e.g., `"view"`) - allows all actions for that tool
185+- Tool:action combinations (e.g., `"edit:write"`) - allows only specific actions
186+
187+You can also skip all permission prompts entirely by running Crush with the `--yolo` flag.
188+
164### OpenAI-Compatible APIs
189### OpenAI-Compatible APIs
165
190
166Crush 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.
191Crush 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+6−2
@@ -59,13 +59,17 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
59 sessions := session.NewService(q)
59 sessions := session.NewService(q)
60 messages := message.NewService(q)
60 messages := message.NewService(q)
61 files := history.NewService(q, conn)
61 files := history.NewService(q, conn)
62− skipPermissionsRequests := cfg.Options != nil && cfg.Options.SkipPermissionsRequests
62+ skipPermissionsRequests := cfg.Permissions != nil && cfg.Permissions.SkipRequests
63+ allowedTools := []string{}
64+ if cfg.Permissions != nil && cfg.Permissions.AllowedTools != nil {
65+ allowedTools = cfg.Permissions.AllowedTools
66+ }
63
67
64 app := &App{
68 app := &App{
65 Sessions: sessions,
69 Sessions: sessions,
66 Messages: messages,
70 Messages: messages,
67 History: files,
71 History: files,
68− Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests),
72+ Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedTools),
69 LSPClients: make(map[string]*lsp.Client),
73 LSPClients: make(map[string]*lsp.Client),
70
74
71 globalCtx: ctx,
75 globalCtx: ctx,
internal/cmd/root.go+4−1
@@ -73,7 +73,10 @@ to assist developers in writing, debugging, and understanding code directly from
73 if err != nil {
73 if err != nil {
74 return err
74 return err
75 }
75 }
76− cfg.Options.SkipPermissionsRequests = yolo
76+ if cfg.Permissions == nil {
77+ cfg.Permissions = &config.Permissions{}
78+ }
79+ cfg.Permissions.SkipRequests = yolo
77
80
78 ctx := cmd.Context()
81 ctx := cmd.Context()
79
82
internal/config/config.go+13−7
@@ -120,14 +120,18 @@ type TUIOptions struct {
120 // Here we can add themes later or any TUI related options
120 // Here we can add themes later or any TUI related options
121}
121}
122
122
123+type Permissions struct {
124+ AllowedTools []string `json:"allowed_tools,omitempty"` // Tools that don't require permission prompts
125+ SkipRequests bool `json:"-"` // Automatically accept all permissions (YOLO mode)
126+}
127+
123type Options struct {
128type Options struct {
124− ContextPaths []string `json:"context_paths,omitempty"`
129+ ContextPaths []string `json:"context_paths,omitempty"`
125− TUI *TUIOptions `json:"tui,omitempty"`
130+ TUI *TUIOptions `json:"tui,omitempty"`
126− Debug bool `json:"debug,omitempty"`
131+ Debug bool `json:"debug,omitempty"`
127− DebugLSP bool `json:"debug_lsp,omitempty"`
132+ DebugLSP bool `json:"debug_lsp,omitempty"`
128− DisableAutoSummarize bool `json:"disable_auto_summarize,omitempty"`
133+ DisableAutoSummarize bool `json:"disable_auto_summarize,omitempty"`
129− DataDirectory string `json:"data_directory,omitempty"` // Relative to the cwd
134+ DataDirectory string `json:"data_directory,omitempty"` // Relative to the cwd
130− SkipPermissionsRequests bool `json:"-"` // Automatically accept all permissions (YOLO mode)
131}
135}
132
136
133type MCPs map[string]MCPConfig
137type MCPs map[string]MCPConfig
@@ -244,6 +248,8 @@ type Config struct {
244
248
245 Options *Options `json:"options,omitempty"`
249 Options *Options `json:"options,omitempty"`
246
250
251+ Permissions *Permissions `json:"permissions,omitempty"`
252+
247 // Internal
253 // Internal
248 workingDir string `json:"-"`
254 workingDir string `json:"-"`
249 // TODO: most likely remove this concept when I come back to it
255 // TODO: most likely remove this concept when I come back to it
internal/permission/permission.go+9−1
@@ -50,6 +50,7 @@ type permissionService struct {
50 autoApproveSessions []string
50 autoApproveSessions []string
51 autoApproveSessionsMu sync.RWMutex
51 autoApproveSessionsMu sync.RWMutex
52 skip bool
52 skip bool
53+ allowedTools []string
53}
54}
54
55
55func (s *permissionService) GrantPersistent(permission PermissionRequest) {
56func (s *permissionService) GrantPersistent(permission PermissionRequest) {
@@ -82,6 +83,12 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
82 return true
83 return true
83 }
84 }
84
85
86+ // Check if the tool/action combination is in the allowlist
87+ commandKey := opts.ToolName + ":" + opts.Action
88+ if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) {
89+ return true
90+ }
91+
85 s.autoApproveSessionsMu.RLock()
92 s.autoApproveSessionsMu.RLock()
86 autoApprove := slices.Contains(s.autoApproveSessions, opts.SessionID)
93 autoApprove := slices.Contains(s.autoApproveSessions, opts.SessionID)
87 s.autoApproveSessionsMu.RUnlock()
94 s.autoApproveSessionsMu.RUnlock()
@@ -130,11 +137,12 @@ func (s *permissionService) AutoApproveSession(sessionID string) {
130 s.autoApproveSessionsMu.Unlock()
137 s.autoApproveSessionsMu.Unlock()
131}
138}
132
139
133−func NewPermissionService(workingDir string, skip bool) Service {
140+func NewPermissionService(workingDir string, skip bool, allowedTools []string) Service {
134 return &permissionService{
141 return &permissionService{
135 Broker: pubsub.NewBroker[PermissionRequest](),
142 Broker: pubsub.NewBroker[PermissionRequest](),
136 workingDir: workingDir,
143 workingDir: workingDir,
137 sessionPermissions: make([]PermissionRequest, 0),
144 sessionPermissions: make([]PermissionRequest, 0),
138 skip: skip,
145 skip: skip,
146+ allowedTools: allowedTools,
139 }
147 }
140}
148}
internal/permission/permission_test.gonew file+92
@@ -0,0 +1,92 @@
1+package permission
2+
3+import (
4+ "testing"
5+)
6+
7+func TestPermissionService_AllowedCommands(t *testing.T) {
8+ tests := []struct {
9+ name string
10+ allowedTools []string
11+ toolName string
12+ action string
13+ expected bool
14+ }{
15+ {
16+ name: "tool in allowlist",
17+ allowedTools: []string{"bash", "view"},
18+ toolName: "bash",
19+ action: "execute",
20+ expected: true,
21+ },
22+ {
23+ name: "tool:action in allowlist",
24+ allowedTools: []string{"bash:execute", "edit:create"},
25+ toolName: "bash",
26+ action: "execute",
27+ expected: true,
28+ },
29+ {
30+ name: "tool not in allowlist",
31+ allowedTools: []string{"view", "ls"},
32+ toolName: "bash",
33+ action: "execute",
34+ expected: false,
35+ },
36+ {
37+ name: "tool:action not in allowlist",
38+ allowedTools: []string{"bash:read", "edit:create"},
39+ toolName: "bash",
40+ action: "execute",
41+ expected: false,
42+ },
43+ {
44+ name: "empty allowlist",
45+ allowedTools: []string{},
46+ toolName: "bash",
47+ action: "execute",
48+ expected: false,
49+ },
50+ }
51+
52+ for _, tt := range tests {
53+ t.Run(tt.name, func(t *testing.T) {
54+ service := NewPermissionService("/tmp", false, tt.allowedTools)
55+
56+ // Create a channel to capture the permission request
57+ // Since we're testing the allowlist logic, we need to simulate the request
58+ ps := service.(*permissionService)
59+
60+ // Test the allowlist logic directly
61+ commandKey := tt.toolName + ":" + tt.action
62+ allowed := false
63+ for _, cmd := range ps.allowedTools {
64+ if cmd == commandKey || cmd == tt.toolName {
65+ allowed = true
66+ break
67+ }
68+ }
69+
70+ if allowed != tt.expected {
71+ t.Errorf("expected %v, got %v for tool %s action %s with allowlist %v",
72+ tt.expected, allowed, tt.toolName, tt.action, tt.allowedTools)
73+ }
74+ })
75+ }
76+}
77+
78+func TestPermissionService_SkipMode(t *testing.T) {
79+ service := NewPermissionService("/tmp", true, []string{})
80+
81+ result := service.Request(CreatePermissionRequest{
82+ SessionID: "test-session",
83+ ToolName: "bash",
84+ Action: "execute",
85+ Description: "test command",
86+ Path: "/tmp",
87+ })
88+
89+ if !result {
90+ t.Error("expected permission to be granted in skip mode")
91+ }
92+}