package main import ( "encoding/base64" "encoding/json" "fmt" "os" "path/filepath" "sort" "strings" ) const ( fsDefaultMaxBytes = 64 * 1024 fsHardMaxBytes = 1024 * 1024 ) // isPathAllowed valida path contra una lista de globs declarados en cap.PathsAllowed. // Aplica EvalSymlinks (best-effort) y filepath.Clean para neutralizar ../ + symlinks. // Soporta el sufijo "/**" para match recursivo bajo un prefijo. func isPathAllowed(path string, allowed []string) bool { if len(allowed) == 0 { return false } abs, err := filepath.Abs(path) if err != nil { return false } abs = filepath.Clean(abs) // Resolve symlinks si el target existe; si no existe (write a archivo nuevo) // resolvemos el directorio padre, no romper. resolved := abs if real, err := filepath.EvalSymlinks(abs); err == nil { resolved = real } else { // Para paths no existentes (write): intenta resolver el dir padre. dir := filepath.Dir(abs) base := filepath.Base(abs) if realDir, err := filepath.EvalSymlinks(dir); err == nil { resolved = filepath.Join(realDir, base) } } resolved = filepath.Clean(resolved) for _, pat := range allowed { patClean := filepath.Clean(pat) // Soporte sufijo /** recursivo if strings.HasSuffix(patClean, string(filepath.Separator)+"**") || strings.HasSuffix(patClean, "/**") { prefix := strings.TrimSuffix(patClean, "/**") prefix = strings.TrimSuffix(prefix, string(filepath.Separator)+"**") prefix = filepath.Clean(prefix) if resolved == prefix || strings.HasPrefix(resolved, prefix+string(filepath.Separator)) { return true } continue } // Si patron es un dir y resolved esta debajo, allow (caso /etc/) if strings.HasSuffix(pat, "/") { prefix := filepath.Clean(pat) if strings.HasPrefix(resolved, prefix+string(filepath.Separator)) || resolved == prefix { return true } } // Match exacto o glob simple if match, _ := filepath.Match(patClean, resolved); match { return true } if resolved == patClean { return true } } return false } // mapIntField extrae un int de map[string]any tolerando float64 (JSON default). func mapIntField(m map[string]any, key string, def int) int { if v, ok := m[key]; ok && v != nil { switch t := v.(type) { case float64: return int(t) case int: return t case int64: return int(t) case json.Number: n, err := t.Int64() if err == nil { return int(n) } } } return def } func mapStringField(m map[string]any, key string) string { if v, ok := m[key]; ok && v != nil { if s, ok := v.(string); ok { return s } } return "" } // runFsRead lee un archivo y devuelve content_b64 + meta. Trunca a max_bytes. func runFsRead(cap *Capability, args map[string]any) (any, int, error) { path := mapStringField(args, "path") if path == "" { return nil, -1, fmt.Errorf("path required") } maxBytes := mapIntField(args, "max_bytes", fsDefaultMaxBytes) if maxBytes <= 0 { maxBytes = fsDefaultMaxBytes } if maxBytes > fsHardMaxBytes { maxBytes = fsHardMaxBytes } if !isPathAllowed(path, cap.PathsAllowed) { return nil, -1, fmt.Errorf("path not allowed by manifest: %s", path) } st, err := os.Stat(path) if err != nil { return nil, -1, fmt.Errorf("stat: %w", err) } if st.IsDir() { return nil, -1, fmt.Errorf("path is a directory") } f, err := os.Open(path) // #nosec G304 — whitelisted above if err != nil { return nil, -1, fmt.Errorf("open: %w", err) } defer f.Close() buf := make([]byte, maxBytes) n, err := f.Read(buf) if err != nil && err.Error() != "EOF" && n == 0 { return nil, -1, fmt.Errorf("read: %w", err) } truncated := int64(n) < st.Size() return map[string]any{ "content_b64": base64.StdEncoding.EncodeToString(buf[:n]), "size": st.Size(), "bytes_read": n, "mtime": st.ModTime().Unix(), "truncated": truncated, "path": path, }, 0, nil } // runFsWrite escribe content_b64 a path. Mkdir parent, default mode 0644. func runFsWrite(cap *Capability, args map[string]any) (any, int, error) { path := mapStringField(args, "path") if path == "" { return nil, -1, fmt.Errorf("path required") } contentB64 := mapStringField(args, "content_b64") if contentB64 == "" { // Soporte fallback "content" plano if c := mapStringField(args, "content"); c != "" { contentB64 = base64.StdEncoding.EncodeToString([]byte(c)) } } mode := mapIntField(args, "mode", 0644) if !isPathAllowed(path, cap.PathsAllowed) { return nil, -1, fmt.Errorf("path not allowed by manifest: %s", path) } data, err := base64.StdEncoding.DecodeString(contentB64) if err != nil { return nil, -1, fmt.Errorf("invalid content_b64: %w", err) } if int64(len(data)) > fsHardMaxBytes { return nil, -1, fmt.Errorf("content too large (>1MB)") } parent := filepath.Dir(path) if err := os.MkdirAll(parent, 0755); err != nil { return nil, -1, fmt.Errorf("mkdir parent: %w", err) } if err := os.WriteFile(path, data, os.FileMode(mode)); err != nil { // #nosec G306 return nil, -1, fmt.Errorf("write: %w", err) } return map[string]any{ "path": path, "bytes_written": len(data), }, 0, nil } // runFsList lista un directorio (no recursivo). glob opcional filtra entries. func runFsList(cap *Capability, args map[string]any) (any, int, error) { dir := mapStringField(args, "dir") if dir == "" { return nil, -1, fmt.Errorf("dir required") } glob := mapStringField(args, "glob") if !isPathAllowed(dir, cap.PathsAllowed) { return nil, -1, fmt.Errorf("dir not allowed by manifest: %s", dir) } entries, err := os.ReadDir(dir) if err != nil { return nil, -1, fmt.Errorf("readdir: %w", err) } out := []map[string]any{} for _, e := range entries { if glob != "" { if m, _ := filepath.Match(glob, e.Name()); !m { continue } } info, ierr := e.Info() kind := "file" var size int64 var mtime int64 if ierr == nil { if info.Mode()&os.ModeSymlink != 0 { kind = "symlink" } else if info.IsDir() { kind = "dir" } size = info.Size() mtime = info.ModTime().Unix() } out = append(out, map[string]any{ "name": e.Name(), "kind": kind, "size": size, "mtime": mtime, }) } sort.Slice(out, func(i, j int) bool { return out[i]["name"].(string) < out[j]["name"].(string) }) return map[string]any{ "dir": dir, "entries": out, "count": len(out), }, 0, nil } // runFsStat devuelve metadata de un archivo o directorio. func runFsStat(cap *Capability, args map[string]any) (any, int, error) { path := mapStringField(args, "path") if path == "" { return nil, -1, fmt.Errorf("path required") } if !isPathAllowed(path, cap.PathsAllowed) { return nil, -1, fmt.Errorf("path not allowed by manifest: %s", path) } st, err := os.Lstat(path) if err != nil { return nil, -1, fmt.Errorf("stat: %w", err) } kind := "file" if st.IsDir() { kind = "dir" } else if st.Mode()&os.ModeSymlink != 0 { kind = "symlink" } res := map[string]any{ "path": path, "kind": kind, "size": st.Size(), "mode": fmt.Sprintf("%#o", st.Mode().Perm()), "mtime": st.ModTime().Unix(), } return res, 0, nil }