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
 76// PermissionKey is a composite key for session permission lookups.
 77type PermissionKey struct {
 78	SessionID string
 79	ToolName  string
 80	Action    string
 81	Path      string
 82}
 83
 84type permissionService struct {
 85	*pubsub.Broker[PermissionRequest]
 86
 87	notificationBroker    *pubsub.Broker[PermissionNotification]
 88	workingDir            string
 89	sessionPermissions    *csync.Map[PermissionKey, bool]
 90	pendingRequests       *csync.Map[string, chan bool]
 91	autoApproveSessions   map[string]bool
 92	autoApproveSessionsMu sync.RWMutex
 93	skip                  bool
 94	allowedTools          []string
 95
 96	// used to make sure we only process one request at a time
 97	requestMu       sync.Mutex
 98	activeRequest   *PermissionRequest
 99	activeRequestMu sync.Mutex
100}
101
102func (s *permissionService) GrantPersistent(permission PermissionRequest) {
103	s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
104		ToolCallID: permission.ToolCallID,
105		Granted:    true,
106	})
107	respCh, ok := s.pendingRequests.Get(permission.ID)
108	if ok {
109		respCh <- true
110	}
111
112	s.sessionPermissions.Set(PermissionKey{
113		SessionID: permission.SessionID,
114		ToolName:  permission.ToolName,
115		Action:    permission.Action,
116		Path:      permission.Path,
117	}, true)
118
119	s.activeRequestMu.Lock()
120	if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
121		s.activeRequest = nil
122	}
123	s.activeRequestMu.Unlock()
124}
125
126func (s *permissionService) Grant(permission PermissionRequest) {
127	s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
128		ToolCallID: permission.ToolCallID,
129		Granted:    true,
130	})
131	respCh, ok := s.pendingRequests.Get(permission.ID)
132	if ok {
133		respCh <- true
134	}
135
136	s.activeRequestMu.Lock()
137	if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
138		s.activeRequest = nil
139	}
140	s.activeRequestMu.Unlock()
141}
142
143func (s *permissionService) Deny(permission PermissionRequest) {
144	s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
145		ToolCallID: permission.ToolCallID,
146		Granted:    false,
147		Denied:     true,
148	})
149	respCh, ok := s.pendingRequests.Get(permission.ID)
150	if ok {
151		respCh <- false
152	}
153
154	s.activeRequestMu.Lock()
155	if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
156		s.activeRequest = nil
157	}
158	s.activeRequestMu.Unlock()
159}
160
161func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRequest) (bool, error) {
162	if s.skip {
163		return true, nil
164	}
165
166	// Check if the tool/action combination is in the allowlist
167	commandKey := opts.ToolName + ":" + opts.Action
168	if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) {
169		return true, nil
170	}
171
172	// A PreToolUse hook that returned decision=allow stamps the context
173	// with the tool call ID. Treat that as a pre-approval and skip the
174	// prompt entirely. We still publish a granted notification so the UI
175	// and audit subscribers see the outcome.
176	if hookApproved(ctx, opts.ToolCallID) {
177		s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
178			ToolCallID: opts.ToolCallID,
179			Granted:    true,
180		})
181		return true, nil
182	}
183
184	s.requestMu.Lock()
185	defer s.requestMu.Unlock()
186
187	// tell the UI that a permission was requested
188	s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
189		ToolCallID: opts.ToolCallID,
190	})
191
192	s.autoApproveSessionsMu.RLock()
193	autoApprove := s.autoApproveSessions[opts.SessionID]
194	s.autoApproveSessionsMu.RUnlock()
195
196	if autoApprove {
197		s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
198			ToolCallID: opts.ToolCallID,
199			Granted:    true,
200		})
201		return true, nil
202	}
203
204	fileInfo, err := os.Stat(opts.Path)
205	dir := opts.Path
206	if err == nil {
207		if fileInfo.IsDir() {
208			dir = opts.Path
209		} else {
210			dir = filepath.Dir(opts.Path)
211		}
212	}
213
214	if dir == "." {
215		dir = s.workingDir
216	}
217	permission := PermissionRequest{
218		ID:          uuid.New().String(),
219		Path:        dir,
220		SessionID:   opts.SessionID,
221		ToolCallID:  opts.ToolCallID,
222		ToolName:    opts.ToolName,
223		Description: opts.Description,
224		Action:      opts.Action,
225		Params:      opts.Params,
226	}
227
228	if _, ok := s.sessionPermissions.Get(PermissionKey{
229		SessionID: permission.SessionID,
230		ToolName:  permission.ToolName,
231		Action:    permission.Action,
232		Path:      permission.Path,
233	}); ok {
234		s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
235			ToolCallID: opts.ToolCallID,
236			Granted:    true,
237		})
238		return true, nil
239	}
240
241	s.activeRequestMu.Lock()
242	s.activeRequest = &permission
243	s.activeRequestMu.Unlock()
244
245	respCh := make(chan bool, 1)
246	s.pendingRequests.Set(permission.ID, respCh)
247	defer s.pendingRequests.Del(permission.ID)
248
249	// Publish the request
250	s.Publish(pubsub.CreatedEvent, permission)
251
252	select {
253	case <-ctx.Done():
254		return false, ctx.Err()
255	case granted := <-respCh:
256		return granted, nil
257	}
258}
259
260func (s *permissionService) AutoApproveSession(sessionID string) {
261	s.autoApproveSessionsMu.Lock()
262	s.autoApproveSessions[sessionID] = true
263	s.autoApproveSessionsMu.Unlock()
264}
265
266func (s *permissionService) SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[PermissionNotification] {
267	return s.notificationBroker.Subscribe(ctx)
268}
269
270func (s *permissionService) SetSkipRequests(skip bool) {
271	s.skip = skip
272}
273
274func (s *permissionService) SkipRequests() bool {
275	return s.skip
276}
277
278func NewPermissionService(workingDir string, skip bool, allowedTools []string) Service {
279	return &permissionService{
280		Broker:              pubsub.NewBroker[PermissionRequest](),
281		notificationBroker:  pubsub.NewBroker[PermissionNotification](),
282		workingDir:          workingDir,
283		sessionPermissions:  csync.NewMap[PermissionKey, bool](),
284		autoApproveSessions: make(map[string]bool),
285		skip:                skip,
286		allowedTools:        allowedTools,
287		pendingRequests:     csync.NewMap[string, chan bool](),
288	}
289}