Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 42 additions & 5 deletions pkg/acp/filesystem.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,28 @@ func (t *FilesystemToolset) Tools(ctx context.Context) ([]tools.Tool, error) {
return baseTools, nil
}

// resolvePath resolves a user-supplied path relative to the working directory
// and validates that the resulting path does not escape the working directory.
func (t *FilesystemToolset) resolvePath(userPath string) (string, error) {
resolved := filepath.Clean(filepath.Join(t.workingDir, userPath))
absWorkingDir, err := filepath.Abs(t.workingDir)
if err != nil {
return "", fmt.Errorf("failed to resolve working directory: %w", err)
}
absResolved, err := filepath.Abs(resolved)
if err != nil {
return "", fmt.Errorf("failed to resolve path: %w", err)
}
// Normalize paths for comparison to prevent bypasses on case-insensitive
// filesystems (macOS, Windows) where differing case could defeat the check.
normResolved := normalizePathForComparison(absResolved)
normWorkingDir := normalizePathForComparison(absWorkingDir)
if !strings.HasPrefix(normResolved, normWorkingDir+string(filepath.Separator)) && normResolved != normWorkingDir {
return "", fmt.Errorf("path %q escapes the working directory", userPath)
}
return absResolved, nil
}

func (t *FilesystemToolset) handleReadFile(ctx context.Context, toolCall tools.ToolCall) (*tools.ToolCallResult, error) {
var args builtin.ReadFileArgs
if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil {
Expand All @@ -79,9 +101,14 @@ func (t *FilesystemToolset) handleReadFile(ctx context.Context, toolCall tools.T
return tools.ResultError("Error: session ID not found in context"), nil
}

resolvedPath, err := t.resolvePath(args.Path)
if err != nil {
return tools.ResultError(fmt.Sprintf("Error: %s", err)), nil
}

resp, err := t.agent.conn.ReadTextFile(ctx, acp.ReadTextFileRequest{
SessionId: acp.SessionId(sessionID),
Path: filepath.Join(t.workingDir, args.Path),
Path: resolvedPath,
})
if err != nil {
return tools.ResultError(fmt.Sprintf("Error reading file: %s", err)), nil
Expand All @@ -101,9 +128,14 @@ func (t *FilesystemToolset) handleWriteFile(ctx context.Context, toolCall tools.
return tools.ResultError("Error: session ID not found in context"), nil
}

_, err := t.agent.conn.WriteTextFile(ctx, acp.WriteTextFileRequest{
resolvedPath, err := t.resolvePath(args.Path)
if err != nil {
return tools.ResultError(fmt.Sprintf("Error: %s", err)), nil
}

_, err = t.agent.conn.WriteTextFile(ctx, acp.WriteTextFileRequest{
SessionId: acp.SessionId(sessionID),
Path: filepath.Join(t.workingDir, args.Path),
Path: resolvedPath,
Content: args.Content,
})
if err != nil {
Expand All @@ -124,9 +156,14 @@ func (t *FilesystemToolset) handleEditFile(ctx context.Context, toolCall tools.T
return tools.ResultError("Error: session ID not found in context"), nil
}

resolvedPath, err := t.resolvePath(args.Path)
if err != nil {
return tools.ResultError(fmt.Sprintf("Error: %s", err)), nil
}

resp, err := t.agent.conn.ReadTextFile(ctx, acp.ReadTextFileRequest{
SessionId: acp.SessionId(sessionID),
Path: filepath.Join(t.workingDir, args.Path),
Path: resolvedPath,
})
if err != nil {
return tools.ResultError(fmt.Sprintf("Error reading file: %s", err)), nil
Expand All @@ -143,7 +180,7 @@ func (t *FilesystemToolset) handleEditFile(ctx context.Context, toolCall tools.T

_, err = t.agent.conn.WriteTextFile(ctx, acp.WriteTextFileRequest{
SessionId: acp.SessionId(sessionID),
Path: filepath.Join(t.workingDir, args.Path),
Path: resolvedPath,
Content: modifiedContent,
})
if err != nil {
Expand Down
87 changes: 87 additions & 0 deletions pkg/acp/filesystem_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package acp

import (
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestResolvePath(t *testing.T) {
t.Parallel()

workingDir := t.TempDir()

ts := &FilesystemToolset{
workingDir: workingDir,
}

absWorkingDir, err := filepath.Abs(workingDir)
require.NoError(t, err)

tests := []struct {
name string
userPath string
wantPath string
wantError bool
}{
{
name: "simple relative path",
userPath: "file.txt",
wantPath: filepath.Join(absWorkingDir, "file.txt"),
},
{
name: "nested relative path",
userPath: "subdir/file.txt",
wantPath: filepath.Join(absWorkingDir, "subdir", "file.txt"),
},
{
name: "dot path resolves to working directory",
userPath: ".",
wantPath: absWorkingDir,
},
{
name: "parent directory escape blocked",
userPath: "../escape.txt",
wantError: true,
},
{
name: "deep parent directory escape blocked",
userPath: "subdir/../../escape.txt",
wantError: true,
},
{
name: "dot-dot within working dir is fine",
userPath: "subdir/../file.txt",
wantPath: filepath.Join(absWorkingDir, "file.txt"),
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

resolved, err := ts.resolvePath(tt.userPath)
if tt.wantError {
require.Error(t, err)
assert.Contains(t, err.Error(), "escapes the working directory")
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantPath, resolved)
})
}
}

func TestNormalizePathForComparison(t *testing.T) {
t.Parallel()

// On macOS/Windows (case-insensitive), normalization should lowercase.
// On Linux (case-sensitive), it should be identity.
result := normalizePathForComparison("/Some/Path")

// This test validates the function exists and returns a string.
// The exact behavior depends on the platform.
assert.NotEmpty(t, result)
}
12 changes: 12 additions & 0 deletions pkg/acp/pathcase_caseinsensitive.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//go:build windows || darwin

package acp

import "strings"

// normalizePathForComparison lowercases the path on case-insensitive filesystems
// (macOS and Windows) to ensure path traversal checks cannot be bypassed by
// varying the case of path components.
func normalizePathForComparison(path string) string {
return strings.ToLower(path)
}
8 changes: 8 additions & 0 deletions pkg/acp/pathcase_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
//go:build !windows && !darwin

package acp

// normalizePathForComparison returns the path unchanged on case-sensitive filesystems (Linux).
func normalizePathForComparison(path string) string {
return path
}
3 changes: 2 additions & 1 deletion pkg/tui/page/chat/input_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package chat
import (
"context"
"errors"
"log/slog"

"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
Expand Down Expand Up @@ -81,7 +82,7 @@ func (p *chatPage) persistSessionTitle(newTitle string) tea.Cmd {
if errors.Is(err, app.ErrTitleGenerating) {
return notification.ShowMsg{Text: "Title is being generated, please wait", Type: notification.TypeWarning}
}
// Log other errors but don't show them
slog.Warn("Failed to persist session title", "title", newTitle, "error", err)
return nil
}
return nil
Expand Down