From bc6fadc81a7ddf74c50863d266ac0e506ea7a75a Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Tue, 22 Jul 2025 23:25:33 -0700 Subject: [PATCH 1/5] add command whitelist --- internal/app/app.go | 6 +- internal/config/config.go | 5 +- internal/permission/permission.go | 10 ++- internal/permission/permission_test.go | 92 ++++++++++++++++++++++++++ 4 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 internal/permission/permission_test.go diff --git a/internal/app/app.go b/internal/app/app.go index d63c90c6e2599f63e3a65cd8069b53638f45cc5f..8aa6ce48333ea3e9e30ccf04bcb2e29bd763e709 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -60,12 +60,16 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { messages := message.NewService(q) files := history.NewService(q, conn) skipPermissionsRequests := cfg.Options != nil && cfg.Options.SkipPermissionsRequests + allowedCommands := []string{} + if cfg.Options != nil && cfg.Options.AllowedCommands != nil { + allowedCommands = cfg.Options.AllowedCommands + } app := &App{ Sessions: sessions, Messages: messages, History: files, - Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests), + Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedCommands), LSPClients: make(map[string]*lsp.Client), globalCtx: ctx, diff --git a/internal/config/config.go b/internal/config/config.go index 1c20188a12a3955fde6b6eeed9f12ea39288e328..0e76693557790c5b30eb2fdbf2ce2d79c7ecbef2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -126,8 +126,9 @@ type Options struct { 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) + DataDirectory string `json:"data_directory,omitempty"` // Relative to the cwd + SkipPermissionsRequests bool `json:"-"` // Automatically accept all permissions (YOLO mode) + AllowedCommands []string `json:"allowed_commands,omitempty"` // Commands that don't require permission prompts } type MCPs map[string]MCPConfig diff --git a/internal/permission/permission.go b/internal/permission/permission.go index 72dca2db9ccdb5b09ee4ff4794bbe5b51e893b40..a016bb9d5d76cfd32539c34f55d57317f6ecf1dd 100644 --- a/internal/permission/permission.go +++ b/internal/permission/permission.go @@ -50,6 +50,7 @@ type permissionService struct { autoApproveSessions []string autoApproveSessionsMu sync.RWMutex skip bool + allowedCommands []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.allowedCommands, commandKey) || slices.Contains(s.allowedCommands, 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, allowedCommands []string) Service { return &permissionService{ Broker: pubsub.NewBroker[PermissionRequest](), workingDir: workingDir, sessionPermissions: make([]PermissionRequest, 0), skip: skip, + allowedCommands: allowedCommands, } } diff --git a/internal/permission/permission_test.go b/internal/permission/permission_test.go new file mode 100644 index 0000000000000000000000000000000000000000..40b03d10298d3e948aed5379ad7ce84ae4e0d8b8 --- /dev/null +++ b/internal/permission/permission_test.go @@ -0,0 +1,92 @@ +package permission + +import ( + "testing" +) + +func TestPermissionService_AllowedCommands(t *testing.T) { + tests := []struct { + name string + allowedCommands []string + toolName string + action string + expected bool + }{ + { + name: "tool in allowlist", + allowedCommands: []string{"bash", "view"}, + toolName: "bash", + action: "execute", + expected: true, + }, + { + name: "tool:action in allowlist", + allowedCommands: []string{"bash:execute", "edit:create"}, + toolName: "bash", + action: "execute", + expected: true, + }, + { + name: "tool not in allowlist", + allowedCommands: []string{"view", "ls"}, + toolName: "bash", + action: "execute", + expected: false, + }, + { + name: "tool:action not in allowlist", + allowedCommands: []string{"bash:read", "edit:create"}, + toolName: "bash", + action: "execute", + expected: false, + }, + { + name: "empty allowlist", + allowedCommands: []string{}, + toolName: "bash", + action: "execute", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service := NewPermissionService("/tmp", false, tt.allowedCommands) + + // 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.allowedCommands { + 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.allowedCommands) + } + }) + } +} + +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") + } +} From 70159438d46e6d02a3f3fcf62bf2e7795a002437 Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Thu, 24 Jul 2025 00:02:27 -0700 Subject: [PATCH 2/5] rename to use toplevel permissions --- internal/app/app.go | 4 ++-- internal/config/config.go | 11 ++++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 8aa6ce48333ea3e9e30ccf04bcb2e29bd763e709..50247b165aea1ccc6df378d0d1d3241b9526388e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -61,8 +61,8 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { files := history.NewService(q, conn) skipPermissionsRequests := cfg.Options != nil && cfg.Options.SkipPermissionsRequests allowedCommands := []string{} - if cfg.Options != nil && cfg.Options.AllowedCommands != nil { - allowedCommands = cfg.Options.AllowedCommands + if cfg.Permissions != nil && cfg.Permissions.AllowedTools != nil { + allowedCommands = cfg.Permissions.AllowedTools } app := &App{ diff --git a/internal/config/config.go b/internal/config/config.go index 0e76693557790c5b30eb2fdbf2ce2d79c7ecbef2..5766825a66fca67c1665074fde737b458d41e933 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -120,15 +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 +} + 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) - AllowedCommands []string `json:"allowed_commands,omitempty"` // Commands that don't require permission prompts + DataDirectory string `json:"data_directory,omitempty"` // Relative to the cwd + SkipPermissionsRequests bool `json:"-"` // Automatically accept all permissions (YOLO mode) } type MCPs map[string]MCPConfig @@ -245,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 From d4575e3c72e2b907c30f0f7c5ac3bfffa02f7f31 Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Thu, 24 Jul 2025 00:14:12 -0700 Subject: [PATCH 3/5] move skip to permissions --- internal/app/app.go | 2 +- internal/cmd/root.go | 5 ++++- internal/config/config.go | 14 +++++++------- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 50247b165aea1ccc6df378d0d1d3241b9526388e..b798dd789daba8fe7f227a3435293674ba4bff85 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -59,7 +59,7 @@ 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 allowedCommands := []string{} if cfg.Permissions != nil && cfg.Permissions.AllowedTools != nil { allowedCommands = cfg.Permissions.AllowedTools diff --git a/internal/cmd/root.go b/internal/cmd/root.go index d63160992141da26b6a26610b06f1b601213e00d..254cf340205d7da54984b291c0f88f8a130af80d 100644 --- a/internal/cmd/root.go +++ b/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() diff --git a/internal/config/config.go b/internal/config/config.go index 5766825a66fca67c1665074fde737b458d41e933..95fd9e509b940159950064f1ded56ffb8c66e1b2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -122,16 +122,16 @@ type TUIOptions struct { 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 From 82e8b47985ca77054e4afd79a8b4b54c039d4c99 Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Thu, 24 Jul 2025 00:22:03 -0700 Subject: [PATCH 4/5] document, cmds => tools --- README.md | 25 ++++++++-- internal/app/app.go | 6 +-- internal/permission/permission.go | 8 ++-- internal/permission/permission_test.go | 66 +++++++++++++------------- 4 files changed, 62 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index b21dae25fb7f32169e0fdd9528b3ec06f5c739f0..70075a97b2448b0c07944fcce397b2012e071647 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ rm -rf ./crush Then, run Crush by typing `crush`. -*** +--- @@ -108,7 +108,7 @@ Crush supports Model Context Protocol (MCP) servers through three transport type "mcp": { "filesystem": { "type": "stdio", - "command": "node", + "command": "node", "args": ["/path/to/mcp-server.js"], "env": { "NODE_ENV": "production" @@ -143,7 +143,7 @@ crush -d # View last 1000 lines crush logs -# Follow logs in real-time +# Follow logs in real-time crush logs -f # Show last 500 lines @@ -161,6 +161,25 @@ 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", "bash:read"] + } +} +``` + +The `allowed_tools` array accepts: + +- Tool names (e.g., `"view"`) - allows all actions for that tool +- Tool:action combinations (e.g., `"bash:read"`) - 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. diff --git a/internal/app/app.go b/internal/app/app.go index b798dd789daba8fe7f227a3435293674ba4bff85..a42a994b7b78b32675fcfaedfed3153f0a7826b1 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -60,16 +60,16 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { messages := message.NewService(q) files := history.NewService(q, conn) skipPermissionsRequests := cfg.Permissions != nil && cfg.Permissions.SkipRequests - allowedCommands := []string{} + allowedTools := []string{} if cfg.Permissions != nil && cfg.Permissions.AllowedTools != nil { - allowedCommands = cfg.Permissions.AllowedTools + allowedTools = cfg.Permissions.AllowedTools } app := &App{ Sessions: sessions, Messages: messages, History: files, - Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedCommands), + Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedTools), LSPClients: make(map[string]*lsp.Client), globalCtx: ctx, diff --git a/internal/permission/permission.go b/internal/permission/permission.go index a016bb9d5d76cfd32539c34f55d57317f6ecf1dd..cd149a49890b54086bd52e562eed0d44f00c407e 100644 --- a/internal/permission/permission.go +++ b/internal/permission/permission.go @@ -50,7 +50,7 @@ type permissionService struct { autoApproveSessions []string autoApproveSessionsMu sync.RWMutex skip bool - allowedCommands []string + allowedTools []string } func (s *permissionService) GrantPersistent(permission PermissionRequest) { @@ -85,7 +85,7 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool { // Check if the tool/action combination is in the allowlist commandKey := opts.ToolName + ":" + opts.Action - if slices.Contains(s.allowedCommands, commandKey) || slices.Contains(s.allowedCommands, opts.ToolName) { + if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) { return true } @@ -137,12 +137,12 @@ func (s *permissionService) AutoApproveSession(sessionID string) { s.autoApproveSessionsMu.Unlock() } -func NewPermissionService(workingDir string, skip bool, allowedCommands []string) Service { +func NewPermissionService(workingDir string, skip bool, allowedTools []string) Service { return &permissionService{ Broker: pubsub.NewBroker[PermissionRequest](), workingDir: workingDir, sessionPermissions: make([]PermissionRequest, 0), skip: skip, - allowedCommands: allowedCommands, + allowedTools: allowedTools, } } diff --git a/internal/permission/permission_test.go b/internal/permission/permission_test.go index 40b03d10298d3e948aed5379ad7ce84ae4e0d8b8..5d10fbd240da6a171e345938cb3382a7f7fcf19b 100644 --- a/internal/permission/permission_test.go +++ b/internal/permission/permission_test.go @@ -6,52 +6,52 @@ import ( func TestPermissionService_AllowedCommands(t *testing.T) { tests := []struct { - name string - allowedCommands []string - toolName string - action string - expected bool + name string + allowedTools []string + toolName string + action string + expected bool }{ { - name: "tool in allowlist", - allowedCommands: []string{"bash", "view"}, - toolName: "bash", - action: "execute", - expected: true, + name: "tool in allowlist", + allowedTools: []string{"bash", "view"}, + toolName: "bash", + action: "execute", + expected: true, }, { - name: "tool:action in allowlist", - allowedCommands: []string{"bash:execute", "edit:create"}, - 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", - allowedCommands: []string{"view", "ls"}, - toolName: "bash", - action: "execute", - expected: false, + name: "tool not in allowlist", + allowedTools: []string{"view", "ls"}, + toolName: "bash", + action: "execute", + expected: false, }, { - name: "tool:action not in allowlist", - allowedCommands: []string{"bash:read", "edit:create"}, - 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", - allowedCommands: []string{}, - 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.allowedCommands) + 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 @@ -60,7 +60,7 @@ func TestPermissionService_AllowedCommands(t *testing.T) { // Test the allowlist logic directly commandKey := tt.toolName + ":" + tt.action allowed := false - for _, cmd := range ps.allowedCommands { + for _, cmd := range ps.allowedTools { if cmd == commandKey || cmd == tt.toolName { allowed = true break @@ -69,7 +69,7 @@ func TestPermissionService_AllowedCommands(t *testing.T) { 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.allowedCommands) + tt.expected, allowed, tt.toolName, tt.action, tt.allowedTools) } }) } From 0546d5baf515d1716703f888d6ff259ddfb51c83 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 24 Jul 2025 09:44:20 +0200 Subject: [PATCH 5/5] chore: update example --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 70075a97b2448b0c07944fcce397b2012e071647..f7488fce836115ac5a3deb1600193de0fbf6ba23 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,13 @@ Crush includes a permission system to control which tools can be executed withou ```json { "permissions": { - "allowed_tools": ["view", "ls", "grep", "bash:read"] + "allowed_tools": [ + "view", + "ls", + "grep", + "edit:write", + "mcp_context7_get-library-doc" + ] } } ```