1package permission
2
3import (
4 "context"
5 "errors"
6 "os"
7 "path/filepath"
8 "slices"
9 "sync"
10
11 "github.com/charmbracelet/crush/internal/csync"
12 "github.com/charmbracelet/crush/internal/proto"
13 "github.com/charmbracelet/crush/internal/pubsub"
14 "github.com/google/uuid"
15)
16
17var ErrorPermissionDenied = errors.New("permission denied")
18
19type (
20 PermissionRequest = proto.PermissionRequest
21 PermissionNotification = proto.PermissionNotification
22 CreatePermissionRequest = proto.CreatePermissionRequest
23)
24
25type Service interface {
26 pubsub.Suscriber[PermissionRequest]
27 GrantPersistent(permission PermissionRequest)
28 Grant(permission PermissionRequest)
29 Deny(permission PermissionRequest)
30 Request(opts CreatePermissionRequest) bool
31 AutoApproveSession(sessionID string)
32 SetSkipRequests(skip bool)
33 SkipRequests() bool
34 SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[PermissionNotification]
35}
36
37type permissionService struct {
38 *pubsub.Broker[PermissionRequest]
39
40 notificationBroker *pubsub.Broker[PermissionNotification]
41 workingDir string
42 sessionPermissions []PermissionRequest
43 sessionPermissionsMu sync.RWMutex
44 pendingRequests *csync.Map[string, chan bool]
45 autoApproveSessions map[string]bool
46 autoApproveSessionsMu sync.RWMutex
47 skip bool
48 allowedTools []string
49
50 // used to make sure we only process one request at a time
51 requestMu sync.Mutex
52 activeRequest *PermissionRequest
53}
54
55func (s *permissionService) GrantPersistent(permission PermissionRequest) {
56 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
57 ToolCallID: permission.ToolCallID,
58 Granted: true,
59 })
60 respCh, ok := s.pendingRequests.Get(permission.ID)
61 if ok {
62 respCh <- true
63 }
64
65 s.sessionPermissionsMu.Lock()
66 s.sessionPermissions = append(s.sessionPermissions, permission)
67 s.sessionPermissionsMu.Unlock()
68
69 if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
70 s.activeRequest = nil
71 }
72}
73
74func (s *permissionService) Grant(permission PermissionRequest) {
75 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
76 ToolCallID: permission.ToolCallID,
77 Granted: true,
78 })
79 respCh, ok := s.pendingRequests.Get(permission.ID)
80 if ok {
81 respCh <- true
82 }
83
84 if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
85 s.activeRequest = nil
86 }
87}
88
89func (s *permissionService) Deny(permission PermissionRequest) {
90 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
91 ToolCallID: permission.ToolCallID,
92 Granted: false,
93 Denied: true,
94 })
95 respCh, ok := s.pendingRequests.Get(permission.ID)
96 if ok {
97 respCh <- false
98 }
99
100 if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
101 s.activeRequest = nil
102 }
103}
104
105func (s *permissionService) Request(opts CreatePermissionRequest) bool {
106 if s.skip {
107 return true
108 }
109
110 // tell the UI that a permission was requested
111 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
112 ToolCallID: opts.ToolCallID,
113 })
114 s.requestMu.Lock()
115 defer s.requestMu.Unlock()
116
117 // Check if the tool/action combination is in the allowlist
118 commandKey := opts.ToolName + ":" + opts.Action
119 if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) {
120 return true
121 }
122
123 s.autoApproveSessionsMu.RLock()
124 autoApprove := s.autoApproveSessions[opts.SessionID]
125 s.autoApproveSessionsMu.RUnlock()
126
127 if autoApprove {
128 return true
129 }
130
131 fileInfo, err := os.Stat(opts.Path)
132 dir := opts.Path
133 if err == nil {
134 if fileInfo.IsDir() {
135 dir = opts.Path
136 } else {
137 dir = filepath.Dir(opts.Path)
138 }
139 }
140
141 if dir == "." {
142 dir = s.workingDir
143 }
144 permission := PermissionRequest{
145 ID: uuid.New().String(),
146 Path: dir,
147 SessionID: opts.SessionID,
148 ToolCallID: opts.ToolCallID,
149 ToolName: opts.ToolName,
150 Description: opts.Description,
151 Action: opts.Action,
152 Params: opts.Params,
153 }
154
155 s.sessionPermissionsMu.RLock()
156 for _, p := range s.sessionPermissions {
157 if p.ToolName == permission.ToolName && p.Action == permission.Action && p.SessionID == permission.SessionID && p.Path == permission.Path {
158 s.sessionPermissionsMu.RUnlock()
159 return true
160 }
161 }
162 s.sessionPermissionsMu.RUnlock()
163
164 s.sessionPermissionsMu.RLock()
165 for _, p := range s.sessionPermissions {
166 if p.ToolName == permission.ToolName && p.Action == permission.Action && p.SessionID == permission.SessionID && p.Path == permission.Path {
167 s.sessionPermissionsMu.RUnlock()
168 return true
169 }
170 }
171 s.sessionPermissionsMu.RUnlock()
172
173 s.activeRequest = &permission
174
175 respCh := make(chan bool, 1)
176 s.pendingRequests.Set(permission.ID, respCh)
177 defer s.pendingRequests.Del(permission.ID)
178
179 // Publish the request
180 s.Publish(pubsub.CreatedEvent, permission)
181
182 return <-respCh
183}
184
185func (s *permissionService) AutoApproveSession(sessionID string) {
186 s.autoApproveSessionsMu.Lock()
187 s.autoApproveSessions[sessionID] = true
188 s.autoApproveSessionsMu.Unlock()
189}
190
191func (s *permissionService) SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[PermissionNotification] {
192 return s.notificationBroker.Subscribe(ctx)
193}
194
195func (s *permissionService) SetSkipRequests(skip bool) {
196 s.skip = skip
197}
198
199func (s *permissionService) SkipRequests() bool {
200 return s.skip
201}
202
203func NewPermissionService(workingDir string, skip bool, allowedTools []string) Service {
204 return &permissionService{
205 Broker: pubsub.NewBroker[PermissionRequest](),
206 notificationBroker: pubsub.NewBroker[PermissionNotification](),
207 workingDir: workingDir,
208 sessionPermissions: make([]PermissionRequest, 0),
209 autoApproveSessions: make(map[string]bool),
210 skip: skip,
211 allowedTools: allowedTools,
212 pendingRequests: csync.NewMap[string, chan bool](),
213 }
214}