using System.Collections.Concurrent; using System.Text; using System.Text.Json; using ClawdDotNet.Core.Tools; using Microsoft.Extensions.Logging; namespace ClawdDotNet.Tools.FileRW; public sealed class FileRWTool : IAgentTool { /// /// Per-Datei Locking: Verhindert Race Conditions wenn mehrere Agenten /// gleichzeitig auf dieselbe Datei zugreifen (read/write/append/delete/copy). /// Key = normalisierter absoluter Pfad (lowercase), Value = SemaphoreSlim(1,1). /// private static readonly ConcurrentDictionary FileLocks = new(); private static SemaphoreSlim GetFileLock(string fullPath) => FileLocks.GetOrAdd(NormalizePath(fullPath), _ => new SemaphoreSlim(1, 1)); private static string NormalizePath(string path) => Path.GetFullPath(path).ToLowerInvariant(); public string Name => "FileRW"; public string Description => "Dateiverwaltung in zwei getrennten Workspace-Verzeichnissen:\n" + "- workspace='personal': Dein PRIVATER Ordner — nur du hast Zugriff.\n" + "- workspace='shared': GETEILTER Ordner — alle Agenten im Team können darauf zugreifen.\n" + "Aktionen: read, write, append, list, delete, copy, stock_add.\n" + "Bei copy kann zwischen Workspaces kopiert werden.\n" + "'stock_add' fügt einen neuen Datenpunkt zur Aktien-Wissensdatenbank hinzu (shared:stocks/{ticker}/). " + "Geschützte Pfade (protectedPaths) erlauben nur das Erstellen neuer Dateien — kein Überschreiben oder Löschen."; public JsonElement InputSchema { get; } = JsonDocument.Parse(""" { "type": "object", "properties": { "action": { "type": "string", "enum": ["read", "write", "append", "list", "delete", "copy", "stock_add"], "description": "Die auszuführende Aktion. 'stock_add' fügt einen strukturierten Datenpunkt zur Aktien-Wissensdatenbank hinzu." }, "workspace": { "type": "string", "enum": ["personal", "shared"], "description": "In welchem Workspace die Aktion ausgeführt werden soll (Quell-Workspace bei copy). Standard ist 'personal'." }, "path": { "type": "string", "description": "Relativer Pfad zur Datei oder zum Verzeichnis" }, "content": { "type": "string", "description": "Inhalt für write oder append Aktionen" }, "destinationWorkspace": { "type": "string", "enum": ["personal", "shared"], "description": "Ziel-Workspace für copy. Standard ist gleicher Workspace wie 'workspace'." }, "destinationPath": { "type": "string", "description": "Relativer Zielpfad für copy. Pflichtfeld bei copy." }, "ticker": { "type": "string", "description": "Aktien-Ticker für stock_add (z.B. 'NVDA', 'TSLA'). Wird automatisch zu Großbuchstaben." }, "category": { "type": "string", "enum": ["news", "social", "analysis", "capitol", "price", "earnings", "filing", "sentiment", "other"], "description": "Kategorie des Datenpunkts für stock_add." }, "source": { "type": "string", "description": "Quelle des Datenpunkts für stock_add (z.B. 'reuters', 'x_unusual_whales', 'youtube', 'reddit')." }, "title": { "type": "string", "description": "Kurze Überschrift/Zusammenfassung des Datenpunkts für stock_add." }, "data": { "type": "object", "description": "Strukturierte Daten des Datenpunkts für stock_add. Kann beliebige Felder enthalten." } }, "required": ["action"] } """).RootElement.Clone(); public async Task ExecuteAsync( JsonElement input, AgentToolContext context, CancellationToken ct) { var action = input.GetProperty("action").GetString() ?? throw new ArgumentException("'action' is required"); var workspace = (input.TryGetProperty("workspace", out var w) ? w.GetString() : null) ?? "personal"; var rootPath = workspace == "shared" ? context.SharedWorkspacePath : context.WorkspacePath; if (string.IsNullOrWhiteSpace(rootPath)) { var wsName = workspace == "shared" ? "SharedWorkspace" : "PersonalWorkspace"; return ToolResult.Fail($"Konfigurationsfehler: '{wsName}' ist nicht definiert."); } // Sicherstellen, dass rootPath absolut ist rootPath = Path.GetFullPath(rootPath); // Zugriffsprüfung if (!IsActionAllowed(action, workspace, context)) { return ToolResult.Fail($"Zugriff verweigert: Die Aktion '{action}' ist im Workspace '{workspace}' für diesen Agenten nicht erlaubt."); } try { return action switch { "read" => await HandleReadAsync(input, rootPath, workspace, context, ct), "write" => await HandleWriteAsync(input, rootPath, workspace, context, ct), "append" => await HandleAppendAsync(input, rootPath, workspace, context, ct), "list" => await HandleListAsync(input, rootPath, workspace, context, ct), "delete" => await HandleDeleteAsync(input, rootPath, workspace, context, ct), "copy" => await HandleCopyAsync(input, rootPath, workspace, context, ct), "stock_add" => await HandleStockAddAsync(input, context, ct), _ => ToolResult.Fail($"Unbekannte Aktion: {action}") }; } catch (Exception ex) { context.Logger.LogError(ex, "Fehler bei FileRW Aktion {Action}", action); return ToolResult.Fail($"Fehler: {ex.Message}"); } } private bool IsActionAllowed(string action, string workspace, AgentToolContext context) { if (workspace == "personal") return true; // Alles erlaubt var level = context.ToolConfig.TryGetValue("sharedAccessLevel", out var lv) ? lv?.ToString() : "Denied"; if (level == "Denied") return false; return action switch { "read" or "list" or "copy" => true, "write" or "append" or "stock_add" => level is "ReadWrite" or "Admin", "delete" => level is "Admin", _ => false }; } /// /// Prüft ob ein Pfad innerhalb eines geschützten Bereichs liegt. /// Geschützte Pfade erlauben nur: read, list, append, write (nur neue Dateien), stock_add. /// Kein Überschreiben existierender Dateien, kein Löschen. /// Admin-Level umgeht den Schutz. /// private bool IsPathProtected(string fullPath, string rootPath, AgentToolContext context) { var protectedPaths = GetProtectedPaths(context); if (protectedPaths.Count == 0) return false; var relativePath = Path.GetRelativePath(rootPath, fullPath) .Replace('\\', '/').TrimStart('/'); return protectedPaths.Any(pp => relativePath.StartsWith(pp, StringComparison.OrdinalIgnoreCase) || relativePath.Equals(pp.TrimEnd('/'), StringComparison.OrdinalIgnoreCase)); } private bool IsAdminLevel(AgentToolContext context) { var level = context.ToolConfig.TryGetValue("sharedAccessLevel", out var lv) ? lv?.ToString() : "Denied"; return level == "Admin"; } private static List GetProtectedPaths(AgentToolContext context) { if (!context.ToolConfig.TryGetValue("protectedPaths", out var pp) || pp is null) return []; if (pp is JsonElement je && je.ValueKind == JsonValueKind.Array) return je.EnumerateArray() .Select(e => e.GetString()?.Replace('\\', '/').Trim().TrimEnd('/') + "/") .Where(s => !string.IsNullOrEmpty(s)) .ToList()!; if (pp is string s && !string.IsNullOrWhiteSpace(s)) return s.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(p => p.Replace('\\', '/').TrimEnd('/') + "/") .ToList(); return []; } private string GetAndValidatePath(JsonElement input, string rootPath, string workspace, bool checkExtension, AgentToolContext context) { var relativePath = input.TryGetProperty("path", out var p) ? p.GetString() : ""; if (string.IsNullOrWhiteSpace(relativePath)) { relativePath = "."; } var fullPath = Path.GetFullPath(Path.Combine(rootPath, relativePath)); // Path Traversal Check if (!fullPath.StartsWith(rootPath, StringComparison.OrdinalIgnoreCase)) { throw new UnauthorizedAccessException($"Zugriff verweigert: Der Pfad liegt außerhalb des {workspace} Workspaces."); } // Extension Check if (checkExtension && !Directory.Exists(fullPath)) { var extension = Path.GetExtension(fullPath).ToLowerInvariant(); var configKey = workspace == "shared" ? "sharedAllowedExtensions" : "personalAllowedExtensions"; var allowedExtensions = context.ToolConfig.TryGetValue(configKey, out var ae) && ae is JsonElement je ? je.EnumerateArray().Select(x => x.GetString()?.ToLowerInvariant()).ToList() : new List { ".txt", ".json", ".md", ".html", ".js", ".css" }; // Default if (!allowedExtensions.Contains(extension)) { throw new UnauthorizedAccessException($"Dateiendung '{extension}' ist im Workspace '{workspace}' nicht erlaubt."); } } return fullPath; } private async Task HandleReadAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct) { var path = GetAndValidatePath(input, rootPath, workspace, true, context); if (!File.Exists(path)) return ToolResult.Fail($"Datei nicht gefunden im {workspace} Workspace: {Path.GetRelativePath(rootPath, path)}"); var fileLock = GetFileLock(path); await fileLock.WaitAsync(ct); try { var content = await File.ReadAllTextAsync(path, ct); var relativePath = Path.GetRelativePath(rootPath, path); return ToolResult.Ok($"[{workspace}:/{relativePath}]\n{content}"); } finally { fileLock.Release(); } } private async Task HandleWriteAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct) { var path = GetAndValidatePath(input, rootPath, workspace, true, context); var content = input.TryGetProperty("content", out var c) ? c.GetString() ?? "" : ""; var fileLock = GetFileLock(path); await fileLock.WaitAsync(ct); try { // Schutz: In geschützten Bereichen darf nur geschrieben werden wenn die Datei NICHT existiert if (workspace == "shared" && !IsAdminLevel(context) && File.Exists(path) && IsPathProtected(path, rootPath, context)) { var relPath = Path.GetRelativePath(rootPath, path); return ToolResult.Fail( $"🛡️ Geschützter Bereich: Die Datei '{relPath}' existiert bereits und darf nicht überschrieben werden. " + $"Verwende 'append' um Daten hinzuzufügen, oder erstelle eine neue Datei mit anderem Namen. " + $"Tipp: Nutze 'stock_add' für strukturierte Datenpunkte."); } var dir = Path.GetDirectoryName(path); if (dir != null && !Directory.Exists(dir)) Directory.CreateDirectory(dir); await File.WriteAllTextAsync(path, content, new UTF8Encoding(false), ct); return ToolResult.Ok($"[{workspace} Workspace] Datei erfolgreich geschrieben: {workspace}:/{Path.GetRelativePath(rootPath, path)}"); } finally { fileLock.Release(); } } private async Task HandleAppendAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct) { var path = GetAndValidatePath(input, rootPath, workspace, true, context); var content = input.TryGetProperty("content", out var c) ? c.GetString() ?? "" : ""; var fileLock = GetFileLock(path); await fileLock.WaitAsync(ct); try { await File.AppendAllTextAsync(path, content, new UTF8Encoding(false), ct); return ToolResult.Ok($"[{workspace} Workspace] Inhalt erfolgreich angehängt an: {workspace}:/{Path.GetRelativePath(rootPath, path)}"); } finally { fileLock.Release(); } } private async Task HandleListAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct) { var path = GetAndValidatePath(input, rootPath, workspace, false, context); if (!Directory.Exists(path)) return ToolResult.Fail($"Verzeichnis nicht gefunden im {workspace} Workspace."); var relativeDirPath = Path.GetRelativePath(rootPath, path); var entries = Directory.GetFileSystemEntries(path) .Select(e => new { Name = Path.GetFileName(e), Type = Directory.Exists(e) ? "directory" : "file", Size = Directory.Exists(e) ? 0 : new FileInfo(e).Length, LastModified = File.GetLastWriteTime(e) }) .ToList(); var header = $"[{workspace} Workspace] Verzeichnis: {workspace}:/{(relativeDirPath == "." ? "" : relativeDirPath)}\n"; return ToolResult.Ok(header + JsonSerializer.Serialize(entries, new JsonSerializerOptions { WriteIndented = true })); } private async Task HandleDeleteAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct) { var path = GetAndValidatePath(input, rootPath, workspace, false, context); // Schutz: In geschützten Bereichen ist Löschen komplett verboten (außer Admin) if (workspace == "shared" && !IsAdminLevel(context) && IsPathProtected(path, rootPath, context)) { var relPath = Path.GetRelativePath(rootPath, path); return ToolResult.Fail( $"🛡️ Geschützter Bereich: '{relPath}' liegt in einem geschützten Verzeichnis und darf nicht gelöscht werden. " + $"Daten in geschützten Bereichen sind append-only — sie können nur ergänzt, nicht entfernt werden."); } var fileLock = GetFileLock(path); await fileLock.WaitAsync(ct); try { if (File.Exists(path)) { File.Delete(path); return ToolResult.Ok($"[{workspace} Workspace] Datei gelöscht: {workspace}:/{Path.GetRelativePath(rootPath, path)}"); } else if (Directory.Exists(path)) { Directory.Delete(path, true); return ToolResult.Ok($"[{workspace} Workspace] Verzeichnis gelöscht: {workspace}:/{Path.GetRelativePath(rootPath, path)}"); } return ToolResult.Fail($"Datei oder Verzeichnis nicht gefunden im {workspace} Workspace."); } finally { fileLock.Release(); } } private async Task HandleCopyAsync(JsonElement input, string sourceRootPath, string sourceWorkspace, AgentToolContext context, CancellationToken ct) { var sourcePath = GetAndValidatePath(input, sourceRootPath, sourceWorkspace, false, context); var destWorkspace = (input.TryGetProperty("destinationWorkspace", out var dw) ? dw.GetString() : null) ?? sourceWorkspace; var destRelPath = input.TryGetProperty("destinationPath", out var dp) ? dp.GetString() : null; if (string.IsNullOrWhiteSpace(destRelPath)) return ToolResult.Fail("'destinationPath' ist für die copy-Aktion erforderlich."); var destRootPath = destWorkspace == "shared" ? context.SharedWorkspacePath : context.WorkspacePath; if (string.IsNullOrWhiteSpace(destRootPath)) return ToolResult.Fail($"Konfigurationsfehler: '{(destWorkspace == "shared" ? "SharedWorkspace" : "PersonalWorkspace")}' ist nicht definiert."); destRootPath = Path.GetFullPath(destRootPath); if (!IsActionAllowed("write", destWorkspace, context)) return ToolResult.Fail($"Zugriff verweigert: Schreiben im Workspace '{destWorkspace}' ist für diesen Agenten nicht erlaubt."); var destInput = JsonDocument.Parse(JsonSerializer.Serialize(new { path = destRelPath })).RootElement; var destPath = GetAndValidatePath(destInput, destRootPath, destWorkspace, false, context); if (File.Exists(sourcePath)) { // Schutz: Kein Überschreiben in geschützten Bereichen per Copy if (destWorkspace == "shared" && !IsAdminLevel(context) && File.Exists(destPath) && IsPathProtected(destPath, destRootPath, context)) { var relPath = Path.GetRelativePath(destRootPath, destPath); return ToolResult.Fail( $"🛡️ Geschützter Bereich: Die Zieldatei '{relPath}' existiert bereits und darf nicht überschrieben werden."); } // Deadlock-sicheres Locking: Immer in alphabetischer Reihenfolge locken var srcNorm = NormalizePath(sourcePath); var dstNorm = NormalizePath(destPath); var first = string.Compare(srcNorm, dstNorm, StringComparison.Ordinal) <= 0 ? GetFileLock(sourcePath) : GetFileLock(destPath); var second = string.Compare(srcNorm, dstNorm, StringComparison.Ordinal) <= 0 ? GetFileLock(destPath) : GetFileLock(sourcePath); await first.WaitAsync(ct); try { await second.WaitAsync(ct); try { var destDir = Path.GetDirectoryName(destPath); if (destDir != null && !Directory.Exists(destDir)) Directory.CreateDirectory(destDir); await Task.Run(() => File.Copy(sourcePath, destPath, overwrite: !IsPathProtected(destPath, destRootPath, context)), ct); var srcLabel = $"{sourceWorkspace}:/{Path.GetRelativePath(sourceRootPath, sourcePath)}"; var dstLabel = $"{destWorkspace}:/{Path.GetRelativePath(destRootPath, destPath)}"; return ToolResult.Ok($"Datei kopiert: {srcLabel} → {dstLabel}"); } finally { second.Release(); } } finally { first.Release(); } } else if (Directory.Exists(sourcePath)) { await Task.Run(() => CopyDirectory(sourcePath, destPath), ct); var srcLabel = $"{sourceWorkspace}:/{Path.GetRelativePath(sourceRootPath, sourcePath)}"; var dstLabel = $"{destWorkspace}:/{Path.GetRelativePath(destRootPath, destPath)}"; return ToolResult.Ok($"Verzeichnis kopiert: {srcLabel} → {dstLabel}"); } return ToolResult.Fail("Quelldatei oder -verzeichnis nicht gefunden."); } // ═══════════════════════════════════════════════════ // STOCK DATABASE (Append-Only Wissensdatenbank) // ═══════════════════════════════════════════════════ private static readonly JsonSerializerOptions StockJsonOpts = new() { WriteIndented = true, PropertyNameCaseInsensitive = true, Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; private async Task HandleStockAddAsync(JsonElement input, AgentToolContext context, CancellationToken ct) { // Pflichtfelder var ticker = (input.TryGetProperty("ticker", out var t) ? t.GetString() : null)?.Trim().ToUpperInvariant(); if (string.IsNullOrWhiteSpace(ticker)) return ToolResult.Fail("'ticker' ist ein Pflichtfeld für stock_add (z.B. 'NVDA', 'TSLA')."); var category = (input.TryGetProperty("category", out var cat) ? cat.GetString() : null) ?? "other"; var source = (input.TryGetProperty("source", out var src) ? src.GetString() : null) ?? "unknown"; var title = (input.TryGetProperty("title", out var ttl) ? ttl.GetString() : null) ?? ""; var content = input.TryGetProperty("content", out var cnt) ? cnt.GetString() : null; var data = input.TryGetProperty("data", out var d) ? d : (JsonElement?)null; if (string.IsNullOrWhiteSpace(title) && string.IsNullOrWhiteSpace(content) && data is null) return ToolResult.Fail("Mindestens 'title', 'content' oder 'data' muss angegeben werden."); // SharedWorkspace ist Pflicht für stocks var sharedRoot = context.SharedWorkspacePath; if (string.IsNullOrWhiteSpace(sharedRoot)) return ToolResult.Fail("SharedWorkspace ist nicht konfiguriert."); sharedRoot = Path.GetFullPath(sharedRoot); // Zugriffsprüfung if (!IsActionAllowed("stock_add", "shared", context)) return ToolResult.Fail("Zugriff verweigert: Schreiben im SharedWorkspace nicht erlaubt."); // Sanitize Ticker für Verzeichnisname var safeTicker = SanitizeFileName(ticker); var stockDir = Path.Combine(sharedRoot, "stocks", safeTicker); Directory.CreateDirectory(stockDir); // Timestamp-basierten Dateinamen generieren var now = DateTime.UtcNow; var timestamp = now.ToString("yyyyMMdd_HHmmss"); var safeSource = SanitizeFileName(source); var fileName = $"{timestamp}_{category}_{safeSource}.json"; var filePath = Path.Combine(stockDir, fileName); // Kollisionsvermeidung var counter = 1; while (File.Exists(filePath)) { fileName = $"{timestamp}_{category}_{safeSource}_{counter}.json"; filePath = Path.Combine(stockDir, fileName); counter++; } // Datenpunkt erstellen var entry = new Dictionary { ["id"] = $"{safeTicker}_{timestamp}_{category}_{safeSource}", ["ticker"] = ticker, ["category"] = category, ["source"] = source, ["title"] = title, ["timestamp"] = now.ToString("o"), ["agent"] = context.AgentId }; if (!string.IsNullOrWhiteSpace(content)) entry["content"] = content; if (data.HasValue) entry["data"] = data.Value; // Datenpunkt speichern var json = JsonSerializer.Serialize(entry, StockJsonOpts); await File.WriteAllTextAsync(filePath, json, new UTF8Encoding(false), ct); // _index.json aktualisieren (thread-safe via per-file SemaphoreSlim, append-only) var indexPath = Path.Combine(stockDir, "_index.json"); var indexEntry = new Dictionary { ["file"] = fileName, ["category"] = category, ["source"] = source, ["title"] = title, ["timestamp"] = now.ToString("o"), ["agent"] = context.AgentId }; var indexLock = GetFileLock(indexPath); await indexLock.WaitAsync(ct); try { List> index; if (File.Exists(indexPath)) { try { var existing = await File.ReadAllTextAsync(indexPath, ct); index = JsonSerializer.Deserialize>>(existing, StockJsonOpts) ?? []; } catch (JsonException) { // Korrupter Index: Backup + Neustart var backup = indexPath + $".bak_{now:yyyyMMdd_HHmmss}"; try { File.Copy(indexPath, backup, overwrite: true); } catch { /* best effort */ } index = []; } } else { index = []; } index.Add(indexEntry); await File.WriteAllTextAsync(indexPath, JsonSerializer.Serialize(index, StockJsonOpts), new UTF8Encoding(false), ct); } finally { indexLock.Release(); } var relPath = $"shared:/stocks/{safeTicker}/{fileName}"; return ToolResult.Ok( $"✅ Datenpunkt hinzugefügt: {relPath}\n" + $"Ticker: {ticker} | Kategorie: {category} | Quelle: {source}\n" + $"Titel: {title}\n" + $"Index: {index_Count(indexPath)} Einträge für {ticker}"); } private static int index_Count(string indexPath) { try { var json = File.ReadAllText(indexPath); using var doc = JsonDocument.Parse(json); return doc.RootElement.GetArrayLength(); } catch { return -1; } } private static string SanitizeFileName(string name) { var sanitized = name.Trim(); foreach (var c in Path.GetInvalidFileNameChars()) sanitized = sanitized.Replace(c, '_'); return sanitized.Replace(' ', '_'); } private static void CopyDirectory(string sourceDir, string destinationDir) { Directory.CreateDirectory(destinationDir); foreach (var file in Directory.GetFiles(sourceDir)) File.Copy(file, Path.Combine(destinationDir, Path.GetFileName(file)), overwrite: true); foreach (var dir in Directory.GetDirectories(sourceDir)) CopyDirectory(dir, Path.Combine(destinationDir, Path.GetFileName(dir))); } }