permission.go

  1package permission
  2
  3import (
  4	"context"
  5	"errors"
  6	"os"
  7	"path/filepath"
  8	"slices"
  9	"sync"
 10
 11	"git.secluded.site/crush/internal/csync"
 12	"git.secluded.site/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.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	// Check if the tool/action combination is in the allowlist
131	commandKey := opts.ToolName + ":" + opts.Action
132	if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) {
133		return true
134	}
135
136	// tell the UI that a permission was requested
137	s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
138		ToolCallID: opts.ToolCallID,
139	})
140	s.requestMu.Lock()
141	defer s.requestMu.Unlock()
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.activeRequest = &permission
185
186	respCh := make(chan bool, 1)
187	s.pendingRequests.Set(permission.ID, respCh)
188	defer s.pendingRequests.Del(permission.ID)
189
190	// Publish the request
191	s.Publish(pubsub.CreatedEvent, permission)
192
193	return <-respCh
194}
195
196func (s *permissionService) AutoApproveSession(sessionID string) {
197	s.autoApproveSessionsMu.Lock()
198	s.autoApproveSessions[sessionID] = true
199	s.autoApproveSessionsMu.Unlock()
200}
201
202func (s *permissionService) SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[PermissionNotification] {
203	return s.notificationBroker.Subscribe(ctx)
204}
205
206func (s *permissionService) SetSkipRequests(skip bool) {
207	s.skip = skip
208}
209
210func (s *permissionService) SkipRequests() bool {
211	return s.skip
212}
213
214func NewPermissionService(workingDir string, skip bool, allowedTools []string) Service {
215	return &permissionService{
216		Broker:              pubsub.NewBroker[PermissionRequest](),
217		notificationBroker:  pubsub.NewBroker[PermissionNotification](),
218		workingDir:          workingDir,
219		sessionPermissions:  make([]PermissionRequest, 0),
220		autoApproveSessions: make(map[string]bool),
221		skip:                skip,
222		allowedTools:        allowedTools,
223		pendingRequests:     csync.NewMap[string, chan bool](),
224	}
225}