S5 — Die Domain-Whitelist wurde nur auf die Ausgangs-URL angewendet, HttpClient folgte Weiterleitungen aber selbst. Eine erlaubte Domain konnte damit auf beliebige interne Adressen weiterleiten: Router, NAS, der Git-Server im LAN, Cloud-Metadatendienste. Der Agent haette deren Inhalt zurueckgeliefert. UrlGuard prueft Schema, private und lokale Netzbereiche sowie die Whitelist. AllowAutoRedirect ist abgeschaltet; Weiterleitungen werden einzeln aufgeloest und JEDER Zwischenschritt erneut geprueft, begrenzt auf fuenf Spruenge. Nebenbei behoben: Die alte www-Behandlung ersetzte die Zeichenfolge ueber den ganzen Hostnamen, aus mywww.example.com wurde myexample.com. Und die Subdomain-Pruefung achtet jetzt auf den Punkt, sodass example.com.attacker.net nicht mehr als Treffer fuer example.com durchgeht. S6 — Die Pfadpruefung in FileRW verglich nur Zeichenketten-Praefixe. Ohne abschliessenden Verzeichnistrenner erlaubte ein Root wie Agent-X\Workspace damit auch Zugriffe auf Agent-X\Workspace-Backup. WorkspacePath vergleicht jetzt auf Verzeichnisgrenzen und lehnt zusaetzlich absolute Pfade, UNC-Freigaben und Alternate Data Streams ab. Das Dateisystem wird in den Tests bewusst nicht abstrahiert — sie sollen die echte Windows-Pfadsemantik pruefen. Eine Abstraktion wuerde genau die Fehlerklasse verstecken, um die es geht. B12 — Der Client warf bei jedem Nicht-2xx sofort; ein einzelnes HTTP 429 beendete damit einen kompletten geplanten Lauf, obwohl Rate-Limits und kurze 5xx bei OpenRouter Normalbetrieb sind. RetryPolicy wiederholt 408/425/429/5xx mit exponentiellem Backoff und Streuung, respektiert ein Retry-After des Servers und deckelt die Wartezeit. Dauerhafte Fehler wie 401 werden nicht wiederholt. Die Wartefunktion ist injizierbar, damit die Tests nicht wirklich warten. 264 Tests gruen (116 Core, 148 Tools). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
596 lines
26 KiB
C#
596 lines
26 KiB
C#
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
|
|
{
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
private static readonly ConcurrentDictionary<string, SemaphoreSlim> 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<ToolResult> 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
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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('/');
|
|
|
|
// GetProtectedPaths hängt an jeden Eintrag ein "/" an, deshalb greift
|
|
// StartsWith hier auf einer echten Verzeichnisgrenze: "stocks/" trifft
|
|
// "stocks/x.json", aber nicht "stocks-alt/x.json".
|
|
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<string> 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() : "";
|
|
|
|
// Prüft Ausbruch aus dem Workspace, absolute Pfade und Alternate Data Streams.
|
|
var fullPath = WorkspacePath.Resolve(rootPath, relativePath, workspace);
|
|
|
|
// 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<string?> { ".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<ToolResult> 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<ToolResult> 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<ToolResult> 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<ToolResult> 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<ToolResult> 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<ToolResult> 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<ToolResult> 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<string, object?>
|
|
{
|
|
["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<string, object?>
|
|
{
|
|
["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<Dictionary<string, object?>> index;
|
|
if (File.Exists(indexPath))
|
|
{
|
|
try
|
|
{
|
|
var existing = await File.ReadAllTextAsync(indexPath, ct);
|
|
index = JsonSerializer.Deserialize<List<Dictionary<string, object?>>>(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)));
|
|
}
|
|
}
|