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