@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "strings"
"charm.land/fantasy"
"github.com/charmbracelet/crush/internal/filepathext"
@@ -57,17 +58,50 @@ func NewTouchTool(
filePath := filepathext.SmartJoin(workingDir, params.FilePath)
- fileInfo, err := os.Stat(filePath)
+ absWorkingDir, err := filepath.Abs(workingDir)
+ if err != nil {
+ return fantasy.ToolResponse{}, fmt.Errorf("error resolving working directory: %w", err)
+ }
+ absFilePath, err := filepath.Abs(filePath)
+ if err != nil {
+ return fantasy.ToolResponse{}, fmt.Errorf("error resolving file path: %w", err)
+ }
+ relPath, relErr := filepath.Rel(absWorkingDir, absFilePath)
+ isOutsideWorkDir := relErr != nil || strings.HasPrefix(relPath, "..")
+
+ if isOutsideWorkDir {
+ granted, permReqErr := permissions.Request(ctx,
+ permission.CreatePermissionRequest{
+ SessionID: sessionID,
+ Path: absFilePath,
+ ToolCallID: call.ID,
+ ToolName: TouchToolName,
+ Action: "write",
+ Description: fmt.Sprintf("Create empty file outside working directory: %s", absFilePath),
+ Params: TouchPermissionsParams{
+ FilePath: absFilePath,
+ },
+ },
+ )
+ if permReqErr != nil {
+ return fantasy.ToolResponse{}, permReqErr
+ }
+ if !granted {
+ return NewPermissionDeniedResponse(), nil
+ }
+ }
+
+ fileInfo, err := os.Stat(absFilePath)
if err == nil {
if fileInfo.IsDir() {
- return fantasy.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
+ return fantasy.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", absFilePath)), nil
}
- return fantasy.NewTextErrorResponse(fmt.Sprintf("File already exists: %s", filePath)), nil
+ return fantasy.NewTextErrorResponse(fmt.Sprintf("File already exists: %s", absFilePath)), nil
} else if !os.IsNotExist(err) {
return fantasy.ToolResponse{}, fmt.Errorf("error checking file: %w", err)
}
- dir := filepath.Dir(filePath)
+ dir := filepath.Dir(absFilePath)
if err = os.MkdirAll(dir, 0o755); err != nil {
return fantasy.ToolResponse{}, fmt.Errorf("error creating directory: %w", err)
}
@@ -75,13 +109,13 @@ func NewTouchTool(
p, err := permissions.Request(ctx,
permission.CreatePermissionRequest{
SessionID: sessionID,
- Path: fsext.PathOrPrefix(filePath, workingDir),
+ Path: fsext.PathOrPrefix(absFilePath, absWorkingDir),
ToolCallID: call.ID,
ToolName: TouchToolName,
Action: "write",
- Description: fmt.Sprintf("Create empty file %s", filePath),
+ Description: fmt.Sprintf("Create empty file %s", absFilePath),
Params: TouchPermissionsParams{
- FilePath: filePath,
+ FilePath: absFilePath,
OldContent: "",
NewContent: "",
},
@@ -94,10 +128,10 @@ func NewTouchTool(
return NewPermissionDeniedResponse(), nil
}
- file, err := os.OpenFile(filePath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o644)
+ file, err := os.OpenFile(absFilePath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o644)
if err != nil {
if os.IsExist(err) {
- return fantasy.NewTextErrorResponse(fmt.Sprintf("File already exists: %s", filePath)), nil
+ return fantasy.NewTextErrorResponse(fmt.Sprintf("File already exists: %s", absFilePath)), nil
}
return fantasy.ToolResponse{}, fmt.Errorf("error creating file: %w", err)
}
@@ -105,21 +139,21 @@ func NewTouchTool(
return fantasy.ToolResponse{}, fmt.Errorf("error closing file: %w", err)
}
- _, err = files.Create(ctx, sessionID, filePath, "")
+ _, err = files.Create(ctx, sessionID, absFilePath, "")
if err != nil {
return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
}
- filetracker.RecordRead(ctx, sessionID, filePath)
+ filetracker.RecordRead(ctx, sessionID, absFilePath)
- notifyLSPs(ctx, lspManager, filePath)
+ notifyLSPs(ctx, lspManager, absFilePath)
- result := fmt.Sprintf("Empty file successfully created: %s", filePath)
+ result := fmt.Sprintf("Empty file successfully created: %s", absFilePath)
result = fmt.Sprintf("<result>\n%s\n</result>", result)
- result += getDiagnostics(filePath, lspManager)
+ result += getDiagnostics(absFilePath, lspManager)
return fantasy.WithResponseMetadata(fantasy.NewTextResponse(result),
TouchResponseMetadata{
- FilePath: filePath,
+ FilePath: absFilePath,
},
), nil
})
@@ -5,13 +5,39 @@ import (
"encoding/json"
"os"
"path/filepath"
+ "strings"
"testing"
"time"
"charm.land/fantasy"
+ "github.com/charmbracelet/crush/internal/permission"
+ "github.com/charmbracelet/crush/internal/pubsub"
"github.com/stretchr/testify/require"
)
+// recordingPermissionService captures permission requests and answers them
+// according to a configurable response.
+type recordingPermissionService struct {
+ *pubsub.Broker[permission.PermissionRequest]
+ requests []permission.CreatePermissionRequest
+ grant bool
+}
+
+func (m *recordingPermissionService) Request(ctx context.Context, req permission.CreatePermissionRequest) (bool, error) {
+ m.requests = append(m.requests, req)
+ return m.grant, nil
+}
+
+func (m *recordingPermissionService) Grant(req permission.PermissionRequest) {}
+func (m *recordingPermissionService) Deny(req permission.PermissionRequest) {}
+func (m *recordingPermissionService) GrantPersistent(req permission.PermissionRequest) {}
+func (m *recordingPermissionService) AutoApproveSession(sessionID string) {}
+func (m *recordingPermissionService) SetSkipRequests(skip bool) {}
+func (m *recordingPermissionService) SkipRequests() bool { return false }
+func (m *recordingPermissionService) SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[permission.PermissionNotification] {
+ return make(<-chan pubsub.Event[permission.PermissionNotification])
+}
+
type mockFileTrackerService struct{}
func (m mockFileTrackerService) RecordRead(ctx context.Context, sessionID, path string) {}
@@ -60,6 +86,60 @@ func TestTouchToolRefusesExistingFile(t *testing.T) {
require.Equal(t, "content", string(content))
}
+func TestTouchToolStaysInsideWorkingDir(t *testing.T) {
+ t.Parallel()
+
+ workingDir := t.TempDir()
+ perms := &recordingPermissionService{grant: true}
+ tool := NewTouchTool(nil, perms, &mockHistoryService{}, mockFileTrackerService{}, workingDir)
+ ctx := context.WithValue(context.Background(), SessionIDContextKey, "test-session")
+
+ resp := runTouchTool(t, tool, ctx, TouchParams{FilePath: "inside.txt"})
+ require.False(t, resp.IsError)
+
+ for _, req := range perms.requests {
+ require.NotContains(t, req.Description, "outside working directory",
+ "inside-workingDir touch should not trigger an outside-workingDir permission prompt")
+ }
+
+ _, err := os.Stat(filepath.Join(workingDir, "inside.txt"))
+ require.NoError(t, err)
+}
+
+func TestTouchToolOutsideWorkingDirRequiresPermission(t *testing.T) {
+ t.Parallel()
+
+ parent := t.TempDir()
+ workingDir := filepath.Join(parent, "wd")
+ require.NoError(t, os.MkdirAll(workingDir, 0o755))
+
+ // Denied: file outside workingDir must not be created.
+ deny := &recordingPermissionService{grant: false}
+ tool := NewTouchTool(nil, deny, &mockHistoryService{}, mockFileTrackerService{}, workingDir)
+ ctx := context.WithValue(context.Background(), SessionIDContextKey, "test-session")
+
+ resp := runTouchTool(t, tool, ctx, TouchParams{FilePath: "../escape.txt"})
+ require.True(t, resp.IsError)
+
+ require.Len(t, deny.requests, 1)
+ require.True(t, strings.Contains(deny.requests[0].Description, "outside working directory"),
+ "expected outside-working-directory permission prompt, got %q", deny.requests[0].Description)
+
+ _, err := os.Stat(filepath.Join(parent, "escape.txt"))
+ require.True(t, os.IsNotExist(err), "denied permission should not create the file")
+
+ // Granted: same path now succeeds.
+ grant := &recordingPermissionService{grant: true}
+ tool = NewTouchTool(nil, grant, &mockHistoryService{}, mockFileTrackerService{}, workingDir)
+ resp = runTouchTool(t, tool, ctx, TouchParams{FilePath: "../escape.txt"})
+ require.False(t, resp.IsError)
+ require.GreaterOrEqual(t, len(grant.requests), 1)
+ require.Contains(t, grant.requests[0].Description, "outside working directory")
+
+ _, err = os.Stat(filepath.Join(parent, "escape.txt"))
+ require.NoError(t, err)
+}
+
func TestWriteToolEmptyContentPointsToTouch(t *testing.T) {
t.Parallel()