Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
35 changes: 33 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 All @@ -326,6 +333,30 @@ references needed to recreate the handoff nudge during recovery.
PDF extraction, OCR, audio transcription, and video keyframe extraction SHALL NOT
be built into `file_read`.

### Requirement: Attachment tool reach

The system SHALL provide an `attach_file` first-party tool that sends a file to
the user. Non-interactive, Team, and Public sessions SHALL only attach files
inside the current session directory or a sibling Netclaw session directory.
Interactive Personal-audience sessions get shell-equivalent reach: any path that
resolves through the read-access policy SHALL be attachable, and the file SHALL
be copied into the current session's attachments directory before delivery.

All audiences SHALL apply the `ToolPathPolicy` read-deny surface to attached
files: a path that `IsReadDenied` (credentials, keys, secrets, control-plane
state, or the shell indicator list) SHALL NOT be attachable, even when the
proximity restriction is lifted.

### Requirement: Working directory declaration stays scoped

The system SHALL provide a `set_working_directory` first-party tool that sets
the session's project root. Its target SHALL be resolved through the read-access
policy WITHOUT interactive Personal shell-equivalent reach: the working
directory widens the shell safe-verb auto-approve zone and loads project
identity files into the system prompt, so it SHALL be clamped to the autonomous
zone (session directory, project directory, and global read roots) in every
audience and mode.

#### Scenario: Text file read preserves existing behavior

- **GIVEN** a readable text file using UTF-8, UTF-16/UTF-32 Unicode, or Windows-1252
Expand Down
20 changes: 16 additions & 4 deletions openspec/specs/session-cwd/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,13 @@ The tool SHALL validate that the target path is a real directory,
resolve it to an absolute path, and validate it against the audience
trust profile's read-allowed roots. The tool SHALL be profile-managed
so that audiences without directory navigation privileges (Public,
Team by default) cannot use it.
Team by default) cannot use it. The working-directory declaration is
deliberately NOT granted interactive Personal shell-equivalent reach
(netclaw-dev/netclaw#1724): it SHALL be clamped to the autonomous zone
(session directory, project directory, and configured global read
roots) in every audience and mode, because declaring a working
directory widens the shell safe-verb auto-approve zone and loads
project identity files into the system prompt.

The tool description visible to the model SHALL frame the tool as
"declare your project root and expand your trusted scope so shell
Expand Down Expand Up @@ -92,11 +98,17 @@ approval friction depends on doing so when the work is project-scoped.
- **THEN** the project directory remains unchanged
- **AND** the tool returns an error indicating the directory does not exist

#### Scenario: Personal audience allows any valid directory
#### Scenario: Personal audience clamps to the autonomous zone

- **GIVEN** a session with personal audience (`ToolFilesystemMode.All`)
- **WHEN** the agent invokes `set_working_directory` with any valid directory
- **THEN** the project directory is updated
- **AND** the target directory is outside the autonomous zone
(session directory, project directory, and configured global read roots)
- **WHEN** the agent invokes `set_working_directory` with that valid directory
- **THEN** the project directory is NOT updated
- **AND** the tool returns an error indicating the target is outside the
session, project, or configured autonomous roots
- **AND** `file_read` / `file_list` / `attach_file` on the same path still
resolve (interactive Personal shell-equivalent reach, netclaw-dev/netclaw#1724)

#### Scenario: set_working_directory not exposed to public audience

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
Expand Up @@ -5,6 +5,7 @@
// -----------------------------------------------------------------------
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
Loading
Loading