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}