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 grants a permission request and remembers the grant
 67	// for the session. It returns true if this call actually resolved the
 68	// pending request; false if the request had already been resolved
 69	// (e.g., by another concurrent caller) or is unknown.
 70	GrantPersistent(permission PermissionRequest) bool
 71	// Grant grants a permission request. It returns true if this call
 72	// actually resolved the pending request; false if the request had
 73	// already been resolved or is unknown.
 74	Grant(permission PermissionRequest) bool
 75	// Deny denies a permission request. It returns true if this call
 76	// actually resolved the pending request; false if the request had
 77	// already been resolved or is unknown.
 78	Deny(permission PermissionRequest) bool
 79	Request(ctx context.Context, opts CreatePermissionRequest) (bool, error)
 80	AutoApproveSession(sessionID string)
 81	SetSkipRequests(skip bool)
 82	SkipRequests() bool
 83	SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[PermissionNotification]
 84}
 85
 86// PermissionKey is a composite key for session permission lookups.
 87type PermissionKey struct {
 88	SessionID string
 89	ToolName  string
 90	Action    string
 91	Path      string
 92}
 93
 94type permissionService struct {
 95	*pubsub.Broker[PermissionRequest]
 96
 97	notificationBroker    *pubsub.Broker[PermissionNotification]
 98	workingDir            string
 99	sessionPermissions    *csync.Map[PermissionKey, bool]
100	pendingRequests       *csync.Map[string, chan bool]
101	autoApproveSessions   map[string]bool
102	autoApproveSessionsMu sync.RWMutex
103	skip                  bool
104	allowedTools          []string
105
106	// used to make sure we only process one request at a time
107	requestMu       sync.Mutex
108	activeRequest   *PermissionRequest
109	activeRequestMu sync.Mutex
110}
111
112// resolve atomically removes the pending request entry for the given
113// permission and, if it was still pending, publishes exactly one
114// PermissionNotification and forwards the outcome to the waiter on
115// respCh. It returns true if this call resolved the request, false if
116// it had already been resolved (e.g., by another concurrent caller) or
117// the request ID is unknown.
118//
119// If onResolve is non-nil it runs after the pending entry has been
120// taken but before the notification is published or the waiter is
121// unblocked. This lets GrantPersistent record the session permission
122// only when it actually wins the race, so a losing GrantPersistent
123// that lost to a Deny does not leak an auto-approve entry.
124//
125// All three public resolution methods (Grant, GrantPersistent, Deny)
126// route through this helper so multi-subscriber UIs can race safely:
127// the first caller wins, the rest become no-ops.
128func (s *permissionService) resolve(permission PermissionRequest, granted, denied bool, onResolve func()) bool {
129	respCh, ok := s.pendingRequests.Take(permission.ID)
130	if !ok {
131		return false
132	}
133
134	if onResolve != nil {
135		onResolve()
136	}
137
138	s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
139		ToolCallID: permission.ToolCallID,
140		Granted:    granted,
141		Denied:     denied,
142	})
143
144	// respCh is buffered (cap 1) and only ever has at most one sender
145	// per request because Take removes the entry under the map lock,
146	// so this send never blocks.
147	respCh <- granted
148
149	s.activeRequestMu.Lock()
150	if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
151		s.activeRequest = nil
152	}
153	s.activeRequestMu.Unlock()
154	return true
155}
156
157func (s *permissionService) GrantPersistent(permission PermissionRequest) bool {
158	// Record the persistent grant only if this call wins the
159	// pending-request race. Otherwise a losing GrantPersistent that
160	// lost to a Deny would still leave an auto-approve entry behind,
161	// silently flipping later denied calls to allowed.
162	return s.resolve(permission, true, false, func() {
163		s.sessionPermissions.Set(PermissionKey{
164			SessionID: permission.SessionID,
165			ToolName:  permission.ToolName,
166			Action:    permission.Action,
167			Path:      permission.Path,
168		}, true)
169	})
170}
171
172func (s *permissionService) Grant(permission PermissionRequest) bool {
173	return s.resolve(permission, true, false, nil)
174}
175
176func (s *permissionService) Deny(permission PermissionRequest) bool {
177	return s.resolve(permission, false, true, nil)
178}
179
180func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRequest) (bool, error) {
181	if s.skip {
182		return true, nil
183	}
184
185	// Check if the tool/action combination is in the allowlist
186	commandKey := opts.ToolName + ":" + opts.Action
187	if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) {
188		return true, nil
189	}
190
191	// A PreToolUse hook that returned decision=allow stamps the context
192	// with the tool call ID. Treat that as a pre-approval and skip the
193	// prompt entirely. We still publish a granted notification so the UI
194	// and audit subscribers see the outcome.
195	if hookApproved(ctx, opts.ToolCallID) {
196		s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
197			ToolCallID: opts.ToolCallID,
198			Granted:    true,
199		})
200		return true, nil
201	}
202
203	s.requestMu.Lock()
204	defer s.requestMu.Unlock()
205
206	// tell the UI that a permission was requested
207	s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
208		ToolCallID: opts.ToolCallID,
209	})
210
211	s.autoApproveSessionsMu.RLock()
212	autoApprove := s.autoApproveSessions[opts.SessionID]
213	s.autoApproveSessionsMu.RUnlock()
214
215	if autoApprove {
216		s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
217			ToolCallID: opts.ToolCallID,
218			Granted:    true,
219		})
220		return true, nil
221	}
222
223	fileInfo, err := os.Stat(opts.Path)
224	dir := opts.Path
225	if err == nil {
226		if fileInfo.IsDir() {
227			dir = opts.Path
228		} else {
229			dir = filepath.Dir(opts.Path)
230		}
231	}
232
233	if dir == "." {
234		dir = s.workingDir
235	}
236	permission := PermissionRequest{
237		ID:          uuid.New().String(),
238		Path:        dir,
239		SessionID:   opts.SessionID,
240		ToolCallID:  opts.ToolCallID,
241		ToolName:    opts.ToolName,
242		Description: opts.Description,
243		Action:      opts.Action,
244		Params:      opts.Params,
245	}
246
247	if _, ok := s.sessionPermissions.Get(PermissionKey{
248		SessionID: permission.SessionID,
249		ToolName:  permission.ToolName,
250		Action:    permission.Action,
251		Path:      permission.Path,
252	}); ok {
253		s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
254			ToolCallID: opts.ToolCallID,
255			Granted:    true,
256		})
257		return true, nil
258	}
259
260	s.activeRequestMu.Lock()
261	s.activeRequest = &permission
262	s.activeRequestMu.Unlock()
263
264	respCh := make(chan bool, 1)
265	s.pendingRequests.Set(permission.ID, respCh)
266	defer s.pendingRequests.Del(permission.ID)
267
268	// Publish the request
269	s.Publish(pubsub.CreatedEvent, permission)
270
271	select {
272	case <-ctx.Done():
273		return false, ctx.Err()
274	case granted := <-respCh:
275		return granted, nil
276	}
277}
278
279func (s *permissionService) AutoApproveSession(sessionID string) {
280	s.autoApproveSessionsMu.Lock()
281	s.autoApproveSessions[sessionID] = true
282	s.autoApproveSessionsMu.Unlock()
283}
284
285func (s *permissionService) SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[PermissionNotification] {
286	return s.notificationBroker.Subscribe(ctx)
287}
288
289func (s *permissionService) SetSkipRequests(skip bool) {
290	s.skip = skip
291}
292
293func (s *permissionService) SkipRequests() bool {
294	return s.skip
295}
296
297func NewPermissionService(workingDir string, skip bool, allowedTools []string) Service {
298	return &permissionService{
299		Broker:              pubsub.NewBroker[PermissionRequest](),
300		notificationBroker:  pubsub.NewBroker[PermissionNotification](),
301		workingDir:          workingDir,
302		sessionPermissions:  csync.NewMap[PermissionKey, bool](),
303		autoApproveSessions: make(map[string]bool),
304		skip:                skip,
305		allowedTools:        allowedTools,
306		pendingRequests:     csync.NewMap[string, chan bool](),
307	}
308}