diff --git a/README.md b/README.md index 8c0900d173783918d49b3f5c33ce08bd54f523dd..c693a94cf4349fdf455975b0abfd1aa7499476e7 100644 --- a/README.md +++ b/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. diff --git a/internal/app/app.go b/internal/app/app.go index 9cb3fefbd2260c7498e8bd51ac08946363f6ed05..05022370977b7c2e0ff6ef6911d3ba40e37982e4 100644 --- a/internal/app/app.go +++ b/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, diff --git a/internal/cmd/root.go b/internal/cmd/root.go index a34a7a7e369090b3da5c63cb250956ec94c7297a..c6c24d5963c57981b1e91911146c1893728ffe37 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 bfbcc5ed91ec86075d269cc53d0e5bf22252ed47..f75b99e2f34ae184908eceb9df8ad01a1042232c 100644 --- a/internal/config/config.go +++ b/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 diff --git a/internal/permission/permission.go b/internal/permission/permission.go index 72dca2db9ccdb5b09ee4ff4794bbe5b51e893b40..cd149a49890b54086bd52e562eed0d44f00c407e 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 + 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, } } diff --git a/internal/permission/permission_test.go b/internal/permission/permission_test.go new file mode 100644 index 0000000000000000000000000000000000000000..5d10fbd240da6a171e345938cb3382a7f7fcf19b --- /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 + 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") + } +}