permission.go

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