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	// Publish the request
207	s.Publish(pubsub.CreatedEvent, permission)
208	s.schedulePermissionNotification(permission)
209
210	return <-respCh
211}
212
213func (s *permissionService) AutoApproveSession(sessionID string) {
214	s.autoApproveSessionsMu.Lock()
215	s.autoApproveSessions[sessionID] = true
216	s.autoApproveSessionsMu.Unlock()
217}
218
219func (s *permissionService) SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[PermissionNotification] {
220	return s.notificationBroker.Subscribe(ctx)
221}
222
223func (s *permissionService) NotifyInteraction(toolCallID string) {
224	s.cancelPermissionNotification(toolCallID)
225	s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
226		ToolCallID: toolCallID,
227		Interacted: true,
228	})
229}
230
231func (s *permissionService) SetSkipRequests(skip bool) {
232	s.skip = skip
233}
234
235func (s *permissionService) SkipRequests() bool {
236	return s.skip
237}
238
239func NewPermissionService(ctx context.Context, workingDir string, skip bool, allowedTools []string, notifier permissionNotifier) Service {
240	if ctx == nil {
241		ctx = context.Background()
242	}
243	return &permissionService{
244		Broker:              pubsub.NewBroker[PermissionRequest](),
245		notificationBroker:  pubsub.NewBroker[PermissionNotification](),
246		notifier:            notifier,
247		notificationCtx:     ctx,
248		notificationCancels: csync.NewMap[string, context.CancelFunc](),
249		workingDir:          workingDir,
250		sessionPermissions:  make([]PermissionRequest, 0),
251		autoApproveSessions: make(map[string]bool),
252		skip:                skip,
253		allowedTools:        allowedTools,
254		pendingRequests:     csync.NewMap[string, chan bool](),
255	}
256}
257
258func (s *permissionService) schedulePermissionNotification(permission PermissionRequest) {
259	if s.notifier == nil {
260		return
261	}
262
263	if cancel, ok := s.notificationCancels.Take(permission.ToolCallID); ok && cancel != nil {
264		cancel()
265	}
266
267	title := "💘 Crush is waiting"
268	message := fmt.Sprintf("Permission required to execute \"%s\"", permission.ToolName)
269	cancel := s.notifier.NotifyPermissionRequest(s.notificationCtx, title, message, permissionNotificationDelay)
270	if cancel == nil {
271		cancel = func() {}
272	}
273	s.notificationCancels.Set(permission.ToolCallID, cancel)
274}
275
276func (s *permissionService) cancelPermissionNotification(toolCallID string) {
277	if s.notifier == nil {
278		return
279	}
280
281	if cancel, ok := s.notificationCancels.Take(toolCallID); ok && cancel != nil {
282		cancel()
283	}
284}