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("permission denied")
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.Suscriber[PermissionRequest]
47 GrantPersistent(permission PermissionRequest)
48 Grant(permission PermissionRequest)
49 Deny(permission PermissionRequest)
50 Request(opts CreatePermissionRequest) bool
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}
74
75func (s *permissionService) GrantPersistent(permission PermissionRequest) {
76 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
77 ToolCallID: permission.ToolCallID,
78 Granted: true,
79 })
80 respCh, ok := s.pendingRequests.Get(permission.ID)
81 if ok {
82 respCh <- true
83 }
84
85 s.sessionPermissionsMu.Lock()
86 s.sessionPermissions = append(s.sessionPermissions, permission)
87 s.sessionPermissionsMu.Unlock()
88
89 if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
90 s.activeRequest = nil
91 }
92}
93
94func (s *permissionService) Grant(permission PermissionRequest) {
95 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
96 ToolCallID: permission.ToolCallID,
97 Granted: true,
98 })
99 respCh, ok := s.pendingRequests.Get(permission.ID)
100 if ok {
101 respCh <- true
102 }
103
104 if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
105 s.activeRequest = nil
106 }
107}
108
109func (s *permissionService) Deny(permission PermissionRequest) {
110 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
111 ToolCallID: permission.ToolCallID,
112 Granted: false,
113 Denied: true,
114 })
115 respCh, ok := s.pendingRequests.Get(permission.ID)
116 if ok {
117 respCh <- false
118 }
119
120 if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
121 s.activeRequest = nil
122 }
123}
124
125func (s *permissionService) Request(opts CreatePermissionRequest) bool {
126 if s.skip {
127 return true
128 }
129
130 // tell the UI that a permission was requested
131 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
132 ToolCallID: opts.ToolCallID,
133 })
134 s.requestMu.Lock()
135 defer s.requestMu.Unlock()
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
141 }
142
143 s.autoApproveSessionsMu.RLock()
144 autoApprove := s.autoApproveSessions[opts.SessionID]
145 s.autoApproveSessionsMu.RUnlock()
146
147 if autoApprove {
148 return true
149 }
150
151 fileInfo, err := os.Stat(opts.Path)
152 dir := opts.Path
153 if err == nil {
154 if fileInfo.IsDir() {
155 dir = opts.Path
156 } else {
157 dir = filepath.Dir(opts.Path)
158 }
159 }
160
161 if dir == "." {
162 dir = s.workingDir
163 }
164 permission := PermissionRequest{
165 ID: uuid.New().String(),
166 Path: dir,
167 SessionID: opts.SessionID,
168 ToolCallID: opts.ToolCallID,
169 ToolName: opts.ToolName,
170 Description: opts.Description,
171 Action: opts.Action,
172 Params: opts.Params,
173 }
174
175 s.sessionPermissionsMu.RLock()
176 for _, p := range s.sessionPermissions {
177 if p.ToolName == permission.ToolName && p.Action == permission.Action && p.SessionID == permission.SessionID && p.Path == permission.Path {
178 s.sessionPermissionsMu.RUnlock()
179 return true
180 }
181 }
182 s.sessionPermissionsMu.RUnlock()
183
184 s.sessionPermissionsMu.RLock()
185 for _, p := range s.sessionPermissions {
186 if p.ToolName == permission.ToolName && p.Action == permission.Action && p.SessionID == permission.SessionID && p.Path == permission.Path {
187 s.sessionPermissionsMu.RUnlock()
188 return true
189 }
190 }
191 s.sessionPermissionsMu.RUnlock()
192
193 s.activeRequest = &permission
194
195 respCh := make(chan bool, 1)
196 s.pendingRequests.Set(permission.ID, respCh)
197 defer s.pendingRequests.Del(permission.ID)
198
199 // Publish the request
200 s.Publish(pubsub.CreatedEvent, permission)
201
202 return <-respCh
203}
204
205func (s *permissionService) AutoApproveSession(sessionID string) {
206 s.autoApproveSessionsMu.Lock()
207 s.autoApproveSessions[sessionID] = true
208 s.autoApproveSessionsMu.Unlock()
209}
210
211func (s *permissionService) SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[PermissionNotification] {
212 return s.notificationBroker.Subscribe(ctx)
213}
214
215func (s *permissionService) SetSkipRequests(skip bool) {
216 s.skip = skip
217}
218
219func (s *permissionService) SkipRequests() bool {
220 return s.skip
221}
222
223func NewPermissionService(workingDir string, skip bool, allowedTools []string) Service {
224 return &permissionService{
225 Broker: pubsub.NewBroker[PermissionRequest](),
226 notificationBroker: pubsub.NewBroker[PermissionNotification](),
227 workingDir: workingDir,
228 sessionPermissions: make([]PermissionRequest, 0),
229 autoApproveSessions: make(map[string]bool),
230 skip: skip,
231 allowedTools: allowedTools,
232 pendingRequests: csync.NewMap[string, chan bool](),
233 }
234}