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(ctx context.Context, permission PermissionRequest)
48 Grant(ctx context.Context, permission PermissionRequest)
49 Deny(ctx context.Context, permission PermissionRequest)
50 Request(ctx context.Context, opts CreatePermissionRequest) (bool, error)
51 AutoApproveSession(sessionID string)
52 SetSkipRequests(skip bool)
53 SkipRequests() bool
54 AddNotificationListener(fn func(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(ctx context.Context, permission PermissionRequest) {
77 s.notificationBroker.Publish(ctx, 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(ctx context.Context, permission PermissionRequest) {
98 s.notificationBroker.Publish(ctx, 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(ctx context.Context, permission PermissionRequest) {
115 s.notificationBroker.Publish(ctx, 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 // tell the UI that a permission was requested
138 s.notificationBroker.Publish(ctx, pubsub.CreatedEvent, PermissionNotification{
139 ToolCallID: opts.ToolCallID,
140 })
141 s.requestMu.Lock()
142 defer s.requestMu.Unlock()
143
144 // Check if the tool/action combination is in the allowlist
145 commandKey := opts.ToolName + ":" + opts.Action
146 if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) {
147 return true, nil
148 }
149
150 s.autoApproveSessionsMu.RLock()
151 autoApprove := s.autoApproveSessions[opts.SessionID]
152 s.autoApproveSessionsMu.RUnlock()
153
154 if autoApprove {
155 return true, nil
156 }
157
158 fileInfo, err := os.Stat(opts.Path)
159 dir := opts.Path
160 if err == nil {
161 if fileInfo.IsDir() {
162 dir = opts.Path
163 } else {
164 dir = filepath.Dir(opts.Path)
165 }
166 }
167
168 if dir == "." {
169 dir = s.workingDir
170 }
171 permission := PermissionRequest{
172 ID: uuid.New().String(),
173 Path: dir,
174 SessionID: opts.SessionID,
175 ToolCallID: opts.ToolCallID,
176 ToolName: opts.ToolName,
177 Description: opts.Description,
178 Action: opts.Action,
179 Params: opts.Params,
180 }
181
182 s.sessionPermissionsMu.RLock()
183 for _, p := range s.sessionPermissions {
184 if p.ToolName == permission.ToolName && p.Action == permission.Action && p.SessionID == permission.SessionID && p.Path == permission.Path {
185 s.sessionPermissionsMu.RUnlock()
186 return true, nil
187 }
188 }
189 s.sessionPermissionsMu.RUnlock()
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, nil
196 }
197 }
198 s.sessionPermissionsMu.RUnlock()
199
200 s.activeRequestMu.Lock()
201 s.activeRequest = &permission
202 s.activeRequestMu.Unlock()
203
204 respCh := make(chan bool, 1)
205 s.pendingRequests.Set(permission.ID, respCh)
206 defer s.pendingRequests.Del(permission.ID)
207
208 // Publish the request
209 s.Publish(ctx, pubsub.CreatedEvent, permission)
210
211 select {
212 case <-ctx.Done():
213 return false, ctx.Err()
214 case granted := <-respCh:
215 return granted, nil
216 }
217}
218
219func (s *permissionService) AutoApproveSession(sessionID string) {
220 s.autoApproveSessionsMu.Lock()
221 s.autoApproveSessions[sessionID] = true
222 s.autoApproveSessionsMu.Unlock()
223}
224
225func (s *permissionService) AddNotificationListener(fn func(pubsub.Event[PermissionNotification])) {
226 s.notificationBroker.AddListener(fn)
227}
228
229func (s *permissionService) SetSkipRequests(skip bool) {
230 s.skip = skip
231}
232
233func (s *permissionService) SkipRequests() bool {
234 return s.skip
235}
236
237func NewPermissionService(workingDir string, skip bool, allowedTools []string) Service {
238 return &permissionService{
239 Broker: pubsub.NewBroker[PermissionRequest](),
240 notificationBroker: pubsub.NewBroker[PermissionNotification](),
241 workingDir: workingDir,
242 sessionPermissions: make([]PermissionRequest, 0),
243 autoApproveSessions: make(map[string]bool),
244 skip: skip,
245 allowedTools: allowedTools,
246 pendingRequests: csync.NewMap[string, chan bool](),
247 }
248}