Merge commit 'refs/pull/464/head' of https://github.com/sipeed/picoclaw

This commit is contained in:
dj-oyu 2026-02-20 13:45:58 +09:00
commit c73a229a1e
4 changed files with 655 additions and 145 deletions

View file

@ -2,7 +2,10 @@ package tools
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io"
"io/fs"
"os" "os"
"strings" "strings"
) )
@ -67,37 +70,18 @@ func (t *EditFileTool) Execute(ctx context.Context, args map[string]interface{})
return ErrorResult("new_text is required") return ErrorResult("new_text is required")
} }
resolvedPath, err := validatePath(path, t.allowedDir, t.restrict) if t.restrict {
if err != nil { return executeInRoot(t.allowedDir, path, func(root *os.Root, relPath string) (*ToolResult, error) {
if err := editFileInRoot(root, relPath, oldText, newText); err != nil {
return nil, err
}
return SilentResult(fmt.Sprintf("File edited: %s", path)), nil
})
}
if err := editFile(&hostRW{}, path, oldText, newText); err != nil {
return ErrorResult(err.Error()) return ErrorResult(err.Error())
} }
if _, err := os.Stat(resolvedPath); os.IsNotExist(err) {
return ErrorResult(fmt.Sprintf("file not found: %s", path))
}
content, err := os.ReadFile(resolvedPath)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to read file: %v", err))
}
contentStr := string(content)
if !strings.Contains(contentStr, oldText) {
return ErrorResult("old_text not found in file. Make sure it matches exactly")
}
count := strings.Count(contentStr, oldText)
if count > 1 {
return ErrorResult(fmt.Sprintf("old_text appears %d times. Please provide more context to make it unique", count))
}
newContent := strings.Replace(contentStr, oldText, newText, 1)
if err := os.WriteFile(resolvedPath, []byte(newContent), 0644); err != nil {
return ErrorResult(fmt.Sprintf("failed to write file: %v", err))
}
return SilentResult(fmt.Sprintf("File edited: %s", path)) return SilentResult(fmt.Sprintf("File edited: %s", path))
} }
@ -141,25 +125,108 @@ func (t *AppendFileTool) Execute(ctx context.Context, args map[string]interface{
return ErrorResult("path is required") return ErrorResult("path is required")
} }
content, ok := args["content"].(string) appendContent, ok := args["content"].(string)
if !ok { if !ok {
return ErrorResult("content is required") return ErrorResult("content is required")
} }
resolvedPath, err := validatePath(path, t.workspace, t.restrict) var rw fileReadWriter
if err != nil { if t.restrict {
return ErrorResult(err.Error()) return executeInRoot(t.workspace, path, func(root *os.Root, relPath string) (*ToolResult, error) {
if err := appendFileWithRW(&rootRW{root: root}, relPath, appendContent); err != nil {
return nil, err
}
return SilentResult(fmt.Sprintf("Appended to %s", path)), nil
})
} }
f, err := os.OpenFile(resolvedPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) rw = &hostRW{}
if err := appendFileWithRW(rw, path, appendContent); err != nil {
return ErrorResult(err.Error())
}
return SilentResult(fmt.Sprintf("Appended to %s", path))
}
// editFile reads the file via rw, performs the replacement, and writes back.
// It uses a fileReadWriter, allowing the same logic for both restricted and unrestricted modes.
func editFile(rw fileReadWriter, path, oldText, newText string) error {
content, err := rw.Read(path)
if err != nil { if err != nil {
return ErrorResult(fmt.Sprintf("failed to open file: %v", err)) return err
}
newContent, err := replaceEditContent(content, oldText, newText)
if err != nil {
return err
}
return rw.Write(path, newContent)
}
// editFileInRoot performs an in-place edit within an os.Root using a single open call.
// By opening with O_RDWR and reusing the same file descriptor for both read and write,
// we narrow the TOCTOU window compared to two separate open calls.
func editFileInRoot(root *os.Root, relPath, oldText, newText string) error {
f, err := root.OpenFile(relPath, os.O_RDWR, 0)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("failed to read file: file not found: %w", err)
}
if os.IsPermission(err) || strings.Contains(err.Error(), "escapes from parent") {
return fmt.Errorf("failed to read file: access denied: %w", err)
}
return fmt.Errorf("failed to open file for editing: %w", err)
} }
defer f.Close() defer f.Close()
if _, err := f.WriteString(content); err != nil { content, err := io.ReadAll(f)
return ErrorResult(fmt.Sprintf("failed to append to file: %v", err)) if err != nil {
return fmt.Errorf("failed to read file content: %w", err)
} }
return SilentResult(fmt.Sprintf("Appended to %s", path)) newContent, err := replaceEditContent(content, oldText, newText)
if err != nil {
return err
}
// Truncate the file and seek back to the beginning before writing.
if err := f.Truncate(0); err != nil {
return fmt.Errorf("failed to truncate file for in-place edit: %w", err)
}
if _, err := f.Seek(0, io.SeekStart); err != nil {
return fmt.Errorf("failed to seek to beginning of file: %w", err)
}
if _, err := f.Write(newContent); err != nil {
return fmt.Errorf("failed to write edited content: %w", err)
}
return nil
}
// appendFileWithRW reads the existing content (if any) via rw, appends new content, and writes back.
func appendFileWithRW(rw fileReadWriter, path, appendContent string) error {
content, err := rw.Read(path)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
newContent := append(content, []byte(appendContent)...)
return rw.Write(path, newContent)
}
// replaceEditContent handles the core logic of finding and replacing a single occurrence of oldText.
func replaceEditContent(content []byte, oldText, newText string) ([]byte, error) {
contentStr := string(content)
if !strings.Contains(contentStr, oldText) {
return nil, fmt.Errorf("old_text not found in file. Make sure it matches exactly")
}
count := strings.Count(contentStr, oldText)
if count > 1 {
return nil, fmt.Errorf("old_text appears %d times. Please provide more context to make it unique", count)
}
newContent := strings.Replace(contentStr, oldText, newText, 1)
return []byte(newContent), nil
} }

