permission.go

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