bash_test.go

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