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 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}