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}