Ablage je Einheit, Stufe 1: benannte Arbeitsbereiche mit Mitgliedern (#684)
- WorkspaceConfig in der Instanzkonfiguration: Id, Name, Zweck, Eigentuemer-Einheit, Mitglieder (Einheit oder Agent, Stufe Read/Write/ Manage, Untereinheiten ein/aus), geschuetzte Pfade. Das Recht steht am Bereich, nicht am Agenten. - OrgChart: AccessTo, WorkspacesFor, UnitWorkspace; Problems nennt ungueltige Ids und tote Verweise. WorkspaceLayout: Bereiche liegen unter Agents/SharedWorkspaces/<id>/, neben dem unveraenderten SharedWorkspace. - FileRW: workspace ist ein Name - personal, shared (wie bisher ueber sharedAccessLevel), unit (eigene Einheit) oder eine Bereichs-Id. Ein unbekannter und ein nicht erlaubter Bereich bekommen dieselbe Antwort. Loeschen braucht Manage wie frueher Admin. Der #662-Hinweis sucht in allen erlaubten Bereichen. - Mit Einheiten ist shared:/tasks/ fuer FileRW gesperrt: Aufgaben laufen ueber das Brett, sonst umginge eine direkt geschriebene Datei Brett- und Adressbuchpruefung. Ohne Einheiten unveraendert. - Organisationsabschnitt im Prompt nennt die Bereiche mit Stufe und Zweck. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ba5fe4cdf7
commit
ff20ce2563
@@ -1,3 +1,5 @@
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Organisation;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
@@ -24,9 +26,11 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
|
||||
public string Name => "FileRW";
|
||||
|
||||
public string Description => "File management across two separate workspace directories:\n" +
|
||||
public string Description => "File management in workspaces:\n" +
|
||||
"- workspace='personal': Your PRIVATE folder — only you have access.\n" +
|
||||
"- workspace='shared': SHARED folder — all agents on the team can access it.\n" +
|
||||
"- workspace='shared': the instance's SHARED folder, if you have access to it.\n" +
|
||||
"- workspace='unit': your unit's workspace; other named workspaces you are a member of " +
|
||||
"are listed in the Organisation section of your instructions.\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 " +
|
||||
@@ -49,8 +53,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
},
|
||||
"workspace": {
|
||||
"type": "string",
|
||||
"enum": ["personal", "shared"],
|
||||
"description": "The workspace in which to execute the action (source workspace for copy). Default is 'personal'."
|
||||
"description": "The workspace in which to execute the action (source workspace for copy): 'personal', 'shared', 'unit' or the name of a workspace you are a member of. Default is 'personal'."
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
@@ -85,8 +88,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
},
|
||||
"destinationWorkspace": {
|
||||
"type": "string",
|
||||
"enum": ["personal", "shared"],
|
||||
"description": "Destination workspace for copy. Default is the same workspace as 'workspace'."
|
||||
"description": "Destination workspace for copy, named like 'workspace'. Default is the same workspace as 'workspace'."
|
||||
},
|
||||
"destinationPath": {
|
||||
"type": "string",
|
||||
@@ -126,36 +128,37 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
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 requested = input.TryGetProperty("workspace", out var w) && w.ValueKind == JsonValueKind.String ? w.GetString() : null;
|
||||
if (ResolveArea(requested, context) is not { } area)
|
||||
return ToolResult.Fail(UnknownWorkspace(requested!, context));
|
||||
|
||||
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.");
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(area.Root))
|
||||
return ToolResult.Fail($"Configuration error: the folder of workspace '{area.Name}' is not defined.");
|
||||
|
||||
// Sicherstellen, dass rootPath absolut ist
|
||||
rootPath = Path.GetFullPath(rootPath);
|
||||
var rootPath = Path.GetFullPath(area.Root);
|
||||
|
||||
// Zugriffsprüfung
|
||||
if (!IsActionAllowed(action, workspace, context))
|
||||
if (!IsActionAllowed(action, area))
|
||||
{
|
||||
return ToolResult.Fail($"Access denied: Action '{action}' is not allowed in workspace '{workspace}' for this agent.");
|
||||
return ToolResult.Fail($"Access denied: Action '{action}' is not allowed in workspace '{area.Name}' for this agent.");
|
||||
}
|
||||
|
||||
// A workspace with a member list comes into being when it is first used.
|
||||
if (!area.IsPersonal && area.Name != WorkspaceLayout.Shared)
|
||||
Directory.CreateDirectory(rootPath);
|
||||
|
||||
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),
|
||||
"read" => await HandleReadAsync(input, rootPath, area, context, ct),
|
||||
"read_many" => await HandleReadManyAsync(input, rootPath, area, context, ct),
|
||||
"write" => await HandleWriteAsync(input, rootPath, area, context, ct),
|
||||
"append" => await HandleAppendAsync(input, rootPath, area, context, ct),
|
||||
"list" => await HandleListAsync(input, rootPath, area, context, ct),
|
||||
"delete" => await HandleDeleteAsync(input, rootPath, area, context, ct),
|
||||
"copy" => await HandleCopyAsync(input, rootPath, area, context, ct),
|
||||
"stock_add" => await HandleStockAddAsync(input, context, ct),
|
||||
_ => ToolResult.Fail($"Unknown action: {action}")
|
||||
};
|
||||
@@ -178,22 +181,24 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
return null;
|
||||
|
||||
var action = a.GetString() ?? "";
|
||||
var workspace = (input.TryGetProperty("workspace", out var w) && w.ValueKind == JsonValueKind.String
|
||||
? w.GetString() : null) ?? "personal";
|
||||
var requested = input.TryGetProperty("workspace", out var w) && w.ValueKind == JsonValueKind.String
|
||||
? w.GetString() : null;
|
||||
|
||||
if (!IsActionAllowed(action, workspace, context))
|
||||
return $"Access denied: Action '{action}' is not allowed in workspace '{workspace}' for this agent.";
|
||||
if (ResolveArea(requested, context) is not { } area)
|
||||
return UnknownWorkspace(requested!, context);
|
||||
|
||||
var rootPath = workspace == "shared" ? context.SharedWorkspacePath : context.WorkspacePath;
|
||||
if (action != "delete" || string.IsNullOrWhiteSpace(rootPath))
|
||||
if (!IsActionAllowed(action, area))
|
||||
return $"Access denied: Action '{action}' is not allowed in workspace '{area.Name}' for this agent.";
|
||||
|
||||
if (action != "delete" || string.IsNullOrWhiteSpace(area.Root))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
rootPath = Path.GetFullPath(rootPath);
|
||||
var path = GetAndValidatePath(input, rootPath, workspace, false, context);
|
||||
var rootPath = Path.GetFullPath(area.Root);
|
||||
var path = GetAndValidatePath(input, rootPath, area, false, context);
|
||||
|
||||
if (workspace == "shared" && !IsAdminLevel(context) && IsPathProtected(path, rootPath, context))
|
||||
if (!CanOverrideProtection(area) && IsPathProtected(path, rootPath, area))
|
||||
return $"🛡️ Protected area: '{Path.GetRelativePath(rootPath, path)}' cannot be deleted.";
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
@@ -205,51 +210,133 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
return null;
|
||||
}
|
||||
|
||||
private bool IsActionAllowed(string action, string workspace, AgentToolContext context)
|
||||
// ─── Workspaces (#684) ───
|
||||
|
||||
/// <summary>The folder in the shared workspace that holds the task files.</summary>
|
||||
private const string TasksFolder = "tasks";
|
||||
|
||||
/// <summary>A workspace as one call sees it: where it lies and what the agent may do there.</summary>
|
||||
private sealed record Area(string Name, string? Root, WorkspaceAccess Access, IReadOnlyList<string> ProtectedPaths)
|
||||
{
|
||||
if (workspace == "personal") return true; // Alles erlaubt
|
||||
public bool IsPersonal => Name == WorkspaceLayout.Personal;
|
||||
}
|
||||
|
||||
var level = context.ToolConfig.TryGetValue("sharedAccessLevel", out var lv) ? lv?.ToString() : "Denied";
|
||||
/// <summary>
|
||||
/// Resolves the name an agent gave. "personal" and "shared" work as they always did —
|
||||
/// "shared" still by the agent's own <c>sharedAccessLevel</c>. "unit" is the own unit's
|
||||
/// workspace; any other name must be a workspace the agent is a member of. Everything
|
||||
/// else is <c>null</c>: an unknown name and a workspace the agent may not see get the
|
||||
/// same answer, so the answer does not reveal that it exists.
|
||||
/// </summary>
|
||||
private static Area? ResolveArea(string? requested, AgentToolContext context)
|
||||
{
|
||||
var name = string.IsNullOrWhiteSpace(requested) ? WorkspaceLayout.Personal : requested.Trim();
|
||||
|
||||
if (level == "Denied") return false;
|
||||
if (name.Equals(WorkspaceLayout.Personal, StringComparison.OrdinalIgnoreCase))
|
||||
return new Area(WorkspaceLayout.Personal, context.WorkspacePath, WorkspaceAccess.Manage, []);
|
||||
|
||||
if (name.Equals(WorkspaceLayout.Shared, StringComparison.OrdinalIgnoreCase))
|
||||
return new Area(WorkspaceLayout.Shared, context.SharedWorkspacePath, SharedAccess(context), GetProtectedPaths(context));
|
||||
|
||||
if (context.Organisation is not { } org)
|
||||
return null;
|
||||
|
||||
var workspace = name.Equals(WorkspaceLayout.Unit, StringComparison.OrdinalIgnoreCase)
|
||||
? org.UnitWorkspace(org.UnitOf(context.AgentId)?.Id)
|
||||
: org.Workspace(name);
|
||||
if (workspace is null)
|
||||
return null;
|
||||
|
||||
var access = org.AccessTo(context.AgentId, workspace.Id);
|
||||
return access == WorkspaceAccess.None
|
||||
? null
|
||||
: new Area(workspace.Id, WorkspaceLayout.PathOf(context.SharedWorkspacePath, workspace.Id), access,
|
||||
NormaliseProtectedPaths(workspace.ProtectedPaths));
|
||||
}
|
||||
|
||||
/// <summary>The answer to a name that is not a workspace this agent may use — with the ones it may.</summary>
|
||||
private static string UnknownWorkspace(string requested, AgentToolContext context)
|
||||
{
|
||||
var names = new List<string> { WorkspaceLayout.Personal };
|
||||
if (SharedAccess(context) > WorkspaceAccess.None)
|
||||
names.Add(WorkspaceLayout.Shared);
|
||||
if (context.Organisation is { } org)
|
||||
{
|
||||
names.AddRange(org.WorkspacesFor(context.AgentId).Select(g =>
|
||||
g.IsOwnUnit ? $"{WorkspaceLayout.Unit} (= {g.Workspace.Id})" : g.Workspace.Id));
|
||||
}
|
||||
return $"Unknown workspace '{requested}'. You can use: {string.Join(", ", names)}.";
|
||||
}
|
||||
|
||||
/// <summary>The one shared folder, by the level the agent carries for it — as before #684.</summary>
|
||||
private static WorkspaceAccess SharedAccess(AgentToolContext context)
|
||||
=> (context.ToolConfig.TryGetValue("sharedAccessLevel", out var lv) ? lv?.ToString() : "Denied") switch
|
||||
{
|
||||
"Read" => WorkspaceAccess.Read,
|
||||
"ReadWrite" => WorkspaceAccess.Write,
|
||||
"Admin" => WorkspaceAccess.Manage,
|
||||
_ => WorkspaceAccess.None
|
||||
};
|
||||
|
||||
private static bool IsActionAllowed(string action, Area area)
|
||||
{
|
||||
if (area.IsPersonal) return true; // Alles erlaubt
|
||||
|
||||
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",
|
||||
"read" or "read_many" or "list" or "copy" => area.Access >= WorkspaceAccess.Read,
|
||||
"write" or "append" or "stock_add" => area.Access >= WorkspaceAccess.Write,
|
||||
"delete" => area.Access >= WorkspaceAccess.Manage,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Managing a workspace includes changing and deleting protected files — as the old Admin level did.</summary>
|
||||
private static bool CanOverrideProtection(Area area) => area.Access >= WorkspaceAccess.Manage;
|
||||
|
||||
/// <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.
|
||||
/// Wer den Bereich verwaltet, umgeht den Schutz.
|
||||
/// </summary>
|
||||
private bool IsPathProtected(string fullPath, string rootPath, AgentToolContext context)
|
||||
private static bool IsPathProtected(string fullPath, string rootPath, Area area)
|
||||
{
|
||||
var protectedPaths = GetProtectedPaths(context);
|
||||
if (protectedPaths.Count == 0) return false;
|
||||
if (area.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 =>
|
||||
// Jeder Eintrag endet auf "/", deshalb greift StartsWith hier auf einer echten
|
||||
// Verzeichnisgrenze: "stocks/" trifft "stocks/x.json", aber nicht "stocks-alt/x.json".
|
||||
return area.ProtectedPaths.Any(pp =>
|
||||
relativePath.StartsWith(pp, StringComparison.OrdinalIgnoreCase) ||
|
||||
relativePath.Equals(pp.TrimEnd('/'), StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private bool IsAdminLevel(AgentToolContext context)
|
||||
/// <summary>
|
||||
/// #684: With units, the task files in the shared folder are closed to FileRW. Written
|
||||
/// directly, a task file would pass every check the task board makes — the board of the
|
||||
/// unit, the address book of the assignment. Tasks go through the Taskboard tool. Without
|
||||
/// units nothing changes, so running installations keep what they have.
|
||||
/// </summary>
|
||||
private static bool IsBlocked(string fullPath, Area area, AgentToolContext context)
|
||||
{
|
||||
var level = context.ToolConfig.TryGetValue("sharedAccessLevel", out var lv) ? lv?.ToString() : "Denied";
|
||||
return level == "Admin";
|
||||
if (area.Name != WorkspaceLayout.Shared || context.Organisation?.HasUnits != true || string.IsNullOrWhiteSpace(area.Root))
|
||||
return false;
|
||||
|
||||
var relative = Path.GetRelativePath(Path.GetFullPath(area.Root), fullPath).Replace('\\', '/');
|
||||
return relative.Equals(TasksFolder, StringComparison.OrdinalIgnoreCase)
|
||||
|| relative.StartsWith(TasksFolder + "/", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static List<string> NormaliseProtectedPaths(IEnumerable<string> paths)
|
||||
=> paths
|
||||
.Select(p => p.Replace('\\', '/').Trim().Trim('/'))
|
||||
.Where(p => p.Length > 0)
|
||||
.Select(p => p + "/")
|
||||
.ToList();
|
||||
|
||||
private static List<string> GetProtectedPaths(AgentToolContext context)
|
||||
{
|
||||
if (!context.ToolConfig.TryGetValue("protectedPaths", out var pp) || pp is null)
|
||||
@@ -295,13 +382,11 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
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))
|
||||
var requested = arguments.TryGetProperty("workspace", out var w) && w.ValueKind == JsonValueKind.String ? w.GetString() : null;
|
||||
if (ResolveArea(requested, context) is not { Root: { Length: > 0 } root } area)
|
||||
return ProbeFinding.Unknown("Workspace path is unknown.");
|
||||
|
||||
var path = GetAndValidatePath(arguments, Path.GetFullPath(rootPath), workspace, true, context);
|
||||
var path = GetAndValidatePath(arguments, Path.GetFullPath(root), area, true, context);
|
||||
var erwartet = arguments.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
|
||||
|
||||
if (!File.Exists(path))
|
||||
@@ -349,19 +434,23 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
}
|
||||
}
|
||||
|
||||
private string GetAndValidatePath(JsonElement input, string rootPath, string workspace, bool checkExtension, AgentToolContext context)
|
||||
private string GetAndValidatePath(JsonElement input, string rootPath, Area area, 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);
|
||||
var fullPath = WorkspacePath.Resolve(rootPath, relativePath, area.Name);
|
||||
|
||||
if (IsBlocked(fullPath, area, context))
|
||||
throw new UnauthorizedAccessException(
|
||||
"Tasks are kept on the task board — use the Taskboard tool (task_list, task_find, task_create, task_update), not their files.");
|
||||
|
||||
// Extension Check
|
||||
if (checkExtension && !Directory.Exists(fullPath))
|
||||
{
|
||||
var extension = Path.GetExtension(fullPath).ToLowerInvariant();
|
||||
|
||||
var configKey = workspace == "shared" ? "sharedAllowedExtensions" : "personalAllowedExtensions";
|
||||
var configKey = area.IsPersonal ? "personalAllowedExtensions" : "sharedAllowedExtensions";
|
||||
|
||||
var allowedExtensions = context.ToolConfig.TryGetValue(configKey, out var ae) && ae is JsonElement je
|
||||
? je.EnumerateArray().Select(x => x.GetString()?.ToLowerInvariant()).ToList()
|
||||
@@ -369,7 +458,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
|
||||
if (!allowedExtensions.Contains(extension))
|
||||
{
|
||||
throw new UnauthorizedAccessException($"File extension '{extension}' is not allowed in workspace '{workspace}'.");
|
||||
throw new UnauthorizedAccessException($"File extension '{extension}' is not allowed in workspace '{area.Name}'.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,37 +466,42 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #662: Gibt es den Pfad im jeweils anderen Workspace, sagt die Fehlermeldung das.
|
||||
/// #662: Gibt es den Pfad in einem 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>
|
||||
/// weiter, und jeder Versuch kostet einen Schritt. Der Hinweis nennt nur Workspaces,
|
||||
/// die der Agent fuer diese Aktion auch benutzen darf - sonst waere er eine Einladung
|
||||
/// zu etwas, das ohnehin abgewiesen wird, und verriete obendrein, dass es sie gibt.</para>
|
||||
///
|
||||
/// <para>Seit #684 gibt es mehr als zwei: gesucht wird in allen, die der Agent kennt.</para>
|
||||
/// </summary>
|
||||
private string AndererWorkspaceHinweis(
|
||||
string action, string workspace, string? relativePath, AgentToolContext context, bool verzeichnis)
|
||||
string action, Area area, string? relativePath, AgentToolContext context, bool verzeichnis)
|
||||
{
|
||||
var anderer = workspace == "shared" ? "personal" : "shared";
|
||||
if (!IsActionAllowed(action, anderer, context))
|
||||
return "";
|
||||
var candidates = new List<string> { WorkspaceLayout.Personal, WorkspaceLayout.Shared };
|
||||
if (context.Organisation is { } org)
|
||||
candidates.AddRange(org.WorkspacesFor(context.AgentId).Select(g => g.Workspace.Id));
|
||||
|
||||
var root = anderer == "shared" ? context.SharedWorkspacePath : context.WorkspacePath;
|
||||
if (string.IsNullOrWhiteSpace(root))
|
||||
return "";
|
||||
foreach (var name in candidates.Where(n => !string.Equals(n, area.Name, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
if (ResolveArea(name, context) is not { Root: { Length: > 0 } root } other || !IsActionAllowed(action, other))
|
||||
continue;
|
||||
|
||||
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.
|
||||
try
|
||||
{
|
||||
var pfad = WorkspacePath.Resolve(Path.GetFullPath(root), relativePath, other.Name);
|
||||
if (IsBlocked(pfad, other, context))
|
||||
continue;
|
||||
if (verzeichnis ? Directory.Exists(pfad) : File.Exists(pfad))
|
||||
return $" The path exists in the {other.Name} workspace — call with workspace: \"{other.Name}\".";
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ein Pfad, der dort gar nicht auflösbar ist, hilft niemandem.
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static string? RelativerPfad(JsonElement input)
|
||||
@@ -415,14 +509,14 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
? p.GetString()
|
||||
: null;
|
||||
|
||||
private async Task<ToolResult> HandleReadAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct)
|
||||
private async Task<ToolResult> HandleReadAsync(JsonElement input, string rootPath, Area area, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var path = GetAndValidatePath(input, rootPath, workspace, true, context);
|
||||
var path = GetAndValidatePath(input, rootPath, area, 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));
|
||||
$"File not found in {area.Name} workspace: {Path.GetRelativePath(rootPath, path)}."
|
||||
+ AndererWorkspaceHinweis("read", area, RelativerPfad(input), context, verzeichnis: false));
|
||||
}
|
||||
|
||||
var fileLock = GetFileLock(path);
|
||||
@@ -431,7 +525,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(path, ct);
|
||||
var relativePath = Path.GetRelativePath(rootPath, path);
|
||||
return ToolResult.Ok($"[{workspace}:/{relativePath}]\n{content}");
|
||||
return ToolResult.Ok($"[{area.Name}:/{relativePath}]\n{content}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -458,17 +552,17 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
/// 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)
|
||||
private async Task<ToolResult> HandleReadManyAsync(JsonElement input, string rootPath, Area area, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var (dateien, fehler) = SammleDateien(input, rootPath, workspace, context);
|
||||
var (dateien, fehler) = SammleDateien(input, rootPath, area, 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));
|
||||
$"No matching file found in {area.Name} workspace."
|
||||
+ AndererWorkspaceHinweis("read_many", area, RelativerPfad(input), context, verzeichnis: true));
|
||||
}
|
||||
|
||||
var uebersprungen = new List<string>();
|
||||
@@ -490,13 +584,13 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
try { inhalt = await File.ReadAllTextAsync(pfad, ct); }
|
||||
finally { sperre.Release(); }
|
||||
|
||||
text.Append($"\n--- {workspace}:/{relativ} ---\n").Append(inhalt.TrimEnd()).Append('\n');
|
||||
text.Append($"\n--- {area.Name}:/{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.");
|
||||
var kopf = new StringBuilder($"[{area.Name} 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)
|
||||
@@ -511,7 +605,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
/// 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)
|
||||
JsonElement input, string rootPath, Area area, AgentToolContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -523,12 +617,12 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
if (eintrag.ValueKind != JsonValueKind.String) continue;
|
||||
var relativ = eintrag.GetString();
|
||||
if (string.IsNullOrWhiteSpace(relativ)) continue;
|
||||
dateien.Add(PruefeLesepfad(relativ, rootPath, workspace, context));
|
||||
dateien.Add(PruefeLesepfad(relativ, rootPath, area, context));
|
||||
}
|
||||
return (dateien, dateien.Count == 0 ? "'paths' contains no valid path." : null);
|
||||
}
|
||||
|
||||
var verzeichnis = WorkspacePath.Resolve(rootPath, RelativerPfad(input), workspace);
|
||||
var verzeichnis = WorkspacePath.Resolve(rootPath, RelativerPfad(input), area.Name);
|
||||
if (!Directory.Exists(verzeichnis))
|
||||
return ([], null); // Die Meldung samt Workspace-Hinweis macht der Aufrufer.
|
||||
|
||||
@@ -544,7 +638,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
|
||||
// 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);
|
||||
return (gefunden.Where(f => IstLesbar(f, area, context)).ToList(), null);
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
@@ -552,18 +646,18 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
}
|
||||
}
|
||||
|
||||
private string PruefeLesepfad(string relativePath, string rootPath, string workspace, AgentToolContext context)
|
||||
private string PruefeLesepfad(string relativePath, string rootPath, Area area, AgentToolContext context)
|
||||
{
|
||||
var json = JsonSerializer.SerializeToElement(new { action = "read", path = relativePath });
|
||||
return GetAndValidatePath(json, rootPath, workspace, true, context);
|
||||
return GetAndValidatePath(json, rootPath, area, true, context);
|
||||
}
|
||||
|
||||
private bool IstLesbar(string vollerPfad, string workspace, AgentToolContext context)
|
||||
private bool IstLesbar(string vollerPfad, Area area, AgentToolContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.SerializeToElement(new { action = "read", path = Path.GetFileName(vollerPfad) });
|
||||
GetAndValidatePath(json, Path.GetDirectoryName(vollerPfad)!, workspace, true, context);
|
||||
GetAndValidatePath(json, Path.GetDirectoryName(vollerPfad)!, area, true, context);
|
||||
return true;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
@@ -572,9 +666,9 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ToolResult> HandleWriteAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct)
|
||||
private async Task<ToolResult> HandleWriteAsync(JsonElement input, string rootPath, Area area, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var path = GetAndValidatePath(input, rootPath, workspace, true, context);
|
||||
var path = GetAndValidatePath(input, rootPath, area, true, context);
|
||||
var content = input.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
|
||||
|
||||
var fileLock = GetFileLock(path);
|
||||
@@ -582,7 +676,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
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))
|
||||
if (!CanOverrideProtection(area) && File.Exists(path) && IsPathProtected(path, rootPath, area))
|
||||
{
|
||||
var relPath = Path.GetRelativePath(rootPath, path);
|
||||
return ToolResult.Fail(
|
||||
@@ -600,7 +694,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
// 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)}");
|
||||
return ToolResult.Ok($"[{area.Name} Workspace] File successfully written: {area.Name}:/{Path.GetRelativePath(rootPath, path)}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -608,9 +702,9 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ToolResult> HandleAppendAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct)
|
||||
private async Task<ToolResult> HandleAppendAsync(JsonElement input, string rootPath, Area area, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var path = GetAndValidatePath(input, rootPath, workspace, true, context);
|
||||
var path = GetAndValidatePath(input, rootPath, area, true, context);
|
||||
var content = input.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
|
||||
|
||||
var fileLock = GetFileLock(path);
|
||||
@@ -625,7 +719,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
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)}");
|
||||
return ToolResult.Ok($"[{area.Name} Workspace] Content successfully appended to: {area.Name}:/{Path.GetRelativePath(rootPath, path)}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -652,14 +746,14 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
/// 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)
|
||||
private async Task<ToolResult> HandleListAsync(JsonElement input, string rootPath, Area area, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var path = GetAndValidatePath(input, rootPath, workspace, false, context);
|
||||
var path = GetAndValidatePath(input, rootPath, area, 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));
|
||||
$"Directory not found in {area.Name} workspace: {Path.GetRelativePath(rootPath, path)}."
|
||||
+ AndererWorkspaceHinweis("list", area, RelativerPfad(input), context, verzeichnis: true));
|
||||
}
|
||||
|
||||
var muster = GetOptionalString(input, "pattern");
|
||||
@@ -669,7 +763,9 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
List<string> pfade;
|
||||
try
|
||||
{
|
||||
pfade = Directory.GetFileSystemEntries(path, string.IsNullOrWhiteSpace(muster) ? "*" : muster).ToList();
|
||||
pfade = Directory.GetFileSystemEntries(path, string.IsNullOrWhiteSpace(muster) ? "*" : muster)
|
||||
.Where(e => !IsBlocked(e, area, context))
|
||||
.ToList();
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
@@ -706,7 +802,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
: (absteigend ? "newest first" : "oldest first");
|
||||
|
||||
var text = new StringBuilder();
|
||||
text.Append($"[{workspace} Workspace] Directory: {workspace}:/{(relativeDirPath == "." ? "" : relativeDirPath)}");
|
||||
text.Append($"[{area.Name} Workspace] Directory: {area.Name}:/{(relativeDirPath == "." ? "" : relativeDirPath)}");
|
||||
text.Append($" - {gezeigt.Count} of {eintraege.Count} entries, {ordnung}.");
|
||||
if (!string.IsNullOrWhiteSpace(muster))
|
||||
text.Append($" Pattern: {muster}.");
|
||||
@@ -747,12 +843,12 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
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)
|
||||
private async Task<ToolResult> HandleDeleteAsync(JsonElement input, string rootPath, Area area, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var path = GetAndValidatePath(input, rootPath, workspace, false, context);
|
||||
var path = GetAndValidatePath(input, rootPath, area, false, context);
|
||||
|
||||
// Schutz: In geschützten Bereichen ist Löschen komplett verboten (außer Admin)
|
||||
if (workspace == "shared" && !IsAdminLevel(context) && IsPathProtected(path, rootPath, context))
|
||||
if (!CanOverrideProtection(area) && IsPathProtected(path, rootPath, area))
|
||||
{
|
||||
var relPath = Path.GetRelativePath(rootPath, path);
|
||||
return ToolResult.Fail(
|
||||
@@ -767,15 +863,15 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
return ToolResult.Ok($"[{workspace} Workspace] File deleted: {workspace}:/{Path.GetRelativePath(rootPath, path)}");
|
||||
return ToolResult.Ok($"[{area.Name} Workspace] File deleted: {area.Name}:/{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.Ok($"[{area.Name} Workspace] Directory deleted: {area.Name}:/{Path.GetRelativePath(rootPath, path)}");
|
||||
}
|
||||
|
||||
return ToolResult.Fail($"File or directory not found in {workspace} workspace.");
|
||||
return ToolResult.Fail($"File or directory not found in {area.Name} workspace.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -783,32 +879,36 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ToolResult> HandleCopyAsync(JsonElement input, string sourceRootPath, string sourceWorkspace, AgentToolContext context, CancellationToken ct)
|
||||
private async Task<ToolResult> HandleCopyAsync(JsonElement input, string sourceRootPath, Area sourceArea, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var sourcePath = GetAndValidatePath(input, sourceRootPath, sourceWorkspace, false, context);
|
||||
var sourcePath = GetAndValidatePath(input, sourceRootPath, sourceArea, false, context);
|
||||
|
||||
var destWorkspace = (input.TryGetProperty("destinationWorkspace", out var dw) ? dw.GetString() : null) ?? sourceWorkspace;
|
||||
var requestedDestination = input.TryGetProperty("destinationWorkspace", out var dw) && dw.ValueKind == JsonValueKind.String
|
||||
? dw.GetString()
|
||||
: null;
|
||||
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.");
|
||||
var destArea = requestedDestination is null ? sourceArea : ResolveArea(requestedDestination, context);
|
||||
if (destArea is null)
|
||||
return ToolResult.Fail(UnknownWorkspace(requestedDestination!, context));
|
||||
if (string.IsNullOrWhiteSpace(destArea.Root))
|
||||
return ToolResult.Fail($"Configuration error: the folder of workspace '{destArea.Name}' is not defined.");
|
||||
|
||||
destRootPath = Path.GetFullPath(destRootPath);
|
||||
var destRootPath = Path.GetFullPath(destArea.Root);
|
||||
|
||||
if (!IsActionAllowed("write", destWorkspace, context))
|
||||
return ToolResult.Fail($"Access denied: Writing to workspace '{destWorkspace}' is not allowed for this agent.");
|
||||
if (!IsActionAllowed("write", destArea))
|
||||
return ToolResult.Fail($"Access denied: Writing to workspace '{destArea.Name}' is not allowed for this agent.");
|
||||
|
||||
var destInput = JsonDocument.Parse(JsonSerializer.Serialize(new { path = destRelPath })).RootElement;
|
||||
var destPath = GetAndValidatePath(destInput, destRootPath, destWorkspace, false, context);
|
||||
var destPath = GetAndValidatePath(destInput, destRootPath, destArea, 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))
|
||||
if (!CanOverrideProtection(destArea) && File.Exists(destPath) && IsPathProtected(destPath, destRootPath, destArea))
|
||||
{
|
||||
var relPath = Path.GetRelativePath(destRootPath, destPath);
|
||||
return ToolResult.Fail(
|
||||
@@ -832,10 +932,10 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
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);
|
||||
await Task.Run(() => File.Copy(sourcePath, destPath, overwrite: !IsPathProtected(destPath, destRootPath, destArea)), ct);
|
||||
|
||||
var srcLabel = $"{sourceWorkspace}:/{Path.GetRelativePath(sourceRootPath, sourcePath)}";
|
||||
var dstLabel = $"{destWorkspace}:/{Path.GetRelativePath(destRootPath, destPath)}";
|
||||
var srcLabel = $"{sourceArea.Name}:/{Path.GetRelativePath(sourceRootPath, sourcePath)}";
|
||||
var dstLabel = $"{destArea.Name}:/{Path.GetRelativePath(destRootPath, destPath)}";
|
||||
return ToolResult.Ok($"File copied: {srcLabel} → {dstLabel}");
|
||||
}
|
||||
finally
|
||||
@@ -852,8 +952,8 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
{
|
||||
await Task.Run(() => CopyDirectory(sourcePath, destPath), ct);
|
||||
|
||||
var srcLabel = $"{sourceWorkspace}:/{Path.GetRelativePath(sourceRootPath, sourcePath)}";
|
||||
var dstLabel = $"{destWorkspace}:/{Path.GetRelativePath(destRootPath, destPath)}";
|
||||
var srcLabel = $"{sourceArea.Name}:/{Path.GetRelativePath(sourceRootPath, sourcePath)}";
|
||||
var dstLabel = $"{destArea.Name}:/{Path.GetRelativePath(destRootPath, destPath)}";
|
||||
return ToolResult.Ok($"Directory copied: {srcLabel} → {dstLabel}");
|
||||
}
|
||||
|
||||
@@ -895,7 +995,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
sharedRoot = Path.GetFullPath(sharedRoot);
|
||||
|
||||
// Zugriffsprüfung
|
||||
if (!IsActionAllowed("stock_add", "shared", context))
|
||||
if (ResolveArea(WorkspaceLayout.Shared, context) is not { } shared || !IsActionAllowed("stock_add", shared))
|
||||
return ToolResult.Fail("Access denied: Writing to SharedWorkspace is not allowed.");
|
||||
|
||||
// Sanitize Ticker für Verzeichnisname
|
||||
|
||||
Reference in New Issue
Block a user