package file import ( "context" "fmt" "os" "path/filepath" "strings" "github.com/enmanuel/agents/internal/config" "github.com/enmanuel/agents/tools" ) // NewReadFile creates a read_file tool that reads local files. // Deny-by-default: if AllowedPaths is empty, all reads are rejected. // Resolves symlinks and normalizes paths to prevent traversal attacks. func NewReadFile(cfg config.FileOpsCfg) tools.Tool { return tools.Tool{ Def: tools.Def{ Name: "read_file", Description: "Read the contents of a local file.", Parameters: []tools.Param{ {Name: "path", Type: "string", Description: "Absolute path to the file to read", Required: true}, }, }, Exec: func(ctx context.Context, args map[string]any) tools.Result { path := tools.GetString(args, "path") if path == "" { return tools.Result{Err: fmt.Errorf("read_file: path is required")} } absPath, err := filepath.Abs(path) if err != nil { return tools.Result{Err: fmt.Errorf("read_file: %w", err)} } if err := validatePath(absPath, cfg.AllowedPaths); err != nil { return tools.Result{Err: err} } data, err := os.ReadFile(absPath) if err != nil { return tools.Result{Err: fmt.Errorf("read_file: %w", err)} } // Limit output to 64 KB content := string(data) if len(content) > 64*1024 { content = content[:64*1024] + "\n... (truncated)" } return tools.Result{Output: content} }, } } // validatePath checks that absPath is under one of the allowed paths. // Deny-by-default: if allowedPaths is empty, no paths are allowed. // Resolves symlinks to prevent traversal via ../ or symlink escapes. func validatePath(absPath string, allowedPaths []string) error { if len(allowedPaths) == 0 { return fmt.Errorf("read_file: no allowed paths configured, all reads denied") } // Resolve symlinks on the requested path to get the real path. // If the file doesn't exist yet, resolve the parent directory. realPath, err := resolveReal(absPath) if err != nil { return fmt.Errorf("read_file: cannot resolve path %q: %w", absPath, err) } for _, allowed := range allowedPaths { a, err := filepath.Abs(allowed) if err != nil { continue } // Resolve symlinks on the allowed path too. realAllowed, err := resolveReal(a) if err != nil { continue } // Ensure the real path is strictly under the allowed directory. // Add trailing separator to prevent /opt matching /opt1234. if strings.HasPrefix(realPath, realAllowed+string(filepath.Separator)) || realPath == realAllowed { return nil } } return fmt.Errorf("path %q not under any allowed path", absPath) } // resolveReal resolves symlinks for a path. // If the exact path doesn't exist, it resolves the deepest existing ancestor // and appends the remaining segments, preventing partial traversal. func resolveReal(path string) (string, error) { real, err := filepath.EvalSymlinks(path) if err == nil { return filepath.Clean(real), nil } // Path doesn't exist — resolve parent and append base. parent := filepath.Dir(path) base := filepath.Base(path) realParent, err := filepath.EvalSymlinks(parent) if err != nil { return "", err } return filepath.Clean(filepath.Join(realParent, base)), nil }