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(ctx context.Context, permission PermissionRequest)
 48	Grant(ctx context.Context, permission PermissionRequest)
 49	Deny(ctx context.Context, permission PermissionRequest)
 50	Request(ctx context.Context, opts CreatePermissionRequest) (bool, error)
 51	AutoApproveSession(sessionID string)
 52	SetSkipRequests(skip bool)
 53	SkipRequests() bool
 54	AddNotificationListener(fn func(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(ctx context.Context, permission PermissionRequest) {
 77	s.notificationBroker.Publish(ctx, 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(ctx context.Context, permission PermissionRequest) {
 98	s.notificationBroker.Publish(ctx, 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(ctx context.Context, permission PermissionRequest) {
115	s.notificationBroker.Publish(ctx, 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	// tell the UI that a permission was requested
138	s.notificationBroker.Publish(ctx, pubsub.CreatedEvent, PermissionNotification{
139		ToolCallID: opts.ToolCallID,
140	})
141	s.requestMu.Lock()
142	defer s.requestMu.Unlock()
143
144	// Check if the tool/action combination is in the allowlist
145	commandKey := opts.ToolName + ":" + opts.Action
146	if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) {
147		return true, nil
148	}
149
150	s.autoApproveSessionsMu.RLock()
151	autoApprove := s.autoApproveSessions[opts.SessionID]
152	s.autoApproveSessionsMu.RUnlock()
153
154	if autoApprove {
155		return true, nil
156	}
157
158	fileInfo, err := os.Stat(opts.Path)
159	dir := opts.Path
160	if err == nil {
161		if fileInfo.IsDir() {
162			dir = opts.Path
163		} else {
164			dir = filepath.Dir(opts.Path)
165		}
166	}
167
168	if dir == "." {
169		dir = s.workingDir
170	}
171	permission := PermissionRequest{
172		ID:          uuid.New().String(),
173		Path:        dir,
174		SessionID:   opts.SessionID,
175		ToolCallID:  opts.ToolCallID,
176		ToolName:    opts.ToolName,
177		Description: opts.Description,
178		Action:      opts.Action,
179		Params:      opts.Params,
180	}
181
182	s.sessionPermissionsMu.RLock()
183	for _, p := range s.sessionPermissions {
184		if p.ToolName == permission.ToolName && p.Action == permission.Action && p.SessionID == permission.SessionID && p.Path == permission.Path {
185			s.sessionPermissionsMu.RUnlock()
186			return true, nil
187		}
188	}
189	s.sessionPermissionsMu.RUnlock()
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, nil
196		}
197	}
198	s.sessionPermissionsMu.RUnlock()
199
200	s.activeRequestMu.Lock()
201	s.activeRequest = &permission
202	s.activeRequestMu.Unlock()
203
204	respCh := make(chan bool, 1)
205	s.pendingRequests.Set(permission.ID, respCh)
206	defer s.pendingRequests.Del(permission.ID)
207
208	// Publish the request
209	s.Publish(ctx, pubsub.CreatedEvent, permission)
210
211	select {
212	case <-ctx.Done():
213		return false, ctx.Err()
214	case granted := <-respCh:
215		return granted, nil
216	}
217}
218
219func (s *permissionService) AutoApproveSession(sessionID string) {
220	s.autoApproveSessionsMu.Lock()
221	s.autoApproveSessions[sessionID] = true
222	s.autoApproveSessionsMu.Unlock()
223}
224
225func (s *permissionService) AddNotificationListener(fn func(pubsub.Event[PermissionNotification])) {
226	s.notificationBroker.AddListener(fn)
227}
228
229func (s *permissionService) SetSkipRequests(skip bool) {
230	s.skip = skip
231}
232
233func (s *permissionService) SkipRequests() bool {
234	return s.skip
235}
236
237func NewPermissionService(workingDir string, skip bool, allowedTools []string) Service {
238	return &permissionService{
239		Broker:              pubsub.NewBroker[PermissionRequest](),
240		notificationBroker:  pubsub.NewBroker[PermissionNotification](),
241		workingDir:          workingDir,
242		sessionPermissions:  make([]PermissionRequest, 0),
243		autoApproveSessions: make(map[string]bool),
244		skip:                skip,
245		allowedTools:        allowedTools,
246		pendingRequests:     csync.NewMap[string, chan bool](),
247	}
248}