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 s.schedulePermissionNotification(permission)
207
208 // Publish the request
209 s.Publish(pubsub.CreatedEvent, permission)
210
211 return <-respCh
212}
213
214func (s *permissionService) AutoApproveSession(sessionID string) {
215 s.autoApproveSessionsMu.Lock()
216 s.autoApproveSessions[sessionID] = true
217 s.autoApproveSessionsMu.Unlock()
218}
219
220func (s *permissionService) SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[PermissionNotification] {
221 return s.notificationBroker.Subscribe(ctx)
222}
223
224func (s *permissionService) NotifyInteraction(toolCallID string) {
225 s.cancelPermissionNotification(toolCallID)
226 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
227 ToolCallID: toolCallID,
228 Interacted: true,
229 })
230}
231
232func (s *permissionService) SetSkipRequests(skip bool) {
233 s.skip = skip
234}
235
236func (s *permissionService) SkipRequests() bool {
237 return s.skip
238}
239
240func NewPermissionService(ctx context.Context, workingDir string, skip bool, allowedTools []string, notifier permissionNotifier) Service {
241 if ctx == nil {
242 ctx = context.Background()
243 }
244 return &permissionService{
245 Broker: pubsub.NewBroker[PermissionRequest](),
246 notificationBroker: pubsub.NewBroker[PermissionNotification](),
247 notifier: notifier,
248 notificationCtx: ctx,
249 notificationCancels: csync.NewMap[string, context.CancelFunc](),
250 workingDir: workingDir,
251 sessionPermissions: make([]PermissionRequest, 0),
252 autoApproveSessions: make(map[string]bool),
253 skip: skip,
254 allowedTools: allowedTools,
255 pendingRequests: csync.NewMap[string, chan bool](),
256 }
257}
258
259func (s *permissionService) schedulePermissionNotification(permission PermissionRequest) {
260 if s.notifier == nil {
261 return
262 }
263
264 if cancel, ok := s.notificationCancels.Take(permission.ToolCallID); ok && cancel != nil {
265 cancel()
266 }
267
268 title := "💘 Crush is waiting"
269 message := fmt.Sprintf("Permission required to execute \"%s\"", permission.ToolName)
270 cancel := s.notifier.NotifyPermissionRequest(s.notificationCtx, title, message, permissionNotificationDelay)
271 if cancel == nil {
272 cancel = func() {}
273 }
274 s.notificationCancels.Set(permission.ToolCallID, cancel)
275}
276
277func (s *permissionService) cancelPermissionNotification(toolCallID string) {
278 if s.notifier == nil {
279 return
280 }
281
282 if cancel, ok := s.notificationCancels.Take(toolCallID); ok && cancel != nil {
283 cancel()
284 }
285}