View file

@ -6,6 +6,8 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"github.com/stretchr/testify/assert"
) )
// TestEditTool_EditFile_Success verifies successful file editing // TestEditTool_EditFile_Success verifies successful file editing
@ -151,14 +153,13 @@ func TestEditTool_EditFile_OutsideAllowedDir(t *testing.T) {
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
// Should return error result // Should return error result
if !result.IsError { assert.True(t, result.IsError, "Expected error when path is outside allowed directory")
t.Errorf("Expected error when path is outside allowed directory")
}
// Should mention outside allowed directory // Should mention outside allowed directory
if !strings.Contains(result.ForLLM, "outside") && !strings.Contains(result.ForUser, "outside") { // Note: ErrorResult only sets ForLLM by default, so ForUser might be empty.
t.Errorf("Expected 'outside allowed' message, got ForLLM: %s", result.ForLLM) // We check ForLLM as it's the primary error channel.
} assert.True(t, strings.Contains(result.ForLLM, "outside") || strings.Contains(result.ForLLM, "access denied") || strings.Contains(result.ForLLM, "escapes"),
"Expected 'outside allowed' or 'access denied' message, got ForLLM: %s", result.ForLLM)
} }
// TestEditTool_EditFile_MissingPath verifies error handling for missing path // TestEditTool_EditFile_MissingPath verifies error handling for missing path
@ -287,3 +288,140 @@ func TestEditTool_AppendFile_MissingContent(t *testing.T) {
t.Errorf("Expected error when content is missing") t.Errorf("Expected error when content is missing")
} }
} }
// TestReplaceEditContent verifies the helper function replaceEditContent
func TestReplaceEditContent(t *testing.T) {
tests := []struct {
name string
content []byte
oldText string
newText string
expected []byte
expectError bool
}{
{
name: "successful replacement",
content: []byte("hello world"),
oldText: "world",
newText: "universe",
expected: []byte("hello universe"),
expectError: false,
},
{
name: "old text not found",
content: []byte("hello world"),
oldText: "golang",
newText: "rust",
expected: nil,
expectError: true,
},
{
name: "multiple matches found",
content: []byte("test text test"),
oldText: "test",
newText: "done",
expected: nil,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := replaceEditContent(tt.content, tt.oldText, tt.newText)
if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expected, result)
}
})
}
}
// TestAppendFileTool_AppendToNonExistent_Restricted verifies that AppendFileTool in restricted mode
// can append to a file that does not yet exist — it should silently create the file.
// This exercises the errors.Is(err, fs.ErrNotExist) path in appendFileWithRW + rootRW.
func TestAppendFileTool_AppendToNonExistent_Restricted(t *testing.T) {
workspace := t.TempDir()
tool := NewAppendFileTool(workspace, true)
ctx := context.Background()
args := map[string]interface{}{
"path": "brand_new_file.txt",
"content": "first content",
}
result := tool.Execute(ctx, args)
assert.False(t, result.IsError, "Expected success when appending to non-existent file in restricted mode, got: %s", result.ForLLM)
// Verify the file was created with correct content
data, err := os.ReadFile(filepath.Join(workspace, "brand_new_file.txt"))
assert.NoError(t, err)
assert.Equal(t, "first content", string(data))
}
// TestAppendFileTool_Restricted_Success verifies that AppendFileTool in restricted mode
// correctly appends to an existing file within the sandbox.
func TestAppendFileTool_Restricted_Success(t *testing.T) {
workspace := t.TempDir()
testFile := "existing.txt"
err := os.WriteFile(filepath.Join(workspace, testFile), []byte("initial"), 0644)
assert.NoError(t, err)
tool := NewAppendFileTool(workspace, true)
ctx := context.Background()
args := map[string]interface{}{
"path": testFile,
"content": " appended",
}
result := tool.Execute(ctx, args)
assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM)
assert.True(t, result.Silent)
data, err := os.ReadFile(filepath.Join(workspace, testFile))
assert.NoError(t, err)
assert.Equal(t, "initial appended", string(data))
}
// TestEditFileTool_Restricted_InPlaceEdit verifies that EditFileTool in restricted mode
// correctly edits a file using the single-open editFileInRoot path.
func TestEditFileTool_Restricted_InPlaceEdit(t *testing.T) {
workspace := t.TempDir()
testFile := "edit_target.txt"
err := os.WriteFile(filepath.Join(workspace, testFile), []byte("Hello World"), 0644)
assert.NoError(t, err)
tool := NewEditFileTool(workspace, true)
ctx := context.Background()
args := map[string]interface{}{
"path": testFile,
"old_text": "World",
"new_text": "Go",
}
result := tool.Execute(ctx, args)
assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM)
assert.True(t, result.Silent)
data, err := os.ReadFile(filepath.Join(workspace, testFile))
assert.NoError(t, err)
assert.Equal(t, "Hello Go", string(data))
}
// TestEditFileTool_Restricted_FileNotFound verifies that editFileInRoot returns a proper
// error message when the target file does not exist.
func TestEditFileTool_Restricted_FileNotFound(t *testing.T) {
workspace := t.TempDir()
tool := NewEditFileTool(workspace, true)
ctx := context.Background()
args := map[string]interface{}{
"path": "no_such_file.txt",
"old_text": "old",
"new_text": "new",
}
result := tool.Execute(ctx, args)
assert.True(t, result.IsError)
assert.Contains(t, result.ForLLM, "not found")
}

