permission.go

  1package permission
  2
  3import (
  4	"context"
  5	"os"
  6	"path/filepath"
  7	"slices"
  8	"sync"
  9
 10	"github.com/charmbracelet/crush/internal/csync"
 11	"github.com/charmbracelet/crush/internal/pubsub"
 12	"github.com/google/uuid"
 13)
 14
 15// hookApprovalKey is the unexported context key used to mark a tool call as
 16// pre-approved by a PreToolUse hook. The value is the tool call ID so an
 17// approval can't be reused across calls that happen to share a context.
 18type hookApprovalKey struct{}
 19
 20// WithHookApproval returns a context that marks the given tool call ID as
 21// pre-approved by a hook. When the permission service sees a matching
 22// request it short-circuits the normal prompt and grants immediately.
 23func WithHookApproval(ctx context.Context, toolCallID string) context.Context {
 24	return context.WithValue(ctx, hookApprovalKey{}, toolCallID)
 25}
 26
 27// hookApproved reports whether the context carries a hook approval for the
 28// given tool call ID.
 29func hookApproved(ctx context.Context, toolCallID string) bool {
 30	if toolCallID == "" {
 31		return false
 32	}
 33	v, _ := ctx.Value(hookApprovalKey{}).(string)
 34	return v == toolCallID
 35}
 36
 37type CreatePermissionRequest struct {
 38	SessionID   string `json:"session_id"`
 39	ToolCallID  string `json:"tool_call_id"`
 40	ToolName    string `json:"tool_name"`
 41	Description string `json:"description"`
 42	Action      string `json:"action"`
 43	Params      any    `json:"params"`
 44	Path        string `json:"path"`
 45}
 46
 47type PermissionNotification struct {
 48	ToolCallID string `json:"tool_call_id"`
 49	Granted    bool   `json:"granted"`
 50	Denied     bool   `json:"denied"`
 51}
 52
 53type PermissionRequest struct {
 54	ID          string `json:"id"`
 55	SessionID   string `json:"session_id"`
 56	ToolCallID  string `json:"tool_call_id"`
 57	ToolName    string `json:"tool_name"`
 58	Description string `json:"description"`
 59	Action      string `json:"action"`
 60	Params      any    `json:"params"`
 61	Path        string `json:"path"`
 62}
 63
 64type Service interface {
 65	pubsub.Subscriber[PermissionRequest]
 66	GrantPersistent(permission PermissionRequest)
 67	Grant(permission PermissionRequest)
 68	Deny(permission PermissionRequest)
 69	Request(ctx context.Context, opts CreatePermissionRequest) (bool, error)
 70	AutoApproveSession(sessionID string)
 71	SetSkipRequests(skip bool)
 72	SkipRequests() bool
 73	SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[PermissionNotification]
 74}
 75
 76type permissionService struct {
 77	*pubsub.Broker[PermissionRequest]
 78
 79	notificationBroker    *pubsub.Broker[PermissionNotification]
 80	workingDir            string
 81	sessionPermissions    []PermissionRequest
 82	sessionPermissionsMu  sync.RWMutex
 83	pendingRequests       *csync.Map[string, chan bool]
 84	autoApproveSessions   map[string]bool
 85	autoApproveSessionsMu sync.RWMutex
 86	skip                  bool
 87	allowedTools          []string
 88
 89	// used to make sure we only process one request at a time
 90	requestMu       sync.Mutex
 91	activeRequest   *PermissionRequest
 92	activeRequestMu sync.Mutex
 93}
 94
 95func (s *permissionService) GrantPersistent(permission PermissionRequest) {
 96	s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
 97		ToolCallID: permission.ToolCallID,
 98		Granted:    true,
 99	})
