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 grants a permission request and remembers the grant
67 // for the session. It returns true if this call actually resolved the
68 // pending request; false if the request had already been resolved
69 // (e.g., by another concurrent caller) or is unknown.
70 GrantPersistent(permission PermissionRequest) bool
71 // Grant grants a permission request. It returns true if this call
72 // actually resolved the pending request; false if the request had
73 // already been resolved or is unknown.
74 Grant(permission PermissionRequest) bool
75 // Deny denies a permission request. It returns true if this call
76 // actually resolved the pending request; false if the request had
77 // already been resolved or is unknown.
78 Deny(permission PermissionRequest) bool
79 Request(ctx context.Context, opts CreatePermissionRequest) (bool, error)
80 AutoApproveSession(sessionID string)
81 SetSkipRequests(skip bool)
82 SkipRequests() bool
83 SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[PermissionNotification]
84}
85
86// PermissionKey is a composite key for session permission lookups.
87type PermissionKey struct {
88 SessionID string
89 ToolName string
90 Action string
91 Path string
92}
93
94type permissionService struct {
95 *pubsub.Broker[PermissionRequest]
96
97 notificationBroker *pubsub.Broker[PermissionNotification]
98 workingDir string
99 sessionPermissions *csync.Map[PermissionKey, bool]
100 pendingRequests *csync.Map[string, chan bool]
101 autoApproveSessions map[string]bool
102 autoApproveSessionsMu sync.RWMutex
103 skip bool
104 allowedTools []string
105
106 // used to make sure we only process one request at a time
107 requestMu sync.Mutex
108 activeRequest *PermissionRequest
109 activeRequestMu sync.Mutex
110}
111
112// resolve atomically removes the pending request entry for the given
113// permission and, if it was still pending, publishes exactly one
114// PermissionNotification and forwards the outcome to the waiter on
115// respCh. It returns true if this call resolved the request, false if
116// it had already been resolved (e.g., by another concurrent caller) or
117// the request ID is unknown.
118//
119// If onResolve is non-nil it runs after the pending entry has been
120// taken but before the notification is published or the waiter is
121// unblocked. This lets GrantPersistent record the session permission
122// only when it actually wins the race, so a losing GrantPersistent
123// that lost to a Deny does not leak an auto-approve entry.
124//
125// All three public resolution methods (Grant, GrantPersistent, Deny)
126// route through this helper so multi-subscriber UIs can race safely:
127// the first caller wins, the rest become no-ops.
128func (s *permissionService) resolve(permission PermissionRequest, granted, denied bool, onResolve func()) bool {
129 respCh, ok := s.pendingRequests.Take(permission.ID)
130 if !ok {
131 return false
132 }
133
134 if onResolve != nil {
135 onResolve()
136 }
137
138 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
139 ToolCallID: permission.ToolCallID,
140 Granted: granted,
141 Denied: denied,
142 })
143
144 // respCh is buffered (cap 1) and only ever has at most one sender
145 // per request because Take removes the entry under the map lock,
146 // so this send never blocks.
147 respCh <- granted
148
149 s.activeRequestMu.Lock()
150 if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
151 s.activeRequest = nil
152 }
153 s.activeRequestMu.Unlock()
154 return true
155}
156
157func (s *permissionService) GrantPersistent(permission PermissionRequest) bool {
158 // Record the persistent grant only if this call wins the
159 // pending-request race. Otherwise a losing GrantPersistent that
160 // lost to a Deny would still leave an auto-approve entry behind,
161 // silently flipping later denied calls to allowed.
162 return s.resolve(permission, true, false, func() {
163 s.sessionPermissions.Set(PermissionKey{
164 SessionID: permission.SessionID,
165 ToolName: permission.ToolName,
166 Action: permission.Action,
167 Path: permission.Path,
168 }, true)
169 })
170}
171
172func (s *permissionService) Grant(permission PermissionRequest) bool {
173 return s.resolve(permission, true, false, nil)
174}
175
176func (s *permissionService) Deny(permission PermissionRequest) bool {
177 return s.resolve(permission, false, true, nil)
178}
179
180func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRequest) (bool, error) {
181 if s.skip {
182 return true, nil
183 }
184
185 // Check if the tool/action combination is in the allowlist
186 commandKey := opts.ToolName + ":" + opts.Action
187 if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) {
188 return true, nil
189 }
190
191 // A PreToolUse hook that returned decision=allow stamps the context
192 // with the tool call ID. Treat that as a pre-approval and skip the
193 // prompt entirely. We still publish a granted notification so the UI
194 // and audit subscribers see the outcome.
195 if hookApproved(ctx, opts.ToolCallID) {
196 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
197 ToolCallID: opts.ToolCallID,
198 Granted: true,
199 })
200 return true, nil
201 }
202
203 s.requestMu.Lock()
204 defer s.requestMu.Unlock()
205
206 // tell the UI that a permission was requested
207 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
208 ToolCallID: opts.ToolCallID,
209 })
210
211 s.autoApproveSessionsMu.RLock()
212 autoApprove := s.autoApproveSessions[opts.SessionID]
213 s.autoApproveSessionsMu.RUnlock()
214
215 if autoApprove {
216 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
217 ToolCallID: opts.ToolCallID,
218 Granted: true,
219 })
220 return true, nil
221 }
222
223 fileInfo, err := os.Stat(opts.Path)
224 dir := opts.Path
225 if err == nil {
226 if fileInfo.IsDir() {
227 dir = opts.Path
228 } else {
229 dir = filepath.Dir(opts.Path)
230 }
231 }
232
233 if dir == "." {
234 dir = s.workingDir
235 }
236 permission := PermissionRequest{
237 ID: uuid.New().String(),
238 Path: dir,
239 SessionID: opts.SessionID,
240 ToolCallID: opts.ToolCallID,
241 ToolName: opts.ToolName,
242 Description: opts.Description,
243 Action: opts.Action,
244 Params: opts.Params,
245 }
246
247 if _, ok := s.sessionPermissions.Get(PermissionKey{
248 SessionID: permission.SessionID,
249 ToolName: permission.ToolName,
250 Action: permission.Action,
251 Path: permission.Path,
252 }); ok {
253 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
254 ToolCallID: opts.ToolCallID,
255 Granted: true,
256 })
257 return true, nil
258 }
259
260 s.activeRequestMu.Lock()
261 s.activeRequest = &permission
262 s.activeRequestMu.Unlock()
263
264 respCh := make(chan bool, 1)
265 s.pendingRequests.Set(permission.ID, respCh)
266 defer s.pendingRequests.Del(permission.ID)
267
268 // Publish the request
269 s.Publish(pubsub.CreatedEvent, permission)
270
271 select {
272 case <-ctx.Done():
273 return false, ctx.Err()
274 case granted := <-respCh:
275 return granted, nil
276 }
277}
278
279func (s *permissionService) AutoApproveSession(sessionID string) {
280 s.autoApproveSessionsMu.Lock()
281 s.autoApproveSessions[sessionID] = true
282 s.autoApproveSessionsMu.Unlock()
283}
284
285func (s *permissionService) SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[PermissionNotification] {
286 return s.notificationBroker.Subscribe(ctx)
287}
288
289func (s *permissionService) SetSkipRequests(skip bool) {
290 s.skip = skip
291}
292
293func (s *permissionService) SkipRequests() bool {
294 return s.skip
295}
296
297func NewPermissionService(workingDir string, skip bool, allowedTools []string) Service {
298 return &permissionService{
299 Broker: pubsub.NewBroker[PermissionRequest](),
300 notificationBroker: pubsub.NewBroker[PermissionNotification](),
301 workingDir: workingDir,
302 sessionPermissions: csync.NewMap[PermissionKey, bool](),
303 autoApproveSessions: make(map[string]bool),
304 skip: skip,
305 allowedTools: allowedTools,
306 pendingRequests: csync.NewMap[string, chan bool](),
307 }
308}