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