Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions openspec/specs/netclaw-tools/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,10 @@ create, modify, or remove any filesystem entry.
same scoped read-access policy used by `file_read`, so the directories an
audience may list are exactly that audience's resolved read roots. A target
outside the audience's read roots SHALL be denied, and the denial message
SHALL NOT disclose configured root paths.
SHALL NOT disclose configured root paths. Interactive Personal-audience
sessions are the exception: they get shell-equivalent reach, so a target
outside the read roots SHALL resolve when the session is interactive and the
audience is Personal. Autonomous sessions keep the hard denial.

#### Scenario: Team session lists a directory within its read roots

Expand Down Expand Up @@ -309,7 +312,11 @@ supported values. The applied filter SHALL be echoed in the result.

The system SHALL provide a `file_read` first-party tool that authorizes the
requested path through the audience-scoped read-file policy before inspecting or
reading bytes. Text-like files SHALL return decoded text for UTF-8, UTF-16/UTF-32
reading bytes. Interactive Personal-audience sessions are the exception: they
get shell-equivalent reach, so a path outside the read roots SHALL resolve when
the session is interactive and the audience is Personal. Autonomous sessions
keep the hard denial. Text-like files SHALL return decoded text for UTF-8,
UTF-16/UTF-32
Unicode, and common Windows-1252 text files using the existing offset/limit and
output-truncation behavior.

