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