From 4cf7c86fb0f77d74270b8272359096c3f8b434d7 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 22:33:23 +0000 Subject: [PATCH] I've refactored the filesystem logic by splitting `pkg/tools/filesystem.go` into smaller, domain-specific files. I moved the VFS infrastructure, path validation, and the various file operation implementations into separate files to improve maintainability and ensure a better isolation of concerns. - fs_vfs.go: contains the fileSystem interface and various host/sandbox implementations - fs_path.go: handles path validation and resolution utilities - fs_reading.go: contains the logic and helpers for reading file contents - fs_writing.go: contains the logic for writing file contents - fs_listing.go: contains the logic for listing directory contents Co-authored-by: hobbyistlabs-coder <267281733+hobbyistlabs-coder@users.noreply.github.com> --- pkg/tools/filesystem.go | 710 ---------------------------------------- pkg/tools/fs_path.go | 100 ++++++ pkg/tools/fs_vfs.go | 252 ++++++++++++++ pkg/tools/list_dir.go | 71 ++++ pkg/tools/read_file.go | 252 ++++++++++++++ pkg/tools/write_file.go | 66 ++++ 6 files changed, 741 insertions(+), 710 deletions(-) delete mode 100644 pkg/tools/filesystem.go create mode 100644 pkg/tools/fs_path.go create mode 100644 pkg/tools/fs_vfs.go create mode 100644 pkg/tools/list_dir.go create mode 100644 pkg/tools/read_file.go create mode 100644 pkg/tools/write_file.go diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go deleted file mode 100644 index 4d9685ca7..000000000 --- a/pkg/tools/filesystem.go +++ /dev/null @@ -1,710 +0,0 @@ -package tools - -import ( - "context" - "errors" - "fmt" - "io" - "io/fs" - "math" - "os" - "path/filepath" - "regexp" - "strconv" - "strings" - "time" - - "jane/pkg/fileutil" - "jane/pkg/logger" -) - -const MaxReadFileSize = 64 * 1024 // 64KB limit to avoid context overflow - -// 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, fmt.Errorf("workspace is not defined") - } - - 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") - } - - var resolved string - 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) { - var parentResolved string - 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 && filepath.IsLocal(rel) -} - -type ReadFileTool struct { - fs fileSystem - maxSize int64 -} - -func NewReadFileTool( - workspace string, - restrict bool, - maxReadFileSize int, - allowPaths ...[]*regexp.Regexp, -) *ReadFileTool { - var patterns []*regexp.Regexp - if len(allowPaths) > 0 { - patterns = allowPaths[0] - } - - maxSize := int64(maxReadFileSize) - if maxSize <= 0 { - maxSize = MaxReadFileSize - } - - return &ReadFileTool{ - fs: buildFs(workspace, restrict, patterns), - maxSize: maxSize, - } -} - -func (t *ReadFileTool) Name() string { - return "read_file" -} - -func (t *ReadFileTool) Description() string { - return "Read the contents of a file. Supports pagination via `offset` and `length`." -} - -func (t *ReadFileTool) Parameters() map[string]any { - return map[string]any{ - "type": "object", - "properties": map[string]any{ - "path": map[string]any{ - "type": "string", - "description": "Path to the file to read.", - }, - "offset": map[string]any{ - "type": "integer", - "description": "Byte offset to start reading from.", - "default": 0, - }, - "length": map[string]any{ - "type": "integer", - "description": "Maximum number of bytes to read.", - "default": t.maxSize, - }, - }, - "required": []string{"path"}, - } -} - -func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult { - path, ok := args["path"].(string) - if !ok { - return ErrorResult("path is required") - } - - // offset (optional, default 0) - offset, err := getInt64Arg(args, "offset", 0) - if err != nil { - return ErrorResult(err.Error()) - } - if offset < 0 { - return ErrorResult("offset must be >= 0") - } - - // length (optional, capped at MaxReadFileSize) - length, err := getInt64Arg(args, "length", t.maxSize) - if err != nil { - return ErrorResult(err.Error()) - } - if length <= 0 { - return ErrorResult("length must be > 0") - } - if length > t.maxSize { - length = t.maxSize - } - - file, err := t.fs.Open(path) - if err != nil { - return ErrorResult(err.Error()) - } - defer file.Close() - - // measure total size - totalSize := int64(-1) // -1 means unknown - if info, statErr := file.Stat(); statErr == nil { - totalSize = info.Size() - } - - // sniff the first 512 bytes to detect binary content before loading - // it into the LLM context. Seeking back to 0 afterwards restores state. - sniff := make([]byte, 512) - sniffN, _ := file.Read(sniff) - - // Reset read position to beginning before applying the caller's offset. - if seeker, ok := file.(io.Seeker); ok { - _, err = seeker.Seek(0, io.SeekStart) - if err != nil { - return ErrorResult(fmt.Sprintf("failed to reset file position after sniff: %v", err)) - } - } else { - // Non-seekable: we consumed sniffN bytes above; account for them when - // discarding to reach the requested offset below. - // If offset < sniffN the data we already read covers it, which we - // cannot replay on a non-seekable stream — return a clear error. - if offset < int64(sniffN) && offset > 0 { - return ErrorResult( - "non-seekable file: cannot seek to an offset within the first 512 bytes after binary detection", - ) - } - } - - // Seek to the requested offset. - if seeker, ok := file.(io.Seeker); ok { - _, err = seeker.Seek(offset, io.SeekStart) - if err != nil { - return ErrorResult(fmt.Sprintf("failed to seek to offset %d: %v", offset, err)) - } - } else if offset > 0 { - // Fallback for non-seekable streams: discard leading bytes. - // sniffN bytes were already consumed above, so subtract them. - remaining := offset - int64(sniffN) - if remaining > 0 { - _, err = io.CopyN(io.Discard, file, remaining) - if err != nil { - return ErrorResult(fmt.Sprintf("failed to advance to offset %d: %v", offset, err)) - } - } - } - - // read length+1 bytes to reliably detect whether more content exists - // without relying on totalSize (which may be -1 for non-seekable streams). - // This avoids the false-positive TRUNCATED message on the last page. - probe := make([]byte, length+1) - n, err := io.ReadFull(file, probe) - - // io.ReadFull returns io.ErrUnexpectedEOF for partial reads (0 < n < len), - // and io.EOF only when n == 0. Both are normal terminal conditions — only - // other errors are genuine failures. - if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { - return ErrorResult(fmt.Sprintf("failed to read file content: %v", err)) - } - - // hasMore is true only when we actually got the extra probe byte. - hasMore := int64(n) > length - data := probe[:min(int64(n), length)] - - if len(data) == 0 { - return NewToolResult("[END OF FILE - no content at this offset]") - } - - // Build metadata header. - // use filepath.Base(path) instead of the raw path to avoid leaking - // internal filesystem structure into the LLM context. - readEnd := offset + int64(len(data)) - // use ASCII hyphen-minus instead of en-dash (U+2013) to keep the - // header parseable by downstream tools and log processors. - readRange := fmt.Sprintf("bytes %d-%d", offset, readEnd-1) - - displayPath := filepath.Base(path) - var header string - if totalSize >= 0 { - header = fmt.Sprintf( - "[file: %s | total: %d bytes | read: %s]", - displayPath, totalSize, readRange, - ) - } else { - header = fmt.Sprintf( - "[file: %s | read: %s | total size unknown]", - displayPath, readRange, - ) - } - - if hasMore { - header += fmt.Sprintf( - "\n[TRUNCATED - file has more content. Call read_file again with offset=%d to continue.]", - readEnd, - ) - } else { - header += "\n[END OF FILE - no further content.]" - } - - logger.DebugCF("tool", "ReadFileTool execution completed successfully", - map[string]any{ - "path": path, - "bytes_read": len(data), - "has_more": hasMore, - }) - - return NewToolResult(header + "\n\n" + string(data)) -} - -// getInt64Arg extracts an integer argument from the args map, returning the -// provided default if the key is absent. -func getInt64Arg(args map[string]any, key string, defaultVal int64) (int64, error) { - raw, exists := args[key] - if !exists { - return defaultVal, nil - } - - switch v := raw.(type) { - case float64: - if v != math.Trunc(v) { - return 0, fmt.Errorf("%s must be an integer, got float %v", key, v) - } - if v > math.MaxInt64 || v < math.MinInt64 { - return 0, fmt.Errorf("%s value %v overflows int64", key, v) - } - return int64(v), nil - case int: - return int64(v), nil - case int64: - return v, nil - case string: - parsed, err := strconv.ParseInt(v, 10, 64) - if err != nil { - return 0, fmt.Errorf("invalid integer format for %s parameter: %w", key, err) - } - return parsed, nil - default: - return 0, fmt.Errorf("unsupported type %T for %s parameter", raw, key) - } -} - -type WriteFileTool struct { - fs fileSystem -} - -func NewWriteFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *WriteFileTool { - var patterns []*regexp.Regexp - if len(allowPaths) > 0 { - patterns = allowPaths[0] - } - return &WriteFileTool{fs: buildFs(workspace, restrict, patterns)} -} - -func (t *WriteFileTool) Name() string { - return "write_file" -} - -func (t *WriteFileTool) Description() string { - return "Write content to a file" -} - -func (t *WriteFileTool) Parameters() map[string]any { - return map[string]any{ - "type": "object", - "properties": map[string]any{ - "path": map[string]any{ - "type": "string", - "description": "Path to the file to write", - }, - "content": map[string]any{ - "type": "string", - "description": "Content to write to the file", - }, - }, - "required": []string{"path", "content"}, - } -} - -func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult { - path, ok := args["path"].(string) - if !ok { - return ErrorResult("path is required") - } - - content, ok := args["content"].(string) - if !ok { - return ErrorResult("content is required") - } - - if err := t.fs.WriteFile(path, []byte(content)); err != nil { - return ErrorResult(err.Error()) - } - - return SilentResult(fmt.Sprintf("File written: %s", path)) -} - -type ListDirTool struct { - fs fileSystem -} - -func NewListDirTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *ListDirTool { - var patterns []*regexp.Regexp - if len(allowPaths) > 0 { - patterns = allowPaths[0] - } - return &ListDirTool{fs: buildFs(workspace, restrict, patterns)} -} - -func (t *ListDirTool) Name() string { - return "list_dir" -} - -func (t *ListDirTool) Description() string { - return "List files and directories in a path" -} - -func (t *ListDirTool) Parameters() map[string]any { - return map[string]any{ - "type": "object", - "properties": map[string]any{ - "path": map[string]any{ - "type": "string", - "description": "Path to list", - }, - }, - "required": []string{"path"}, - } -} - -func (t *ListDirTool) Execute(ctx context.Context, args map[string]any) *ToolResult { - path, ok := args["path"].(string) - if !ok { - path = "." - } - - entries, err := t.fs.ReadDir(path) - if err != nil { - return ErrorResult(fmt.Sprintf("failed to read directory: %v", err)) - } - return formatDirEntries(entries) -} - -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()) -} - -// fileSystem abstracts reading, writing, and listing files, allowing both -// unrestricted (host filesystem) and sandbox (os.Root) implementations to share the same polymorphic interface. -type fileSystem interface { - ReadFile(path string) ([]byte, error) - WriteFile(path string, data []byte) error - ReadDir(path string) ([]os.DirEntry, error) - Open(path string) (fs.File, error) -} - -// hostFs is an unrestricted fileReadWriter that operates directly on the host filesystem. -type hostFs struct{} - -func (h *hostFs) ReadFile(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 *hostFs) ReadDir(path string) ([]os.DirEntry, error) { - return os.ReadDir(path) -} - -func (h *hostFs) WriteFile(path string, data []byte) error { - // Use unified atomic write utility with explicit sync for flash storage reliability. - // Using 0o600 (owner read/write only) for secure default permissions. - return fileutil.WriteFileAtomic(path, data, 0o600) -} - -func (h *hostFs) Open(path string) (fs.File, error) { - f, err := os.Open(path) - if err != nil { - if os.IsNotExist(err) { - return nil, fmt.Errorf("failed to open file: file not found: %w", err) - } - if os.IsPermission(err) { - return nil, fmt.Errorf("failed to open file: access denied: %w", err) - } - return nil, fmt.Errorf("failed to open file: %w", err) - } - return f, nil -} - -// sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root. -type sandboxFs struct { - workspace string -} - -func (r *sandboxFs) execute(path string, fn func(root *os.Root, relPath string) error) error { - if r.workspace == "" { - return fmt.Errorf("workspace is not defined") - } - - root, err := os.OpenRoot(r.workspace) - if err != nil { - return fmt.Errorf("failed to open workspace: %w", err) - } - defer root.Close() - - relPath, err := getSafeRelPath(r.workspace, path) - if err != nil { - return err - } - - return fn(root, relPath) -} - -func (r *sandboxFs) ReadFile(path string) ([]byte, error) { - var content []byte - err := r.execute(path, func(root *os.Root, relPath string) error { - fileContent, err := root.ReadFile(relPath) - if err != nil { - if os.IsNotExist(err) { - return 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 fmt.Errorf("failed to read file: access denied: %w", err) - } - return fmt.Errorf("failed to read file: %w", err) - } - content = fileContent - return nil - }) - return content, err -} - -func (r *sandboxFs) WriteFile(path string, data []byte) error { - return r.execute(path, func(root *os.Root, relPath string) error { - dir := filepath.Dir(relPath) - if dir != "." && dir != "/" { - if err := root.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("failed to create parent directories: %w", err) - } - } - - // Use atomic write pattern with explicit sync for flash storage reliability. - // Using 0o600 (owner read/write only) for secure default permissions. - tmpRelPath := fmt.Sprintf(".tmp-%d-%d", os.Getpid(), time.Now().UnixNano()) - - tmpFile, err := root.OpenFile(tmpRelPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) - if err != nil { - root.Remove(tmpRelPath) - return fmt.Errorf("failed to open temp file: %w", err) - } - - if _, err := tmpFile.Write(data); err != nil { - tmpFile.Close() - root.Remove(tmpRelPath) - return fmt.Errorf("failed to write temp file: %w", err) - } - - // CRITICAL: Force sync to storage medium before rename. - // This ensures data is physically written to disk, not just cached. - if err := tmpFile.Sync(); err != nil { - tmpFile.Close() - root.Remove(tmpRelPath) - return fmt.Errorf("failed to sync temp file: %w", err) - } - - if err := tmpFile.Close(); err != nil { - root.Remove(tmpRelPath) - return fmt.Errorf("failed to close temp file: %w", err) - } - - if err := root.Rename(tmpRelPath, relPath); err != nil { - root.Remove(tmpRelPath) - return fmt.Errorf("failed to rename temp file over target: %w", err) - } - - // Sync directory to ensure rename is durable - if dirFile, err := root.Open("."); err == nil { - _ = dirFile.Sync() - dirFile.Close() - } - - return nil - }) -} - -func (r *sandboxFs) ReadDir(path string) ([]os.DirEntry, error) { - var entries []os.DirEntry - err := r.execute(path, func(root *os.Root, relPath string) error { - dirEntries, err := fs.ReadDir(root.FS(), relPath) - if err != nil { - return err - } - entries = dirEntries - return nil - }) - return entries, err -} - -func (r *sandboxFs) Open(path string) (fs.File, error) { - var f fs.File - err := r.execute(path, func(root *os.Root, relPath string) error { - file, err := root.Open(relPath) - if err != nil { - if os.IsNotExist(err) { - return fmt.Errorf("failed to open file: file not found: %w", err) - } - if os.IsPermission(err) || strings.Contains(err.Error(), "escapes from parent") || - strings.Contains(err.Error(), "permission denied") { - return fmt.Errorf("failed to open file: access denied: %w", err) - } - return fmt.Errorf("failed to open file: %w", err) - } - f = file - return nil - }) - return f, err -} - -// whitelistFs wraps a sandboxFs and allows access to specific paths outside -// the workspace when they match any of the provided patterns. -type whitelistFs struct { - sandbox *sandboxFs - host hostFs - patterns []*regexp.Regexp -} - -func (w *whitelistFs) matches(path string) bool { - for _, p := range w.patterns { - if p.MatchString(path) { - return true - } - } - return false -} - -func (w *whitelistFs) ReadFile(path string) ([]byte, error) { - if w.matches(path) { - return w.host.ReadFile(path) - } - return w.sandbox.ReadFile(path) -} - -func (w *whitelistFs) WriteFile(path string, data []byte) error { - if w.matches(path) { - return w.host.WriteFile(path, data) - } - return w.sandbox.WriteFile(path, data) -} - -func (w *whitelistFs) ReadDir(path string) ([]os.DirEntry, error) { - if w.matches(path) { - return w.host.ReadDir(path) - } - return w.sandbox.ReadDir(path) -} - -func (w *whitelistFs) Open(path string) (fs.File, error) { - if w.matches(path) { - return w.host.Open(path) - } - return w.sandbox.Open(path) -} - -// buildFs returns the appropriate fileSystem implementation based on restriction -// settings and optional path whitelist patterns. -func buildFs(workspace string, restrict bool, patterns []*regexp.Regexp) fileSystem { - if !restrict { - return &hostFs{} - } - sandbox := &sandboxFs{workspace: workspace} - if len(patterns) > 0 { - return &whitelistFs{sandbox: sandbox, patterns: patterns} - } - return sandbox -} - -// 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 not defined") - } - - rel := filepath.Clean(path) - if filepath.IsAbs(rel) { - var err error - rel, err = filepath.Rel(workspace, rel) - if err != nil { - return "", fmt.Errorf("failed to calculate relative path: %w", err) - } - } - - if !filepath.IsLocal(rel) { - return "", fmt.Errorf("path escapes workspace: %s", path) - } - - return rel, nil -} - -func (t *ReadFileTool) RequiresApproval() bool { - return false -} - -func (t *WriteFileTool) RequiresApproval() bool { - return false -} - -func (t *ListDirTool) RequiresApproval() bool { - return false -} diff --git a/pkg/tools/fs_path.go b/pkg/tools/fs_path.go new file mode 100644 index 000000000..91c2a27b8 --- /dev/null +++ b/pkg/tools/fs_path.go @@ -0,0 +1,100 @@ +package tools + +import ( + "fmt" + "os" + "path/filepath" +) + +// 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, fmt.Errorf("workspace is not defined") + } + + 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") + } + + var resolved string + 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) { + var parentResolved string + 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 && filepath.IsLocal(rel) +} + +// 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 not defined") + } + + rel := filepath.Clean(path) + if filepath.IsAbs(rel) { + var err error + rel, err = filepath.Rel(workspace, rel) + if err != nil { + return "", fmt.Errorf("failed to calculate relative path: %w", err) + } + } + + if !filepath.IsLocal(rel) { + return "", fmt.Errorf("path escapes workspace: %s", path) + } + + return rel, nil +} diff --git a/pkg/tools/fs_vfs.go b/pkg/tools/fs_vfs.go new file mode 100644 index 000000000..ee8314e8f --- /dev/null +++ b/pkg/tools/fs_vfs.go @@ -0,0 +1,252 @@ +package tools + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "jane/pkg/fileutil" +) + +// fileSystem abstracts reading, writing, and listing files, allowing both +// unrestricted (host filesystem) and sandbox (os.Root) implementations to share the same polymorphic interface. +type fileSystem interface { + ReadFile(path string) ([]byte, error) + WriteFile(path string, data []byte) error + ReadDir(path string) ([]os.DirEntry, error) + Open(path string) (fs.File, error) +} + +// hostFs is an unrestricted fileReadWriter that operates directly on the host filesystem. +type hostFs struct{} + +func (h *hostFs) ReadFile(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 *hostFs) ReadDir(path string) ([]os.DirEntry, error) { + return os.ReadDir(path) +} + +func (h *hostFs) WriteFile(path string, data []byte) error { + // Use unified atomic write utility with explicit sync for flash storage reliability. + // Using 0o600 (owner read/write only) for secure default permissions. + return fileutil.WriteFileAtomic(path, data, 0o600) +} + +func (h *hostFs) Open(path string) (fs.File, error) { + f, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("failed to open file: file not found: %w", err) + } + if os.IsPermission(err) { + return nil, fmt.Errorf("failed to open file: access denied: %w", err) + } + return nil, fmt.Errorf("failed to open file: %w", err) + } + return f, nil +} + +// sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root. +type sandboxFs struct { + workspace string +} + +func (r *sandboxFs) execute(path string, fn func(root *os.Root, relPath string) error) error { + if r.workspace == "" { + return fmt.Errorf("workspace is not defined") + } + + root, err := os.OpenRoot(r.workspace) + if err != nil { + return fmt.Errorf("failed to open workspace: %w", err) + } + defer root.Close() + + relPath, err := getSafeRelPath(r.workspace, path) + if err != nil { + return err + } + + return fn(root, relPath) +} + +func (r *sandboxFs) ReadFile(path string) ([]byte, error) { + var content []byte + err := r.execute(path, func(root *os.Root, relPath string) error { + fileContent, err := root.ReadFile(relPath) + if err != nil { + if os.IsNotExist(err) { + return 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 fmt.Errorf("failed to read file: access denied: %w", err) + } + return fmt.Errorf("failed to read file: %w", err) + } + content = fileContent + return nil + }) + return content, err +} + +func (r *sandboxFs) WriteFile(path string, data []byte) error { + return r.execute(path, func(root *os.Root, relPath string) error { + dir := filepath.Dir(relPath) + if dir != "." && dir != "/" { + if err := root.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("failed to create parent directories: %w", err) + } + } + + // Use atomic write pattern with explicit sync for flash storage reliability. + // Using 0o600 (owner read/write only) for secure default permissions. + tmpRelPath := fmt.Sprintf(".tmp-%d-%d", os.Getpid(), time.Now().UnixNano()) + + tmpFile, err := root.OpenFile(tmpRelPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + root.Remove(tmpRelPath) + return fmt.Errorf("failed to open temp file: %w", err) + } + + if _, err := tmpFile.Write(data); err != nil { + tmpFile.Close() + root.Remove(tmpRelPath) + return fmt.Errorf("failed to write temp file: %w", err) + } + + // CRITICAL: Force sync to storage medium before rename. + // This ensures data is physically written to disk, not just cached. + if err := tmpFile.Sync(); err != nil { + tmpFile.Close() + root.Remove(tmpRelPath) + return fmt.Errorf("failed to sync temp file: %w", err) + } + + if err := tmpFile.Close(); err != nil { + root.Remove(tmpRelPath) + return fmt.Errorf("failed to close temp file: %w", err) + } + + if err := root.Rename(tmpRelPath, relPath); err != nil { + root.Remove(tmpRelPath) + return fmt.Errorf("failed to rename temp file over target: %w", err) + } + + // Sync directory to ensure rename is durable + if dirFile, err := root.Open("."); err == nil { + _ = dirFile.Sync() + dirFile.Close() + } + + return nil + }) +} + +func (r *sandboxFs) ReadDir(path string) ([]os.DirEntry, error) { + var entries []os.DirEntry + err := r.execute(path, func(root *os.Root, relPath string) error { + dirEntries, err := fs.ReadDir(root.FS(), relPath) + if err != nil { + return err + } + entries = dirEntries + return nil + }) + return entries, err +} + +func (r *sandboxFs) Open(path string) (fs.File, error) { + var f fs.File + err := r.execute(path, func(root *os.Root, relPath string) error { + file, err := root.Open(relPath) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("failed to open file: file not found: %w", err) + } + if os.IsPermission(err) || strings.Contains(err.Error(), "escapes from parent") || + strings.Contains(err.Error(), "permission denied") { + return fmt.Errorf("failed to open file: access denied: %w", err) + } + return fmt.Errorf("failed to open file: %w", err) + } + f = file + return nil + }) + return f, err +} + +// whitelistFs wraps a sandboxFs and allows access to specific paths outside +// the workspace when they match any of the provided patterns. +type whitelistFs struct { + sandbox *sandboxFs + host hostFs + patterns []*regexp.Regexp +} + +func (w *whitelistFs) matches(path string) bool { + for _, p := range w.patterns { + if p.MatchString(path) { + return true + } + } + return false +} + +func (w *whitelistFs) ReadFile(path string) ([]byte, error) { + if w.matches(path) { + return w.host.ReadFile(path) + } + return w.sandbox.ReadFile(path) +} + +func (w *whitelistFs) WriteFile(path string, data []byte) error { + if w.matches(path) { + return w.host.WriteFile(path, data) + } + return w.sandbox.WriteFile(path, data) +} + +func (w *whitelistFs) ReadDir(path string) ([]os.DirEntry, error) { + if w.matches(path) { + return w.host.ReadDir(path) + } + return w.sandbox.ReadDir(path) +} + +func (w *whitelistFs) Open(path string) (fs.File, error) { + if w.matches(path) { + return w.host.Open(path) + } + return w.sandbox.Open(path) +} + +// buildFs returns the appropriate fileSystem implementation based on restriction +// settings and optional path whitelist patterns. +func buildFs(workspace string, restrict bool, patterns []*regexp.Regexp) fileSystem { + if !restrict { + return &hostFs{} + } + sandbox := &sandboxFs{workspace: workspace} + if len(patterns) > 0 { + return &whitelistFs{sandbox: sandbox, patterns: patterns} + } + return sandbox +} diff --git a/pkg/tools/list_dir.go b/pkg/tools/list_dir.go new file mode 100644 index 000000000..944a6324d --- /dev/null +++ b/pkg/tools/list_dir.go @@ -0,0 +1,71 @@ +package tools + +import ( + "context" + "fmt" + "os" + "regexp" + "strings" +) + +type ListDirTool struct { + fs fileSystem +} + +func NewListDirTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *ListDirTool { + var patterns []*regexp.Regexp + if len(allowPaths) > 0 { + patterns = allowPaths[0] + } + return &ListDirTool{fs: buildFs(workspace, restrict, patterns)} +} + +func (t *ListDirTool) Name() string { + return "list_dir" +} + +func (t *ListDirTool) Description() string { + return "List files and directories in a path" +} + +func (t *ListDirTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{ + "type": "string", + "description": "Path to list", + }, + }, + "required": []string{"path"}, + } +} + +func (t *ListDirTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + path, ok := args["path"].(string) + if !ok { + path = "." + } + + entries, err := t.fs.ReadDir(path) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to read directory: %v", err)) + } + return formatDirEntries(entries) +} + +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()) +} + +func (t *ListDirTool) RequiresApproval() bool { + return false +} diff --git a/pkg/tools/read_file.go b/pkg/tools/read_file.go new file mode 100644 index 000000000..78e416c6b --- /dev/null +++ b/pkg/tools/read_file.go @@ -0,0 +1,252 @@ +package tools + +import ( + "context" + "errors" + "fmt" + "io" + "math" + "path/filepath" + "regexp" + "strconv" + + "jane/pkg/logger" +) + +const MaxReadFileSize = 64 * 1024 // 64KB limit to avoid context overflow + +type ReadFileTool struct { + fs fileSystem + maxSize int64 +} + +func NewReadFileTool( + workspace string, + restrict bool, + maxReadFileSize int, + allowPaths ...[]*regexp.Regexp, +) *ReadFileTool { + var patterns []*regexp.Regexp + if len(allowPaths) > 0 { + patterns = allowPaths[0] + } + + maxSize := int64(maxReadFileSize) + if maxSize <= 0 { + maxSize = MaxReadFileSize + } + + return &ReadFileTool{ + fs: buildFs(workspace, restrict, patterns), + maxSize: maxSize, + } +} + +func (t *ReadFileTool) Name() string { + return "read_file" +} + +func (t *ReadFileTool) Description() string { + return "Read the contents of a file. Supports pagination via `offset` and `length`." +} + +func (t *ReadFileTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{ + "type": "string", + "description": "Path to the file to read.", + }, + "offset": map[string]any{ + "type": "integer", + "description": "Byte offset to start reading from.", + "default": 0, + }, + "length": map[string]any{ + "type": "integer", + "description": "Maximum number of bytes to read.", + "default": t.maxSize, + }, + }, + "required": []string{"path"}, + } +} + +func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + path, ok := args["path"].(string) + if !ok { + return ErrorResult("path is required") + } + + // offset (optional, default 0) + offset, err := getInt64Arg(args, "offset", 0) + if err != nil { + return ErrorResult(err.Error()) + } + if offset < 0 { + return ErrorResult("offset must be >= 0") + } + + // length (optional, capped at MaxReadFileSize) + length, err := getInt64Arg(args, "length", t.maxSize) + if err != nil { + return ErrorResult(err.Error()) + } + if length <= 0 { + return ErrorResult("length must be > 0") + } + if length > t.maxSize { + length = t.maxSize + } + + file, err := t.fs.Open(path) + if err != nil { + return ErrorResult(err.Error()) + } + defer file.Close() + + // measure total size + totalSize := int64(-1) // -1 means unknown + if info, statErr := file.Stat(); statErr == nil { + totalSize = info.Size() + } + + // sniff the first 512 bytes to detect binary content before loading + // it into the LLM context. Seeking back to 0 afterwards restores state. + sniff := make([]byte, 512) + sniffN, _ := file.Read(sniff) + + // Reset read position to beginning before applying the caller's offset. + if seeker, ok := file.(io.Seeker); ok { + _, err = seeker.Seek(0, io.SeekStart) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to reset file position after sniff: %v", err)) + } + } else { + // Non-seekable: we consumed sniffN bytes above; account for them when + // discarding to reach the requested offset below. + // If offset < sniffN the data we already read covers it, which we + // cannot replay on a non-seekable stream — return a clear error. + if offset < int64(sniffN) && offset > 0 { + return ErrorResult( + "non-seekable file: cannot seek to an offset within the first 512 bytes after binary detection", + ) + } + } + + // Seek to the requested offset. + if seeker, ok := file.(io.Seeker); ok { + _, err = seeker.Seek(offset, io.SeekStart) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to seek to offset %d: %v", offset, err)) + } + } else if offset > 0 { + // Fallback for non-seekable streams: discard leading bytes. + // sniffN bytes were already consumed above, so subtract them. + remaining := offset - int64(sniffN) + if remaining > 0 { + _, err = io.CopyN(io.Discard, file, remaining) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to advance to offset %d: %v", offset, err)) + } + } + } + + // read length+1 bytes to reliably detect whether more content exists + // without relying on totalSize (which may be -1 for non-seekable streams). + // This avoids the false-positive TRUNCATED message on the last page. + probe := make([]byte, length+1) + n, err := io.ReadFull(file, probe) + + // io.ReadFull returns io.ErrUnexpectedEOF for partial reads (0 < n < len), + // and io.EOF only when n == 0. Both are normal terminal conditions — only + // other errors are genuine failures. + if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { + return ErrorResult(fmt.Sprintf("failed to read file content: %v", err)) + } + + // hasMore is true only when we actually got the extra probe byte. + hasMore := int64(n) > length + data := probe[:min(int64(n), length)] + + if len(data) == 0 { + return NewToolResult("[END OF FILE - no content at this offset]") + } + + // Build metadata header. + // use filepath.Base(path) instead of the raw path to avoid leaking + // internal filesystem structure into the LLM context. + readEnd := offset + int64(len(data)) + // use ASCII hyphen-minus instead of en-dash (U+2013) to keep the + // header parseable by downstream tools and log processors. + readRange := fmt.Sprintf("bytes %d-%d", offset, readEnd-1) + + displayPath := filepath.Base(path) + var header string + if totalSize >= 0 { + header = fmt.Sprintf( + "[file: %s | total: %d bytes | read: %s]", + displayPath, totalSize, readRange, + ) + } else { + header = fmt.Sprintf( + "[file: %s | read: %s | total size unknown]", + displayPath, readRange, + ) + } + + if hasMore { + header += fmt.Sprintf( + "\n[TRUNCATED - file has more content. Call read_file again with offset=%d to continue.]", + readEnd, + ) + } else { + header += "\n[END OF FILE - no further content.]" + } + + logger.DebugCF("tool", "ReadFileTool execution completed successfully", + map[string]any{ + "path": path, + "bytes_read": len(data), + "has_more": hasMore, + }) + + return NewToolResult(header + "\n\n" + string(data)) +} + +// getInt64Arg extracts an integer argument from the args map, returning the +// provided default if the key is absent. +func getInt64Arg(args map[string]any, key string, defaultVal int64) (int64, error) { + raw, exists := args[key] + if !exists { + return defaultVal, nil + } + + switch v := raw.(type) { + case float64: + if v != math.Trunc(v) { + return 0, fmt.Errorf("%s must be an integer, got float %v", key, v) + } + if v > math.MaxInt64 || v < math.MinInt64 { + return 0, fmt.Errorf("%s value %v overflows int64", key, v) + } + return int64(v), nil + case int: + return int64(v), nil + case int64: + return v, nil + case string: + parsed, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid integer format for %s parameter: %w", key, err) + } + return parsed, nil + default: + return 0, fmt.Errorf("unsupported type %T for %s parameter", raw, key) + } +} + +func (t *ReadFileTool) RequiresApproval() bool { + return false +} diff --git a/pkg/tools/write_file.go b/pkg/tools/write_file.go new file mode 100644 index 000000000..d4d65ca2b --- /dev/null +++ b/pkg/tools/write_file.go @@ -0,0 +1,66 @@ +package tools + +import ( + "context" + "fmt" + "regexp" +) + +type WriteFileTool struct { + fs fileSystem +} + +func NewWriteFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *WriteFileTool { + var patterns []*regexp.Regexp + if len(allowPaths) > 0 { + patterns = allowPaths[0] + } + return &WriteFileTool{fs: buildFs(workspace, restrict, patterns)} +} + +func (t *WriteFileTool) Name() string { + return "write_file" +} + +func (t *WriteFileTool) Description() string { + return "Write content to a file" +} + +func (t *WriteFileTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{ + "type": "string", + "description": "Path to the file to write", + }, + "content": map[string]any{ + "type": "string", + "description": "Content to write to the file", + }, + }, + "required": []string{"path", "content"}, + } +} + +func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + path, ok := args["path"].(string) + if !ok { + return ErrorResult("path is required") + } + + content, ok := args["content"].(string) + if !ok { + return ErrorResult("content is required") + } + + if err := t.fs.WriteFile(path, []byte(content)); err != nil { + return ErrorResult(err.Error()) + } + + return SilentResult(fmt.Sprintf("File written: %s", path)) +} + +func (t *WriteFileTool) RequiresApproval() bool { + return false +}