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