1package tools
2
3import (
4 "context"
5 "encoding/json"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11
12 "charm.land/fantasy"
13 "github.com/charmbracelet/crush/internal/permission"
14 "github.com/charmbracelet/crush/internal/pubsub"
15 "github.com/stretchr/testify/require"
16)
17
18// recordingPermissionService captures permission requests and answers them
19// according to a configurable response.
20type recordingPermissionService struct {
21 *pubsub.Broker[permission.PermissionRequest]
22 requests []permission.CreatePermissionRequest
23 grant bool
24}
25
26func (m *recordingPermissionService) Request(ctx context.Context, req permission.CreatePermissionRequest) (bool, error) {
27 m.requests = append(m.requests, req)
28 return m.grant, nil
29}
30
31func (m *recordingPermissionService) Grant(req permission.PermissionRequest) {}
32func (m *recordingPermissionService) Deny(req permission.PermissionRequest) {}
33func (m *recordingPermissionService) GrantPersistent(req permission.PermissionRequest) {}
34func (m *recordingPermissionService) AutoApproveSession(sessionID string) {}
35func (m *recordingPermissionService) SetSkipRequests(skip bool) {}
36func (m *recordingPermissionService) SkipRequests() bool { return false }
37func (m *recordingPermissionService) SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[permission.PermissionNotification] {
38 return make(<-chan pubsub.Event[permission.PermissionNotification])
39}
40
41type mockFileTrackerService struct{}
42
43func (m mockFileTrackerService) RecordRead(ctx context.Context, sessionID, path string) {}
44
45func (m mockFileTrackerService) LastReadTime(ctx context.Context, sessionID, path string) time.Time {
46 return time.Now()
47}
48
49func (m mockFileTrackerService) ListReadFiles(ctx context.Context, sessionID string) ([]string, error) {
50 return nil, nil
51}
52
53func TestTouchToolCreatesEmptyFile(t *testing.T) {
54 t.Parallel()
55
56 workingDir := t.TempDir()
57 tool := NewTouchTool(nil, &mockPermissionService{}, &mockHistoryService{}, mockFileTrackerService{}, workingDir)
58 ctx := context.WithValue(context.Background(), SessionIDContextKey, "test-session")
59
60 resp := runTouchTool(t, tool, ctx, TouchParams{FilePath: "nested/empty.txt"})
61 require.False(t, resp.IsError)
62
63 filePath := filepath.Join(workingDir, "nested", "empty.txt")
64 info, err := os.Stat(filePath)
65 require.NoError(t, err)
66 require.False(t, info.IsDir())
67 require.Zero(t, info.Size())
68}
69
70func TestTouchToolRefusesExistingFile(t *testing.T) {
71 t.Parallel()
72
73 workingDir := t.TempDir()
74 filePath := filepath.Join(workingDir, "existing.txt")
75 require.NoError(t, os.WriteFile(filePath, []byte("content"), 0o644))
76
77 tool := NewTouchTool(nil, &mockPermissionService{}, &mockHistoryService{}, mockFileTrackerService{}, workingDir)
78 ctx := context.WithValue(context.Background(), SessionIDContextKey, "test-session")
79
80 resp := runTouchTool(t, tool, ctx, TouchParams{FilePath: "existing.txt"})
81 require.True(t, resp.IsError)
82 require.Contains(t, resp.Content, "File already exists")
83
84 content, err := os.ReadFile(filePath)
85 require.NoError(t, err)
86 require.Equal(t, "content", string(content))
87}
88
89func TestTouchToolStaysInsideWorkingDir(t *testing.T) {
90 t.Parallel()
91
92 workingDir := t.TempDir()
93 perms := &recordingPermissionService{grant: true}
94 tool := NewTouchTool(nil, perms, &mockHistoryService{}, mockFileTrackerService{}, workingDir)
95 ctx := context.WithValue(context.Background(), SessionIDContextKey, "test-session")
96
97 resp := runTouchTool(t, tool, ctx, TouchParams{FilePath: "inside.txt"})
98 require.False(t, resp.IsError)
99
100 for _, req := range perms.requests {
101 require.NotContains(t, req.Description, "outside working directory",
102 "inside-workingDir touch should not trigger an outside-workingDir permission prompt")
103 }
104
105 _, err := os.Stat(filepath.Join(workingDir, "inside.txt"))
106 require.NoError(t, err)
107}
108
109func TestTouchToolOutsideWorkingDirRequiresPermission(t *testing.T) {
110 t.Parallel()
111
112 parent := t.TempDir()
113 workingDir := filepath.Join(parent, "wd")
114 require.NoError(t, os.MkdirAll(workingDir, 0o755))
115
116 // Denied: file outside workingDir must not be created.
117 deny := &recordingPermissionService{grant: false}
118 tool := NewTouchTool(nil, deny, &mockHistoryService{}, mockFileTrackerService{}, workingDir)
119 ctx := context.WithValue(context.Background(), SessionIDContextKey, "test-session")
120
121 resp := runTouchTool(t, tool, ctx, TouchParams{FilePath: "../escape.txt"})
122 require.True(t, resp.IsError)
123
124 require.Len(t, deny.requests, 1)
125 require.True(t, strings.Contains(deny.requests[0].Description, "outside working directory"),
126 "expected outside-working-directory permission prompt, got %q", deny.requests[0].Description)
127
128 _, err := os.Stat(filepath.Join(parent, "escape.txt"))
129 require.True(t, os.IsNotExist(err), "denied permission should not create the file")
130
131 // Granted: same path now succeeds.
132 grant := &recordingPermissionService{grant: true}
133 tool = NewTouchTool(nil, grant, &mockHistoryService{}, mockFileTrackerService{}, workingDir)
134 resp = runTouchTool(t, tool, ctx, TouchParams{FilePath: "../escape.txt"})
135 require.False(t, resp.IsError)
136 require.GreaterOrEqual(t, len(grant.requests), 1)
137 require.Contains(t, grant.requests[0].Description, "outside working directory")
138
139 _, err = os.Stat(filepath.Join(parent, "escape.txt"))
140 require.NoError(t, err)
141}
142
143func TestWriteToolEmptyContentPointsToTouch(t *testing.T) {
144 t.Parallel()
145
146 tool := NewWriteTool(nil, nil, nil, nil, t.TempDir())
147
148 input, err := json.Marshal(WriteParams{FilePath: "empty.txt"})
149 require.NoError(t, err)
150
151 resp, err := tool.Run(context.Background(), fantasy.ToolCall{
152 ID: "test-call",
153 Name: WriteToolName,
154 Input: string(input),
155 })
156 require.NoError(t, err)
157 require.True(t, resp.IsError)
158 require.Equal(t, `content is required. use the "touch" tool to create an empty file`, resp.Content)
159}
160
161func runTouchTool(t *testing.T, tool fantasy.AgentTool, ctx context.Context, params TouchParams) fantasy.ToolResponse {
162 t.Helper()
163
164 input, err := json.Marshal(params)
165 require.NoError(t, err)
166
167 resp, err := tool.Run(ctx, fantasy.ToolCall{
168 ID: "test-call",
169 Name: TouchToolName,
170 Input: string(input),
171 })
172 require.NoError(t, err)
173 return resp
174}