bash_test.go

  1package tools
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"testing"
  7
  8	"charm.land/fantasy"
  9	"git.secluded.site/crush/internal/config"
 10	"git.secluded.site/crush/internal/permission"
 11	"git.secluded.site/crush/internal/pubsub"
 12	"git.secluded.site/crush/internal/shell"
 13	"github.com/stretchr/testify/require"
 14)
 15
 16type mockBashPermissionService struct {
 17	*pubsub.Broker[permission.PermissionRequest]
 18}
 19
 20func (m *mockBashPermissionService) Request(ctx context.Context, req permission.CreatePermissionRequest) (bool, error) {
 21	return true, nil
 22}
 23
 24func (m *mockBashPermissionService) Grant(req permission.PermissionRequest) {}
 25
 26func (m *mockBashPermissionService) Deny(req permission.PermissionRequest) {}
 27
 28func (m *mockBashPermissionService) GrantPersistent(req permission.PermissionRequest) {}
 29
 30func (m *mockBashPermissionService) AutoApproveSession(sessionID string) {}
 31
 32func (m *mockBashPermissionService) SetSkipRequests(skip bool) {}
 33
 34func (m *mockBashPermissionService) SkipRequests() bool {
 35	return false
 36}
 37
 38func (m *mockBashPermissionService) SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[permission.PermissionNotification] {
 39	return make(<-chan pubsub.Event[permission.PermissionNotification])
 40}
 41
 42func TestBashTool_DefaultAutoBackgroundThreshold(t *testing.T) {
 43	workingDir := t.TempDir()
 44	tool := newBashToolForTest(workingDir)
 45	ctx := context.WithValue(context.Background(), SessionIDContextKey, "test-session")
 46
 47	resp := runBashTool(t, tool, ctx, BashParams{
 48		Description: "default threshold",
 49		Command:     "echo done",
 50	})
 51
 52	require.False(t, resp.IsError)
 53	var meta BashResponseMetadata
 54	require.NoError(t, json.Unmarshal([]byte(resp.Metadata), &meta))
 55	require.False(t, meta.Background)
 56	require.Empty(t, meta.ShellID)
 57	require.Contains(t, meta.Output, "done")
 58}
 59
 60func TestBashTool_CustomAutoBackgroundThreshold(t *testing.T) {
 61	workingDir := t.TempDir()
 62	tool := newBashToolForTest(workingDir)
 63	ctx := context.WithValue(context.Background(), SessionIDContextKey, "test-session")
 64
 65	resp := runBashTool(t, tool, ctx, BashParams{
 66		Description:         "custom threshold",
 67		Command:             "sleep 1.5 && echo done",
 68		AutoBackgroundAfter: 1,
 69	})
 70
 71	require.False(t, resp.IsError)
 72	var meta BashResponseMetadata
 73	require.NoError(t, json.Unmarshal([]byte(resp.Metadata), &meta))
 74	require.True(t, meta.Background)
 75	require.NotEmpty(t, meta.ShellID)
 76	require.Contains(t, resp.Content, "moved to background")
 77
 78	bgManager := shell.GetBackgroundShellManager()
 79	require.NoError(t, bgManager.Kill(meta.ShellID))
 80}
 81
 82type recordingPermissionService struct {
 83	*pubsub.Broker[permission.PermissionRequest]
 84	requestCount int
 85	allow        bool
 86}
 87
 88func (m *recordingPermissionService) Request(ctx context.Context, req permission.CreatePermissionRequest) (bool, error) {
 89	m.requestCount++
 90	return m.allow, nil
 91}
 92
 93func (m *recordingPermissionService) Grant(req permission.PermissionRequest) {}
 94
 95func (m *recordingPermissionService) Deny(req permission.PermissionRequest) {}
 96
 97func (m *recordingPermissionService) GrantPersistent(req permission.PermissionRequest) {}
 98
 99func (m *recordingPermissionService) AutoApproveSession(sessionID string) {}
100
101func (m *recordingPermissionService) SetSkipRequests(skip bool) {}
102
103func (m *recordingPermissionService) SkipRequests() bool {
104	return false
105}
106
107func (m *recordingPermissionService) SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[permission.PermissionNotification] {
108	return make(<-chan pubsub.Event[permission.PermissionNotification])
109}
110
111func newBashToolForTest(workingDir string) fantasy.AgentTool {
112	permissions := &mockBashPermissionService{Broker: pubsub.NewBroker[permission.PermissionRequest]()}
113	attribution := &config.Attribution{TrailerStyle: config.TrailerStyleNone}
114	return NewBashTool(permissions, workingDir, attribution, "test-model")
115}
116
117func newBashToolWithRecordingPerms(workingDir string, allow bool) (fantasy.AgentTool, *recordingPermissionService) {
118	perms := &recordingPermissionService{
119		Broker: pubsub.NewBroker[permission.PermissionRequest](),
120		allow:  allow,
121	}
122	attribution := &config.Attribution{TrailerStyle: config.TrailerStyleNone}
123	return NewBashTool(perms, workingDir, attribution, "test-model"), perms
124}
125
126func TestBashTool_ChainedCommandsRequirePermission(t *testing.T) {
127	workingDir := t.TempDir()
128	tool, perms := newBashToolWithRecordingPerms(workingDir, true)
129	ctx := context.WithValue(context.Background(), SessionIDContextKey, "test-session")
130
131	// ls && echo should trigger permission check.
132	resp := runBashTool(t, tool, ctx, BashParams{
133		Description: "chained ls",
134		Command:     "ls && echo done",
135	})
136
137	require.False(t, resp.IsError)
138	require.Equal(t, 1, perms.requestCount, "chained command should trigger permission request")
139
140	// Plain ls should NOT trigger permission check.
141	perms.requestCount = 0
142	resp = runBashTool(t, tool, ctx, BashParams{
143		Description: "plain ls",
144		Command:     "ls -la",
145	})
146
147	require.False(t, resp.IsError)
148	require.Equal(t, 0, perms.requestCount, "plain ls should not trigger permission request")
149}
150
151func TestBashTool_ChainedCommandsDenied(t *testing.T) {
152	workingDir := t.TempDir()
153	tool, perms := newBashToolWithRecordingPerms(workingDir, false)
154	ctx := context.WithValue(context.Background(), SessionIDContextKey, "test-session")
155
156	resp := runBashTool(t, tool, ctx, BashParams{
157		Description: "chained ls denied",
158		Command:     "ls && rm -rf /",
159	})
160
161	require.Equal(t, 1, perms.requestCount)
162	require.Contains(t, resp.Content, "User denied permission")
163}
164
165func runBashTool(t *testing.T, tool fantasy.AgentTool, ctx context.Context, params BashParams) fantasy.ToolResponse {
166	t.Helper()
167
168	input, err := json.Marshal(params)
169	require.NoError(t, err)
170
171	call := fantasy.ToolCall{
172		ID:    "test-call",
173		Name:  BashToolName,
174		Input: string(input),
175	}
176
177	resp, err := tool.Run(ctx, call)
178	require.NoError(t, err)
179	return resp
180}