Expand Down
58 changes: 47 additions & 11 deletions src/Netclaw.Actors.Tests/Tools/AttachFileToolTests.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
// -----------------------------------------------------------------------
// -----------------------------------------------------------------------
// <copyright file="AttachFileToolTests.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------
using Netclaw.Actors.Tools;
using Netclaw.Configuration;
using Netclaw.Security;
using Netclaw.Tests.Utilities;
using Netclaw.Tools;
using Xunit;
Expand All @@ -14,7 +15,7 @@ namespace Netclaw.Actors.Tests.Tools;
public class AttachFileToolTests : IDisposable
{
private readonly DisposableTempDir _dir = new();
private readonly AttachFileTool _tool = new(new ToolConfig(), new NetclawPaths());
private readonly AttachFileTool _tool = new(new ToolConfig(), new NetclawPaths(), new ToolPathPolicy([]));

public void Dispose()
{
Expand All @@ -40,13 +41,21 @@ public async Task Valid_file_within_session_directory_succeeds()
[Fact]
public async Task Path_traversal_attempt_is_rejected()
{
// Create a file outside the session directory
// Autonomous Personal: the out-of-session boundary holds for
// non-interactive sessions (#1724). Interactive Personal gets
// shell-equivalent reach instead.
var outsidePath = Path.Combine(Path.GetTempPath(), $"netclaw-outside-{Guid.NewGuid():N}.txt");
await File.WriteAllTextAsync(outsidePath, "sensitive data", TestContext.Current.CancellationToken);

try
{
var context = TestToolExecutionContext.CreateBound("test-session", _dir.Path, TrustAudience.Personal);
var context = TestToolExecutionContext.CreateBound("reminder/test-session", _dir.Path, new TestToolExecutionContextOptions
{
Audience = TrustAudience.Personal,
Boundary = SecurityPolicyDefaults.ResolveBoundaryFromAudience(TrustAudience.Personal),
InteractiveApproval = TestToolExecutionContext.InteractiveApproval(false),
ChannelType = "reminder"
});
var args = ToolInput.Create("Path", outsidePath);

var result = await _tool.ExecuteAsync(args, context, CancellationToken.None);
Expand All @@ -63,7 +72,14 @@ public async Task Path_traversal_attempt_is_rejected()
[Fact]
public async Task Dotdot_traversal_is_rejected()
{
var context = TestToolExecutionContext.CreateBound("test-session", _dir.Path, TrustAudience.Personal);
// Autonomous Personal: dotdot escape is denied outside the zone (#1724).
var context = TestToolExecutionContext.CreateBound("reminder/test-session", _dir.Path, new TestToolExecutionContextOptions
{
Audience = TrustAudience.Personal,
Boundary = SecurityPolicyDefaults.ResolveBoundaryFromAudience(TrustAudience.Personal),
InteractiveApproval = TestToolExecutionContext.InteractiveApproval(false),
ChannelType = "reminder"
});
var args = ToolInput.Create("Path", Path.Combine(_dir.Path, "..", "..", "etc", "passwd"));

var result = await _tool.ExecuteAsync(args, context, CancellationToken.None);
Expand Down Expand Up @@ -156,12 +172,20 @@ public async Task Failed_attach_does_not_populate_file_attachments()
[Fact]
public async Task Prefix_collision_path_is_rejected()
{
// Autonomous Personal: a sibling directory sharing the session dir's
// name prefix is outside the zone and denied (#1724).
var outsideDir = _dir.Path + "-outside";
Directory.CreateDirectory(outsideDir);
var outsideFile = Path.Combine(outsideDir, "secret.txt");
await File.WriteAllTextAsync(outsideFile, "sensitive", TestContext.Current.CancellationToken);

var context = TestToolExecutionContext.CreateBound("test-session", _dir.Path, TrustAudience.Personal);
var context = TestToolExecutionContext.CreateBound("reminder/test-session", _dir.Path, new TestToolExecutionContextOptions
{
Audience = TrustAudience.Personal,
Boundary = SecurityPolicyDefaults.ResolveBoundaryFromAudience(TrustAudience.Personal),
InteractiveApproval = TestToolExecutionContext.InteractiveApproval(false),
ChannelType = "reminder"
});
var args = ToolInput.Create("Path", outsideFile);

var result = await _tool.ExecuteAsync(args, context, CancellationToken.None);
Expand All @@ -174,6 +198,9 @@ public async Task Prefix_collision_path_is_rejected()
[Fact]
public async Task Symlink_to_outside_file_is_rejected()
{
// Autonomous Personal: a symlink in the session dir that resolves
// outside is denied by the proximity gate (#1724). Interactive Personal
// gets shell-equivalent reach instead.
var outsideFile = Path.Combine(Path.GetTempPath(), $"netclaw-outside-{Guid.NewGuid():N}.txt");
var symlinkPath = Path.Combine(_dir.Path, "linked.txt");

Expand All @@ -183,13 +210,21 @@ public async Task Symlink_to_outside_file_is_rejected()
{
File.CreateSymbolicLink(symlinkPath, outsideFile);

var context = TestToolExecutionContext.CreateBound("test-session", _dir.Path, TrustAudience.Personal);
var context = TestToolExecutionContext.CreateBound("reminder/test-session", _dir.Path, new TestToolExecutionContextOptions
{
Audience = TrustAudience.Personal,
Boundary = SecurityPolicyDefaults.ResolveBoundaryFromAudience(TrustAudience.Personal),
InteractiveApproval = TestToolExecutionContext.InteractiveApproval(false),
ChannelType = "reminder"
});
var args = ToolInput.Create("Path", symlinkPath);

var result = await _tool.ExecuteAsync(args, context, CancellationToken.None);

// The autonomous zone rejects symlinked paths outright — stricter
// than the proximity gate, and the intended behavior (#1724).
Assert.Contains("Error", result);
Assert.Contains("session directory", result, StringComparison.OrdinalIgnoreCase);
Assert.Contains("symlink", result, StringComparison.OrdinalIgnoreCase);
Assert.Empty(context.FileAttachments);
}
catch (UnauthorizedAccessException)
Expand Down Expand Up @@ -278,11 +313,12 @@ public async Task Symlink_from_sibling_session_to_outside_root_is_rejected()
{
File.CreateSymbolicLink(symlinkPath, outsidePath);

var context = TestToolExecutionContext.CreateBound("signalr/thread-1", currentSessionDir, new TestToolExecutionContextOptions
{
var context = TestToolExecutionContext.CreateBound("reminder/thread-1", currentSessionDir, new TestToolExecutionContextOptions
{
Audience = TrustAudience.Personal,
Boundary = TrustBoundary.TrustedInstance,
ChannelType = "signalr"
InteractiveApproval = TestToolExecutionContext.InteractiveApproval(false),
ChannelType = "reminder"
});
var args = ToolInput.Create("Path", symlinkPath);

Expand Down
230 changes: 230 additions & 0 deletions src/Netclaw.Actors.Tests/Tools/InteractivePersonalReadReachTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
// -----------------------------------------------------------------------
// <copyright file="InteractivePersonalReadReachTests.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------
using Netclaw.Actors.Tools;
using Netclaw.Configuration;
using Netclaw.Security;
using Netclaw.Tests.Utilities;
using Netclaw.Tools;
using Xunit;

namespace Netclaw.Actors.Tests.Tools;

/// <summary>
/// Interactive Personal-audience sessions get shell-equivalent read/attach
/// reach: out-of-root paths resolve instead of hard-failing, matching the
/// approval-gated shell surface. Autonomous sessions, Team, and Public keep
/// their roots-scoped or fail-closed behavior. Regression guard for
/// netclaw-dev/netclaw#1724.
/// </summary>
public sealed class InteractivePersonalReadReachTests : IDisposable
{
private readonly DisposableTempDir _dir = new();
private readonly string _sessionDir;
private readonly string _outsideDir;
private readonly NetclawPaths _paths;

public InteractivePersonalReadReachTests()
{
_sessionDir = Path.Combine(_dir.Path, "sessions", "s1");
_outsideDir = Path.Combine(_dir.Path, "outside");
Directory.CreateDirectory(_sessionDir);
Directory.CreateDirectory(_outsideDir);
_paths = new NetclawPaths(_dir.Path);
}

public void Dispose() => _dir.Dispose();

private ToolInvocationContext Ctx(TrustAudience audience, bool autonomous)
=> TestToolExecutionContext.CreateBound(
autonomous ? "reminder/s1" : "signalr/s1",
_sessionDir,
new TestToolExecutionContextOptions
{
Audience = audience,
Boundary = SecurityPolicyDefaults.ResolveBoundaryFromAudience(audience),
InteractiveApproval = TestToolExecutionContext.InteractiveApproval(!autonomous),
ProjectDirectory = null,
ChannelType = autonomous ? "reminder" : "signalr"
}).Invocation;

private static ToolConfig BuildPersonalReadRootsConfig(string root)
{
var toolConfig = new ToolConfig();
toolConfig.AudienceProfiles.Personal.ReadFiles = new ToolFilesystemAccessProfile
{
Mode = ToolFilesystemMode.Roots,
Roots = [root]
};
return toolConfig;
}

public static TheoryData<TrustAudience, bool, bool, bool, bool> ReadReachCases => new()
{
// audience, interactive, outsideRoots, hardenedPersonalRoots, expectedAllow
// Default Personal (Mode.All): blanket interactive grant, autonomous clamp.
{ TrustAudience.Personal, true, false, false, true },
{ TrustAudience.Personal, true, true, false, true },
{ TrustAudience.Personal, false, false, false, true },
{ TrustAudience.Personal, false, true, false, false },
// Hardened Personal (ReadFiles = Roots = session dir): the #1724 trigger.
{ TrustAudience.Personal, true, false, true, true },
{ TrustAudience.Personal, true, true, true, true }, // NEW: shell-equivalent reach
{ TrustAudience.Personal, false, false, true, true },
{ TrustAudience.Personal, false, true, true, false },
// Team (Roots): roots-scoped everywhere.
{ TrustAudience.Team, true, false, false, true },
{ TrustAudience.Team, true, true, false, false },
{ TrustAudience.Team, false, false, false, true },
{ TrustAudience.Team, false, true, false, false },
// Public (session only): never widened.
{ TrustAudience.Public, true, false, false, true },
{ TrustAudience.Public, true, true, false, false },
{ TrustAudience.Public, false, false, false, true },
{ TrustAudience.Public, false, true, false, false },
};

[Theory]
[MemberData(nameof(ReadReachCases))]
public void Read_reach_matches_expected(
TrustAudience audience,
bool interactive,
bool outsideRoots,
bool hardenedPersonalRoots,
bool expectedAllow)
{
var config = hardenedPersonalRoots
? BuildPersonalReadRootsConfig(_sessionDir)
: new ToolConfig();
var policy = new ScopedFileAccessPolicy(config, _paths);
var ctx = Ctx(audience, autonomous: !interactive);

var path = outsideRoots
? Path.Combine(_outsideDir, "notes.txt")
: Path.Combine(_sessionDir, "notes.txt");

var allowed = policy.TryResolveReadPath(path, ctx, out _, out _);

Assert.Equal(expectedAllow, allowed);
}

[Theory]
[MemberData(nameof(ReadReachCases))]
public void Attach_reach_matches_expected(
TrustAudience audience,
bool interactive,
bool outsideRoots,
bool hardenedPersonalRoots,
bool expectedAllow)
{
var config = hardenedPersonalRoots
? BuildPersonalReadRootsConfig(_sessionDir)
: new ToolConfig();
var policy = new ScopedFileAccessPolicy(config, _paths);
var ctx = Ctx(audience, autonomous: !interactive);

var path = outsideRoots
? Path.Combine(_outsideDir, "report.png")
: Path.Combine(_sessionDir, "report.png");

var allowed = policy.TryResolveAttachPath(path, ctx, out _, out _);

Assert.Equal(expectedAllow, allowed);
}

public static TheoryData<TrustAudience, bool, bool> AttachToolReachCases => new()
{
// audience, interactive, expectedAttached
{ TrustAudience.Personal, true, true },
{ TrustAudience.Personal, false, false },
{ TrustAudience.Team, true, false },
{ TrustAudience.Public, true, false },
};

[Theory]
[MemberData(nameof(AttachToolReachCases))]
public async Task Attach_tool_outside_session_matches_expected(
TrustAudience audience,
bool interactive,
bool expectedAttached)
{
var outsideFile = Path.Combine(_outsideDir, "report.png");
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
await File.WriteAllBytesAsync(outsideFile, [0x89, 0x50, 0x4E, 0x47], TestContext.Current.CancellationToken);

var context = TestToolExecutionContext.CreateBound(
interactive ? "signalr/s1" : "reminder/s1",
_sessionDir,
new TestToolExecutionContextOptions
{
Audience = audience,
Boundary = SecurityPolicyDefaults.ResolveBoundaryFromAudience(audience),
InteractiveApproval = TestToolExecutionContext.InteractiveApproval(interactive),
ProjectDirectory = null,
ChannelType = interactive ? "signalr" : "reminder"
});
var tool = new AttachFileTool(new ToolConfig(), new NetclawPaths(), new ToolPathPolicy([]));
var args = ToolInput.Create("Path", outsideFile);

var result = await tool.ExecuteAsync(args, context.Invocation, CancellationToken.None);

if (expectedAttached)
{
Assert.Contains("File attached", result);
Assert.Single(context.FileAttachments);
}
else
{
Assert.Contains("Error", result);
Assert.Empty(context.FileAttachments);
}
}

[Fact]
public async Task Attach_tool_denies_control_plane_files_even_with_interactive_reach()
{
// BLOCKER regression (#1724): attach must use the same hard-deny surface
// as file_read/file_list, so interactive Personal reach cannot ship
// secrets/keys/db/pid/lock that shell cannot even reference.
var secretsPath = Path.Combine(_outsideDir, "secrets.json");
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
await File.WriteAllTextAsync(secretsPath, """{"apiKey":"top-secret"}""", TestContext.Current.CancellationToken);

var context = TestToolExecutionContext.CreateBound(
"signalr/s1",
_sessionDir,
new TestToolExecutionContextOptions
{
Audience = TrustAudience.Personal,
Boundary = SecurityPolicyDefaults.ResolveBoundaryFromAudience(TrustAudience.Personal),
InteractiveApproval = TestToolExecutionContext.InteractiveApproval(true),
ProjectDirectory = null,
ChannelType = "signalr"
});
var tool = new AttachFileTool(new ToolConfig(), new NetclawPaths(), new ToolPathPolicy([secretsPath]));
var args = ToolInput.Create("Path", secretsPath);

var result = await tool.ExecuteAsync(args, context.Invocation, CancellationToken.None);

Assert.Contains("cannot be read", result, StringComparison.OrdinalIgnoreCase);
Assert.Empty(context.FileAttachments);
}

[Fact]
public void Set_working_directory_stays_roots_scoped_for_interactive_personal()
{
// BLOCKER regression (#1724): set_working_directory must NOT inherit
// shell-equivalent reach — its declaration widens the safe-verb
// auto-approve zone and feeds project identity files into the prompt.
var config = BuildPersonalReadRootsConfig(_sessionDir);
var policy = new ScopedFileAccessPolicy(config, _paths);
var ctx = Ctx(TrustAudience.Personal, autonomous: false);

var outside = Path.Combine(_outsideDir, "notes.txt");
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed

// Reads resolve (shell-equivalent reach)...
Assert.True(policy.TryResolveReadPath(outside, ctx, out _, out _));
// ...but the working-directory declaration stays roots-scoped.
Assert.False(policy.TryResolveWorkingDirectory(outside, ctx, out _, out _));
}
}
Loading
Loading