permission.go

  1package permission
  2
  3import (
  4	"context"
  5	"errors"
  6	"fmt"
  7	"os"
  8	"path/filepath"
  9	"slices"
 10	"sync"
 11	"time"
 12
 13	"git.secluded.site/crush/internal/csync"
 14	"git.secluded.site/crush/internal/pubsub"
 15	"github.com/google/uuid"
 16)
 17
 18var ErrorPermissionDenied = errors.New("user denied permission")
 19
 20type CreatePermissionRequest struct {
 21	SessionID   string `json:"session_id"`
 22	ToolCallID  string `json:"tool_call_id"`
 23	ToolName    string `json:"tool_name"`
 24	Description string `json:"description"`
 25	Action      string `json:"action"`
 26	Params      any    `json:"params"`
 27	Path        string `json:"path"`
 28}
 29
 30type PermissionNotification struct {
 31	ToolCallID string `json:"tool_call_id"`
 32	Granted    bool   `json:"granted"`
 33	Denied     bool   `json:"denied"`
 34	Interacted bool   `json:"interacted"` // true when user scrolls or navigates the dialog
 35}
 36
 37type PermissionRequest struct {
 38	ID          string `json:"id"`
 39	SessionID   string `json:"session_id"`
 40	ToolCallID  string `json:"tool_call_id"`
 41	ToolName    string `json:"tool_name"`
 42	Description string `json:"description"`
 43	Action      string `json:"action"`
 44	Params      any    `json:"params"`
 45	Path        string `json:"path"`
 46}
 47
 48type Service interface {
 49	pubsub.Suscriber[PermissionRequest]
 50	GrantPersistent(permission PermissionRequest)
 51	Grant(permission PermissionRequest)
 52	Deny(permission PermissionRequest)
 53	Request(opts CreatePermissionRequest) bool
 54	AutoApproveSession(sessionID string)
 55	SetSkipRequests(skip bool)
 56	SkipRequests() bool
 57	SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[PermissionNotification]
 58	NotifyInteraction(toolCallID string)
 59}
 60
 61type permissionNotifier interface {
 62	NotifyPermissionRequest(ctx context.Context, title, message string, delay time.Duration) context.CancelFunc
 63}
 64
 65const permissionNotificationDelay = 5 * time.Second
 66
 67type permissionService struct {
 68	*pubsub.Broker[PermissionRequest]
 69
 70	notificationBroker    *pubsub.Broker[PermissionNotification]
 71	notifier              permissionNotifier
 72	notificationCtx       context.Context
 73	notificationCancels   *csync.Map[string, context.CancelFunc]
 74	workingDir            string
 75	sessionPermissions    []PermissionRequest
 76	sessionPermissionsMu  sync.RWMutex
 77	pendingRequests       *csync.Map[string, chan bool]
 78	autoApproveSessions   map[string]bool
 79	autoApproveSessionsMu sync.RWMutex
 80	skip                  bool
 81	allowedTools          []string
 82
 83	// used to make sure we only process one request at a time
 84	requestMu     sync.Mutex
 85	activeRequest *PermissionRequest
 86}
 87
 88func (s *permissionService) GrantPersistent(permission PermissionRequest) {
 89	s.cancelPermissionNotification(permission.ToolCallID)
 90	s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
 91		ToolCallID: permission.ToolCallID,
 92		Granted:    true,
 93	})
 94	respCh, ok := s.pendingRequests.Get(permission.ID)
 95	if ok {
 96		respCh <- true
 97	}
 98
 99	s.sessionPermissionsMu.Lock()