View file

@ -6,77 +6,9 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
) )
// validatePath ensures the given path is within the workspace if restrict is true.
func validatePath(path, workspace string, restrict bool) (string, error) {
if workspace == "" {
return path, nil
}
absWorkspace, err := filepath.Abs(workspace)
if err != nil {
return "", fmt.Errorf("failed to resolve workspace path: %w", err)
}
var absPath string
if filepath.IsAbs(path) {
absPath = filepath.Clean(path)
} else {
absPath, err = filepath.Abs(filepath.Join(absWorkspace, path))
if err != nil {
return "", fmt.Errorf("failed to resolve file path: %w", err)
}
}
if restrict {
if !isWithinWorkspace(absPath, absWorkspace) {
return "", fmt.Errorf("access denied: path is outside the workspace")
}
workspaceReal := absWorkspace
if resolved, err := filepath.EvalSymlinks(absWorkspace); err == nil {
workspaceReal = resolved
}
if resolved, err := filepath.EvalSymlinks(absPath); err == nil {
if !isWithinWorkspace(resolved, workspaceReal) {
return "", fmt.Errorf("access denied: symlink resolves outside workspace")
}
} else if os.IsNotExist(err) {
if parentResolved, err := resolveExistingAncestor(filepath.Dir(absPath)); err == nil {
if !isWithinWorkspace(parentResolved, workspaceReal) {
return "", fmt.Errorf("access denied: symlink resolves outside workspace")
}
} else if !os.IsNotExist(err) {
return "", fmt.Errorf("failed to resolve path: %w", err)
}
} else {
return "", fmt.Errorf("failed to resolve path: %w", err)
}
}
return absPath, nil
}
func resolveExistingAncestor(path string) (string, error) {
for current := filepath.Clean(path); ; current = filepath.Dir(current) {
if resolved, err := filepath.EvalSymlinks(current); err == nil {
return resolved, nil
} else if !os.IsNotExist(err) {
return "", err
}
if filepath.Dir(current) == current {
return "", os.ErrNotExist
}
}
}
func isWithinWorkspace(candidate, workspace string) bool {
rel, err := filepath.Rel(filepath.Clean(workspace), filepath.Clean(candidate))
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
}
type ReadFileTool struct { type ReadFileTool struct {
workspace string workspace string
restrict bool restrict bool
@ -113,16 +45,20 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]interface{})
return ErrorResult("path is required") return ErrorResult("path is required")
} }
resolvedPath, err := validatePath(path, t.workspace, t.restrict) if t.restrict {
return executeInRoot(t.workspace, path, func(root *os.Root, relPath string) (*ToolResult, error) {
content, err := (&rootRW{root: root}).Read(relPath)
if err != nil {
return nil, err
}
return NewToolResult(string(content)), nil
})
}
content, err := (&hostRW{}).Read(path)
if err != nil { if err != nil {
return ErrorResult(err.Error()) return ErrorResult(err.Error())
} }
content, err := os.ReadFile(resolvedPath)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to read file: %v", err))
}
return NewToolResult(string(content)) return NewToolResult(string(content))
} }
@ -171,20 +107,19 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]interface{}
return ErrorResult("content is required") return ErrorResult("content is required")
} }
resolvedPath, err := validatePath(path, t.workspace, t.restrict) if t.restrict {
if err != nil { return executeInRoot(t.workspace, path, func(root *os.Root, relPath string) (*ToolResult, error) {
if err := (&rootRW{root: root}).Write(relPath, []byte(content)); err != nil {
return nil, err
}
return SilentResult(fmt.Sprintf("File written: %s", path)), nil
})
}
if err := (&hostRW{}).Write(path, []byte(content)); err != nil {
return ErrorResult(err.Error()) return ErrorResult(err.Error())
} }
dir := filepath.Dir(resolvedPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return ErrorResult(fmt.Sprintf("failed to create directory: %v", err))
}
if err := os.WriteFile(resolvedPath, []byte(content), 0644); err != nil {
return ErrorResult(fmt.Sprintf("failed to write file: %v", err))
}
return SilentResult(fmt.Sprintf("File written: %s", path)) return SilentResult(fmt.Sprintf("File written: %s", path))
} }
@ -224,24 +159,188 @@ func (t *ListDirTool) Execute(ctx context.Context, args map[string]interface{})
path = "." path = "."
} }
resolvedPath, err := validatePath(path, t.workspace, t.restrict) if !t.restrict {
entries, err := os.ReadDir(path)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to read directory: %v", err))
}
return formatDirEntries(entries)
}
return executeInRoot(t.workspace, path, func(root *os.Root, relPath string) (*ToolResult, error) {
f, err := root.Open(relPath)
if err != nil {
return nil, fmt.Errorf("failed to open directory: %w", err)
}
defer f.Close()
entries, err := f.ReadDir(-1)
if err != nil {
return nil, fmt.Errorf("failed to read directory: %w", err)
}
return formatDirEntries(entries), nil
})
}
func formatDirEntries(entries []os.DirEntry) *ToolResult {
var result strings.Builder
for _, entry := range entries {
if entry.IsDir() {
result.WriteString("DIR: " + entry.Name() + "\n")
} else {
result.WriteString("FILE: " + entry.Name() + "\n")
}
}
return NewToolResult(result.String())
}
// fileReadWriter abstracts reading and writing files, allowing both unrestricted
// (host filesystem) and sandbox (os.Root) implementations to share the same logic.
type fileReadWriter interface {
Read(path string) ([]byte, error)
Write(path string, data []byte) error
}
// hostRW is an unrestricted fileReadWriter that operates directly on the host filesystem.
type hostRW struct{}
func (h *hostRW) Read(path string) ([]byte, error) {
content, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("failed to read file: file not found: %w", err)
}
if os.IsPermission(err) {
return nil, fmt.Errorf("failed to read file: access denied: %w", err)
}
return nil, fmt.Errorf("failed to read file: %w", err)
}
return content, nil
}
func (h *hostRW) Write(path string, data []byte) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create parent directories: %w", err)
}
tmpPath := fmt.Sprintf("%s.%d.tmp", path, time.Now().UnixNano())
if err := os.WriteFile(tmpPath, data, 0644); err != nil {
return fmt.Errorf("failed to write temp file: %w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("failed to replace original file: %w", err)
}
return nil
}
// rootRW is a sandboxed fileReadWriter that operates within an os.Root boundary.
// All paths passed to Read/Write must be relative to the root.
type rootRW struct {
root *os.Root
}
func (r *rootRW) Read(path string) ([]byte, error) {
content, err := r.root.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("failed to read file: file not found: %w", err)
}
// os.Root returns "escapes from parent" for paths outside the root
if os.IsPermission(err) || strings.Contains(err.Error(), "escapes from parent") || strings.Contains(err.Error(), "permission denied") {
return nil, fmt.Errorf("failed to read file: access denied: %w", err)
}
return nil, fmt.Errorf("failed to read file: %w", err)
}
return content, nil
}
func (r *rootRW) Write(path string, data []byte) error {
dir := filepath.Dir(path)
if dir != "." && dir != "/" {
// Use native root.MkdirAll which handles the "file exists at path" check internally.
if err := r.root.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create parent directories: %w", err)
}
}
tmpRelPath := fmt.Sprintf("%s.%d.tmp", path, time.Now().UnixNano())
fw, err := r.root.Create(tmpRelPath)
if err != nil {
return fmt.Errorf("failed to create temp file for writing: %w", err)
}
if _, err := fw.Write(data); err != nil {
fw.Close()
r.root.Remove(tmpRelPath)
return fmt.Errorf("failed to write to temp file: %w", err)
}
if err := fw.Close(); err != nil {
r.root.Remove(tmpRelPath)
return fmt.Errorf("failed to close temp file: %w", err)
}
if err := r.root.Rename(tmpRelPath, path); err != nil {
r.root.Remove(tmpRelPath)
return fmt.Errorf("failed to rename temp file over target: %w", err)
}
return nil
}
// Helper to get a safe relative path for os.Root usage
func getSafeRelPath(workspace, path string) (string, error) {
if workspace == "" {
return "", fmt.Errorf("workspace is empty and not defined")
}
path = filepath.Clean(path)
// If absolute, make it relative to workspace
// os.Root only accepts relative paths
if filepath.IsAbs(path) {
rel, err := filepath.Rel(workspace, path)
if err != nil {
return "", fmt.Errorf("failed to calculate relative path: %w", err)
}
path = rel
}
// Check for escape manually (defense-in-depth, as os.Root also rejects paths that escape the root)
if path == ".." || strings.HasPrefix(path, "../") {
return "", fmt.Errorf("path escapes workspace: %s", path)
}
return path, nil
}
// executeInRoot executes a function within the safety of os.Root
func executeInRoot(workspace string, path string, fn func(root *os.Root, relPath string) (*ToolResult, error)) *ToolResult {
if workspace == "" {
return ErrorResult("workspace is not defined")
}
// 1. Open the Root
root, err := os.OpenRoot(workspace)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to open workspace root: %v", err))
}
defer root.Close()
// 2. Calculate relative path
relPath, err := getSafeRelPath(workspace, path)
if err != nil { if err != nil {
return ErrorResult(err.Error()) return ErrorResult(err.Error())
} }
entries, err := os.ReadDir(resolvedPath) // 3. Execute the operation
result, err := fn(root, relPath)
if err != nil { if err != nil {
return ErrorResult(fmt.Sprintf("failed to read directory: %v", err)) return ErrorResult(err.Error())
} }
result := "" return result
for _, entry := range entries {
if entry.IsDir() {
result += "DIR: " + entry.Name() + "\n"
} else {
result += "FILE: " + entry.Name() + "\n"
}
}
return NewToolResult(result)
} }

