1package permission
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "slices"
10 "sync"
11 "time"
12
13 "git.secluded.site/crush/internal/csync"
14 "git.secluded.site/crush/internal/pubsub"
15 "github.com/google/uuid"
16)
17
18var ErrorPermissionDenied = errors.New("user denied permission")
19
20type CreatePermissionRequest struct {
21 SessionID string `json:"session_id"`
22 ToolCallID string `json:"tool_call_id"`
23 ToolName string `json:"tool_name"`
24 Description string `json:"description"`
25 Action string `json:"action"`
26 Params any `json:"params"`
27 Path string `json:"path"`
28}
29
30type PermissionNotification struct {
31 ToolCallID string `json:"tool_call_id"`
32 Granted bool `json:"granted"`
33 Denied bool `json:"denied"`
34 Interacted bool `json:"interacted"` // true when user scrolls or navigates the dialog
35}
36
37type PermissionRequest struct {
38 ID string `json:"id"`
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 Service interface {
49 pubsub.Suscriber[PermissionRequest]
50 GrantPersistent(permission PermissionRequest)
51 Grant(permission PermissionRequest)
52 Deny(permission PermissionRequest)
53 Request(opts CreatePermissionRequest) bool
54 AutoApproveSession(sessionID string)
55 SetSkipRequests(skip bool)
56 SkipRequests() bool
57 SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[PermissionNotification]
58 NotifyInteraction(toolCallID string)
59}
60
61type permissionNotifier interface {
62 NotifyPermissionRequest(ctx context.Context, title, message string, delay time.Duration) context.CancelFunc
63}
64
65const permissionNotificationDelay = 5 * time.Second
66
67type permissionService struct {
68 *pubsub.Broker[PermissionRequest]
69
70 notificationBroker *pubsub.Broker[PermissionNotification]
71 notifier permissionNotifier
72 notificationCtx context.Context
73 notificationCancels *csync.Map[string, context.CancelFunc]
74 workingDir string
75 sessionPermissions []PermissionRequest
76 sessionPermissionsMu sync.RWMutex
77 pendingRequests *csync.Map[string, chan bool]
78 autoApproveSessions map[string]bool
79 autoApproveSessionsMu sync.RWMutex
80 skip bool
81 allowedTools []string
82
83 // used to make sure we only process one request at a time
84 requestMu sync.Mutex
85 activeRequest *PermissionRequest
86}
87
88func (s *permissionService) GrantPersistent(permission PermissionRequest) {
89 s.cancelPermissionNotification(permission.ToolCallID)
90 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
91 ToolCallID: permission.ToolCallID,
92 Granted: true,
93 })
94 respCh, ok := s.pendingRequests.Get(permission.ID)
95 if ok {
96 respCh <- true
97 }
98
99 s.sessionPermissionsMu.Lock()
100 s.sessionPermissions = append(s.sessionPermissions, permission)
101 s.sessionPermissionsMu.Unlock()
102
103 if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
104 s.activeRequest = nil
105 }
106}
107
108func (s *permissionService) Grant(permission PermissionRequest) {
109 s.cancelPermissionNotification(permission.ToolCallID)
110 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
111 ToolCallID: permission.ToolCallID,
112 Granted: true,
113 })
114 respCh, ok := s.pendingRequests.Get(permission.ID)
115 if ok {
116 respCh <- true
117 }
118
119 if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
120 s.activeRequest = nil
121 }
122}
123
124func (s *permissionService) Deny(permission PermissionRequest) {
125 s.cancelPermissionNotification(permission.ToolCallID)
126 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
127 ToolCallID: permission.ToolCallID,
128 Granted: false,
129 Denied: true,
130 })
131 respCh, ok := s.pendingRequests.Get(permission.ID)
132 if ok {
133 respCh <- false
134 }
135
136 if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
137 s.activeRequest = nil
138 }
139}
140
141func (s *permissionService) Request(opts CreatePermissionRequest) bool {
142 if s.skip {
143 return true
144 }
145
146 // Check if the tool/action combination is in the allowlist
147 commandKey := opts.ToolName + ":" + opts.Action
148 if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) {
149 return true
150 }
151
152 // tell the UI that a permission was requested
153 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
154 ToolCallID: opts.ToolCallID,
155 })
156 s.requestMu.Lock()
157 defer s.requestMu.Unlock()
158
159 s.autoApproveSessionsMu.RLock()
160 autoApprove := s.autoApproveSessions[opts.SessionID]
161 s.autoApproveSessionsMu.RUnlock()
162
163 if autoApprove {
164 return true
165 }
166
167 fileInfo, err := os.Stat(opts.Path)
168 dir := opts.Path
169 if err == nil {
170 if fileInfo.IsDir() {
171 dir = opts.Path
172 } else {
173 dir = filepath.Dir(opts.Path)
174 }
175 }
176
177 if dir == "." {
178 dir = s.workingDir
179 }
180 permission := PermissionRequest{
181 ID: uuid.New().String(),
182 Path: dir,
183 SessionID: opts.SessionID,
184 ToolCallID: opts.ToolCallID,
185 ToolName: opts.ToolName,
186 Description: opts.Description,
187 Action: opts.Action,
188 Params: opts.Params,
189 }
190
191 s.sessionPermissionsMu.RLock()
192 for _, p := range s.sessionPermissions {
193 if p.ToolName == permission.ToolName && p.Action == permission.Action && p.SessionID == permission.SessionID && p.Path == permission.Path {
194 s.sessionPermissionsMu.RUnlock()
195 return true
196 }
197 }
198 s.sessionPermissionsMu.RUnlock()
199
200 s.activeRequest = &permission
201
202 respCh := make(chan bool, 1)
203 s.pendingRequests.Set(permission.ID, respCh)
204 defer s.pendingRequests.Del(permission.ID)
205
206 // Publish the request
207 s.Publish(pubsub.CreatedEvent, permission)
208 s.schedulePermissionNotification(permission)
209
210 return <-respCh
211}
212
213func (s *permissionService) AutoApproveSession(sessionID string) {
214 s.autoApproveSessionsMu.Lock()
215 s.autoApproveSessions[sessionID] = true
216 s.autoApproveSessionsMu.Unlock()
217}
218
219func (s *permissionService) SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[PermissionNotification] {
220 return s.notificationBroker.Subscribe(ctx)
221}
222
223func (s *permissionService) NotifyInteraction(toolCallID string) {
224 s.cancelPermissionNotification(toolCallID)
225 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
226 ToolCallID: toolCallID,
227 Interacted: true,
228 })
229}
230
231func (s *permissionService) SetSkipRequests(skip bool) {
232 s.skip = skip
233}
234
235func (s *permissionService) SkipRequests() bool {
236 return s.skip
237}
238
239func NewPermissionService(ctx context.Context, workingDir string, skip bool, allowedTools []string, notifier permissionNotifier) Service {
240 if ctx == nil {
241 ctx = context.Background()
242 }
243 return &permissionService{
244 Broker: pubsub.NewBroker[PermissionRequest](),
245 notificationBroker: pubsub.NewBroker[PermissionNotification](),
246 notifier: notifier,
247 notificationCtx: ctx,
248 notificationCancels: csync.NewMap[string, context.CancelFunc](),
249 workingDir: workingDir,
250 sessionPermissions: make([]PermissionRequest, 0),
251 autoApproveSessions: make(map[string]bool),
252 skip: skip,
253 allowedTools: allowedTools,
254 pendingRequests: csync.NewMap[string, chan bool](),
255 }
256}
257
258func (s *permissionService) schedulePermissionNotification(permission PermissionRequest) {
259 if s.notifier == nil {
260 return
261 }
262
263 if cancel, ok := s.notificationCancels.Take(permission.ToolCallID); ok && cancel != nil {
264 cancel()
265 }
266
267 title := "💘 Crush is waiting"
268 message := fmt.Sprintf("Permission required to execute \"%s\"", permission.ToolName)
269 cancel := s.notifier.NotifyPermissionRequest(s.notificationCtx, title, message, permissionNotificationDelay)
270 if cancel == nil {
271 cancel = func() {}
272 }
273 s.notificationCancels.Set(permission.ToolCallID, cancel)
274}
275
276func (s *permissionService) cancelPermissionNotification(toolCallID string) {
277 if s.notifier == nil {
278 return
279 }
280
281 if cancel, ok := s.notificationCancels.Take(toolCallID); ok && cancel != nil {
282 cancel()
283 }
284}