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 // tell the UI that a permission was requested
138 s.notificationBroker.Publish(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.activeRequestMu.Lock()
192 s.activeRequest = &permission
193 s.activeRequestMu.Unlock()
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 select {
203 case <-ctx.Done():
204 return false, ctx.Err()
205 case granted := <-respCh:
206 return granted, nil
207 }
208}
209
210func (s *permissionService) AutoApproveSession(sessionID string) {
211 s.autoApproveSessionsMu.Lock()
212 s.autoApproveSessions[sessionID] = true
213 s.autoApproveSessionsMu.Unlock()
214}
215
216func (s *permissionService) SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[PermissionNotification] {
217 return s.notificationBroker.Subscribe(ctx)
218}
219
220func (s *permissionService) SetSkipRequests(skip bool) {
221 s.skip = skip
222}
223
224func (s *permissionService) SkipRequests() bool {
225 return s.skip
226}
227
228func NewPermissionService(workingDir string, skip bool, allowedTools []string) Service {
229 return &permissionService{
230 Broker: pubsub.NewBroker[PermissionRequest](),
231 notificationBroker: pubsub.NewBroker[PermissionNotification](),
232 workingDir: workingDir,
233 sessionPermissions: make([]PermissionRequest, 0),
234 autoApproveSessions: make(map[string]bool),
235 skip: skip,
236 allowedTools: allowedTools,
237 pendingRequests: csync.NewMap[string, chan bool](),
238 }
239}