Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
56 changes: 46 additions & 10 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 Down Expand Up @@ -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
183 changes: 183 additions & 0 deletions src/Netclaw.Actors.Tests/Tools/InteractivePersonalReadReachTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// -----------------------------------------------------------------------
// <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());
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);
}
}
}
12 changes: 10 additions & 2 deletions src/Netclaw.Actors/Tools/AttachFileTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,18 @@ protected override Task<string> ExecuteAsync(Params args, ToolInvocationContext
var sessionDir = PathUtility.Normalize(context.SessionDirectory);
var sessionRoot = TryGetSessionRootDirectory(sessionDir);

// Interactive Personal-audience sessions get shell-equivalent reach:
// shell can attach anything it can read, so the session-proximity
// restriction is lifted for them. The out-of-session file is still
// copied into this session's attachments directory below, preserving
// delivery semantics. Non-interactive, Team, and Public sessions keep
// the proximity gate.
var interactivePersonalReach = ScopedFileAccessPolicy.HasInteractivePersonalReach(context);

var requestedInCurrentSession = PathUtility.IsWithinRoot(requestedPath, sessionDir);
var requestedInSessionRoot = sessionRoot is not null && PathUtility.IsWithinRoot(requestedPath, sessionRoot);

if (!requestedInCurrentSession && !requestedInSessionRoot)
if (!interactivePersonalReach && !requestedInCurrentSession && !requestedInSessionRoot)
{
return Task.FromResult(
$"Error: File path must be within the current session directory ({sessionDir}) or another Netclaw session under {sessionRoot ?? "<unknown>"}.");
Expand All @@ -63,7 +71,7 @@ protected override Task<string> ExecuteAsync(Params args, ToolInvocationContext
var resolvedInCurrentSession = PathUtility.IsWithinRoot(resolvedPath, sessionDir);
var resolvedInSessionRoot = sessionRoot is not null && PathUtility.IsWithinRoot(resolvedPath, sessionRoot);

if (!resolvedInCurrentSession && !resolvedInSessionRoot)
if (!interactivePersonalReach && !resolvedInCurrentSession && !resolvedInSessionRoot)
{
return Task.FromResult(
$"Error: File path must be within the current session directory ({sessionDir}) or another Netclaw session under {sessionRoot ?? "<unknown>"}.");
Expand Down
Loading
Loading