feat: agregar write_file, list_directory, append_file, delete_file a tools/file/

Expande el paquete tools/file/ con 4 operaciones nuevas para que los agentes
puedan interactuar con carpetas de trabajo (workspaces, outputs).

Cambios:
- Extraer validatePath() y resolveReal() a validate.go para reutilizarlos
- Agregar validateWritePath() que verifica ReadOnly == false
- write_file: crea/sobreescribe archivos, crea dirs padre, limite 1MB
- list_directory: lista archivos con metadata, modo recursivo, limite 500 entries
- append_file: agrega contenido al final, crea si no existe, limite 10MB total
- delete_file: borra solo archivos (nunca directorios), previene rm -rf accidental
- Registrar las 4 tools nuevas en runtime.go condicionalmente:
  - list_directory: siempre (no requiere escritura)
  - write/append/delete: solo si ReadOnly == false

Seguridad: todas las tools reutilizan validatePath() con deny-by-default,
resolucion de symlinks y proteccion contra path traversal.
This commit is contained in:
2026-04-08 23:01:31 +00:00
parent dacf241806
commit 4ab879e461
7 changed files with 454 additions and 54 deletions
+7 -1
View File
@@ -1094,7 +1094,13 @@ func buildToolRegistry(
if cfg.Tools.FileOps.Enabled {
reg.Register(toolfile.NewReadFile(cfg.Tools.FileOps))
logger.Debug("registered file tool")
reg.Register(toolfile.NewListDirectory(cfg.Tools.FileOps))
if !cfg.Tools.FileOps.ReadOnly {
reg.Register(toolfile.NewWriteFile(cfg.Tools.FileOps))
reg.Register(toolfile.NewAppendFile(cfg.Tools.FileOps))
reg.Register(toolfile.NewDeleteFile(cfg.Tools.FileOps))
}
logger.Debug("registered file tools")
}
// current_time is always available
+83
View File
@@ -0,0 +1,83 @@
package file
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/enmanuel/agents/internal/config"
"github.com/enmanuel/agents/tools"
)
// maxAppendTotal is the maximum total file size after appending (10 MB).
const maxAppendTotal = 10 * 1024 * 1024
// NewAppendFile creates an append_file tool that appends content to a local file.
// Deny-by-default: if AllowedPaths is empty, all operations are rejected.
// Rejects if ReadOnly is true. Creates the file (and parent directories) if it does not exist.
func NewAppendFile(cfg config.FileOpsCfg) tools.Tool {
return tools.Tool{
Def: tools.Def{
Name: "append_file",
Description: "Append content to the end of a local file. Creates the file if it does not exist.",
Parameters: []tools.Param{
{Name: "path", Type: "string", Description: "Absolute path to the file to append to", Required: true},
{Name: "content", Type: "string", Description: "Content to append to the file", 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("append_file: path is required")}
}
content := tools.GetString(args, "content")
if content == "" {
return tools.Result{Err: fmt.Errorf("append_file: content is required")}
}
absPath, err := filepath.Abs(path)
if err != nil {
return tools.Result{Err: fmt.Errorf("append_file: %w", err)}
}
if err := validateWritePath(absPath, cfg.AllowedPaths, cfg.ReadOnly); err != nil {
return tools.Result{Err: err}
}
// Check existing file size to enforce the total limit.
var existingSize int64
info, err := os.Stat(absPath)
if err == nil {
existingSize = info.Size()
}
// err != nil means file doesn't exist, which is fine (will be created).
newTotal := existingSize + int64(len(content))
if newTotal > maxAppendTotal {
return tools.Result{Err: fmt.Errorf("append_file: resulting file size (%d bytes) exceeds 10 MB limit", newTotal)}
}
// Create parent directories if they don't exist.
dir := filepath.Dir(absPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return tools.Result{Err: fmt.Errorf("append_file: cannot create directories: %w", err)}
}
f, err := os.OpenFile(absPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return tools.Result{Err: fmt.Errorf("append_file: %w", err)}
}
defer f.Close()
n, err := f.WriteString(content)
if err != nil {
return tools.Result{Err: fmt.Errorf("append_file: %w", err)}
}
finalSize := existingSize + int64(n)
return tools.Result{Output: fmt.Sprintf("appended %d bytes to %s (total size: %d bytes)", n, absPath, finalSize)}
},
}
}
+57
View File
@@ -0,0 +1,57 @@
package file
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/enmanuel/agents/internal/config"
"github.com/enmanuel/agents/tools"
)
// NewDeleteFile creates a delete_file tool that deletes a single file.
// Deny-by-default: if AllowedPaths is empty, all operations are rejected.
// Rejects if ReadOnly is true. Only deletes files, never directories.
// Resolves symlinks before deleting to prevent escaping allowed paths.
func NewDeleteFile(cfg config.FileOpsCfg) tools.Tool {
return tools.Tool{
Def: tools.Def{
Name: "delete_file",
Description: "Delete a single file. Cannot delete directories.",
Parameters: []tools.Param{
{Name: "path", Type: "string", Description: "Absolute path to the file to delete", 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("delete_file: path is required")}
}
absPath, err := filepath.Abs(path)
if err != nil {
return tools.Result{Err: fmt.Errorf("delete_file: %w", err)}
}
if err := validateWritePath(absPath, cfg.AllowedPaths, cfg.ReadOnly); err != nil {
return tools.Result{Err: err}
}
// Stat the file to ensure it exists and is not a directory.
info, err := os.Stat(absPath)
if err != nil {
return tools.Result{Err: fmt.Errorf("delete_file: %w", err)}
}
if info.IsDir() {
return tools.Result{Err: fmt.Errorf("delete_file: %q is a directory, only files can be deleted", absPath)}
}
if err := os.Remove(absPath); err != nil {
return tools.Result{Err: fmt.Errorf("delete_file: %w", err)}
}
return tools.Result{Output: fmt.Sprintf("deleted %s", absPath)}
},
}
}
-53
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/enmanuel/agents/internal/config"
"github.com/enmanuel/agents/tools"
@@ -53,55 +52,3 @@ func NewReadFile(cfg config.FileOpsCfg) tools.Tool {
},
}
}
// 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
}
+173
View File
@@ -0,0 +1,173 @@
package file
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/enmanuel/agents/internal/config"
"github.com/enmanuel/agents/tools"
)
// maxListEntries is the maximum number of entries returned by list_directory.
const maxListEntries = 500
// NewListDirectory creates a list_directory tool that lists files and directories.
// Deny-by-default: if AllowedPaths is empty, all listings are rejected.
// Does not follow symlinks that point outside of AllowedPaths.
func NewListDirectory(cfg config.FileOpsCfg) tools.Tool {
return tools.Tool{
Def: tools.Def{
Name: "list_directory",
Description: "List files and directories at the given path. Returns name, size, type (file/dir), and modification date for each entry.",
Parameters: []tools.Param{
{Name: "path", Type: "string", Description: "Absolute path to the directory to list", Required: true},
{Name: "recursive", Type: "boolean", Description: "List recursively (default: false)", Required: false},
},
},
Exec: func(ctx context.Context, args map[string]any) tools.Result {
path := tools.GetString(args, "path")
if path == "" {
return tools.Result{Err: fmt.Errorf("list_directory: path is required")}
}
recursive := getBool(args, "recursive")
absPath, err := filepath.Abs(path)
if err != nil {
return tools.Result{Err: fmt.Errorf("list_directory: %w", err)}
}
if err := validatePath(absPath, cfg.AllowedPaths); err != nil {
return tools.Result{Err: err}
}
info, err := os.Stat(absPath)
if err != nil {
return tools.Result{Err: fmt.Errorf("list_directory: %w", err)}
}
if !info.IsDir() {
return tools.Result{Err: fmt.Errorf("list_directory: %q is not a directory", absPath)}
}
var entries []string
if recursive {
entries, err = listRecursive(absPath, cfg.AllowedPaths)
} else {
entries, err = listFlat(absPath, cfg.AllowedPaths)
}
if err != nil {
return tools.Result{Err: fmt.Errorf("list_directory: %w", err)}
}
if len(entries) > maxListEntries {
entries = entries[:maxListEntries]
entries = append(entries, fmt.Sprintf("... (truncated, showing %d of more entries)", maxListEntries))
}
return tools.Result{Output: strings.Join(entries, "\n")}
},
}
}
// listFlat lists immediate children of dir.
func listFlat(dir string, allowedPaths []string) ([]string, error) {
dirEntries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
var results []string
for _, e := range dirEntries {
entryPath := filepath.Join(dir, e.Name())
// Skip symlinks that point outside allowed paths.
if e.Type()&os.ModeSymlink != 0 {
if err := validatePath(entryPath, allowedPaths); err != nil {
continue
}
}
info, err := e.Info()
if err != nil {
continue
}
results = append(results, formatEntry("", e.Name(), info))
}
return results, nil
}
// listRecursive lists all files under dir recursively.
func listRecursive(root string, allowedPaths []string) ([]string, error) {
var results []string
count := 0
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil // skip entries with errors
}
if path == root {
return nil // skip the root directory itself
}
if count >= maxListEntries {
return filepath.SkipAll
}
// Skip symlinks that point outside allowed paths.
if d.Type()&os.ModeSymlink != 0 {
if err := validatePath(path, allowedPaths); err != nil {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
}
rel, err := filepath.Rel(root, path)
if err != nil {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
results = append(results, formatEntry("", rel, info))
count++
return nil
})
return results, err
}
// formatEntry formats a single directory entry for output.
func formatEntry(prefix, name string, info os.FileInfo) string {
kind := "file"
if info.IsDir() {
kind = "dir"
}
mod := info.ModTime().Format(time.RFC3339)
display := name
if prefix != "" {
display = prefix + "/" + name
}
return fmt.Sprintf("%s\t%s\t%d\t%s", display, kind, info.Size(), mod)
}
// getBool extracts a boolean argument by name, returning false if missing or wrong type.
func getBool(args map[string]any, key string) bool {
v, ok := args[key]
if !ok {
return false
}
b, ok := v.(bool)
if !ok {
return false
}
return b
}
+67
View File
@@ -0,0 +1,67 @@
package file
import (
"fmt"
"path/filepath"
"strings"
)
// 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("file: no allowed paths configured, all operations 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("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)
}
// validateWritePath checks path validity AND that writing is allowed.
func validateWritePath(absPath string, allowedPaths []string, readOnly bool) error {
if readOnly {
return fmt.Errorf("file: write operations denied (read_only mode)")
}
return validatePath(absPath, allowedPaths)
}
// 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
}
+67
View File
@@ -0,0 +1,67 @@
package file
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/enmanuel/agents/internal/config"
"github.com/enmanuel/agents/tools"
)
// maxWriteSize is the maximum content size for write_file (1 MB).
const maxWriteSize = 1 * 1024 * 1024
// NewWriteFile creates a write_file tool that writes content to a local file.
// Deny-by-default: if AllowedPaths is empty, all writes are rejected.
// Rejects if ReadOnly is true. Creates parent directories if needed.
func NewWriteFile(cfg config.FileOpsCfg) tools.Tool {
return tools.Tool{
Def: tools.Def{
Name: "write_file",
Description: "Write content to a local file. Creates the file if it does not exist. Creates parent directories if needed. Overwrites existing content.",
Parameters: []tools.Param{
{Name: "path", Type: "string", Description: "Absolute path to the file to write", Required: true},
{Name: "content", Type: "string", Description: "Content to write to the file", 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("write_file: path is required")}
}
content := tools.GetString(args, "content")
if content == "" {
return tools.Result{Err: fmt.Errorf("write_file: content is required")}
}
if len(content) > maxWriteSize {
return tools.Result{Err: fmt.Errorf("write_file: content exceeds maximum size of 1 MB")}
}
absPath, err := filepath.Abs(path)
if err != nil {
return tools.Result{Err: fmt.Errorf("write_file: %w", err)}
}
if err := validateWritePath(absPath, cfg.AllowedPaths, cfg.ReadOnly); err != nil {
return tools.Result{Err: err}
}
// Create parent directories if they don't exist.
dir := filepath.Dir(absPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return tools.Result{Err: fmt.Errorf("write_file: cannot create directories: %w", err)}
}
data := []byte(content)
if err := os.WriteFile(absPath, data, 0644); err != nil {
return tools.Result{Err: fmt.Errorf("write_file: %w", err)}
}
return tools.Result{Output: fmt.Sprintf("wrote %d bytes to %s", len(data), absPath)}
},
}
}