4ab879e461
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.
174 lines
4.3 KiB
Go
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
|
|
}
|