permission_test.go

  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}