View file

@ -2,10 +2,13 @@ package tools
import ( import (
"context" "context"
"io"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"github.com/stretchr/testify/assert"
) )
// TestFilesystemTool_ReadFile_Success verifies successful file reading // TestFilesystemTool_ReadFile_Success verifies successful file reading
@ -275,7 +278,210 @@ func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) {
if !result.IsError { if !result.IsError {
t.Fatalf("expected symlink escape to be blocked") t.Fatalf("expected symlink escape to be blocked")
} }
if !strings.Contains(result.ForLLM, "symlink resolves outside workspace") { // os.Root might return different errors depending on platform/implementation
// but it definitely should error.
// Our wrapper returns "access denied or file not found"
if !strings.Contains(result.ForLLM, "access denied") && !strings.Contains(result.ForLLM, "file not found") && !strings.Contains(result.ForLLM, "no such file") {
t.Fatalf("expected symlink escape error, got: %s", result.ForLLM) t.Fatalf("expected symlink escape error, got: %s", result.ForLLM)
} }
} }
func TestFilesystemTool_EmptyWorkspace_AccessDenied(t *testing.T) {
tool := NewReadFileTool("", true) // restrict=true but workspace=""
// Try to read a sensitive file (simulated by a temp file outside workspace)
tmpDir := t.TempDir()
secretFile := filepath.Join(tmpDir, "shadow")
os.WriteFile(secretFile, []byte("secret data"), 0600)
result := tool.Execute(context.Background(), map[string]any{
"path": secretFile,
})
// We EXPECT IsError=true (access blocked due to empty workspace)
assert.True(t, result.IsError, "Security Regression: Empty workspace allowed access! content: %s", result.ForLLM)
// Verify it failed for the right reason
assert.Contains(t, result.ForLLM, "workspace is not defined", "Expected 'workspace is not defined' error")
}
// TestRootMkdirAll verifies that root.MkdirAll (used by atomicWriteFileInRoot) handles all cases:
// single dir, deeply nested dirs, already-existing dirs, and a file blocking a directory path.
func TestRootMkdirAll(t *testing.T) {
workspace := t.TempDir()
root, err := os.OpenRoot(workspace)
if err != nil {
t.Fatalf("failed to open root: %v", err)
}
defer root.Close()
// Case 1: Single directory
err = root.MkdirAll("dir1", 0755)
assert.NoError(t, err)
_, err = os.Stat(filepath.Join(workspace, "dir1"))
assert.NoError(t, err)
// Case 2: Deeply nested directory
err = root.MkdirAll("a/b/c/d", 0755)
assert.NoError(t, err)
_, err = os.Stat(filepath.Join(workspace, "a/b/c/d"))
assert.NoError(t, err)
// Case 3: Already exists — must be idempotent
err = root.MkdirAll("a/b/c/d", 0755)
assert.NoError(t, err)
// Case 4: A regular file blocks directory creation — must error
err = os.WriteFile(filepath.Join(workspace, "file_exists"), []byte("data"), 0644)
assert.NoError(t, err)
err = root.MkdirAll("file_exists", 0755)
assert.Error(t, err, "expected error when a file exists at the directory path")
}
func TestFilesystemTool_WriteFile_Restricted_CreateDir(t *testing.T) {
workspace := t.TempDir()
tool := NewWriteFileTool(workspace, true)
ctx := context.Background()
testFile := "deep/nested/path/to/file.txt"
content := "deep content"
args := map[string]any{
"path": testFile,
"content": content,
}
result := tool.Execute(ctx, args)
assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM)
// Verify file content
actualPath := filepath.Join(workspace, testFile)
data, err := os.ReadFile(actualPath)
assert.NoError(t, err)
assert.Equal(t, content, string(data))
}
// TestHostRW_Read_PermissionDenied verifies that hostRW.Read surfaces access denied errors.
func TestHostRW_Read_PermissionDenied(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("skipping permission test: running as root")
}
tmpDir := t.TempDir()
protected := filepath.Join(tmpDir, "protected.txt")
err := os.WriteFile(protected, []byte("secret"), 0000)
assert.NoError(t, err)
defer os.Chmod(protected, 0644) // ensure cleanup
_, err = (&hostRW{}).Read(protected)
assert.Error(t, err)
assert.Contains(t, err.Error(), "access denied")
}
// TestHostRW_Read_Directory verifies that hostRW.Read returns an error when given a directory path.
func TestHostRW_Read_Directory(t *testing.T) {
tmpDir := t.TempDir()
_, err := (&hostRW{}).Read(tmpDir)
assert.Error(t, err, "expected error when reading a directory as a file")
}
// TestRootRW_Read_Directory verifies that rootRW.Read returns an error when given a directory.
func TestRootRW_Read_Directory(t *testing.T) {
workspace := t.TempDir()
root, err := os.OpenRoot(workspace)
assert.NoError(t, err)
defer root.Close()
// Create a subdirectory
err = root.Mkdir("subdir", 0755)
assert.NoError(t, err)
_, err = (&rootRW{root: root}).Read("subdir")
assert.Error(t, err, "expected error when reading a directory as a file")
}
// TestHostRW_Write_ParentDirMissing verifies that hostRW.Write creates parent dirs automatically.
func TestHostRW_Write_ParentDirMissing(t *testing.T) {
tmpDir := t.TempDir()
target := filepath.Join(tmpDir, "a", "b", "c", "file.txt")
err := (&hostRW{}).Write(target, []byte("hello"))
assert.NoError(t, err)
data, err := os.ReadFile(target)
assert.NoError(t, err)
assert.Equal(t, "hello", string(data))
}
// TestRootRW_Write_ParentDirMissing verifies that rootRW.Write creates
// nested parent directories automatically within the sandbox.
func TestRootRW_Write_ParentDirMissing(t *testing.T) {
workspace := t.TempDir()
root, err := os.OpenRoot(workspace)
assert.NoError(t, err)
defer root.Close()
relPath := "x/y/z/file.txt"
err = (&rootRW{root: root}).Write(relPath, []byte("nested"))
assert.NoError(t, err)
data, err := os.ReadFile(filepath.Join(workspace, relPath))
assert.NoError(t, err)
assert.Equal(t, "nested", string(data))
}
// TestHostRW_Write verifies the hostRW.Write helper function
func TestHostRW_Write(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "atomic_test.txt")
testData := []byte("atomic test content")
err := (&hostRW{}).Write(testFile, testData)
assert.NoError(t, err)
content, err := os.ReadFile(testFile)
assert.NoError(t, err)
assert.Equal(t, testData, content)
// Verify it overwrites correctly
newData := []byte("new atomic content")
err = (&hostRW{}).Write(testFile, newData)
assert.NoError(t, err)
content, err = os.ReadFile(testFile)
assert.NoError(t, err)
assert.Equal(t, newData, content)
}
// TestRootRW_Write verifies the rootRW.Write helper function
func TestRootRW_Write(t *testing.T) {
tmpDir := t.TempDir()
root, err := os.OpenRoot(tmpDir)
assert.NoError(t, err)
defer root.Close()
relPath := "atomic_root_test.txt"
testData := []byte("atomic root test content")
erw := &rootRW{root: root}
err = erw.Write(relPath, testData)
assert.NoError(t, err)
f, err := root.Open(relPath)
assert.NoError(t, err)
content, err := io.ReadAll(f)
assert.NoError(t, err)
f.Close()
assert.Equal(t, testData, content)
// Verify it overwrites correctly
newData := []byte("new root atomic content")
err = erw.Write(relPath, newData)
assert.NoError(t, err)
f, err = root.Open(relPath)
assert.NoError(t, err)
content, err = io.ReadAll(f)
assert.NoError(t, err)
f.Close()
assert.Equal(t, newData, content)
}