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
76// PermissionKey is a composite key for session permission lookups.
77type PermissionKey struct {
78 SessionID string
79 ToolName string
80 Action string
81 Path string
82}
83
84type permissionService struct {
85 *pubsub.Broker[PermissionRequest]
86
87 notificationBroker *pubsub.Broker[PermissionNotification]
88 workingDir string
89 sessionPermissions *csync.Map[PermissionKey, bool]
90 pendingRequests *csync.Map[string, chan bool]
91 autoApproveSessions map[string]bool
92 autoApproveSessionsMu sync.RWMutex
93 skip bool
94 allowedTools []string
95
96 // used to make sure we only process one request at a time
97 requestMu sync.Mutex
98 activeRequest *PermissionRequest
99 activeRequestMu sync.Mutex
100}
101
102func (s *permissionService) GrantPersistent(permission PermissionRequest) {
103 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
104 ToolCallID: permission.ToolCallID,
105 Granted: true,
106 })
107 respCh, ok := s.pendingRequests.Get(permission.ID)
108 if ok {
109 respCh <- true
110 }
111
112 s.sessionPermissions.Set(PermissionKey{
113 SessionID: permission.SessionID,
114 ToolName: permission.ToolName,
115 Action: permission.Action,
116 Path: permission.Path,
117 }, true)
118
119 s.activeRequestMu.Lock()
120 if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
121 s.activeRequest = nil
122 }
123 s.activeRequestMu.Unlock()
124}
125
126func (s *permissionService) Grant(permission PermissionRequest) {
127 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
128 ToolCallID: permission.ToolCallID,
129 Granted: true,
130 })
131 respCh, ok := s.pendingRequests.Get(permission.ID)
132 if ok {
133 respCh <- true
134 }
135
136 s.activeRequestMu.Lock()
137 if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
138 s.activeRequest = nil
139 }
140 s.activeRequestMu.Unlock()
141}
142
143func (s *permissionService) Deny(permission PermissionRequest) {
144 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
145 ToolCallID: permission.ToolCallID,
146 Granted: false,
147 Denied: true,
148 })
149 respCh, ok := s.pendingRequests.Get(permission.ID)
150 if ok {
151 respCh <- false
152 }
153
154 s.activeRequestMu.Lock()
155 if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
156 s.activeRequest = nil
157 }
158 s.activeRequestMu.Unlock()
159}
160
161func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRequest) (bool, error) {
162 if s.skip {
163 return true, nil
164 }
165
166 // Check if the tool/action combination is in the allowlist
167 commandKey := opts.ToolName + ":" + opts.Action
168 if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) {
169 return true, nil
170 }
171
172 // A PreToolUse hook that returned decision=allow stamps the context
173 // with the tool call ID. Treat that as a pre-approval and skip the
174 // prompt entirely. We still publish a granted notification so the UI
175 // and audit subscribers see the outcome.
176 if hookApproved(ctx, opts.ToolCallID) {
177 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
178 ToolCallID: opts.ToolCallID,
179 Granted: true,
180 })
181 return true, nil
182 }
183
184 s.requestMu.Lock()
185 defer s.requestMu.Unlock()
186
187 // tell the UI that a permission was requested
188 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
189 ToolCallID: opts.ToolCallID,
190 })
191
192 s.autoApproveSessionsMu.RLock()
193 autoApprove := s.autoApproveSessions[opts.SessionID]
194 s.autoApproveSessionsMu.RUnlock()
195
196 if autoApprove {
197 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
198 ToolCallID: opts.ToolCallID,
199 Granted: true,
200 })
201 return true, nil
202 }
203
204 fileInfo, err := os.Stat(opts.Path)
205 dir := opts.Path
206 if err == nil {
207 if fileInfo.IsDir() {
208 dir = opts.Path
209 } else {
210 dir = filepath.Dir(opts.Path)
211 }
212 }
213
214 if dir == "." {
215 dir = s.workingDir
216 }
217 permission := PermissionRequest{
218 ID: uuid.New().String(),
219 Path: dir,
220 SessionID: opts.SessionID,
221 ToolCallID: opts.ToolCallID,
222 ToolName: opts.ToolName,
223 Description: opts.Description,
224 Action: opts.Action,
225 Params: opts.Params,
226 }
227
228 if _, ok := s.sessionPermissions.Get(PermissionKey{
229 SessionID: permission.SessionID,
230 ToolName: permission.ToolName,
231 Action: permission.Action,
232 Path: permission.Path,
233 }); ok {
234 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
235 ToolCallID: opts.ToolCallID,
236 Granted: true,
237 })
238 return true, nil
239 }
240
241 s.activeRequestMu.Lock()
242 s.activeRequest = &permission
243 s.activeRequestMu.Unlock()
244
245 respCh := make(chan bool, 1)
246 s.pendingRequests.Set(permission.ID, respCh)
247 defer s.pendingRequests.Del(permission.ID)
248
249 // Publish the request
250 s.Publish(pubsub.CreatedEvent, permission)
251
252 select {
253 case <-ctx.Done():
254 return false, ctx.Err()
255 case granted := <-respCh:
256 return granted, nil
257 }
258}
259
260func (s *permissionService) AutoApproveSession(sessionID string) {
261 s.autoApproveSessionsMu.Lock()
262 s.autoApproveSessions[sessionID] = true
263 s.autoApproveSessionsMu.Unlock()
264}
265
266func (s *permissionService) SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[PermissionNotification] {
267 return s.notificationBroker.Subscribe(ctx)
268}
269
270func (s *permissionService) SetSkipRequests(skip bool) {
271 s.skip = skip
272}
273
274func (s *permissionService) SkipRequests() bool {
275 return s.skip
276}
277
278func NewPermissionService(workingDir string, skip bool, allowedTools []string) Service {
279 return &permissionService{
280 Broker: pubsub.NewBroker[PermissionRequest](),
281 notificationBroker: pubsub.NewBroker[PermissionNotification](),
282 workingDir: workingDir,
283 sessionPermissions: csync.NewMap[PermissionKey, bool](),
284 autoApproveSessions: make(map[string]bool),
285 skip: skip,
286 allowedTools: allowedTools,
287 pendingRequests: csync.NewMap[string, chan bool](),
288 }
289}