100	s.sessionPermissions = append(s.sessionPermissions, permission)
101	s.sessionPermissionsMu.Unlock()
102
103	if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
104		s.activeRequest = nil
105	}
106}
107
108func (s *permissionService) Grant(permission PermissionRequest) {
109	s.cancelPermissionNotification(permission.ToolCallID)
110	s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
111		ToolCallID: permission.ToolCallID,
112		Granted:    true,
113	})
114	respCh, ok := s.pendingRequests.Get(permission.ID)
115	if ok {
116		respCh <- true
117	}
118
119	if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
120		s.activeRequest = nil
121	}
122}
123
124func (s *permissionService) Deny(permission PermissionRequest) {
125	s.cancelPermissionNotification(permission.ToolCallID)
126	s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
127		ToolCallID: permission.ToolCallID,
128		Granted:    false,
129		Denied:     true,
130	})
131	respCh, ok := s.pendingRequests.Get(permission.ID)
132	if ok {
133		respCh <- false
134	}
135
136	if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
137		s.activeRequest = nil
138	}
139}
140
141func (s *permissionService) Request(opts CreatePermissionRequest) bool {
142	if s.skip {
143		return true
144	}
145
146	// Check if the tool/action combination is in the allowlist
147	commandKey := opts.ToolName + ":" + opts.Action
148	if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) {
149		return true
150	}
151
152	// tell the UI that a permission was requested
153	s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
154		ToolCallID: opts.ToolCallID,
155	})
156	s.requestMu.Lock()
157	defer s.requestMu.Unlock()
158
159	s.autoApproveSessionsMu.RLock()
160	autoApprove := s.autoApproveSessions[opts.SessionID]
161	s.autoApproveSessionsMu.RUnlock()
162
163	if autoApprove {
164		return true
165	}
166
167	fileInfo, err := os.Stat(opts.Path)
168	dir := opts.Path
169	if err == nil {
170		if fileInfo.IsDir() {
171			dir = opts.Path
172		} else {
173			dir = filepath.Dir(opts.Path)
174		}
175	}
176
177	if dir == "." {
178		dir = s.workingDir
179	}
180	permission := PermissionRequest{
181		ID:          uuid.New().String(),
182		Path:        dir,
183		SessionID:   opts.SessionID,
184		ToolCallID:  opts.ToolCallID,
185		ToolName:    opts.ToolName,
186		Description: opts.Description,
187		Action:      opts.Action,
188		Params:      opts.Params,
189	}
190
191	s.sessionPermissionsMu.RLock()
192	for _, p := range s.sessionPermissions {
193		if p.ToolName == permission.ToolName && p.Action == permission.Action && p.SessionID == permission.SessionID && p.Path == permission.Path {
194			s.sessionPermissionsMu.RUnlock()
195			return true
196		}
197	}
198	s.sessionPermissionsMu.RUnlock()
199
200	s.activeRequest = &permission
201
202	respCh := make(chan bool, 1)
203	s.pendingRequests.Set(permission.ID, respCh)
204	defer s.pendingRequests.Del(permission.ID)
205
206	s.schedulePermissionNotification(permission)
207
208	// Publish the request
209	s.Publish(pubsub.CreatedEvent, permission)
210
211	return <-respCh
212}
213
214func (s *permissionService) AutoApproveSession(sessionID string) {
215	s.autoApproveSessionsMu.Lock()
216	s.autoApproveSessions[sessionID] = true
217	s.autoApproveSessionsMu.Unlock()
218}
219
220func (s *permissionService) SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[PermissionNotification] {
221	return s.notificationBroker.Subscribe(ctx)
222}
223
224func (s *permissionService) NotifyInteraction(toolCallID string) {
225	s.cancelPermissionNotification(toolCallID)
226	s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
227		ToolCallID: toolCallID,
228		Interacted: true,
229	})
230}
231
232func (s *permissionService) SetSkipRequests(skip bool) {
233	s.skip = skip
234}
235
236func (s *permissionService) SkipRequests() bool {
237	return s.skip
238}
239
240func NewPermissionService(ctx context.Context, workingDir string, skip bool, allowedTools []string, notifier permissionNotifier) Service {
241	if ctx == nil {
242		ctx = context.Background()
243	}
244	return &permissionService{
245		Broker:              pubsub.NewBroker[PermissionRequest](),
246		notificationBroker:  pubsub.NewBroker[PermissionNotification](),
247		notifier:            notifier,
248		notificationCtx:     ctx,
249		notificationCancels: csync.NewMap[string, context.CancelFunc](),
250		workingDir:          workingDir,
251		sessionPermissions:  make([]PermissionRequest, 0),
252		autoApproveSessions: make(map[string]bool),
253		skip:                skip,
254		allowedTools:        allowedTools,
255		pendingRequests:     csync.NewMap[string, chan bool](),
256	}
257}
258
259func (s *permissionService) schedulePermissionNotification(permission PermissionRequest) {
260	if s.notifier == nil {
261		return
262	}
263
264	if cancel, ok := s.notificationCancels.Take(permission.ToolCallID); ok && cancel != nil {
265		cancel()
266	}
267
268	title := "💘 Crush is waiting"
269	message := fmt.Sprintf("Permission required to execute \"%s\"", permission.ToolName)
270	cancel := s.notifier.NotifyPermissionRequest(s.notificationCtx, title, message, permissionNotificationDelay)
271	if cancel == nil {
272		cancel = func() {}
273	}
274	s.notificationCancels.Set(permission.ToolCallID, cancel)
275}
276
277func (s *permissionService) cancelPermissionNotification(toolCallID string) {
278	if s.notifier == nil {
279		return
280	}
281
282	if cancel, ok := s.notificationCancels.Take(toolCallID); ok && cancel != nil {
283		cancel()
284	}
285}