Files
agents_and_robots/tools/file/list.go
T
egutierrez 4ab879e461 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.
2026-04-09 00:21:30 +00:00

174 lines
4.3 KiB
Go

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
}