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
@@ -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.");
}
}