permission.go

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