on main help sign in
Merge pull request #288 from charmbracelet/feat/whitelist-commands

add command allowlist

commit0f3b6c6a5d7a9c4d33031e769b3c8338185a388d authorKujtim Hoxha <kujtimii.h@gmail.com> dateThu 24 Jul 2025, 09:50 (1 year ago) parent506c3b62128964
6 files changed+149−11
README.md+25 internal/app/app.go+6−2 internal/cmd/root.go+4−1 internal/config/config.go+13−7 internal/permission/permission.go+9−1 internal/permission/permission_test.go+92
README.md+25
@@ -161,6 +161,31 @@ Add to your `crush.json` config file:
161161}
162162```
163163
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+
164189### OpenAI-Compatible APIs
165190
166191Crush 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) {
5959 sessions := session.NewService(q)
6060 messages := message.NewService(q)
6161 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+ }
6367
6468 app := &App{
6569 Sessions: sessions,
6670 Messages: messages,
6771 History: files,
68− Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests),
72+ Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedTools),
6973 LSPClients: make(map[string]*lsp.Client),
7074
7175 globalCtx: ctx,
internal/cmd/root.go+4−1
@@ -73,7 +73,10 @@ to assist developers in writing, debugging, and understanding code directly from
7373 if err != nil {
7474 return err
7575 }
76− cfg.Options.SkipPermissionsRequests = yolo
76+ if cfg.Permissions == nil {
77+ cfg.Permissions = &config.Permissions{}
78+ }
79+ cfg.Permissions.SkipRequests = yolo
7780
7881 ctx := cmd.Context()
7982
internal/config/config.go+13−7
@@ -120,14 +120,18 @@ type TUIOptions struct {
120120 // Here we can add themes later or any TUI related options
121121}
122122
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+
123128type Options struct {
124− ContextPaths []string `json:"context_paths,omitempty"`
125− TUI *TUIOptions `json:"tui,omitempty"`
126− Debug bool `json:"debug,omitempty"`
127− DebugLSP bool `json:"debug_lsp,omitempty"`
128− DisableAutoSummarize bool `json:"disable_auto_summarize,omitempty"`
129− DataDirectory string `json:"data_directory,omitempty"` // Relative to the cwd
130− SkipPermissionsRequests bool `json:"-"` // Automatically accept all permissions (YOLO mode)
129+ ContextPaths []string `json:"context_paths,omitempty"`
130+ TUI *TUIOptions `json:"tui,omitempty"`
131+ Debug bool `json:"debug,omitempty"`
132+ DebugLSP bool `json:"debug_lsp,omitempty"`
133+ DisableAutoSummarize bool `json:"disable_auto_summarize,omitempty"`
134+ DataDirectory string `json:"data_directory,omitempty"` // Relative to the cwd
131135}
132136
133137type MCPs map[string]MCPConfig
@@ -244,6 +248,8 @@ type Config struct {
244248
245249 Options *Options `json:"options,omitempty"`
246250
251+ Permissions *Permissions `json:"permissions,omitempty"`
252+
247253 // Internal
248254 workingDir string `json:"-"`
249255 // 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 {
5050 autoApproveSessions []string
5151 autoApproveSessionsMu sync.RWMutex
5252 skip bool
53+ allowedTools []string
5354}
5455
5556func (s *permissionService) GrantPersistent(permission PermissionRequest) {
@@ -82,6 +83,12 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
8283 return true
8384 }
8485
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+
8592 s.autoApproveSessionsMu.RLock()
8693 autoApprove := slices.Contains(s.autoApproveSessions, opts.SessionID)
8794 s.autoApproveSessionsMu.RUnlock()
@@ -130,11 +137,12 @@ func (s *permissionService) AutoApproveSession(sessionID string) {
130137 s.autoApproveSessionsMu.Unlock()
131138}
132139
133−func NewPermissionService(workingDir string, skip bool) Service {
140+func NewPermissionService(workingDir string, skip bool, allowedTools []string) Service {
134141 return &permissionService{
135142 Broker: pubsub.NewBroker[PermissionRequest](),
136143 workingDir: workingDir,
137144 sessionPermissions: make([]PermissionRequest, 0),
138145 skip: skip,
146+ allowedTools: allowedTools,
139147 }
140148}
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+}