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