diff --git a/src/ClawdDotNet.Core/Config/InstanceConfig.cs b/src/ClawdDotNet.Core/Config/InstanceConfig.cs index 054dbbd..bb8b22b 100644 --- a/src/ClawdDotNet.Core/Config/InstanceConfig.cs +++ b/src/ClawdDotNet.Core/Config/InstanceConfig.cs @@ -179,6 +179,14 @@ public sealed class InstanceConfig [JsonPropertyName("units")] public List Units { get; set; } = new(); + /// + /// #684: Shared workspaces with member lists — one per unit, plus those shared across + /// units on purpose. The instance's one shared folder is not listed here; it stays the + /// workspace shared, governed by each agent's level as before. + /// + [JsonPropertyName("workspaces")] + public List Workspaces { get; set; } = new(); + [JsonPropertyName("services")] public List Services { get; set; } = new(); } diff --git a/src/ClawdDotNet.Core/Config/WorkspaceConfig.cs b/src/ClawdDotNet.Core/Config/WorkspaceConfig.cs new file mode 100644 index 0000000..17bd6e1 --- /dev/null +++ b/src/ClawdDotNet.Core/Config/WorkspaceConfig.cs @@ -0,0 +1,85 @@ +using System.Text.Json.Serialization; + +namespace ClawdDotNet.Core.Config; + +/// +/// #684: A shared workspace — a folder several agents work in, with a named owner and a list +/// of who may use it how. +/// +/// The right stands at the workspace, not at the agent (Organisation-Konzept, +/// section 3). Before, every agent carried one sharedAccessLevel for the one shared +/// folder of the instance, and "accounting may write its files, marketing may only read +/// them" could not be said. Here each workspace lists its members; whoever is not on the +/// list does not see it — not even that it exists. +/// +/// The one shared folder stays what it was. The folder SharedWorkspace +/// with the per-agent level keeps working as the workspace shared, so every running +/// installation and every task that names it goes on as before. Workspaces configured here +/// live next to it, under SharedWorkspaces/<id>/. There is never a second +/// value for the same thing: shared is governed only by the agent's level, every +/// workspace here only by its member list. +/// +public sealed class WorkspaceConfig +{ + /// The name agents use and the folder name; lower case, digits and dashes. + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + /// What goes in here — shown to the members so they file things in the right place. + [JsonPropertyName("purpose")] + public string Purpose { get; set; } = ""; + + /// + /// The unit the workspace belongs to — what is filed here is its property, and stays + /// when an agent moves on. null: owned by the instance (the human). + /// + [JsonPropertyName("ownerUnitId")] + public string? OwnerUnitId { get; set; } + + [JsonPropertyName("members")] + public List Members { get; set; } = new(); + + /// + /// Folders in which files can only be added, not changed or deleted — except by members + /// with . Relative to the workspace. + /// + [JsonPropertyName("protectedPaths")] + public List ProtectedPaths { get; set; } = new(); +} + +/// +/// #684: One entry on a workspace's member list — a unit or a single agent, with a level. +/// A unit entry includes the units below it unless is off; +/// a unit's own workspace is meant for the whole department. +/// +public sealed class WorkspaceMember +{ + [JsonPropertyName("unitId")] + public string? UnitId { get; set; } + + [JsonPropertyName("agentId")] + public string? AgentId { get; set; } + + [JsonPropertyName("access")] + public WorkspaceAccess Access { get; set; } = WorkspaceAccess.Read; + + [JsonPropertyName("includeSubUnits")] + public bool IncludeSubUnits { get; set; } = true; +} + +/// +/// #684: What a member may do in a workspace. Each level includes the ones below it. +/// Deleting and changing protected files needs — as the old +/// Admin level did for the one shared folder. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum WorkspaceAccess +{ + None = 0, + Read = 1, + Write = 2, + Manage = 3 +} diff --git a/src/ClawdDotNet.Core/Organisation/OrgBriefing.cs b/src/ClawdDotNet.Core/Organisation/OrgBriefing.cs index 33fe1cb..bce3d4d 100644 --- a/src/ClawdDotNet.Core/Organisation/OrgBriefing.cs +++ b/src/ClawdDotNet.Core/Organisation/OrgBriefing.cs @@ -98,10 +98,42 @@ public static class OrgBriefing sb.AppendLine($"- {Name(contact)}{Describe(contact.Description)}"); } + AppendWorkspaces(sb, org, agentId); + sb.Append("Agents not named here cannot be reached from your position — go through your lead."); return sb.ToString(); } + /// + /// #684: The shared workspaces the agent may use. FileRW's own description cannot name + /// them — it is the same for every agent — so they are listed here, where the agent's + /// position is described anyway, and change with it when it moves. + /// + private static void AppendWorkspaces(StringBuilder sb, OrgChart org, string agentId) + { + var grants = org.WorkspacesFor(agentId); + if (grants.Count == 0) + return; + + sb.AppendLine("Your shared workspaces (FileRW, parameter \"workspace\"):"); + foreach (var grant in grants) + { + var workspace = grant.Workspace; + var name = grant.IsOwnUnit + ? $"\"{WorkspaceLayout.Unit}\" — your unit's workspace (also \"{workspace.Id}\")" + : $"\"{workspace.Id}\"" + (string.IsNullOrWhiteSpace(workspace.Name) || workspace.Name == workspace.Id ? "" : $" ({workspace.Name})"); + var access = grant.Access switch + { + Config.WorkspaceAccess.Read => "read only", + Config.WorkspaceAccess.Write => "read and write", + _ => "read, write and delete" + }; + sb.AppendLine($"- {name}, {access}{Describe(workspace.Purpose)}"); + } + if (grants.Any(g => g.IsOwnUnit)) + sb.AppendLine("What your unit works on belongs in \"unit\", where your colleagues find it; \"personal\" is yours alone."); + } + /// /// Replaces the organisation section of a system prompt, appends it, or removes it — /// leaving everything before it untouched. Existing chat contexts keep their system diff --git a/src/ClawdDotNet.Core/Organisation/OrgChart.cs b/src/ClawdDotNet.Core/Organisation/OrgChart.cs index afc3060..e13220d 100644 --- a/src/ClawdDotNet.Core/Organisation/OrgChart.cs +++ b/src/ClawdDotNet.Core/Organisation/OrgChart.cs @@ -24,12 +24,17 @@ public sealed class OrgChart private readonly Dictionary _agents; private readonly Dictionary> _children; private readonly Dictionary> _members; + private readonly Dictionary _workspaces; /// The key under which top-level units and agents without a unit are grouped. private const string Root = ""; - public OrgChart(IEnumerable units, IEnumerable agents) + public OrgChart(IEnumerable units, IEnumerable agents, IEnumerable? workspaces = null) { + _workspaces = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var workspace in workspaces ?? []) + _workspaces.TryAdd(workspace.Id, workspace); + _units = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var unit in units) _units.TryAdd(unit.Id, unit); @@ -57,7 +62,7 @@ public sealed class OrgChart } } - public static OrgChart From(InstanceConfig instance) => new(instance.Units, instance.Agents); + public static OrgChart From(InstanceConfig instance) => new(instance.Units, instance.Agents, instance.Workspaces); /// Whether the instance has any structure at all. public bool HasUnits => _units.Count > 0; @@ -241,6 +246,63 @@ public sealed class OrgChart return ceiling; } + // ─── workspaces (#684) ─── + + public IReadOnlyCollection Workspaces => _workspaces.Values; + + public WorkspaceConfig? Workspace(string? workspaceId) + => workspaceId is not null && _workspaces.TryGetValue(workspaceId, out var workspace) ? workspace : null; + + /// + /// A unit's own workspace: the one owned by the unit that carries its id. Created with + /// the unit; an agent reaches it as unit without knowing the id. + /// + public WorkspaceConfig? UnitWorkspace(string? unitId) + => Workspace(unitId) is { } workspace + && string.Equals(workspace.OwnerUnitId, unitId, StringComparison.OrdinalIgnoreCase) + ? workspace + : null; + + /// + /// How far an agent may use a workspace — the highest level among the entries that name + /// it: the agent itself, its unit, or a unit above it whose entry includes sub-units. + /// + public WorkspaceAccess AccessTo(string agentId, string workspaceId) + { + if (Workspace(workspaceId) is not { } workspace || Agent(agentId) is null) + return WorkspaceAccess.None; + + var unitId = UnitOf(agentId)?.Id; + var above = unitId is null + ? new HashSet(StringComparer.OrdinalIgnoreCase) + : Ancestors(unitId).Select(u => u.Id).ToHashSet(StringComparer.OrdinalIgnoreCase); + + var access = WorkspaceAccess.None; + foreach (var member in workspace.Members) + { + var matches = + (member.AgentId is { Length: > 0 } a && string.Equals(a, agentId, StringComparison.OrdinalIgnoreCase)) + || (member.UnitId is { Length: > 0 } u && unitId is not null + && (string.Equals(u, unitId, StringComparison.OrdinalIgnoreCase) + || (member.IncludeSubUnits && above.Contains(u)))); + if (matches && member.Access > access) + access = member.Access; + } + return access; + } + + /// Every workspace the agent may use, with its level — its own unit's first. + public IReadOnlyList WorkspacesFor(string agentId) + { + var own = UnitWorkspace(UnitOf(agentId)?.Id); + return _workspaces.Values + .Select(w => new WorkspaceGrant(w, AccessTo(agentId, w.Id), ReferenceEquals(w, own))) + .Where(g => g.Access > WorkspaceAccess.None) + .OrderByDescending(g => g.IsOwnUnit) + .ThenBy(g => g.Workspace.Id, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + /// /// What is wrong with the structure — empty when it is sound. Checked when the /// administration writes and when an instance starts; the tree is still usable with @@ -264,6 +326,21 @@ public sealed class OrgChart : $"Unit '{unit.Id}' names the lead '{leaderId}', which does not exist."); } + foreach (var workspace in _workspaces.Values) + { + if (!WorkspaceLayout.IsValidId(workspace.Id)) + problems.Add($"Workspace '{workspace.Id}' has an invalid id — lower case letters, digits and dashes, not one of {string.Join(", ", WorkspaceLayout.Reserved)}."); + if (workspace.OwnerUnitId is { Length: > 0 } owner && !_units.ContainsKey(owner)) + problems.Add($"Workspace '{workspace.Id}' belongs to the unit '{owner}', which does not exist."); + foreach (var member in workspace.Members) + { + if (member.UnitId is { Length: > 0 } u && !_units.ContainsKey(u)) + problems.Add($"Workspace '{workspace.Id}' names the unit '{u}' as member, which does not exist."); + if (member.AgentId is { Length: > 0 } a && !_agents.ContainsKey(a)) + problems.Add($"Workspace '{workspace.Id}' names the agent '{a}' as member, which does not exist."); + } + } + foreach (var agent in _agents.Values) { if (agent.UnitId is { Length: > 0 } unitId && !_units.ContainsKey(unitId)) diff --git a/src/ClawdDotNet.Core/Organisation/WorkspaceLayout.cs b/src/ClawdDotNet.Core/Organisation/WorkspaceLayout.cs new file mode 100644 index 0000000..d2651de --- /dev/null +++ b/src/ClawdDotNet.Core/Organisation/WorkspaceLayout.cs @@ -0,0 +1,56 @@ +using ClawdDotNet.Core.Config; + +namespace ClawdDotNet.Core.Organisation; + +/// #684: A workspace an agent may use, and how far. +/// The workspace of the agent's own unit — reachable as unit. +public sealed record WorkspaceGrant(WorkspaceConfig Workspace, WorkspaceAccess Access, bool IsOwnUnit); + +/// +/// #684: Where the workspaces live on disk, and which names are taken. +/// +/// The instance's one shared folder is Agents/SharedWorkspace, and it stays +/// there: FTP, RocketChat, the agent editor, the task files and the backups all find it by +/// that path. The workspaces with member lists live beside it, one folder each under +/// Agents/SharedWorkspaces/. Found from the shared folder's path, which every tool +/// context already carries, so nothing new has to be passed around. +/// +public static class WorkspaceLayout +{ + public const string Personal = "personal"; + public const string Shared = "shared"; + + /// The own unit's workspace, without having to know the unit's id. + public const string Unit = "unit"; + + /// The folder next to SharedWorkspace that holds the workspaces. + public const string ContainerFolder = "SharedWorkspaces"; + + /// Names a workspace cannot have — they mean something else in FileRW. + public static readonly IReadOnlyList Reserved = [Personal, Shared, Unit]; + + /// Letters, digits, '-' and '_' — the same rule as for unit ids, since a unit's + /// workspace carries the unit's id — and not one of the reserved names. + public static bool IsValidId(string? id) + => !string.IsNullOrWhiteSpace(id) + && id.Length <= 64 + && id.All(c => char.IsLetterOrDigit(c) || c is '-' or '_') + && !Reserved.Contains(id, StringComparer.OrdinalIgnoreCase); + + /// + /// The folder of a workspace, from the path of the instance's shared folder; + /// null when that is not configured or the id is not valid. + /// + public static string? PathOf(string? sharedWorkspacePath, string workspaceId) + { + if (string.IsNullOrWhiteSpace(sharedWorkspacePath) || !IsValidId(workspaceId)) + return null; + + var shared = Path.GetFullPath(sharedWorkspacePath) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var agentsDirectory = Path.GetDirectoryName(shared); + return agentsDirectory is null + ? null + : Path.Combine(agentsDirectory, ContainerFolder, workspaceId); + } +} diff --git a/src/ClawdDotNet.Tools.FileRW/FileRWTool.cs b/src/ClawdDotNet.Tools.FileRW/FileRWTool.cs index cce1277..4ae5a23 100644 --- a/src/ClawdDotNet.Tools.FileRW/FileRWTool.cs +++ b/src/ClawdDotNet.Tools.FileRW/FileRWTool.cs @@ -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) ─── + + /// The folder in the shared workspace that holds the task files. + private const string TasksFolder = "tasks"; + + /// A workspace as one call sees it: where it lies and what the agent may do there. + private sealed record Area(string Name, string? Root, WorkspaceAccess Access, IReadOnlyList 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"; + /// + /// Resolves the name an agent gave. "personal" and "shared" work as they always did — + /// "shared" still by the agent's own sharedAccessLevel. "unit" is the own unit's + /// workspace; any other name must be a workspace the agent is a member of. Everything + /// else is null: an unknown name and a workspace the agent may not see get the + /// same answer, so the answer does not reveal that it exists. + /// + 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)); + } + + /// The answer to a name that is not a workspace this agent may use — with the ones it may. + private static string UnknownWorkspace(string requested, AgentToolContext context) + { + var names = new List { 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)}."; + } + + /// The one shared folder, by the level the agent carries for it — as before #684. + 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 }; } + /// Managing a workspace includes changing and deleting protected files — as the old Admin level did. + private static bool CanOverrideProtection(Area area) => area.Access >= WorkspaceAccess.Manage; + /// /// 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. /// - 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) + /// + /// #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. + /// + 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 NormaliseProtectedPaths(IEnumerable paths) + => paths + .Select(p => p.Replace('\\', '/').Trim().Trim('/')) + .Where(p => p.Length > 0) + .Select(p => p + "/") + .ToList(); + private static List 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 } /// - /// #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. /// /// Anlass aus dem Testbetrieb: Ein guenstiges Modell vergisst regelmaessig /// workspace: "shared" 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. + /// 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. + /// + /// Seit #684 gibt es mehr als zwei: gesucht wird in allen, die der Agent kennt. /// 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 { 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 HandleReadAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct) + private async Task 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 Zeichen; was nicht mehr hineinpasst, wird am /// Ende namentlich genannt, damit der Agent den Rest gezielt nachholen kann. /// - private async Task HandleReadManyAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct) + private async Task 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(); @@ -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 read. /// private (List 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 HandleWriteAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct) + private async Task 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 HandleAppendAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct) + private async Task 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. /// - private async Task HandleListAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct) + private async Task 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 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 HandleDeleteAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct) + private async Task 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 HandleCopyAsync(JsonElement input, string sourceRootPath, string sourceWorkspace, AgentToolContext context, CancellationToken ct) + private async Task 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 diff --git a/tests/ClawdDotNet.Core.Tests/Organisation/WorkspaceAccessTests.cs b/tests/ClawdDotNet.Core.Tests/Organisation/WorkspaceAccessTests.cs new file mode 100644 index 0000000..755596f --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Organisation/WorkspaceAccessTests.cs @@ -0,0 +1,133 @@ +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Organisation; +using Shouldly; + +namespace ClawdDotNet.Core.Tests.Organisation; + +/// +/// #684: Who may use which workspace. The right stands at the workspace — a member list of +/// units and agents with a level — and the tree decides what a unit entry covers. +/// +public sealed class WorkspaceAccessTests +{ + private static readonly UnitConfig[] Units = + [ + new() { Id = "purchasing", Name = "Purchasing", LeaderAgentId = "head" }, + new() { Id = "suppliers", Name = "Suppliers", ParentId = "purchasing" }, + new() { Id = "marketing", Name = "Marketing" } + ]; + + private static readonly AgentConfig[] Agents = + [ + new() { AgentId = "head", UnitId = "purchasing" }, + new() { AgentId = "scout", UnitId = "suppliers" }, + new() { AgentId = "ads", UnitId = "marketing" }, + new() { AgentId = "ceo" } + ]; + + private static OrgChart Chart(params WorkspaceConfig[] workspaces) => new(Units, Agents, workspaces); + + private static WorkspaceConfig Workspace(string id, string? owner, params WorkspaceMember[] members) + => new() { Id = id, Name = id, OwnerUnitId = owner, Members = [.. members] }; + + [Fact] + public void A_unit_entry_covers_the_units_below() + { + var org = Chart(Workspace("purchasing", "purchasing", new WorkspaceMember { UnitId = "purchasing", Access = WorkspaceAccess.Write })); + + org.AccessTo("head", "purchasing").ShouldBe(WorkspaceAccess.Write); + org.AccessTo("scout", "purchasing").ShouldBe(WorkspaceAccess.Write); + org.AccessTo("ads", "purchasing").ShouldBe(WorkspaceAccess.None); + org.AccessTo("ceo", "purchasing").ShouldBe(WorkspaceAccess.None, "the top level is not a member of every unit"); + } + + [Fact] + public void Without_sub_units_the_entry_covers_the_unit_alone() + { + var org = Chart(Workspace("round", null, + new WorkspaceMember { UnitId = "purchasing", Access = WorkspaceAccess.Read, IncludeSubUnits = false })); + + org.AccessTo("head", "round").ShouldBe(WorkspaceAccess.Read); + org.AccessTo("scout", "round").ShouldBe(WorkspaceAccess.None); + } + + [Fact] + public void The_highest_level_of_all_matching_entries_counts() + { + var org = Chart(Workspace("articles", "purchasing", + new WorkspaceMember { UnitId = "purchasing", Access = WorkspaceAccess.Read }, + new WorkspaceMember { AgentId = "scout", Access = WorkspaceAccess.Manage })); + + org.AccessTo("scout", "articles").ShouldBe(WorkspaceAccess.Manage); + org.AccessTo("head", "articles").ShouldBe(WorkspaceAccess.Read); + } + + [Fact] + public void An_agent_on_the_top_level_can_be_named_directly() + => Chart(Workspace("board", null, new WorkspaceMember { AgentId = "ceo", Access = WorkspaceAccess.Write })) + .AccessTo("ceo", "board").ShouldBe(WorkspaceAccess.Write); + + [Fact] + public void The_own_unit_s_workspace_comes_first_and_only_visible_ones_are_listed() + { + var org = Chart( + Workspace("articles", "purchasing", new WorkspaceMember { UnitId = "marketing", Access = WorkspaceAccess.Read }), + Workspace("marketing", "marketing", new WorkspaceMember { UnitId = "marketing", Access = WorkspaceAccess.Write }), + Workspace("purchasing", "purchasing", new WorkspaceMember { UnitId = "purchasing", Access = WorkspaceAccess.Write })); + + var grants = org.WorkspacesFor("ads"); + + grants.Select(g => g.Workspace.Id).ShouldBe(["marketing", "articles"]); + grants[0].IsOwnUnit.ShouldBeTrue(); + org.UnitWorkspace("marketing")!.Id.ShouldBe("marketing"); + } + + [Fact] + public void A_workspace_carrying_a_unit_s_id_but_owned_by_another_is_not_that_unit_s() + => Chart(Workspace("marketing", "purchasing")).UnitWorkspace("marketing").ShouldBeNull(); + + [Fact] + public void Broken_references_and_reserved_names_are_named() + { + var problems = Chart( + Workspace("shared", null), + Workspace("orphan", "gone", new WorkspaceMember { UnitId = "nowhere" }, new WorkspaceMember { AgentId = "ghost" })).Problems(); + + problems.ShouldContain(p => p.Contains("'shared' has an invalid id")); + problems.ShouldContain(p => p.Contains("'gone'")); + problems.ShouldContain(p => p.Contains("'nowhere'")); + problems.ShouldContain(p => p.Contains("'ghost'")); + } + + [Fact] + public void Workspaces_live_next_to_the_one_shared_folder() + { + var shared = Path.Combine(Path.GetTempPath(), "inst", "Agents", "SharedWorkspace"); + + WorkspaceLayout.PathOf(shared, "purchasing") + .ShouldBe(Path.Combine(Path.GetTempPath(), "inst", "Agents", "SharedWorkspaces", "purchasing")); + WorkspaceLayout.PathOf(shared + Path.DirectorySeparatorChar, "purchasing") + .ShouldBe(Path.Combine(Path.GetTempPath(), "inst", "Agents", "SharedWorkspaces", "purchasing"), "a trailing separator changes nothing"); + WorkspaceLayout.PathOf(shared, "../escape").ShouldBeNull(); + WorkspaceLayout.PathOf(null, "purchasing").ShouldBeNull(); + } + + [Fact] + public void The_briefing_names_the_workspaces_and_what_the_agent_may_do_there() + { + var org = Chart( + Workspace("marketing", "marketing", new WorkspaceMember { UnitId = "marketing", Access = WorkspaceAccess.Write }), + new WorkspaceConfig + { + Id = "articles", Name = "Article master", OwnerUnitId = "purchasing", Purpose = "One file per article.", + Members = [new WorkspaceMember { UnitId = "marketing", Access = WorkspaceAccess.Read }] + }); + + var section = OrgBriefing.For(org, "ads", DateTime.UtcNow)!; + + section.ShouldContain("\"unit\" — your unit's workspace (also \"marketing\"), read and write"); + section.ShouldContain("\"articles\" (Article master), read only — One file per article."); + OrgBriefing.For(org, "scout", DateTime.UtcNow)!.ShouldNotContain("workspaces", Case.Insensitive, + "the scout is in none of them — and must not learn that they exist"); + } +} diff --git a/tests/ClawdDotNet.Tools.Tests/FileRW/WorkspaceAreasTests.cs b/tests/ClawdDotNet.Tools.Tests/FileRW/WorkspaceAreasTests.cs new file mode 100644 index 0000000..8dbb596 --- /dev/null +++ b/tests/ClawdDotNet.Tools.Tests/FileRW/WorkspaceAreasTests.cs @@ -0,0 +1,271 @@ +using System.Text.Json; +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Organisation; +using ClawdDotNet.Core.State; +using ClawdDotNet.Core.Tools; +using ClawdDotNet.Tools.FileRW; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; + +namespace ClawdDotNet.Tools.Tests.FileRW; + +/// +/// #684: Named workspaces with member lists. Purchasing files its things in its own +/// workspace, marketing reads the article master that purchasing owns, and accounting's +/// workspace does not exist for anyone outside accounting — not even as a name. +/// +public sealed class WorkspaceAreasTests : IDisposable +{ + private readonly string _agents; + private readonly FileRWTool _tool = new(); + + private readonly List _units = + [ + new() { Id = "purchasing", Name = "Purchasing" }, + new() { Id = "suppliers", Name = "Suppliers", ParentId = "purchasing" }, + new() { Id = "marketing", Name = "Marketing" }, + new() { Id = "accounting", Name = "Accounting" } + ]; + + private readonly List _people = + [ + new() { AgentId = "buyer", UnitId = "purchasing" }, + new() { AgentId = "scout", UnitId = "suppliers" }, + new() { AgentId = "ads", UnitId = "marketing" }, + new() { AgentId = "copywriter", UnitId = "marketing" }, + new() { AgentId = "bookkeeper", UnitId = "accounting" }, + new() { AgentId = "auditor" } + ]; + + private readonly List _workspaces = + [ + Own("purchasing"), + Own("suppliers"), + Own("marketing"), + Own("accounting"), + new() + { + Id = "articles", Name = "Article master", OwnerUnitId = "purchasing", + Members = + [ + new() { UnitId = "purchasing", Access = WorkspaceAccess.Manage }, + new() { UnitId = "marketing", Access = WorkspaceAccess.Read }, + new() { AgentId = "auditor", Access = WorkspaceAccess.Read }, + new() { AgentId = "copywriter", Access = WorkspaceAccess.Write } + ], + ProtectedPaths = ["prices"] + } + ]; + + private static WorkspaceConfig Own(string unit) => new() + { + Id = unit, Name = unit, OwnerUnitId = unit, + Members = [new() { UnitId = unit, Access = WorkspaceAccess.Write }] + }; + + public WorkspaceAreasTests() + { + _agents = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"), "Agents"); + Directory.CreateDirectory(Path.Combine(_agents, "SharedWorkspace")); + } + + public void Dispose() + { + try { Directory.Delete(Path.GetDirectoryName(_agents)!, recursive: true); } + catch { /* cleaning up is secondary */ } + } + + private string Area(string id) => Path.Combine(_agents, "SharedWorkspaces", id); + + private AgentToolContext Context(string agentId, string sharedLevel = "Denied", bool withUnits = true) + { + var personal = Path.Combine(_agents, agentId, "Workspace"); + Directory.CreateDirectory(personal); + return new AgentToolContext( + agentId, "inst", + new Dictionary { ["sharedAccessLevel"] = sharedLevel }, + new NullStateStore(), NullLogger.Instance, CancellationToken.None, + WorkspacePath: personal, + SharedWorkspacePath: Path.Combine(_agents, "SharedWorkspace")) + { + Organisation = withUnits ? new OrgChart(_units, _people, _workspaces) : new OrgChart([], _people) + }; + } + + private Task Run(string agentId, object input, string sharedLevel = "Denied", bool withUnits = true) + => _tool.ExecuteAsync(JsonSerializer.SerializeToElement(input), Context(agentId, sharedLevel, withUnits), CancellationToken.None); + + // ═══════════════════ the own unit ═══════════════════ + + [Fact] + public async Task An_agent_writes_to_its_unit_without_knowing_the_unit_id() + { + var result = await Run("buyer", new { action = "write", workspace = "unit", path = "orders.md", content = "3 pallets" }); + + result.Success.ShouldBeTrue(result.ErrorMessage); + File.ReadAllText(Path.Combine(Area("purchasing"), "orders.md")).ShouldBe("3 pallets"); + result.Content.ShouldContain("purchasing:/orders.md", Case.Sensitive, "the answer names the workspace by its id"); + } + + [Fact] + public async Task A_colleague_finds_it_under_the_same_name() + { + await Run("buyer", new { action = "write", workspace = "purchasing", path = "orders.md", content = "3 pallets" }); + + // The scout sits in suppliers, below purchasing — the purchasing entry includes sub-units. + var read = await Run("scout", new { action = "read", workspace = "purchasing", path = "orders.md" }); + + read.Success.ShouldBeTrue(read.ErrorMessage); + read.Content.ShouldContain("3 pallets"); + } + + [Fact] + public async Task A_unit_s_workspace_does_not_exist_for_other_units() + { + await Run("bookkeeper", new { action = "write", workspace = "unit", path = "salaries.md", content = "secret" }); + + var probe = await Run("ads", new { action = "read", workspace = "accounting", path = "salaries.md" }); + var unknown = await Run("ads", new { action = "read", workspace = "does-not-exist", path = "x.md" }); + + probe.Success.ShouldBeFalse(); + probe.ErrorMessage!.ShouldStartWith("Unknown workspace 'accounting'"); + // The same answer as for a name that does not exist — nothing tells marketing it is there. + probe.ErrorMessage.Replace("accounting", "X").ShouldBe(unknown.ErrorMessage!.Replace("does-not-exist", "X")); + probe.ErrorMessage.ShouldNotContain("accounting,"); + } + + [Fact] + public async Task The_answer_to_an_unknown_name_lists_what_the_agent_can_use() + { + var result = await Run("ads", new { action = "list", workspace = "team" }, sharedLevel: "Read"); + + result.ErrorMessage!.ShouldContain("personal, shared, unit (= marketing), articles"); + } + + // ═══════════════════ shared across units ═══════════════════ + + [Fact] + public async Task Marketing_reads_the_article_master_but_cannot_write_it() + { + await Run("buyer", new { action = "write", workspace = "articles", path = "a-100.md", content = "Chair" }); + + (await Run("ads", new { action = "read", workspace = "articles", path = "a-100.md" })).Content.ShouldContain("Chair"); + + var write = await Run("ads", new { action = "write", workspace = "articles", path = "a-100.md", content = "Sofa" }); + write.Success.ShouldBeFalse(); + write.ErrorMessage!.ShouldContain("not allowed in workspace 'articles'"); + } + + [Fact] + public async Task A_single_agent_can_be_a_member_without_its_unit() + { + var result = await Run("auditor", new { action = "list", workspace = "articles" }); + + result.Success.ShouldBeTrue(result.ErrorMessage); + } + + [Fact] + public async Task Protected_paths_hold_for_writers_but_not_for_managers() + { + (await Run("buyer", new { action = "write", workspace = "articles", path = "prices/a-100.md", content = "10" })).Success.ShouldBeTrue(); + + // The copywriter may write here, but prices are append-only for writers… + var overwrite = await Run("copywriter", new { action = "write", workspace = "articles", path = "prices/a-100.md", content = "1" }); + overwrite.Success.ShouldBeFalse(); + overwrite.ErrorMessage!.ShouldContain("Protected area"); + (await Run("copywriter", new { action = "write", workspace = "articles", path = "prices/a-101.md", content = "5" })).Success + .ShouldBeTrue("adding a new file is what protected paths allow"); + + // …while purchasing manages the workspace, which includes changing them, as Admin did. + (await Run("buyer", new { action = "write", workspace = "articles", path = "prices/a-100.md", content = "12" })).Success.ShouldBeTrue(); + File.ReadAllText(Path.Combine(Area("articles"), "prices", "a-100.md")).ShouldBe("12"); + } + + [Fact] + public async Task Writing_is_not_deleting() + { + await Run("buyer", new { action = "write", workspace = "unit", path = "draft.md", content = "x" }); + + var delete = await Run("buyer", new { action = "delete", workspace = "unit", path = "draft.md" }); + + delete.Success.ShouldBeFalse("deleting needs Manage, as it needed Admin before"); + File.Exists(Path.Combine(Area("purchasing"), "draft.md")).ShouldBeTrue(); + } + + [Fact] + public async Task Copy_goes_from_the_own_unit_to_a_shared_workspace() + { + await Run("buyer", new { action = "write", workspace = "unit", path = "a-200.md", content = "Table" }); + + var copy = await Run("buyer", new { action = "copy", workspace = "unit", path = "a-200.md", destinationWorkspace = "articles", destinationPath = "a-200.md" }); + + copy.Success.ShouldBeTrue(copy.ErrorMessage); + File.ReadAllText(Path.Combine(Area("articles"), "a-200.md")).ShouldBe("Table"); + } + + [Fact] + public async Task The_hint_points_to_the_workspace_where_the_file_is() + { + await Run("buyer", new { action = "write", workspace = "unit", path = "orders.md", content = "3 pallets" }); + + var result = await Run("buyer", new { action = "read", path = "orders.md" }); + + result.ErrorMessage!.ShouldContain("exists in the purchasing workspace"); + } + + // ═══════════════════ the one shared folder ═══════════════════ + + [Fact] + public async Task Shared_works_as_before_by_the_agent_s_own_level() + { + (await Run("ads", new { action = "write", workspace = "shared", path = "note.md", content = "x" }, sharedLevel: "ReadWrite")) + .Success.ShouldBeTrue(); + + var denied = await Run("ads", new { action = "write", workspace = "shared", path = "note.md", content = "y" }, sharedLevel: "Read"); + denied.ErrorMessage!.ShouldContain("not allowed in workspace 'shared'"); + } + + [Fact] + public async Task With_units_the_task_files_are_closed_to_FileRW() + { + var tasks = Path.Combine(_agents, "SharedWorkspace", "tasks"); + Directory.CreateDirectory(tasks); + File.WriteAllText(Path.Combine(tasks, "t-1.md"), "---\nassignee: bookkeeper\n---"); + + var read = await Run("ads", new { action = "read", workspace = "shared", path = "tasks/t-1.md" }, sharedLevel: "Admin"); + var write = await Run("ads", new { action = "write", workspace = "shared", path = "tasks/t-2.md", content = "sneaky" }, sharedLevel: "Admin"); + var list = await Run("ads", new { action = "list", workspace = "shared" }, sharedLevel: "Admin"); + + read.Success.ShouldBeFalse(); + read.ErrorMessage!.ShouldContain("Taskboard tool"); + write.Success.ShouldBeFalse(); + File.Exists(Path.Combine(tasks, "t-2.md")).ShouldBeFalse(); + list.Content.ShouldNotContain("tasks/"); + } + + [Fact] + public async Task Without_units_the_task_files_stay_as_they_were() + { + var tasks = Path.Combine(_agents, "SharedWorkspace", "tasks"); + Directory.CreateDirectory(tasks); + File.WriteAllText(Path.Combine(tasks, "t-1.md"), "task"); + + var read = await Run("ads", new { action = "read", workspace = "shared", path = "tasks/t-1.md" }, sharedLevel: "Read", withUnits: false); + + read.Success.ShouldBeTrue(read.ErrorMessage); + } + + [Fact] + public async Task Without_an_organisation_only_personal_and_shared_exist() + { + var context = new AgentToolContext( + "solo", "inst", new Dictionary(), new NullStateStore(), NullLogger.Instance, CancellationToken.None, + WorkspacePath: Path.Combine(_agents, "solo"), SharedWorkspacePath: Path.Combine(_agents, "SharedWorkspace")); + Directory.CreateDirectory(context.WorkspacePath!); + + var result = await _tool.ExecuteAsync( + JsonSerializer.SerializeToElement(new { action = "list", workspace = "unit" }), context, CancellationToken.None); + + result.ErrorMessage!.ShouldBe("Unknown workspace 'unit'. You can use: personal."); + } +}