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:
Richard
2026-09-24 16:52:03 +02:00
co-authored by Claude Opus 5.5
parent ba5fe4cdf7
commit ff20ce2563
8 changed files with 901 additions and 139 deletions
@@ -179,6 +179,14 @@ public sealed class InstanceConfig
[JsonPropertyName("units")]
public List<UnitConfig> Units { get; set; } = new();
/// <summary>
/// #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 <c>shared</c>, governed by each agent's level as before.
/// </summary>
[JsonPropertyName("workspaces")]
public List<WorkspaceConfig> Workspaces { get; set; } = new();
[JsonPropertyName("services")]
public List<ServiceConfig> Services { get; set; } = new();
}
@@ -0,0 +1,85 @@
using System.Text.Json.Serialization;
namespace ClawdDotNet.Core.Config;
/// <summary>
/// #684: A shared workspace — a folder several agents work in, with a named owner and a list
/// of who may use it how.
///
/// <para><b>The right stands at the workspace, not at the agent</b> (Organisation-Konzept,
/// section 3). Before, every agent carried one <c>sharedAccessLevel</c> 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.</para>
///
/// <para><b>The one shared folder stays what it was.</b> The folder <c>SharedWorkspace</c>
/// with the per-agent level keeps working as the workspace <c>shared</c>, so every running
/// installation and every task that names it goes on as before. Workspaces configured here
/// live next to it, under <c>SharedWorkspaces/&lt;id&gt;/</c>. There is never a second
/// value for the same thing: <c>shared</c> is governed only by the agent's level, every
/// workspace here only by its member list.</para>
/// </summary>
public sealed class WorkspaceConfig
{
/// <summary>The name agents use and the folder name; lower case, digits and dashes.</summary>
[JsonPropertyName("id")]
public string Id { get; set; } = "";
[JsonPropertyName("name")]
public string Name { get; set; } = "";
/// <summary>What goes in here — shown to the members so they file things in the right place.</summary>
[JsonPropertyName("purpose")]
public string Purpose { get; set; } = "";
/// <summary>
/// The unit the workspace belongs to — what is filed here is its property, and stays
/// when an agent moves on. <c>null</c>: owned by the instance (the human).
/// </summary>
[JsonPropertyName("ownerUnitId")]
public string? OwnerUnitId { get; set; }
[JsonPropertyName("members")]
public List<WorkspaceMember> Members { get; set; } = new();
/// <summary>
/// Folders in which files can only be added, not changed or deleted — except by members
/// with <see cref="WorkspaceAccess.Manage"/>. Relative to the workspace.
/// </summary>
[JsonPropertyName("protectedPaths")]
public List<string> ProtectedPaths { get; set; } = new();
}
/// <summary>
/// #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 <see cref="IncludeSubUnits"/> is off;
/// a unit's own workspace is meant for the whole department.
/// </summary>
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;
}
/// <summary>
/// #684: What a member may do in a workspace. Each level includes the ones below it.
/// Deleting and changing protected files needs <see cref="Manage"/> — as the old
/// <c>Admin</c> level did for the one shared folder.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<WorkspaceAccess>))]
public enum WorkspaceAccess
{
None = 0,
Read = 1,
Write = 2,
Manage = 3
}
@@ -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();
}
/// <summary>
/// #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.
/// </summary>
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.");
}
/// <summary>
/// Replaces the organisation section of a system prompt, appends it, or removes it —
/// leaving everything before it untouched. Existing chat contexts keep their system
+79 -2
View File
@@ -24,12 +24,17 @@ public sealed class OrgChart
private readonly Dictionary<string, AgentConfig> _agents;
private readonly Dictionary<string, List<UnitConfig>> _children;
private readonly Dictionary<string, List<AgentConfig>> _members;
private readonly Dictionary<string, WorkspaceConfig> _workspaces;
/// <summary>The key under which top-level units and agents without a unit are grouped.</summary>
private const string Root = "";
public OrgChart(IEnumerable<UnitConfig> units, IEnumerable<AgentConfig> agents)
public OrgChart(IEnumerable<UnitConfig> units, IEnumerable<AgentConfig> agents, IEnumerable<WorkspaceConfig>? workspaces = null)
{
_workspaces = new Dictionary<string, WorkspaceConfig>(StringComparer.OrdinalIgnoreCase);
foreach (var workspace in workspaces ?? [])
_workspaces.TryAdd(workspace.Id, workspace);
_units = new Dictionary<string, UnitConfig>(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);
/// <summary>Whether the instance has any structure at all.</summary>
public bool HasUnits => _units.Count > 0;
@@ -241,6 +246,63 @@ public sealed class OrgChart
return ceiling;
}
// ─── workspaces (#684) ───
public IReadOnlyCollection<WorkspaceConfig> Workspaces => _workspaces.Values;
public WorkspaceConfig? Workspace(string? workspaceId)
=> workspaceId is not null && _workspaces.TryGetValue(workspaceId, out var workspace) ? workspace : null;
/// <summary>
/// A unit's own workspace: the one owned by the unit that carries its id. Created with
/// the unit; an agent reaches it as <c>unit</c> without knowing the id.
/// </summary>
public WorkspaceConfig? UnitWorkspace(string? unitId)
=> Workspace(unitId) is { } workspace
&& string.Equals(workspace.OwnerUnitId, unitId, StringComparison.OrdinalIgnoreCase)
? workspace
: null;
/// <summary>
/// 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.
/// </summary>
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<string>(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;
}
/// <summary>Every workspace the agent may use, with its level — its own unit's first.</summary>
public IReadOnlyList<WorkspaceGrant> 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();
}
/// <summary>
/// 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))
@@ -0,0 +1,56 @@
using ClawdDotNet.Core.Config;
namespace ClawdDotNet.Core.Organisation;
/// <summary>#684: A workspace an agent may use, and how far.</summary>
/// <param name="IsOwnUnit">The workspace of the agent's own unit — reachable as <c>unit</c>.</param>
public sealed record WorkspaceGrant(WorkspaceConfig Workspace, WorkspaceAccess Access, bool IsOwnUnit);
/// <summary>
/// #684: Where the workspaces live on disk, and which names are taken.
///
/// <para>The instance's one shared folder is <c>Agents/SharedWorkspace</c>, 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
/// <c>Agents/SharedWorkspaces/</c>. Found from the shared folder's path, which every tool
/// context already carries, so nothing new has to be passed around.</para>
/// </summary>
public static class WorkspaceLayout
{
public const string Personal = "personal";
public const string Shared = "shared";
/// <summary>The own unit's workspace, without having to know the unit's id.</summary>
public const string Unit = "unit";
/// <summary>The folder next to <c>SharedWorkspace</c> that holds the workspaces.</summary>
public const string ContainerFolder = "SharedWorkspaces";
/// <summary>Names a workspace cannot have — they mean something else in FileRW.</summary>
public static readonly IReadOnlyList<string> Reserved = [Personal, Shared, Unit];
/// <summary>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.</summary>
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);
/// <summary>
/// The folder of a workspace, from the path of the instance's shared folder;
/// <c>null</c> when that is not configured or the id is not valid.
/// </summary>
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);
}
}
+232 -132
View File
@@ -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,52 +466,57 @@ 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}\"."
: "";
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
{
return ""; // Ein Pfad, der dort gar nicht auflösbar ist, hilft niemandem.
// Ein Pfad, der dort gar nicht auflösbar ist, hilft niemandem.
}
}
return "";
}
private static string? RelativerPfad(JsonElement input)
=> input.ValueKind == JsonValueKind.Object && input.TryGetProperty("path", out var p) && p.ValueKind == JsonValueKind.String
? p.GetString()
: null;
private async Task<ToolResult> HandleReadAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct)
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
@@ -0,0 +1,133 @@
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Organisation;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Organisation;
/// <summary>
/// #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.
/// </summary>
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");
}
}
@@ -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;
/// <summary>
/// #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.
/// </summary>
public sealed class WorkspaceAreasTests : IDisposable
{
private readonly string _agents;
private readonly FileRWTool _tool = new();
private readonly List<UnitConfig> _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<AgentConfig> _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<WorkspaceConfig> _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<string, object?> { ["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<ToolResult> 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<string, object?>(), 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.");
}
}