100	respCh, ok := s.pendingRequests.Get(permission.ID)
101	if ok {
102		respCh <- true
103	}
104
105	s.sessionPermissionsMu.Lock()
106	s.sessionPermissions = append(s.sessionPermissions, permission)
107	s.sessionPermissionsMu.Unlock()
108
109	s.activeRequestMu.Lock()
110	if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
111		s.activeRequest = nil
112	}
113	s.activeRequestMu.Unlock()
114}
115
116func (s *permissionService) Grant(permission PermissionRequest) {
117	s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
118		ToolCallID: permission.ToolCallID,
119		Granted:    true,
120	})
121	respCh, ok := s.pendingRequests.Get(permission.ID)
122	if ok {
123		respCh <- true
124	}
125
126	s.activeRequestMu.Lock()
127	if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
128		s.activeRequest = nil
129	}
130	s.activeRequestMu.Unlock()
131}
132
133func (s *permissionService) Deny(permission PermissionRequest) {
134	s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
135		ToolCallID: permission.ToolCallID,
136		Granted:    false,
137		Denied:     true,
138	})
139	respCh, ok := s.pendingRequests.Get(permission.ID)
140	if ok {
141		respCh <- false
142	}
143
144	s.activeRequestMu.Lock()
145	if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
146		s.activeRequest = nil
147	}
148	s.activeRequestMu.Unlock()
149}
150
151func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRequest) (bool, error) {
152	if s.skip {
153		return true, nil
154	}
155
156	// Check if the tool/action combination is in the allowlist
157	commandKey := opts.ToolName + ":" + opts.Action
158	if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) {
159		return true, nil
160	}
161
162	// A PreToolUse hook that returned decision=allow stamps the context
163	// with the tool call ID. Treat that as a pre-approval and skip the
164	// prompt entirely. We still publish a granted notification so the UI
165	// and audit subscribers see the outcome.
166	if hookApproved(ctx, opts.ToolCallID) {
167		s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
168			ToolCallID: opts.ToolCallID,
169			Granted:    true,
170		})
171		return true, nil
172	}
173
174	// tell the UI that a permission was requested
175	s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
176		ToolCallID: opts.ToolCallID,
177	})
178	s.requestMu.Lock()
179	defer s.requestMu.Unlock()
180
181	s.autoApproveSessionsMu.RLock()
182	autoApprove := s.autoApproveSessions[opts.SessionID]
183	s.autoApproveSessionsMu.RUnlock()
184
185	if autoApprove {
186		s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
187			ToolCallID: opts.ToolCallID,
188			Granted:    true,
189		})
190		return true, nil
191	}
192
193	fileInfo, err := os.Stat(opts.Path)
194	dir := opts.Path
195	if err == nil {
196		if fileInfo.IsDir() {
197			dir = opts.Path
198		} else {
199			dir = filepath.Dir(opts.Path)
200		}
201	}
202
203	if dir == "." {
204		dir = s.workingDir
205	}
206	permission := PermissionRequest{
207		ID:          uuid.New().String(),
208		Path:        dir,
209		SessionID:   opts.SessionID,
210		ToolCallID:  opts.ToolCallID,
211		ToolName:    opts.ToolName,
212		Description: opts.Description,
213		Action:      opts.Action,
214		Params:      opts.Params,
215	}
216
217	s.sessionPermissionsMu.RLock()
218	for _, p := range s.sessionPermissions {
219		if p.ToolName == permission.ToolName && p.Action == permission.Action && p.SessionID == permission.SessionID && p.Path == permission.Path {
220			s.sessionPermissionsMu.RUnlock()
221			s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
222				ToolCallID: opts.ToolCallID,
223				Granted:    true,
224			})
225			return true, nil
226		}
227	}
228	s.sessionPermissionsMu.RUnlock()
229
230	s.activeRequestMu.Lock()
231	s.activeRequest = &permission
232	s.activeRequestMu.Unlock()
233
234	respCh := make(chan bool, 1)
235	s.pendingRequests.Set(permission.ID, respCh)
236	defer s.pendingRequests.Del(permission.ID)
237
238	// Publish the request
239	s.Publish(pubsub.CreatedEvent, permission)
240
241	select {
242	case <-ctx.Done():
243		return false, ctx.Err()
244	case granted := <-respCh:
245		return granted, nil
246	}
247}
248
249func (s *permissionService) AutoApproveSession(sessionID string) {
250	s.autoApproveSessionsMu.Lock()
251	s.autoApproveSessions[sessionID] = true
252	s.autoApproveSessionsMu.Unlock()
253}
254
255func (s *permissionService) SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[PermissionNotification] {
256	return s.notificationBroker.Subscribe(ctx)
257}
258
259func (s *permissionService) SetSkipRequests(skip bool) {
260	s.skip = skip
261}
262
263func (s *permissionService) SkipRequests() bool {
264	return s.skip
265}
266
267func NewPermissionService(workingDir string, skip bool, allowedTools []string) Service {
268	return &permissionService{
269		Broker:              pubsub.NewBroker[PermissionRequest](),
270		notificationBroker:  pubsub.NewBroker[PermissionNotification](),
271		workingDir:          workingDir,
272		sessionPermissions:  make([]PermissionRequest, 0),
273		autoApproveSessions: make(map[string]bool),
274		skip:                skip,
275		allowedTools:        allowedTools,
276		pendingRequests:     csync.NewMap[string, chan bool](),
277	}
278}