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