touch_test.go

  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}