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("permission denied")
 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.Suscriber[PermissionRequest]
 47	GrantPersistent(permission PermissionRequest)
 48	Grant(permission PermissionRequest)
 49	Deny(permission PermissionRequest)
 50	Request(opts CreatePermissionRequest) bool
 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}
 74
 75func (s *permissionService) GrantPersistent(permission PermissionRequest) {
 76	s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
 77		ToolCallID: permission.ToolCallID,
 78		Granted:    true,
 79	})
 80	respCh, ok := s.pendingRequests.Get(permission.ID)
 81	if ok {
 82		respCh <- true
 83	}
 84
 85	s.sessionPermissionsMu.Lock()
 86	s.sessionPermissions = append(s.sessionPermissions, permission)
 87	s.sessionPermissionsMu.Unlock()
 88
 89	if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
 90		s.activeRequest = nil
 91	}
 92}
 93
 94func (s *permissionService) Grant(permission PermissionRequest) {
 95	s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
 96		ToolCallID: permission.ToolCallID,
 97		Granted:    true,
 98	})
 99	respCh, ok := s.pendingRequests.Get(permission.ID)
100	if ok {
101		respCh <- true
102	}
103
104	if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
105		s.activeRequest = nil
106	}
107}
108
109func (s *permissionService) Deny(permission PermissionRequest) {
110	s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
111		ToolCallID: permission.ToolCallID,
112		Granted:    false,
113		Denied:     true,
114	})
115	respCh, ok := s.pendingRequests.Get(permission.ID)
116	if ok {
117		respCh <- false
118	}
119
120	if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
121		s.activeRequest = nil
122	}
123}
124
125func (s *permissionService) Request(opts CreatePermissionRequest) bool {
126	if s.skip {
127		return true
128	}
129
130	// tell the UI that a permission was requested
131	s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
132		ToolCallID: opts.ToolCallID,
133	})
134	s.requestMu.Lock()
135	defer s.requestMu.Unlock()
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
141	}
142
143	s.autoApproveSessionsMu.RLock()
144	autoApprove := s.autoApproveSessions[opts.SessionID]
145	s.autoApproveSessionsMu.RUnlock()
146
147	if autoApprove {
148		return true
149	}
150
151	fileInfo, err := os.Stat(opts.Path)
152	dir := opts.Path
153	if err == nil {
154		if fileInfo.IsDir() {
155			dir = opts.Path
156		} else {
157			dir = filepath.Dir(opts.Path)
158		}
159	}
160
161	if dir == "." {
162		dir = s.workingDir
163	}
164	permission := PermissionRequest{
165		ID:          uuid.New().String(),
166		Path:        dir,
167		SessionID:   opts.SessionID,
168		ToolCallID:  opts.ToolCallID,
169		ToolName:    opts.ToolName,
170		Description: opts.Description,
171		Action:      opts.Action,
172		Params:      opts.Params,
173	}
174
175	s.sessionPermissionsMu.RLock()
176	for _, p := range s.sessionPermissions {
177		if p.ToolName == permission.ToolName && p.Action == permission.Action && p.SessionID == permission.SessionID && p.Path == permission.Path {
178			s.sessionPermissionsMu.RUnlock()
179			return true
180		}
181	}
182	s.sessionPermissionsMu.RUnlock()
183
184	s.sessionPermissionsMu.RLock()
185	for _, p := range s.sessionPermissions {
186		if p.ToolName == permission.ToolName && p.Action == permission.Action && p.SessionID == permission.SessionID && p.Path == permission.Path {
187			s.sessionPermissionsMu.RUnlock()
188			return true
189		}
190	}
191	s.sessionPermissionsMu.RUnlock()
192
193	s.activeRequest = &permission
194
195	respCh := make(chan bool, 1)
196	s.pendingRequests.Set(permission.ID, respCh)
197	defer s.pendingRequests.Del(permission.ID)
198
199	// Publish the request
200	s.Publish(pubsub.CreatedEvent, permission)
201
202	return <-respCh
203}
204
205func (s *permissionService) AutoApproveSession(sessionID string) {
206	s.autoApproveSessionsMu.Lock()
207	s.autoApproveSessions[sessionID] = true
208	s.autoApproveSessionsMu.Unlock()
209}
210
211func (s *permissionService) SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[PermissionNotification] {
212	return s.notificationBroker.Subscribe(ctx)
213}
214
215func (s *permissionService) SetSkipRequests(skip bool) {
216	s.skip = skip
217}
218
219func (s *permissionService) SkipRequests() bool {
220	return s.skip
221}
222
223func NewPermissionService(workingDir string, skip bool, allowedTools []string) Service {
224	return &permissionService{
225		Broker:              pubsub.NewBroker[PermissionRequest](),
226		notificationBroker:  pubsub.NewBroker[PermissionNotification](),
227		workingDir:          workingDir,
228		sessionPermissions:  make([]PermissionRequest, 0),
229		autoApproveSessions: make(map[string]bool),
230		skip:                skip,
231		allowedTools:        allowedTools,
232		pendingRequests:     csync.NewMap[string, chan bool](),
233	}
234}