FileRW: Arbeitsbereich im Pfad annehmen, Muster-Hinweis nur bei Treffern (#757)

- "shared:betrieb/protokoll.md" wird zu workspace + Pfad (wie FTP/RocketChat seit #718);
  muss zu einem angegebenen workspace passen, eine Pfadliste nennt nur einen Bereich
- read_many mit Muster: Hinweis auf einen anderen Bereich nur, wenn dort Dateien passen;
  ohne Pfad und Muster kein Hinweis (die Wurzel gibt es ueberall)
- verweigerter Pfad: kein doppeltes "Access denied", Warnung statt ERR mit Stacktrace
- Tests: WorkspaceInPathTests (6)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-10-01 09:04:38 +02:00
co-authored by Claude Opus 5.5
parent b6405a57d3
commit 92b2075e14
2 changed files with 194 additions and 6 deletions
@@ -0,0 +1,97 @@
using System.Text.Json;
using ClawdDotNet.Core.Tools;
using ClawdDotNet.Tools.FileRW;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Tools.Tests.FileRW;
/// <summary>
/// Found in operation on 0.1.16: an agent wrote <c>shared:betrieb/protokoll.md</c> — the form
/// FTP and RocketChat take (#718) — and FileRW refused it every morning. And a pattern that
/// matched nowhere sent the agent back and forth between two workspaces.
/// </summary>
public sealed class WorkspaceInPathTests : IDisposable
{
private readonly string _agents = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"), "Agents");
private readonly FileRWTool _tool = new();
public WorkspaceInPathTests()
{
Directory.CreateDirectory(Path.Combine(_agents, "SharedWorkspace", "betrieb"));
File.WriteAllText(Path.Combine(_agents, "SharedWorkspace", "betrieb", "protokoll.md"), "shared log");
Directory.CreateDirectory(Path.Combine(_agents, "redaktion", "Workspace"));
File.WriteAllText(Path.Combine(_agents, "redaktion", "Workspace", "notiz.md"), "personal note");
}
public void Dispose()
{
try { Directory.Delete(Path.GetDirectoryName(_agents)!, recursive: true); }
catch { /* cleaning up is secondary */ }
}
private Task<ToolResult> Run(object input)
{
var context = new AgentToolContext("redaktion", "inst",
new Dictionary<string, object?> { ["sharedAccessLevel"] = "ReadWrite" },
new NullStateStore(), NullLogger.Instance, CancellationToken.None,
WorkspacePath: Path.Combine(_agents, "redaktion", "Workspace"), SharedWorkspacePath: Path.Combine(_agents, "SharedWorkspace"));
return _tool.ExecuteAsync(JsonSerializer.SerializeToElement(input), context, CancellationToken.None);
}
[Fact]
public async Task A_path_that_names_its_workspace_is_read_from_there()
{
var result = await Run(new { action = "read", path = "shared:betrieb/protokoll.md" });
result.Success.ShouldBeTrue(result.ErrorMessage);
result.Content.ShouldContain("shared log");
}
[Fact]
public async Task A_list_of_paths_from_one_workspace_is_read_from_there()
{
var result = await Run(new { action = "read_many", paths = new[] { "shared:betrieb/protokoll.md" } });
result.Success.ShouldBeTrue(result.ErrorMessage);
result.Content.ShouldContain("shared log");
}
[Fact]
public async Task Two_workspaces_in_one_list_or_a_contradiction_are_refused_plainly()
{
(await Run(new { action = "read_many", paths = new[] { "shared:betrieb/protokoll.md", "personal:notiz.md" } }))
.ErrorMessage!.ShouldContain("more than one workspace");
(await Run(new { action = "read", workspace = "personal", path = "shared:betrieb/protokoll.md" }))
.ErrorMessage!.ShouldContain("'workspace' says 'personal'");
}
[Fact]
public async Task A_colon_that_names_no_workspace_is_still_refused_and_only_once_denied()
{
var result = await Run(new { action = "read", path = "notiz.md:versteckt" });
result.Success.ShouldBeFalse();
result.ErrorMessage!.ShouldStartWith("Access denied: Path contains");
result.ErrorMessage.ShouldNotContain("Access denied: Access denied");
}
[Fact]
public async Task A_pattern_that_matches_nowhere_points_nowhere()
{
var result = await Run(new { action = "read_many", workspace = "shared", pattern = "berlin_*.txt" });
result.Success.ShouldBeFalse();
result.ErrorMessage!.ShouldNotContain("workspace:", customMessage: "nothing matches in personal either — no hint");
}
[Fact]
public async Task A_pattern_that_matches_elsewhere_says_where()
{
File.WriteAllText(Path.Combine(_agents, "redaktion", "Workspace", "berlin_1.txt"), "x");
var result = await Run(new { action = "read_many", workspace = "shared", pattern = "berlin_*.txt" });
result.ErrorMessage!.ShouldContain("Matching files exist in the personal workspace");
}
}