Files
RichardandClaude Opus 5.5 ad71682c2d i18n: Englisch als Hauptsprache, Sprachpakete fuer Desktop und WebUI (#661)
Umbau durch Gemini Flash, Durchsicht und Nacharbeit durch Claude.

Umfang:
- Desktop: LocalizationManager (JSON-Pakete, Rueckfall auf Englisch,
  Umschalten zur Laufzeit), Localize-Markup-Erweiterung, Sprachwahl in den
  Einstellungen (AppSettings.Language, Vorgabe "en"); 317 Schluessel je Sprache
- WebUI: alle Ansichten auf t()/data-i18n, 656 Schluessel je Sprache
- Agenten-Werkzeuge: Beschreibungen, Schemata und Meldungen auf Englisch
- Tests an die englischen Meldungen angepasst, keine Pruefung abgeschwaecht

Nacharbeit bei der Durchsicht:
- ~25 Kommentarbloecke mit Begruendungen waren ersatzlos entfallen, darunter
  sicherheitsrelevante (NTFS-Datenstrom beim shared:-Praefix, Bremse gegen
  Agentenschleifen, doppelter POST beim Anlegen einer Instanz). Wieder
  eingesetzt: auf Englisch, wo die Datei umgestellt ist, sonst im Wortlaut.
- Drei WebUI-Schluessel fehlten (logs.paused, instances.instanceOfflineError,
  instances.noInstanceActiveError). Weil t() bei fehlendem Schluessel den
  Schluessel selbst liefert, griff der "|| err.message"-Rueckfall nie — die
  Oberflaeche haette den Schluesselnamen angezeigt.

Geprueft: Schluesselgleichheit en/de, Platzhalter je Schluessel, jeder im
Code benutzte Schluessel vorhanden, node --check fuer alle Module,
1.220 Tests gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 09:43:51 +02:00

1029 lines
47 KiB
C#

using System.Collections.Concurrent;
using System.Text;
using System.Text.Json;
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tools;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Tools.FileRW;
public sealed class FileRWTool : IAgentTool, IToolStateProbe
{
/// <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 => "File management across two separate workspace directories:\n" +
"- workspace='personal': Your PRIVATE folder — only you have access.\n" +
"- workspace='shared': SHARED folder — all agents on the team can access it.\n" +
"Actions: read, read_many, write, append, list, delete, copy, stock_add.\n" +
"'read_many' reads multiple files in ONE call - either 'paths' (list) or 'path' " +
"(directory) with 'pattern' (e.g. \"berlin_2026-09-20_*.txt\"). Use this instead of reading " +
"many files individually: each individual call costs a step.\n" +
"'list' by default shows the 50 most recently modified entries, newest first; " +
"'pattern', 'sort', 'order' and 'limit' control this. It is better to query a specific file " +
"using 'pattern' rather than searching through a long list.\n" +
"For copy, files can be copied between workspaces.\n" +
"'stock_add' adds a new data point to the stock knowledge base (shared:stocks/{ticker}/). " +
"Protected paths (protectedPaths) only allow creating new files — no overwriting or deleting.";
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
{
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["read", "read_many", "write", "append", "list", "delete", "copy", "stock_add"],
"description": "The action to perform. 'read_many' reads multiple files at once. 'stock_add' adds a structured data point to the stock knowledge base."
},
"workspace": {
"type": "string",
"enum": ["personal", "shared"],
"description": "The workspace in which to execute the action (source workspace for copy). Default is 'personal'."
},
"path": {
"type": "string",
"description": "Relative path to the file or directory"
},
"content": {
"type": "string",
"description": "Content for write or append actions"
},
"paths": {
"type": "array",
"items": { "type": "string" },
"description": "Only for read_many: files to read. Alternatively 'path' (directory) with 'pattern'."
},
"pattern": {
"type": "string",
"description": "For read_many and list: file pattern within directory 'path', e.g. 'berlin_2026-09-20_*.txt'. Without pattern, the entire directory is used."
},
"sort": {
"type": "string",
"enum": ["modified", "name"],
"description": "Only for list: sorting. Default 'modified' (newest first)."
},
"order": {
"type": "string",
"enum": ["desc", "asc"],
"description": "Only for list: sort direction. Default descending for 'modified', ascending for 'name'."
},
"limit": {
"type": "integer",
"description": "Only for list: maximum number of entries. Default 50, maximum 500. The response header always mentions total count."
},
"destinationWorkspace": {
"type": "string",
"enum": ["personal", "shared"],
"description": "Destination workspace for copy. Default is the same workspace as 'workspace'."
},
"destinationPath": {
"type": "string",
"description": "Relative destination path for copy. Required for copy."
},
"ticker": {
"type": "string",
"description": "Stock ticker for stock_add (e.g. 'NVDA', 'TSLA'). Automatically converted to uppercase."
},
"category": {
"type": "string",
"enum": ["news", "social", "analysis", "capitol", "price", "earnings", "filing", "sentiment", "other"],
"description": "Category of data point for stock_add."
},
"source": {
"type": "string",
"description": "Source of data point for stock_add (e.g. 'reuters', 'x_unusual_whales', 'youtube', 'reddit')."
},
"title": {
"type": "string",
"description": "Short headline/summary of data point for stock_add."
},
"data": {
"type": "object",
"description": "Structured data of data point for stock_add. Can contain arbitrary fields."
}
},
"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($"Configuration error: '{wsName}' is not defined.");
}
// Sicherstellen, dass rootPath absolut ist
rootPath = Path.GetFullPath(rootPath);
// Zugriffsprüfung
if (!IsActionAllowed(action, workspace, context))
{
return ToolResult.Fail($"Access denied: Action '{action}' is not allowed in workspace '{workspace}' for this agent.");
}
try
{
return action switch
{
"read" => await HandleReadAsync(input, rootPath, workspace, context, ct),
"read_many" => await HandleReadManyAsync(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($"Unknown action: {action}")
};
}
catch (Exception ex)
{
context.Logger.LogError(ex, "Error during FileRW action {Action}", action);
return ToolResult.Fail($"Error: {ex.Message}");
}
}
/// <summary>
/// #579: Rechte und Pfad pruefen, bevor ein Aufruf in die Freigabeliste kommt. Dieselben
/// Pruefungen wie in <see cref="ExecuteAsync"/>, ohne etwas anzufassen.
/// </summary>
public string? Preflight(JsonElement input, AgentToolContext context)
{
if (input.ValueKind != JsonValueKind.Object
|| !input.TryGetProperty("action", out var a) || a.ValueKind != JsonValueKind.String)
return null;
var action = a.GetString() ?? "";
var workspace = (input.TryGetProperty("workspace", out var w) && w.ValueKind == JsonValueKind.String
? w.GetString() : null) ?? "personal";
if (!IsActionAllowed(action, workspace, context))
return $"Access denied: Action '{action}' is not allowed in workspace '{workspace}' for this agent.";
var rootPath = workspace == "shared" ? context.SharedWorkspacePath : context.WorkspacePath;
if (action != "delete" || string.IsNullOrWhiteSpace(rootPath))
return null;
try
{
rootPath = Path.GetFullPath(rootPath);
var path = GetAndValidatePath(input, rootPath, workspace, false, context);
if (workspace == "shared" && !IsAdminLevel(context) && IsPathProtected(path, rootPath, context))
return $"🛡️ Protected area: '{Path.GetRelativePath(rootPath, path)}' cannot be deleted.";
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// Dieselbe Pfadpruefung wirft bei der Ausfuehrung - dort wird daraus ein Fehler.
return $"Access denied: {ex.Message}";
}
return null;
}
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 "read_many" 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 [];
}
/// <summary>
/// Nachschau nach einem Absturz (#555). Sieht nach, was von einem unterbrochenen
/// Aufruf übrig ist — verändert nichts.
///
/// <para>Wie weit die Auskunft trägt, hängt an der Aktion:</para>
/// <list type="bullet">
/// <item><b>write</b> — seit #553 atomar. Entweder die alte oder die neue Fassung,
/// nie eine halbe. Ein Vergleich mit dem beabsichtigten Inhalt sagt deshalb
/// zuverlässig, ob der Aufruf durchkam.</item>
/// <item><b>append</b> — nicht atomar und nicht atomar zu machen. Hier kann wirklich
/// ein Riss entstehen, und hier lohnt die Nachschau: Steht der beabsichtigte Text
/// vollständig am Ende, kam der Aufruf durch; steht ein Anfang davon da, riss es
/// mittendrin.</item>
/// <item>alles andere — <see cref="ProbeOutcome.Unknown"/>. Lieber schweigen als
/// raten.</item>
/// </list>
/// </summary>
public async Task<ProbeFinding> ProbeAsync(JsonElement arguments, AgentToolContext context, CancellationToken ct)
{
try
{
var action = arguments.TryGetProperty("action", out var a) ? a.GetString() : null;
if (action is not ("write" or "append"))
return ProbeFinding.Unknown($"Nothing to probe for action '{action ?? "?"}'.");
var workspace = (arguments.TryGetProperty("workspace", out var w) ? w.GetString() : null) ?? "personal";
var rootPath = workspace == "shared" ? context.SharedWorkspacePath : context.WorkspacePath;
if (string.IsNullOrWhiteSpace(rootPath))
return ProbeFinding.Unknown("Workspace path is unknown.");
var path = GetAndValidatePath(arguments, Path.GetFullPath(rootPath), workspace, true, context);
var erwartet = arguments.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
if (!File.Exists(path))
{
return new ProbeFinding(ProbeOutcome.NotStarted,
"File does not exist — nothing was written.");
}
var vorhanden = await File.ReadAllTextAsync(path, ct);
if (action == "write")
{
return vorhanden == erwartet
? new ProbeFinding(ProbeOutcome.Complete,
$"Content matches intended content exactly ({erwartet.Length} characters).")
: new ProbeFinding(ProbeOutcome.NotStarted,
"File still has old content. Writes are atomic — partial versions cannot exist.");
}
// append
if (vorhanden.EndsWith(erwartet, StringComparison.Ordinal))
{
return new ProbeFinding(ProbeOutcome.Complete,
$"Intended text is completely at the end ({erwartet.Length} characters).");
}
// Wie viel vom Erwarteten steht am Ende? Das ist die Rissstelle.
for (var laenge = Math.Min(erwartet.Length - 1, vorhanden.Length); laenge > 0; laenge--)
{
if (vorhanden.EndsWith(erwartet[..laenge], StringComparison.Ordinal))
{
return new ProbeFinding(ProbeOutcome.Partial,
$"Appended {laenge} of {erwartet.Length} characters — call was interrupted.");
}
}
return new ProbeFinding(ProbeOutcome.NotStarted,
"None of the intended text is at the end of the file.");
}
catch (Exception ex)
{
// Nie werfen: Eine gescheiterte Nachschau ist keine Aussage über die Welt,
// sondern eine über die Nachschau.
return ProbeFinding.Unknown($"Probe not possible: {ex.Message}");
}
}
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($"File extension '{extension}' is not allowed in workspace '{workspace}'.");
}
}
return fullPath;
}
/// <summary>
/// #662: Gibt es den Pfad im jeweils anderen Workspace, sagt die Fehlermeldung das.
///
/// <para>Anlass aus dem Testbetrieb: Ein guenstiges Modell vergisst regelmaessig
/// <c>workspace: "shared"</c> und bekommt nur "nicht gefunden" zurueck. Es rät dann
/// weiter, und jeder Versuch kostet einen Schritt. Der Hinweis kommt nur, wenn der
/// Agent den anderen Workspace auch benutzen darf - sonst waere er eine Einladung
/// zu etwas, das ohnehin abgewiesen wird.</para>
/// </summary>
private string AndererWorkspaceHinweis(
string action, string workspace, string? relativePath, AgentToolContext context, bool verzeichnis)
{
var anderer = workspace == "shared" ? "personal" : "shared";
if (!IsActionAllowed(action, anderer, context))
return "";
var root = anderer == "shared" ? context.SharedWorkspacePath : context.WorkspacePath;
if (string.IsNullOrWhiteSpace(root))
return "";
try
{
var pfad = WorkspacePath.Resolve(Path.GetFullPath(root), relativePath, anderer);
var da = verzeichnis ? Directory.Exists(pfad) : File.Exists(pfad);
return da
? $" The path exists in the {anderer} workspace — call with workspace: \"{anderer}\"."
: "";
}
catch
{
return ""; // Ein Pfad, der dort gar nicht auflösbar ist, hilft niemandem.
}
}
private static string? RelativerPfad(JsonElement input)
=> input.ValueKind == JsonValueKind.Object && input.TryGetProperty("path", out var p) && p.ValueKind == JsonValueKind.String
? p.GetString()
: null;
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(
$"File not found in {workspace} workspace: {Path.GetRelativePath(rootPath, path)}."
+ AndererWorkspaceHinweis("read", workspace, RelativerPfad(input), context, verzeichnis: false));
}
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();
}
}
/// <summary>Hoechstzahl Dateien je <c>read_many</c>.</summary>
public const int MaxDateienJeAufruf = 50;
/// <summary>Hoechstzahl Zeichen je <c>read_many</c> - danach werden nur noch Namen genannt.</summary>
public const int MaxZeichenJeAufruf = 200_000;
/// <summary>
/// #663: Mehrere Dateien in einem Aufruf lesen.
///
/// <para>Anlass: Der Tagesabschluss im Testbetrieb muss 24 Stundendateien zu einem
/// Tagesarchiv zusammenfassen. Einzeln gelesen sind das 24 Schritte, und der Lauf
/// stirbt an der Schrittgrenze, bevor er fertig ist. Die Grenze soll Endlosschleifen
/// abfangen, nicht ehrliche Arbeit - also liest das Werkzeug jetzt auf einmal.</para>
///
/// <para>Entweder <c>paths</c> (ausdrueckliche Liste) oder <c>path</c> als Verzeichnis
/// mit optionalem <c>pattern</c>. Gedeckelt auf <see cref="MaxDateienJeAufruf"/> Dateien
/// und <see cref="MaxZeichenJeAufruf"/> Zeichen; was nicht mehr hineinpasst, wird am
/// Ende namentlich genannt, damit der Agent den Rest gezielt nachholen kann.</para>
/// </summary>
private async Task<ToolResult> HandleReadManyAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct)
{
var (dateien, fehler) = SammleDateien(input, rootPath, workspace, context);
if (fehler is not null)
return ToolResult.Fail(fehler);
if (dateien.Count == 0)
{
return ToolResult.Fail(
$"No matching file found in {workspace} workspace."
+ AndererWorkspaceHinweis("read_many", workspace, RelativerPfad(input), context, verzeichnis: true));
}
var uebersprungen = new List<string>();
var fehlend = new List<string>();
var text = new StringBuilder();
var gelesen = 0;
foreach (var pfad in dateien.Take(MaxDateienJeAufruf))
{
ct.ThrowIfCancellationRequested();
var relativ = Path.GetRelativePath(rootPath, pfad).Replace('\\', '/');
if (!File.Exists(pfad)) { fehlend.Add(relativ); continue; }
if (text.Length >= MaxZeichenJeAufruf) { uebersprungen.Add(relativ); continue; }
var sperre = GetFileLock(pfad);
await sperre.WaitAsync(ct);
string inhalt;
try { inhalt = await File.ReadAllTextAsync(pfad, ct); }
finally { sperre.Release(); }
text.Append($"\n--- {workspace}:/{relativ} ---\n").Append(inhalt.TrimEnd()).Append('\n');
gelesen++;
}
uebersprungen.AddRange(dateien.Skip(MaxDateienJeAufruf).Select(d => Path.GetRelativePath(rootPath, d).Replace('\\', '/')));
var kopf = new StringBuilder($"[{workspace} Workspace] {gelesen} of {dateien.Count} file(s) read.");
if (fehlend.Count > 0)
kopf.Append($"\nNot found: {string.Join(", ", fehlend.Take(10))}{(fehlend.Count > 10 ? " …" : "")}");
if (uebersprungen.Count > 0)
kopf.Append($"\nSkipped reading (limit {MaxDateienJeAufruf} files / {MaxZeichenJeAufruf} chars), "
+ $"fetch with an additional call: {string.Join(", ", uebersprungen.Take(10))}{(uebersprungen.Count > 10 ? " …" : "")}");
return ToolResult.Ok(kopf.Append('\n').Append(text).ToString());
}
/// <summary>
/// Die zu lesenden Dateien: entweder aus <c>paths</c> oder aus Verzeichnis und Muster.
/// Jeder Pfad geht durch dieselbe Pruefung wie ein einzelnes <c>read</c>.
/// </summary>
private (List<string> Dateien, string? Fehler) SammleDateien(
JsonElement input, string rootPath, string workspace, AgentToolContext context)
{
try
{
if (input.TryGetProperty("paths", out var liste) && liste.ValueKind == JsonValueKind.Array)
{
var dateien = new List<string>();
foreach (var eintrag in liste.EnumerateArray())
{
if (eintrag.ValueKind != JsonValueKind.String) continue;
var relativ = eintrag.GetString();
if (string.IsNullOrWhiteSpace(relativ)) continue;
dateien.Add(PruefeLesepfad(relativ, rootPath, workspace, context));
}
return (dateien, dateien.Count == 0 ? "'paths' contains no valid path." : null);
}
var verzeichnis = WorkspacePath.Resolve(rootPath, RelativerPfad(input), workspace);
if (!Directory.Exists(verzeichnis))
return ([], null); // Die Meldung samt Workspace-Hinweis macht der Aufrufer.
var muster = input.TryGetProperty("pattern", out var m) && m.ValueKind == JsonValueKind.String
? m.GetString()
: null;
if (MusterFehler(muster) is { } musterFehler)
return ([], musterFehler);
var gefunden = Directory.GetFiles(verzeichnis, string.IsNullOrWhiteSpace(muster) ? "*" : muster)
.OrderBy(f => f, StringComparer.Ordinal)
.ToList();
// Dieselbe Endungspruefung wie beim einzelnen Lesen - was dort verboten ist,
// kommt auch hier nicht durch, nur ohne den Lauf abzubrechen.
return (gefunden.Where(f => IstLesbar(f, workspace, context)).ToList(), null);
}
catch (UnauthorizedAccessException ex)
{
return ([], $"Access denied: {ex.Message}");
}
}
private string PruefeLesepfad(string relativePath, string rootPath, string workspace, AgentToolContext context)
{
var json = JsonSerializer.SerializeToElement(new { action = "read", path = relativePath });
return GetAndValidatePath(json, rootPath, workspace, true, context);
}
private bool IstLesbar(string vollerPfad, string workspace, AgentToolContext context)
{
try
{
var json = JsonSerializer.SerializeToElement(new { action = "read", path = Path.GetFileName(vollerPfad) });
GetAndValidatePath(json, Path.GetDirectoryName(vollerPfad)!, workspace, true, context);
return true;
}
catch (UnauthorizedAccessException)
{
return false;
}
}
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(
$"🛡️ Protected area: File '{relPath}' already exists and cannot be overwritten. " +
$"Use 'append' to add data, or create a new file with a different name. " +
$"Tip: Use 'stock_add' for structured data points.");
}
var dir = Path.GetDirectoryName(path);
if (dir != null && !Directory.Exists(dir)) Directory.CreateDirectory(dir);
// Atomar schreiben (#553): erst in eine Nebendatei, dann ersetzen. Ein Absturz
// mitten im Vorgang hinterlässt damit entweder die alte oder die neue Fassung,
// nie eine halbe. Zuvor stand hier File.WriteAllTextAsync — die Anwendung
// schützte also ihre eigenen Konfigurationsdateien, die Arbeitsergebnisse der
// Agenten aber nicht.
await AtomicFile.WriteAllTextAsync(path, content, new UTF8Encoding(false), ct);
return ToolResult.Ok($"[{workspace} Workspace] File successfully written: {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
{
// Bewusst NICHT atomar (#553): Anhängen ließe sich nur atomar machen, indem
// die ganze Datei neu geschrieben wird — bei einer großen Protokolldatei wäre
// das der falsche Handel. Hier bleibt ein Riss möglich; genau dafür gibt es
// die Zustandsprüfung in #555.
// #575: Neu entstehende Dateien gehoeren nur dem Besitzer (0600).
await using (var stream = ClawdDotNet.Core.Storage.OwnerOnlyFile.OpenAppend(path))
await using (var writer = new StreamWriter(stream, new UTF8Encoding(false)))
await writer.WriteAsync(content.AsMemory(), ct);
return ToolResult.Ok($"[{workspace} Workspace] Content successfully appended to: {workspace}:/{Path.GetRelativePath(rootPath, path)}");
}
finally
{
fileLock.Release();
}
}
/// <summary>Wie viele Eintraege ein <c>list</c> ohne ausdrueckliches <c>limit</c> zeigt.</summary>
public const int StandardEintraege = 50;
/// <summary>Obergrenze fuer <c>limit</c> - darueber wird die Antwort zur Heuhaufensuche.</summary>
public const int MaxEintraege = 500;
/// <summary>
/// #673: Verzeichnis auflisten - sortiert, gedeckelt, kompakt.
///
/// <para>Anlass: Die Betriebskontrolle im Testbetrieb meldete Stundendateien als fehlend,
/// die es gab. Das Verzeichnis lieferte 105 Eintraege in der Reihenfolge des Dateisystems,
/// vierzeilig als eingeruecktes JSON. Darin zwei bestimmte Namen zu finden ist Sucharbeit,
/// und ein guenstiges Modell erledigt sie falsch.</para>
///
/// <para>Also: neueste zuerst (umstellbar ueber <c>sort</c> und <c>order</c>), <c>pattern</c>
/// zum Eingrenzen, eine Zeile je Eintrag - und der Kopf sagt, wie viele Eintraege es
/// insgesamt gibt. Eine gedeckelte Liste, die ihre Deckelung verschweigt, waere die
/// schlimmere Falle.</para>
/// </summary>
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(
$"Directory not found in {workspace} workspace: {Path.GetRelativePath(rootPath, path)}."
+ AndererWorkspaceHinweis("list", workspace, RelativerPfad(input), context, verzeichnis: true));
}
var muster = GetOptionalString(input, "pattern");
if (MusterFehler(muster) is { } musterFehler)
return ToolResult.Fail(musterFehler);
List<string> pfade;
try
{
pfade = Directory.GetFileSystemEntries(path, string.IsNullOrWhiteSpace(muster) ? "*" : muster).ToList();
}
catch (ArgumentException ex)
{
return ToolResult.Fail($"Invalid pattern '{muster}': {ex.Message}");
}
var eintraege = pfade
.Select(e => new Verzeichniseintrag(
Path.GetFileName(e),
Directory.Exists(e),
Directory.Exists(e) ? 0 : new FileInfo(e).Length,
File.GetLastWriteTime(e)))
.ToList();
var nachName = string.Equals(GetOptionalString(input, "sort"), "name", StringComparison.OrdinalIgnoreCase);
var absteigend = GetOptionalString(input, "order") is { } o
? string.Equals(o, "desc", StringComparison.OrdinalIgnoreCase)
: !nachName; // newest first, names ascending
IEnumerable<Verzeichniseintrag> sortiert = nachName
? (absteigend
? eintraege.OrderByDescending(e => e.Name, StringComparer.OrdinalIgnoreCase)
: eintraege.OrderBy(e => e.Name, StringComparer.OrdinalIgnoreCase))
: (absteigend
? eintraege.OrderByDescending(e => e.Geaendert)
: eintraege.OrderBy(e => e.Geaendert));
var grenze = Math.Clamp(GetInt(input, "limit") ?? StandardEintraege, 1, MaxEintraege);
var gezeigt = sortiert.Take(grenze).ToList();
var relativeDirPath = Path.GetRelativePath(rootPath, path);
var ordnung = nachName
? (absteigend ? "name descending" : "name ascending")
: (absteigend ? "newest first" : "oldest first");
var text = new StringBuilder();
text.Append($"[{workspace} Workspace] Directory: {workspace}:/{(relativeDirPath == "." ? "" : relativeDirPath)}");
text.Append($" - {gezeigt.Count} of {eintraege.Count} entries, {ordnung}.");
if (!string.IsNullOrWhiteSpace(muster))
text.Append($" Pattern: {muster}.");
if (eintraege.Count > gezeigt.Count)
text.Append($"\n{eintraege.Count - gezeigt.Count} more not shown - narrow down with 'pattern' or set 'limit' (max {MaxEintraege}).");
text.Append("\nModified Size Name\n");
foreach (var e in gezeigt)
{
var groesse = e.IstVerzeichnis ? "<DIR>" : e.Groesse.ToString();
text.Append($"{e.Geaendert:yyyy-MM-dd HH:mm} {groesse,9} {e.Name}{(e.IstVerzeichnis ? "/" : "")}\n");
}
return await Task.FromResult(ToolResult.Ok(text.ToString().TrimEnd()));
}
private readonly record struct Verzeichniseintrag(string Name, bool IstVerzeichnis, long Groesse, DateTime Geaendert);
/// <summary>
/// Ein Muster ist ein Dateiname mit Platzhaltern, kein Pfad. <c>Directory.GetFiles</c>
/// wertet Trennzeichen im Muster aus — <c>pattern: "../*"</c> listet damit das
/// Elternverzeichnis, an der Pfadpruefung vorbei. Deshalb hier und nicht erst dort.
/// </summary>
private static string? MusterFehler(string? muster)
{
if (string.IsNullOrWhiteSpace(muster)) return null;
if (muster.Contains('/') || muster.Contains('\\') || muster.Contains(".."))
return $"Invalid pattern '{muster}': a pattern is a file name with wildcards "
+ "(e.g. 'berlin_2026-09-21_*.txt'), not a path. The directory belongs in 'path'.";
return null;
}
private static string? GetOptionalString(JsonElement input, string name)
=> input.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
private static int? GetInt(JsonElement input, string name)
=> input.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var i) ? i : null;
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(
$"🛡️ Protected area: '{relPath}' is in a protected directory and cannot be deleted. " +
$"Data in protected areas is append-only — it can only be added, not removed.");
}
var fileLock = GetFileLock(path);
await fileLock.WaitAsync(ct);
try
{
if (File.Exists(path))
{
File.Delete(path);
return ToolResult.Ok($"[{workspace} Workspace] File deleted: {workspace}:/{Path.GetRelativePath(rootPath, path)}");
}
else if (Directory.Exists(path))
{
Directory.Delete(path, true);
return ToolResult.Ok($"[{workspace} Workspace] Directory deleted: {workspace}:/{Path.GetRelativePath(rootPath, path)}");
}
return ToolResult.Fail($"File or directory not found in {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' is required for copy action.");
var destRootPath = destWorkspace == "shared" ? context.SharedWorkspacePath : context.WorkspacePath;
if (string.IsNullOrWhiteSpace(destRootPath))
return ToolResult.Fail($"Configuration error: '{(destWorkspace == "shared" ? "SharedWorkspace" : "PersonalWorkspace")}' is not defined.");
destRootPath = Path.GetFullPath(destRootPath);
if (!IsActionAllowed("write", destWorkspace, context))
return ToolResult.Fail($"Access denied: Writing to workspace '{destWorkspace}' is not allowed for this agent.");
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(
$"🛡️ Protected area: Destination file '{relPath}' already exists and cannot be overwritten.");
}
// 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($"File copied: {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($"Directory copied: {srcLabel} → {dstLabel}");
}
return ToolResult.Fail("Source file or directory not found.");
}
// ═══════════════════════════════════════════════════
// 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' is required for stock_add (e.g. '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("At least 'title', 'content', or 'data' must be specified.");
// SharedWorkspace ist Pflicht für stocks
var sharedRoot = context.SharedWorkspacePath;
if (string.IsNullOrWhiteSpace(sharedRoot))
return ToolResult.Fail("SharedWorkspace is not configured.");
sharedRoot = Path.GetFullPath(sharedRoot);
// Zugriffsprüfung
if (!IsActionAllowed("stock_add", "shared", context))
return ToolResult.Fail("Access denied: Writing to SharedWorkspace is not allowed.");
// 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 ClawdDotNet.Core.Storage.AtomicFile.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
};
int indexCount;
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);
// Atomar: Der Index ist die einzige Übersicht über die Datenpunkte —
// eine halb geschriebene Datei wäre nicht wiederherstellbar.
await ClawdDotNet.Core.Storage.AtomicFile.WriteAllTextAsync(
indexPath, JsonSerializer.Serialize(index, StockJsonOpts), ct: ct);
// Den Stand hier festhalten, solange die Sperre noch gilt (B9). Vorher
// wurde die Datei nach dem Freigeben ein zweites Mal gelesen und geparst:
// ein paralleler stock_add konnte dazwischenschreiben, und die gemeldete
// Zahl gehörte dann zu einem anderen Aufruf als die gemeldete Datei.
// Innerhalb der Sperre ist der Zähler ohnehin bekannt.
indexCount = index.Count;
}
finally
{
indexLock.Release();
}
var relPath = $"shared:/stocks/{safeTicker}/{fileName}";
return ToolResult.Ok(
$"✅ Data point added: {relPath}\n" +
$"Ticker: {ticker} | Category: {category} | Source: {source}\n" +
$"Title: {title}\n" +
$"Index: {indexCount} entries for {ticker}");
}
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)));
}
}