1package permission
2
3import (
4 "sync"
5 "sync/atomic"
6 "testing"
7 "time"
8
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/require"
11)
12
13func TestPermissionService_AllowedCommands(t *testing.T) {
14 tests := []struct {
15 name string
16 allowedTools []string
17 toolName string
18 action string
19 expected bool
20 }{
21 {
22 name: "tool in allowlist",
23 allowedTools: []string{"bash", "view"},
24 toolName: "bash",
25 action: "execute",
26 expected: true,
27 },
28 {
29 name: "tool:action in allowlist",
30 allowedTools: []string{"bash:execute", "edit:create"},
31 toolName: "bash",
32 action: "execute",
33 expected: true,
34 },
35 {
36 name: "tool not in allowlist",
37 allowedTools: []string{"view", "ls"},
38 toolName: "bash",
39 action: "execute",
40 expected: false,
41 },
42 {
43 name: "tool:action not in allowlist",
44 allowedTools: []string{"bash:read", "edit:create"},
45 toolName: "bash",
46 action: "execute",
47 expected: false,
48 },
49 {
50 name: "empty allowlist",
51 allowedTools: []string{},
52 toolName: "bash",
53 action: "execute",
54 expected: false,
55 },
56 }
57
58 for _, tt := range tests {
59 t.Run(tt.name, func(t *testing.T) {
60 service := NewPermissionService("/tmp", false, tt.allowedTools)
61
62 // Create a channel to capture the permission request
63 // Since we're testing the allowlist logic, we need to simulate the request
64 ps := service.(*permissionService)
65
66 // Test the allowlist logic directly
67 commandKey := tt.toolName + ":" + tt.action
68 allowed := false
69 for _, cmd := range ps.allowedTools {
70 if cmd == commandKey || cmd == tt.toolName {
71 allowed = true
72 break
73 }
74 }
75
76 if allowed != tt.expected {
77 t.Errorf("expected %v, got %v for tool %s action %s with allowlist %v",
78 tt.expected, allowed, tt.toolName, tt.action, tt.allowedTools)
79 }
80 })
81 }
82}
83
84func TestSkipRace(t *testing.T) {
85 svc := NewPermissionService("/tmp", false, nil)
86 var wg sync.WaitGroup
87 wg.Add(2)
88 go func() {
89 defer wg.Done()
90 svc.SetSkipRequests(true)
91 }()
92 go func() {
93 defer wg.Done()
94 svc.SkipRequests()
95 }()
96 wg.Wait()
97}
98
99func TestPermissionService_SkipMode(t *testing.T) {
100 service := NewPermissionService("/tmp", true, []string{})
101
102 result, err := service.Request(t.Context(), CreatePermissionRequest{
103 SessionID: "test-session",
104 ToolName: "bash",
105 Action: "execute",
106 Description: "test command",
107 Path: "/tmp",
108 })
109 if err != nil {
110 t.Errorf("unexpected error: %v", err)
111 }
112 if !result {
113 t.Error("expected permission to be granted in skip mode")
114 }
115}
116
117func TestPermissionService_HookApproval(t *testing.T) {
118 t.Parallel()
119
120 t.Run("matching tool call ID short-circuits the prompt", func(t *testing.T) {
121 t.Parallel()
122 service := NewPermissionService("/tmp", false, nil)
123
124 ctx := WithHookApproval(t.Context(), "call-42")
125 granted, err := service.Request(ctx, CreatePermissionRequest{
126 SessionID: "s1",
127 ToolCallID: "call-42",
128 ToolName: "bash",
129 Action: "execute",
130 Description: "hook-approved command",
131 Path: "/tmp",
132 })
133 require.NoError(t, err)
134 assert.True(t, granted, "hook-approved call should bypass the prompt")
135 })
136
137 t.Run("approval is scoped to the stamped tool call ID", func(t *testing.T) {
138 t.Parallel()
139 service := NewPermissionService("/tmp", false, nil)
140
141 // Stamp for call-42, ask for a different call ID — must not leak.
142 ctx := WithHookApproval(t.Context(), "call-42")
143
144 // Kick off a real request that will need a subscriber to resolve it.
145 events := service.Subscribe(t.Context())
146 var (
147 wg sync.WaitGroup
148 granted bool
149 err error
150 )
151 wg.Go(func() {
152 granted, err = service.Request(ctx, CreatePermissionRequest{
153 SessionID: "s1",
154 ToolCallID: "call-other",
155 ToolName: "bash",
156 Action: "execute",
157 Description: "unrelated call",
158 Path: "/tmp",
159 })
160 })
161
162 // Confirm the service published a real request (i.e. didn't bypass).
163 event := <-events
164 service.Deny(event.Payload)
165 wg.Wait()
166 require.NoError(t, err)
167 assert.False(t, granted, "stamped approval must not apply to a different tool call")
168 })
169
170 t.Run("notifies subscribers that permission was granted", func(t *testing.T) {
171 t.Parallel()
172 service := NewPermissionService("/tmp", false, nil)
173
174 notifications := service.SubscribeNotifications(t.Context())
175
176 ctx := WithHookApproval(t.Context(), "call-99")
177 granted, err := service.Request(ctx, CreatePermissionRequest{
178 SessionID: "s1",
179 ToolCallID: "call-99",
180 ToolName: "view",
181 Action: "read",
182 Path: "/tmp",
183 })
184 require.NoError(t, err)
185 assert.True(t, granted)
186
187 event := <-notifications
188 assert.Equal(t, "call-99", event.Payload.ToolCallID)
189 assert.True(t, event.Payload.Granted, "subscribers should see a granted notification")
190 })
191}
192
193func TestPermissionService_SequentialProperties(t *testing.T) {
194 t.Run("Sequential permission requests with persistent grants", func(t *testing.T) {
195 service := NewPermissionService("/tmp", false, []string{})
196
197 req1 := CreatePermissionRequest{
198 SessionID: "session1",
199 ToolName: "file_tool",
200 Description: "Read file",
201 Action: "read",
202 Params: map[string]string{"file": "test.txt"},
203 Path: "/tmp/test.txt",
204 }
205
206 var result1 bool
207 var wg sync.WaitGroup
208 wg.Add(1)
209
210 events := service.Subscribe(t.Context())
211
212 go func() {
213 defer wg.Done()
214 result1, _ = service.Request(t.Context(), req1)
215 }()
216
217 var permissionReq PermissionRequest
218 event := <-events
219
220 permissionReq = event.Payload
221 service.GrantPersistent(permissionReq)
222
223 wg.Wait()
224 assert.True(t, result1, "First request should be granted")
225
226 // Second identical request should be automatically approved due to persistent permission
227 req2 := CreatePermissionRequest{
228 SessionID: "session1",
229 ToolName: "file_tool",
230 Description: "Read file again",
231 Action: "read",
232 Params: map[string]string{"file": "test.txt"},
233 Path: "/tmp/test.txt",
234 }
235 result2, err := service.Request(t.Context(), req2)
236 require.NoError(t, err)
237 assert.True(t, result2, "Second request should be auto-approved")
238 })
239 t.Run("Sequential requests with temporary grants", func(t *testing.T) {
240 service := NewPermissionService("/tmp", false, []string{})
241
242 req := CreatePermissionRequest{
243 SessionID: "session2",
244 ToolName: "file_tool",
245 Description: "Write file",
246 Action: "write",
247 Params: map[string]string{"file": "test.txt"},
248 Path: "/tmp/test.txt",
249 }
250
251 events := service.Subscribe(t.Context())
252 var result1 bool
253 var wg sync.WaitGroup
254
255 wg.Go(func() {
256 result1, _ = service.Request(t.Context(), req)
257 })
258
259 var permissionReq PermissionRequest
260 event := <-events
261 permissionReq = event.Payload
262
263 service.Grant(permissionReq)
264 wg.Wait()
265 assert.True(t, result1, "First request should be granted")
266
267 var result2 bool
268
269 wg.Go(func() {
270 result2, _ = service.Request(t.Context(), req)
271 })
272
273 event = <-events
274 permissionReq = event.Payload
275 service.Deny(permissionReq)
276 wg.Wait()
277 assert.False(t, result2, "Second request should be denied")
278 })
279 t.Run("Concurrent requests with different outcomes", func(t *testing.T) {
280 service := NewPermissionService("/tmp", false, []string{})
281
282 events := service.Subscribe(t.Context())
283
284 var wg sync.WaitGroup
285 results := make([]bool, 3)
286
287 requests := []CreatePermissionRequest{
288 {
289 SessionID: "concurrent1",
290 ToolName: "tool1",
291 Action: "action1",
292 Path: "/tmp/file1.txt",
293 Description: "First concurrent request",
294 },
295 {
296 SessionID: "concurrent2",
297 ToolName: "tool2",
298 Action: "action2",
299 Path: "/tmp/file2.txt",
300 Description: "Second concurrent request",
301 },
302 {
303 SessionID: "concurrent3",
304 ToolName: "tool3",
305 Action: "action3",
306 Path: "/tmp/file3.txt",
307 Description: "Third concurrent request",
308 },
309 }
310
311 for i, req := range requests {
312 wg.Add(1)
313 go func(index int, request CreatePermissionRequest) {
314 defer wg.Done()
315 result, _ := service.Request(t.Context(), request)
316 results[index] = result
317 }(i, req)
318 }
319
320 for range 3 {
321 event := <-events
322 switch event.Payload.ToolName {
323 case "tool1":
324 service.Grant(event.Payload)
325 case "tool2":
326 service.GrantPersistent(event.Payload)
327 case "tool3":
328 service.Deny(event.Payload)
329 }
330 }
331 wg.Wait()
332 grantedCount := 0
333 for _, result := range results {
334 if result {
335 grantedCount++
336 }
337 }
338
339 assert.Equal(t, 2, grantedCount, "Should have 2 granted and 1 denied")
340 secondReq := requests[1]
341 secondReq.Description = "Repeat of second request"
342 result, err := service.Request(t.Context(), secondReq)
343 require.NoError(t, err)
344 assert.True(t, result, "Repeated request should be auto-approved due to persistent permission")
345 })
346}
347
348// TestPermissionService_ResolveIdempotency covers the multi-subscriber
349// resolve guarantees added for client/server mode: exactly one
350// notification per resolution, racing callers see "already resolved",
351// and stray Grant/Deny calls for unknown IDs are safe no-ops.
352func TestPermissionService_ResolveIdempotency(t *testing.T) {
353 t.Parallel()
354
355 t.Run("concurrent grants resolve exactly once", func(t *testing.T) {
356 t.Parallel()
357 service := NewPermissionService("/tmp", false, nil)
358
359 events := service.Subscribe(t.Context())
360 notifications := service.SubscribeNotifications(t.Context())
361
362 req := CreatePermissionRequest{
363 SessionID: "race-session",
364 ToolCallID: "race-call",
365 ToolName: "tool",
366 Action: "act",
367 Path: "/tmp/race",
368 }
369
370 var (
371 wg sync.WaitGroup
372 granted bool
373 requestErr error
374 )
375 wg.Go(func() {
376 granted, requestErr = service.Request(t.Context(), req)
377 })
378
379 // Wait for the request to be published so we have a real
380 // PermissionRequest (with its server-side ID) to race on.
381 var pending PermissionRequest
382 select {
383 case ev := <-events:
384 pending = ev.Payload
385 case <-time.After(2 * time.Second):
386 t.Fatal("permission request was never published")
387 }
388
389 // Drain the initial "request opened" notification (Granted ==
390 // false && Denied == false) so the next read is the resolution
391 // itself.
392 select {
393 case ev := <-notifications:
394 require.False(t, ev.Payload.Granted, "initial notification must not be granted")
395 require.False(t, ev.Payload.Denied, "initial notification must not be denied")
396 case <-time.After(2 * time.Second):
397 t.Fatal("initial notification was never published")
398 }
399
400 // Race two grants from two goroutines.
401 var (
402 resolvedCount atomic.Int32
403 start = make(chan struct{})
404 racers sync.WaitGroup
405 )
406 for range 2 {
407 racers.Go(func() {
408 <-start
409 if service.Grant(pending) {
410 resolvedCount.Add(1)
411 }
412 })
413 }
414 close(start)
415 racers.Wait()
416
417 // Original Request must return granted exactly once.
418 wg.Wait()
419 require.NoError(t, requestErr)
420 assert.True(t, granted, "request should observe its grant")
421
422 // Exactly one of the two grants resolved the request.
423 assert.Equal(t, int32(1), resolvedCount.Load(),
424 "exactly one Grant should report it resolved the request")
425
426 // Exactly one resolution notification, and no further ones.
427 select {
428 case ev := <-notifications:
429 assert.True(t, ev.Payload.Granted, "resolution notification should be granted")
430 assert.Equal(t, "race-call", ev.Payload.ToolCallID)
431 case <-time.After(2 * time.Second):
432 t.Fatal("resolution notification was never published")
433 }
434 select {
435 case ev := <-notifications:
436 t.Fatalf("unexpected duplicate notification: %+v", ev.Payload)
437 case <-time.After(50 * time.Millisecond):
438 // good: no duplicate.
439 }
440
441 // pendingRequests must be empty: no goroutine is left blocked
442 // on a send, and a future Grant for the same ID is a no-op.
443 ps := service.(*permissionService)
444 assert.Equal(t, 0, ps.pendingRequests.Len(),
445 "pendingRequests must be empty after resolution")
446
447 assert.False(t, service.Grant(pending),
448 "a third Grant should report already-resolved")
449 })
450
451 t.Run("grant after deny is a no-op", func(t *testing.T) {
452 t.Parallel()
453 service := NewPermissionService("/tmp", false, nil)
454
455 events := service.Subscribe(t.Context())
456 notifications := service.SubscribeNotifications(t.Context())
457
458 req := CreatePermissionRequest{
459 SessionID: "deny-first",
460 ToolCallID: "df-call",
461 ToolName: "tool",
462 Action: "act",
463 Path: "/tmp/df",
464 }
465
466 var (
467 wg sync.WaitGroup
468 granted bool
469 requestErr error
470 )
471 wg.Go(func() {
472 granted, requestErr = service.Request(t.Context(), req)
473 })
474
475 var pending PermissionRequest
476 select {
477 case ev := <-events:
478 pending = ev.Payload
479 case <-time.After(2 * time.Second):
480 t.Fatal("permission request was never published")
481 }
482
483 // Drain the initial neither-granted-nor-denied notification.
484 <-notifications
485
486 assert.True(t, service.Deny(pending), "Deny should resolve the request")
487 wg.Wait()
488 require.NoError(t, requestErr)
489 assert.False(t, granted, "request should observe denial")
490
491 // A follow-up Grant must be a no-op and must not flip the
492 // outcome or publish anything new.
493 assert.False(t, service.Grant(pending),
494 "Grant after Deny should report already-resolved")
495
496 select {
497 case ev := <-notifications:
498 // The first resolution notification (denial) is expected;
499 // anything after that is a bug.
500 require.True(t, ev.Payload.Denied,
501 "the only post-initial notification must be the denial")
502 case <-time.After(2 * time.Second):
503 t.Fatal("denial notification was never published")
504 }
505 select {
506 case ev := <-notifications:
507 t.Fatalf("Grant after Deny must not publish: %+v", ev.Payload)
508 case <-time.After(50 * time.Millisecond):
509 // good.
510 }
511 })
512
513 t.Run("losing GrantPersistent does not record session permission", func(t *testing.T) {
514 t.Parallel()
515 service := NewPermissionService("/tmp", false, nil)
516
517 events := service.Subscribe(t.Context())
518 notifications := service.SubscribeNotifications(t.Context())
519
520 req := CreatePermissionRequest{
521 SessionID: "race-persist",
522 ToolCallID: "rp-call",
523 ToolName: "tool",
524 Action: "act",
525 Path: "/tmp/rp",
526 }
527
528 var (
529 wg sync.WaitGroup
530 granted bool
531 requestErr error
532 )
533 wg.Go(func() {
534 granted, requestErr = service.Request(t.Context(), req)
535 })
536
537 // Wait for the request to be published so we have the real
538 // pending PermissionRequest to race on.
539 var pending PermissionRequest
540 select {
541 case ev := <-events:
542 pending = ev.Payload
543 case <-time.After(2 * time.Second):
544 t.Fatal("permission request was never published")
545 }
546
547 // Drain the initial neither-granted-nor-denied notification.
548 <-notifications
549
550 // Deny wins, then a competing GrantPersistent loses.
551 assert.True(t, service.Deny(pending), "Deny should resolve the request")
552 assert.False(t, service.GrantPersistent(pending),
553 "GrantPersistent after Deny should report already-resolved")
554
555 wg.Wait()
556 require.NoError(t, requestErr)
557 assert.False(t, granted, "request should observe denial")
558
559 // The losing GrantPersistent must not have inserted an
560 // auto-approve entry. Issue a matching follow-up request and
561 // confirm the service still publishes a pending request (i.e.
562 // not auto-approved). We then Deny it to drain the goroutine.
563 var (
564 wg2 sync.WaitGroup
565 granted2 bool
566 requestErr2 error
567 )
568 wg2.Go(func() {
569 granted2, requestErr2 = service.Request(t.Context(), req)
570 })
571
572 select {
573 case ev := <-events:
574 assert.Equal(t, pending.SessionID, ev.Payload.SessionID)
575 service.Deny(ev.Payload)
576 case <-time.After(2 * time.Second):
577 t.Fatal("follow-up request was auto-approved; persistent grant leaked")
578 }
579
580 wg2.Wait()
581 require.NoError(t, requestErr2)
582 assert.False(t, granted2, "follow-up request should be denied, not auto-approved")
583 })
584
585 t.Run("grant for unknown id is a safe no-op", func(t *testing.T) {
586 t.Parallel()
587 service := NewPermissionService("/tmp", false, nil)
588
589 notifications := service.SubscribeNotifications(t.Context())
590
591 bogus := PermissionRequest{
592 ID: "does-not-exist",
593 ToolCallID: "ghost",
594 ToolName: "tool",
595 Action: "act",
596 Path: "/tmp/ghost",
597 }
598
599 assert.NotPanics(t, func() {
600 assert.False(t, service.Grant(bogus),
601 "Grant for unknown ID should report already-resolved")
602 assert.False(t, service.GrantPersistent(bogus),
603 "GrantPersistent for unknown ID should report already-resolved")
604 assert.False(t, service.Deny(bogus),
605 "Deny for unknown ID should report already-resolved")
606 })
607
608 select {
609 case ev := <-notifications:
610 t.Fatalf("unknown-ID resolution must not publish: %+v", ev.Payload)
611 case <-time.After(50 * time.Millisecond):
612 // good: no notification.
613 }
614 })
615}