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