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
 18// UserCommentaryTag formats user commentary for the LLM.
 19func UserCommentaryTag(message string) string {
 20	if message == "" {
 21		return ""
 22	}
 23	return "\n\nUser feedback: " + message
 24}
 25
 26type CreatePermissionRequest struct {
 27	SessionID   string `json:"session_id"`
 28	ToolCallID  string `json:"tool_call_id"`
 29	ToolName    string `json:"tool_name"`
 30	Description string `json:"description"`
 31	Action      string `json:"action"`
 32	Params      any    `json:"params"`
 33	Path        string `json:"path"`
 34}
 35
 36type PermissionNotification struct {
 37	ToolCallID string `json:"tool_call_id"`
 38	Granted    bool   `json:"granted"`
 39	Denied     bool   `json:"denied"`
 40	Message    string `json:"message,omitempty"` // User commentary or instructions.
 41}
 42
 43type PermissionRequest struct {
 44	ID          string `json:"id"`
 45	SessionID   string `json:"session_id"`
 46	ToolCallID  string `json:"tool_call_id"`
 47	ToolName    string `json:"tool_name"`
 48	Description string `json:"description"`
 49	Action      string `json:"action"`
 50	Params      any    `json:"params"`
 51	Path        string `json:"path"`
 52}
 53
 54type Service interface {
 55	pubsub.Subscriber[PermissionRequest]
 56	GrantPersistent(permission PermissionRequest, message string)
 57	Grant(permission PermissionRequest, message string)
 58	Deny(permission PermissionRequest, message string)
 59	Request(ctx context.Context, opts CreatePermissionRequest) (PermissionResult, error)
 60	AutoApproveSession(sessionID string)
 61	SetSkipRequests(skip bool)
 62	SkipRequests() bool
 63	SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[PermissionNotification]
 64}
 65
 66// PermissionResult contains the result of a permission request.
 67type PermissionResult struct {
 68	Granted bool
 69	Message string // User's commentary or instructions.
 70}
 71
 72// AppendCommentary appends user commentary to content if present.
 73func (r PermissionResult) AppendCommentary(content string) string {
 74	if r.Message == "" {
 75		return content
 76	}
 77	return content + UserCommentaryTag(r.Message)
 78}
 79
 80type permissionService struct {
 81	*pubsub.Broker[PermissionRequest]
 82
 83	notificationBroker    *pubsub.Broker[PermissionNotification]
 84	workingDir            string
 85	sessionPermissions    []PermissionRequest
 86	sessionPermissionsMu  sync.RWMutex
 87	pendingRequests       *csync.Map[string, chan PermissionResult]
 88	autoApproveSessions   map[string]bool
 89	autoApproveSessionsMu sync.RWMutex
 90	skip                  bool
 91	allowedTools          []string
 92
 93	// used to make sure we only process one request at a time
 94	requestMu       sync.Mutex
 95	activeRequest   *PermissionRequest
 96	activeRequestMu sync.Mutex
 97}
 98
 99func (s *permissionService) GrantPersistent(permission PermissionRequest, message string) {
100	s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
101		ToolCallID: permission.ToolCallID,
102		Granted:    true,
103		Message:    message,
104	})
105	respCh, ok := s.pendingRequests.Get(permission.ID)
106	if ok {
107		respCh <- PermissionResult{Granted: true, Message: message}
108	}
109
110	s.sessionPermissionsMu.Lock()
111	s.sessionPermissions = append(s.sessionPermissions, permission)
112	s.sessionPermissionsMu.Unlock()
113
114	s.activeRequestMu.Lock()
115	if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
116		s.activeRequest = nil
117	}
118	s.activeRequestMu.Unlock()
119}
120
121func (s *permissionService) Grant(permission PermissionRequest, message string) {
122	s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
123		ToolCallID: permission.ToolCallID,
124		Granted:    true,
125		Message:    message,
126	})
127	respCh, ok := s.pendingRequests.Get(permission.ID)
128	if ok {
129		respCh <- PermissionResult{Granted: true, Message: message}
130	}
131
132	s.activeRequestMu.Lock()
133	if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
134		s.activeRequest = nil
135	}
136	s.activeRequestMu.Unlock()
137}
138
139func (s *permissionService) Deny(permission PermissionRequest, message string) {
140	s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
141		ToolCallID: permission.ToolCallID,
142		Granted:    false,
143		Denied:     true,
144		Message:    message,
145	})
146	respCh, ok := s.pendingRequests.Get(permission.ID)
147	if ok {
148		respCh <- PermissionResult{Granted: false, Message: message}
149	}
150
151	s.activeRequestMu.Lock()
152	if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
153		s.activeRequest = nil
154	}
155	s.activeRequestMu.Unlock()
156}
157
158func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRequest) (PermissionResult, error) {
159	if s.skip {
160		return PermissionResult{Granted: true}, nil
161	}
162
163	// tell the UI that a permission was requested
164	s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
165		ToolCallID: opts.ToolCallID,
166	})
167	s.requestMu.Lock()
168	defer s.requestMu.Unlock()
169
170	// Check if the tool/action combination is in the allowlist
171	commandKey := opts.ToolName + ":" + opts.Action
172	if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) {
173		return PermissionResult{Granted: true}, nil
174	}
175
176	s.autoApproveSessionsMu.RLock()
177	autoApprove := s.autoApproveSessions[opts.SessionID]
178	s.autoApproveSessionsMu.RUnlock()
179
180	if autoApprove {
181		return PermissionResult{Granted: true}, nil
182	}
183
184	fileInfo, err := os.Stat(opts.Path)
185	dir := opts.Path
186	if err == nil {
187		if fileInfo.IsDir() {
188			dir = opts.Path
189		} else {
190			dir = filepath.Dir(opts.Path)
191		}
192	}
193
194	if dir == "." {
195		dir = s.workingDir
196	}
197	permission := PermissionRequest{
198		ID:          uuid.New().String(),
199		Path:        dir,
200		SessionID:   opts.SessionID,
201		ToolCallID:  opts.ToolCallID,
202		ToolName:    opts.ToolName,
203		Description: opts.Description,
204		Action:      opts.Action,
205		Params:      opts.Params,
206	}
207
208	s.sessionPermissionsMu.RLock()
209	for _, p := range s.sessionPermissions {
210		if p.ToolName == permission.ToolName && p.Action == permission.Action && p.SessionID == permission.SessionID && p.Path == permission.Path {
211			s.sessionPermissionsMu.RUnlock()
212			return PermissionResult{Granted: true}, nil
213		}
214	}
215	s.sessionPermissionsMu.RUnlock()
216
217	s.activeRequestMu.Lock()
218	s.activeRequest = &permission
219	s.activeRequestMu.Unlock()
220
221	respCh := make(chan PermissionResult, 1)
222	s.pendingRequests.Set(permission.ID, respCh)
223	defer s.pendingRequests.Del(permission.ID)
224
225	// Publish the request
226	s.Publish(pubsub.CreatedEvent, permission)
227
228	select {
229	case <-ctx.Done():
230		return PermissionResult{Granted: false}, ctx.Err()
231	case result := <-respCh:
232		return result, nil
233	}
234}
235
236func (s *permissionService) AutoApproveSession(sessionID string) {
237	s.autoApproveSessionsMu.Lock()
238	s.autoApproveSessions[sessionID] = true
239	s.autoApproveSessionsMu.Unlock()
240}
241
242func (s *permissionService) SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[PermissionNotification] {
243	return s.notificationBroker.Subscribe(ctx)
244}
245
246func (s *permissionService) SetSkipRequests(skip bool) {
247	s.skip = skip
248}
249
250func (s *permissionService) SkipRequests() bool {
251	return s.skip
252}
253
254func NewPermissionService(workingDir string, skip bool, allowedTools []string) Service {
255	return &permissionService{
256		Broker:              pubsub.NewBroker[PermissionRequest](),
257		notificationBroker:  pubsub.NewBroker[PermissionNotification](),
258		workingDir:          workingDir,
259		sessionPermissions:  make([]PermissionRequest, 0),
260		autoApproveSessions: make(map[string]bool),
261		skip:                skip,
262		allowedTools:        allowedTools,
263		pendingRequests:     csync.NewMap[string, chan PermissionResult](),
264	}
265}