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