permission.go

  1package permission
  2
  3import (
  4	"context"
  5	"errors"
  6	"log/slog"
  7	"os"
  8	"path/filepath"
  9	"slices"
 10	"sync"
 11
 12	"github.com/charmbracelet/crush/internal/config"
 13	"github.com/charmbracelet/crush/internal/csync"
 14	"github.com/charmbracelet/crush/internal/hooks"
 15	"github.com/charmbracelet/crush/internal/pubsub"
 16	"github.com/google/uuid"
 17)
 18
 19var ErrorPermissionDenied = errors.New("permission denied")
 20
 21type CreatePermissionRequest struct {
 22	SessionID   string `json:"session_id"`
 23	ToolCallID  string `json:"tool_call_id"`
 24	ToolName    string `json:"tool_name"`
 25	Description string `json:"description"`
 26	Action      string `json:"action"`
 27	Params      any    `json:"params"`
 28	Path        string `json:"path"`
 29}
 30
 31type PermissionNotification struct {
 32	ToolCallID string `json:"tool_call_id"`
 33	Granted    bool   `json:"granted"`
 34	Denied     bool   `json:"denied"`
 35}
 36
 37type PermissionRequest struct {
 38	ID          string `json:"id"`
 39	SessionID   string `json:"session_id"`
 40	ToolCallID  string `json:"tool_call_id"`
 41	ToolName    string `json:"tool_name"`
 42	Description string `json:"description"`
 43	Action      string `json:"action"`
 44	Params      any    `json:"params"`
 45	Path        string `json:"path"`
 46}
 47
 48type Service interface {
 49	pubsub.Suscriber[PermissionRequest]
 50	GrantPersistent(permission PermissionRequest)
 51	Grant(permission PermissionRequest)
 52	Deny(permission PermissionRequest)
 53	Request(opts CreatePermissionRequest) bool
 54	AutoApproveSession(sessionID string)
 55	SetSkipRequests(skip bool)
 56	SkipRequests() bool
 57	SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[PermissionNotification]
 58}
 59
 60type permissionService struct {
 61	*pubsub.Broker[PermissionRequest]
 62
 63	notificationBroker    *pubsub.Broker[PermissionNotification]
 64	workingDir            string
 65	sessionPermissions    []PermissionRequest
 66	sessionPermissionsMu  sync.RWMutex
 67	pendingRequests       *csync.Map[string, chan bool]
 68	autoApproveSessions   map[string]bool
 69	autoApproveSessionsMu sync.RWMutex
 70	skip                  bool
 71	allowedTools          []string
 72	hooks                 *hooks.Executor
 73
 74	// used to make sure we only process one request at a time
 75	requestMu     sync.Mutex
 76	activeRequest *PermissionRequest
 77}
 78
 79func (s *permissionService) GrantPersistent(permission PermissionRequest) {
 80	s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
 81		ToolCallID: permission.ToolCallID,
 82		Granted:    true,
 83	})
 84	respCh, ok := s.pendingRequests.Get(permission.ID)
 85	if ok {
 86		respCh <- true
 87	}
 88
 89	s.sessionPermissionsMu.Lock()
 90	s.sessionPermissions = append(s.sessionPermissions, permission)
 91	s.sessionPermissionsMu.Unlock()
 92
 93	if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
 94		s.activeRequest = nil
 95	}
 96}
 97
 98func (s *permissionService) Grant(permission PermissionRequest) {
 99	s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
100		ToolCallID: permission.ToolCallID,
101		Granted:    true,
102	})
103	respCh, ok := s.pendingRequests.Get(permission.ID)
104	if ok {
105		respCh <- true
106	}
107
108	if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
109		s.activeRequest = nil
110	}
111}
112
113func (s *permissionService) Deny(permission PermissionRequest) {
114	s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
115		ToolCallID: permission.ToolCallID,
116		Granted:    false,
117		Denied:     true,
118	})
119	respCh, ok := s.pendingRequests.Get(permission.ID)
120	if ok {
121		respCh <- false
122	}
123
124	if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
125		s.activeRequest = nil
126	}
127}
128
129func (s *permissionService) Request(opts CreatePermissionRequest) bool {
130	if s.skip {
131		return true
132	}
133
134	// tell the UI that a permission was requested
135	s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
136		ToolCallID: opts.ToolCallID,
137	})
138	s.requestMu.Lock()
139	defer s.requestMu.Unlock()
140
141	// Check if the tool/action combination is in the allowlist
142	commandKey := opts.ToolName + ":" + opts.Action
143	if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) {
144		return true
145	}
146
147	s.autoApproveSessionsMu.RLock()
148	autoApprove := s.autoApproveSessions[opts.SessionID]
149	s.autoApproveSessionsMu.RUnlock()
150
151	if autoApprove {
152		return true
153	}
154
155	fileInfo, err := os.Stat(opts.Path)
156	dir := opts.Path
157	if err == nil {
158		if fileInfo.IsDir() {
159			dir = opts.Path
160		} else {
161			dir = filepath.Dir(opts.Path)
162		}
163	}
164
165	if dir == "." {
166		dir = s.workingDir
167	}
168	permission := PermissionRequest{
169		ID:          uuid.New().String(),
170		Path:        dir,
171		SessionID:   opts.SessionID,
172		ToolCallID:  opts.ToolCallID,
173		ToolName:    opts.ToolName,
174		Description: opts.Description,
175		Action:      opts.Action,
176		Params:      opts.Params,
177	}
178
179	s.sessionPermissionsMu.RLock()
180	for _, p := range s.sessionPermissions {
181		if p.ToolName == permission.ToolName && p.Action == permission.Action && p.SessionID == permission.SessionID && p.Path == permission.Path {
182			s.sessionPermissionsMu.RUnlock()
183			return true
184		}
185	}
186	s.sessionPermissionsMu.RUnlock()
187
188	s.activeRequest = &permission
189
190	// Execute PermissionRequested hook.
191	// Uses context.Background() since Request() is called synchronously and hooks should
192	// run even if the calling operation is cancelled. Hooks have their own timeout.
193	if s.hooks != nil {
194		if err := s.hooks.Execute(context.Background(), hooks.HookContext{
195			EventType:          config.PermissionRequested,
196			SessionID:          permission.SessionID,
197			ToolName:           permission.ToolName,
198			PermissionAction:   permission.Action,
199			PermissionPath:     permission.Path,
200			PermissionParams:   permission.Params,
201			PermissionToolCall: permission.ToolCallID,
202		}); err != nil {
203			slog.Debug("permission_requested hook execution failed", "error", err)
204		}
205	}
206
207	respCh := make(chan bool, 1)
208	s.pendingRequests.Set(permission.ID, respCh)
209	defer s.pendingRequests.Del(permission.ID)
210
211	// Publish the request
212	s.Publish(pubsub.CreatedEvent, permission)
213
214	return <-respCh
215}
216
217func (s *permissionService) AutoApproveSession(sessionID string) {
218	s.autoApproveSessionsMu.Lock()
219	s.autoApproveSessions[sessionID] = true
220	s.autoApproveSessionsMu.Unlock()
221}
222
223func (s *permissionService) SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[PermissionNotification] {
224	return s.notificationBroker.Subscribe(ctx)
225}
226
227func (s *permissionService) SetSkipRequests(skip bool) {
228	s.skip = skip
229}
230
231func (s *permissionService) SkipRequests() bool {
232	return s.skip
233}
234
235func NewPermissionService(workingDir string, skip bool, allowedTools []string, hooksExecutor *hooks.Executor) Service {
236	return &permissionService{
237		Broker:              pubsub.NewBroker[PermissionRequest](),
238		notificationBroker:  pubsub.NewBroker[PermissionNotification](),
239		workingDir:          workingDir,
240		sessionPermissions:  make([]PermissionRequest, 0),
241		autoApproveSessions: make(map[string]bool),
242		skip:                skip,
243		allowedTools:        allowedTools,
244		pendingRequests:     csync.NewMap[string, chan bool](),
245		hooks:               hooksExecutor,
246	}
247}