1package permission
2
3import (
4 "context"
5 "os"
6 "path/filepath"
7 "slices"
8 "sync"
9
10 "github.com/charmbracelet/crush/internal/csync"
11 "github.com/charmbracelet/crush/internal/pubsub"
12 "github.com/google/uuid"
13)
14
15// hookApprovalKey is the unexported context key used to mark a tool call as
16// pre-approved by a PreToolUse hook. The value is the tool call ID so an
17// approval can't be reused across calls that happen to share a context.
18type hookApprovalKey struct{}
19
20// WithHookApproval returns a context that marks the given tool call ID as
21// pre-approved by a hook. When the permission service sees a matching
22// request it short-circuits the normal prompt and grants immediately.
23func WithHookApproval(ctx context.Context, toolCallID string) context.Context {
24 return context.WithValue(ctx, hookApprovalKey{}, toolCallID)
25}
26
27// hookApproved reports whether the context carries a hook approval for the
28// given tool call ID.
29func hookApproved(ctx context.Context, toolCallID string) bool {
30 if toolCallID == "" {
31 return false
32 }
33 v, _ := ctx.Value(hookApprovalKey{}).(string)
34 return v == toolCallID
35}
36
37type CreatePermissionRequest struct {
38 SessionID string `json:"session_id"`
39 ToolCallID string `json:"tool_call_id"`
40 ToolName string `json:"tool_name"`
41 Description string `json:"description"`
42 Action string `json:"action"`
43 Params any `json:"params"`
44 Path string `json:"path"`
45}
46
47type PermissionNotification struct {
48 ToolCallID string `json:"tool_call_id"`
49 Granted bool `json:"granted"`
50 Denied bool `json:"denied"`
51}
52
53type PermissionRequest struct {
54 ID string `json:"id"`
55 SessionID string `json:"session_id"`
56 ToolCallID string `json:"tool_call_id"`
57 ToolName string `json:"tool_name"`
58 Description string `json:"description"`
59 Action string `json:"action"`
60 Params any `json:"params"`
61 Path string `json:"path"`
62}
63
64type Service interface {
65 pubsub.Subscriber[PermissionRequest]
66 GrantPersistent(permission PermissionRequest)
67 Grant(permission PermissionRequest)
68 Deny(permission PermissionRequest)
69 Request(ctx context.Context, opts CreatePermissionRequest) (bool, error)
70 AutoApproveSession(sessionID string)
71 SetSkipRequests(skip bool)
72 SkipRequests() bool
73 SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[PermissionNotification]
74}
75
76type permissionService struct {
77 *pubsub.Broker[PermissionRequest]
78
79 notificationBroker *pubsub.Broker[PermissionNotification]
80 workingDir string
81 sessionPermissions []PermissionRequest
82 sessionPermissionsMu sync.RWMutex
83 pendingRequests *csync.Map[string, chan bool]
84 autoApproveSessions map[string]bool
85 autoApproveSessionsMu sync.RWMutex
86 skip bool
87 allowedTools []string
88
89 // used to make sure we only process one request at a time
90 requestMu sync.Mutex
91 activeRequest *PermissionRequest
92 activeRequestMu sync.Mutex
93}
94
95func (s *permissionService) GrantPersistent(permission PermissionRequest) {
96 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
97 ToolCallID: permission.ToolCallID,
98 Granted: true,
99 })
100 respCh, ok := s.pendingRequests.Get(permission.ID)
101 if ok {
102 respCh <- true
103 }
104
105 s.sessionPermissionsMu.Lock()
106 s.sessionPermissions = append(s.sessionPermissions, permission)
107 s.sessionPermissionsMu.Unlock()
108
109 s.activeRequestMu.Lock()
110 if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
111 s.activeRequest = nil
112 }
113 s.activeRequestMu.Unlock()
114}
115
116func (s *permissionService) Grant(permission PermissionRequest) {
117 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
118 ToolCallID: permission.ToolCallID,
119 Granted: true,
120 })
121 respCh, ok := s.pendingRequests.Get(permission.ID)
122 if ok {
123 respCh <- true
124 }
125
126 s.activeRequestMu.Lock()
127 if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
128 s.activeRequest = nil
129 }
130 s.activeRequestMu.Unlock()
131}
132
133func (s *permissionService) Deny(permission PermissionRequest) {
134 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
135 ToolCallID: permission.ToolCallID,
136 Granted: false,
137 Denied: true,
138 })
139 respCh, ok := s.pendingRequests.Get(permission.ID)
140 if ok {
141 respCh <- false
142 }
143
144 s.activeRequestMu.Lock()
145 if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
146 s.activeRequest = nil
147 }
148 s.activeRequestMu.Unlock()
149}
150
151func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRequest) (bool, error) {
152 if s.skip {
153 return true, nil
154 }
155
156 // Check if the tool/action combination is in the allowlist
157 commandKey := opts.ToolName + ":" + opts.Action
158 if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) {
159 return true, nil
160 }
161
162 // A PreToolUse hook that returned decision=allow stamps the context
163 // with the tool call ID. Treat that as a pre-approval and skip the
164 // prompt entirely. We still publish a granted notification so the UI
165 // and audit subscribers see the outcome.
166 if hookApproved(ctx, opts.ToolCallID) {
167 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
168 ToolCallID: opts.ToolCallID,
169 Granted: true,
170 })
171 return true, nil
172 }
173
174 // tell the UI that a permission was requested
175 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
176 ToolCallID: opts.ToolCallID,
177 })
178 s.requestMu.Lock()
179 defer s.requestMu.Unlock()
180
181 s.autoApproveSessionsMu.RLock()
182 autoApprove := s.autoApproveSessions[opts.SessionID]
183 s.autoApproveSessionsMu.RUnlock()
184
185 if autoApprove {
186 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
187 ToolCallID: opts.ToolCallID,
188 Granted: true,
189 })
190 return true, nil
191 }
192
193 fileInfo, err := os.Stat(opts.Path)
194 dir := opts.Path
195 if err == nil {
196 if fileInfo.IsDir() {
197 dir = opts.Path
198 } else {
199 dir = filepath.Dir(opts.Path)
200 }
201 }
202
203 if dir == "." {
204 dir = s.workingDir
205 }
206 permission := PermissionRequest{
207 ID: uuid.New().String(),
208 Path: dir,
209 SessionID: opts.SessionID,
210 ToolCallID: opts.ToolCallID,
211 ToolName: opts.ToolName,
212 Description: opts.Description,
213 Action: opts.Action,
214 Params: opts.Params,
215 }
216
217 s.sessionPermissionsMu.RLock()
218 for _, p := range s.sessionPermissions {
219 if p.ToolName == permission.ToolName && p.Action == permission.Action && p.SessionID == permission.SessionID && p.Path == permission.Path {
220 s.sessionPermissionsMu.RUnlock()
221 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
222 ToolCallID: opts.ToolCallID,
223 Granted: true,
224 })
225 return true, nil
226 }
227 }
228 s.sessionPermissionsMu.RUnlock()
229
230 s.activeRequestMu.Lock()
231 s.activeRequest = &permission
232 s.activeRequestMu.Unlock()
233
234 respCh := make(chan bool, 1)
235 s.pendingRequests.Set(permission.ID, respCh)
236 defer s.pendingRequests.Del(permission.ID)
237
238 // Publish the request
239 s.Publish(pubsub.CreatedEvent, permission)
240
241 select {
242 case <-ctx.Done():
243 return false, ctx.Err()
244 case granted := <-respCh:
245 return granted, nil
246 }
247}
248
249func (s *permissionService) AutoApproveSession(sessionID string) {
250 s.autoApproveSessionsMu.Lock()
251 s.autoApproveSessions[sessionID] = true
252 s.autoApproveSessionsMu.Unlock()
253}
254
255func (s *permissionService) SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[PermissionNotification] {
256 return s.notificationBroker.Subscribe(ctx)
257}
258
259func (s *permissionService) SetSkipRequests(skip bool) {
260 s.skip = skip
261}
262
263func (s *permissionService) SkipRequests() bool {
264 return s.skip
265}
266
267func NewPermissionService(workingDir string, skip bool, allowedTools []string) Service {
268 return &permissionService{
269 Broker: pubsub.NewBroker[PermissionRequest](),
270 notificationBroker: pubsub.NewBroker[PermissionNotification](),
271 workingDir: workingDir,
272 sessionPermissions: make([]PermissionRequest, 0),
273 autoApproveSessions: make(map[string]bool),
274 skip: skip,
275 allowedTools: allowedTools,
276 pendingRequests: csync.NewMap[string, chan bool](),
277 }
278}