diff --git a/openspec/specs/netclaw-tools/spec.md b/openspec/specs/netclaw-tools/spec.md
index 907b79ec1..5b5f04698 100644
--- a/openspec/specs/netclaw-tools/spec.md
+++ b/openspec/specs/netclaw-tools/spec.md
@@ -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
@@ -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.
@@ -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
diff --git a/openspec/specs/session-cwd/spec.md b/openspec/specs/session-cwd/spec.md
index 228b2cc7d..2ddccd4ac 100644
--- a/openspec/specs/session-cwd/spec.md
+++ b/openspec/specs/session-cwd/spec.md
@@ -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
@@ -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
diff --git a/src/Netclaw.Actors.Tests/Tools/AttachFileToolTests.cs b/src/Netclaw.Actors.Tests/Tools/AttachFileToolTests.cs
index da431a3c6..258bb0275 100644
--- a/src/Netclaw.Actors.Tests/Tools/AttachFileToolTests.cs
+++ b/src/Netclaw.Actors.Tests/Tools/AttachFileToolTests.cs
@@ -5,6 +5,7 @@
// -----------------------------------------------------------------------
using Netclaw.Actors.Tools;
using Netclaw.Configuration;
+using Netclaw.Security;
using Netclaw.Tests.Utilities;
using Netclaw.Tools;
using Xunit;
@@ -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()
{
@@ -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);
@@ -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);
@@ -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);
@@ -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");
@@ -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)
@@ -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);
diff --git a/src/Netclaw.Actors.Tests/Tools/InteractivePersonalReadReachTests.cs b/src/Netclaw.Actors.Tests/Tools/InteractivePersonalReadReachTests.cs
new file mode 100644
index 000000000..f3816f351
--- /dev/null
+++ b/src/Netclaw.Actors.Tests/Tools/InteractivePersonalReadReachTests.cs
@@ -0,0 +1,282 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (C) 2026 - 2026 Petabridge, LLC
+//
+// -----------------------------------------------------------------------
+using Netclaw.Actors.Tools;
+using Netclaw.Configuration;
+using Netclaw.Security;
+using Netclaw.Tests.Utilities;
+using Netclaw.Tools;
+using Xunit;
+
+namespace Netclaw.Actors.Tests.Tools;
+
+///
+/// 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.
+///
+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 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 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");
+ 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()
+ {
+ // 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");
+ 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()
+ {
+ // 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");
+
+ // 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 _));
+ }
+
+ [Fact]
+ public void Set_working_directory_stays_roots_scoped_for_default_mode_all()
+ {
+ // Regression (#1724): the opt-out must also hold for the DEFAULT
+ // Personal profile (Mode.All) — the most common configuration. The
+ // Mode.All interactive blanket grant must not leak into the
+ // working-directory declaration.
+ var policy = new ScopedFileAccessPolicy(new ToolConfig(), _paths);
+ var ctx = Ctx(TrustAudience.Personal, autonomous: false);
+
+ var outside = Path.Combine(_outsideDir, "notes.txt");
+
+ // Reads resolve under Mode.All interactive...
+ Assert.True(policy.TryResolveReadPath(outside, ctx, out _, out _));
+ // ...but the working-directory declaration clamps to the autonomous zone.
+ Assert.False(policy.TryResolveWorkingDirectory(outside, ctx, out _, out _));
+ }
+
+ public static TheoryData AttachRootsModeCases => new()
+ {
+ // interactive, expectedAllow
+ { true, true },
+ { false, false },
+ };
+
+ [Theory]
+ [MemberData(nameof(AttachRootsModeCases))]
+ public void Attach_reach_roots_mode_matches_expected(bool interactive, bool expectedAllow)
+ {
+ // Regression (#1724): the new Roots-mode attach branch must actually be
+ // exercised — the main matrix hardens only ReadFiles, so its attach rows
+ // hit the Mode.All branch. This pins the AccessKind.Attach clause.
+ var config = new ToolConfig();
+ config.AudienceProfiles.Personal.ReadFiles = new ToolFilesystemAccessProfile
+ {
+ Mode = ToolFilesystemMode.Roots,
+ Roots = [_sessionDir]
+ };
+ config.AudienceProfiles.Personal.AttachFiles = new ToolFilesystemAccessProfile
+ {
+ Mode = ToolFilesystemMode.Roots,
+ Roots = [_sessionDir]
+ };
+ var policy = new ScopedFileAccessPolicy(config, _paths);
+ var ctx = Ctx(TrustAudience.Personal, autonomous: !interactive);
+
+ var path = Path.Combine(_outsideDir, "report.png");
+ var allowed = policy.TryResolveAttachPath(path, ctx, out _, out _);
+
+ Assert.Equal(expectedAllow, allowed);
+ }
+}
diff --git a/src/Netclaw.Actors/Tools/AttachFileTool.cs b/src/Netclaw.Actors/Tools/AttachFileTool.cs
index 7c0d1fcd9..053a86a81 100644
--- a/src/Netclaw.Actors/Tools/AttachFileTool.cs
+++ b/src/Netclaw.Actors/Tools/AttachFileTool.cs
@@ -23,14 +23,16 @@ namespace Netclaw.Actors.Tools;
public sealed partial class AttachFileTool : NetclawTool
{
private readonly ScopedFileAccessPolicy _fileAccessPolicy;
+ private readonly ToolPathPolicy _pathPolicy;
public record Params(
[property: Description("Absolute path to the file to attach")] string Path,
[property: Description("Optional display name for the file")] string? DisplayName = null);
- public AttachFileTool(ToolConfig config, NetclawPaths paths)
+ public AttachFileTool(ToolConfig config, NetclawPaths paths, ToolPathPolicy pathPolicy)
{
_fileAccessPolicy = new ScopedFileAccessPolicy(config, paths);
+ _pathPolicy = pathPolicy;
}
protected override Task ExecuteAsync(Params args, ToolInvocationContext context, CancellationToken ct)
@@ -44,13 +46,27 @@ protected override Task ExecuteAsync(Params args, ToolInvocationContext
if (!_fileAccessPolicy.TryResolveAttachPath(args.Path, context, out var requestedPath, out var accessError))
return Task.FromResult(accessError);
+ // Same hard-deny surface as file_read/file_list: attach must never ship
+ // control-plane files (secrets, keys, webhooks, config, sqlite, pid,
+ // lock, restart manifest) that shell cannot even reference (#1724).
+ if (_pathPolicy.IsReadDenied(requestedPath))
+ return Task.FromResult(FileToolErrors.CredentialReadDenied(requestedPath));
+
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 ?? ""}.");
@@ -60,10 +76,17 @@ protected override Task ExecuteAsync(Params args, ToolInvocationContext
return Task.FromResult($"Error: File not found: {requestedPath}");
var resolvedPath = ResolveFinalPath(requestedPath);
+
+ // Defense-in-depth: re-check the deny against the symlink-resolved
+ // target so any future divergence between requestedPath and resolvedPath
+ // cannot widen attach's surface.
+ if (_pathPolicy.IsReadDenied(resolvedPath))
+ return Task.FromResult(FileToolErrors.CredentialReadDenied(resolvedPath));
+
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 ?? ""}.");
diff --git a/src/Netclaw.Actors/Tools/FileToolErrors.cs b/src/Netclaw.Actors/Tools/FileToolErrors.cs
index 6585952e1..5420bd261 100644
--- a/src/Netclaw.Actors/Tools/FileToolErrors.cs
+++ b/src/Netclaw.Actors/Tools/FileToolErrors.cs
@@ -1,4 +1,4 @@
-// -----------------------------------------------------------------------
+// -----------------------------------------------------------------------
//
// Copyright (C) 2026 - 2026 Petabridge, LLC
//
@@ -14,6 +14,6 @@ public static string ControlPlaneWriteDenied(string path)
+ "(e.g. `netclaw doctor --fix`, `netclaw secrets set`) or edit the file directly.";
public static string CredentialReadDenied(string path)
- => $"Error: Access denied: '{path}' contains credentials or keys "
- + "and cannot be read by agent tools.";
+ => $"Error: Access denied: '{path}' is a protected Netclaw file "
+ + "(credentials, keys, secrets, or control-plane state) and cannot be read by agent tools.";
}
diff --git a/src/Netclaw.Actors/Tools/ScopedFileAccessPolicy.cs b/src/Netclaw.Actors/Tools/ScopedFileAccessPolicy.cs
index 84481b50f..b4f8a1f44 100644
--- a/src/Netclaw.Actors/Tools/ScopedFileAccessPolicy.cs
+++ b/src/Netclaw.Actors/Tools/ScopedFileAccessPolicy.cs
@@ -36,6 +36,28 @@ public ScopedFileAccessPolicy(ToolConfig toolConfig, NetclawPaths paths)
public bool TryResolveReadPath(string rawPath, ToolInvocationContext context, out string fullPath, out string error)
=> TryResolvePath(rawPath, context, AccessKind.Read, out fullPath, out error);
+ ///
+ /// Resolves a path for set_working_directory. Deliberately does NOT
+ /// grant interactive Personal shell-equivalent reach: the working directory
+ /// becomes the safe-verb auto-approve zone and feeds project identity files
+ /// into the system prompt, so it is clamped to the autonomous zone (session
+ /// dir + project dir + global read roots) in every mode, even the default
+ /// Mode.All Personal profile.
+ ///
+ public bool TryResolveWorkingDirectory(string rawPath, ToolInvocationContext context, out string fullPath, out string error)
+ => TryResolvePath(rawPath, context, AccessKind.Read, out fullPath, out error, allowInteractivePersonalReach: false);
+
+ ///
+ /// True when an interactive Personal-audience session gets shell-equivalent
+ /// file reach: read and attach tools resolve outside the configured roots,
+ /// matching the approval-gated shell surface. Autonomous sessions, Team,
+ /// and Public audiences are never granted this — they keep their
+ /// roots-scoped or fail-closed behavior.
+ ///
+ internal static bool HasInteractivePersonalReach(ToolInvocationContext context)
+ => context.Audience == TrustAudience.Personal
+ && context.RunScope.InteractiveApproval is InteractiveApprovalCapability.Available;
+
public bool TryResolveWritePath(string rawPath, ToolInvocationContext context, out string fullPath, out string error)
=> TryResolvePath(rawPath, context, AccessKind.Write, out fullPath, out error);
@@ -54,7 +76,8 @@ private bool TryResolvePath(
ToolInvocationContext context,
AccessKind accessKind,
out string fullPath,
- out string error)
+ out string error,
+ bool allowInteractivePersonalReach = true)
{
try
{
@@ -69,6 +92,7 @@ private bool TryResolvePath(
var profile = _profileResolver.ResolveProfile(context);
var access = GetAccessProfile(profile, accessKind);
+ var audience = context.Audience;
if (access.Mode == ToolFilesystemMode.All)
{
@@ -78,15 +102,20 @@ private bool TryResolvePath(
// granted blanket filesystem access. Interactive channels keep the
// blanket grant — the live approval gate is their backstop. This is the
// single seam that covers shell (via TryResolveWritePath) and every file
- // tool at once.
- if (context.RunScope.InteractiveApproval is InteractiveApprovalCapability.Unavailable)
+ // tool at once. set_working_directory opts out (allowInteractivePersonalReach
+ // == false) and is clamped to the autonomous zone even for default
+ // Mode.All profiles: its declaration widens the safe-verb auto-approve
+ // zone and feeds project identity files into the system prompt.
+ if (!allowInteractivePersonalReach
+ || context.RunScope.InteractiveApproval is InteractiveApprovalCapability.Unavailable)
+ {
return TryResolveWithinAutonomousZone(fullPath, context, accessKind, out error);
+ }
error = string.Empty;
return true;
}
- var audience = context.Audience;
var label = GetAudienceLabel(audience);
if (access.Mode == ToolFilesystemMode.None)
@@ -95,6 +124,23 @@ private bool TryResolvePath(
return false;
}
+ // Interactive Personal-audience reads are shell-equivalent: shell reaches
+ // any path in an interactive session (approval gate + ToolPathPolicy hard
+ // deny), so read/attach tools do too. This kills the shell-workaround
+ // (cat, cp-into-session) for legitimate out-of-roots files. The hard deny
+ // surface still applies inside the tools via ToolPathPolicy.IsReadDenied
+ // (file_read, file_list, attach_file), and autonomous sessions never reach
+ // this branch — InteractiveApproval is Unavailable there, so they clamp to
+ // the zone or fail closed below. set_working_directory opts out via
+ // TryResolveWorkingDirectory because its reach widens the safe-verb zone.
+ if (allowInteractivePersonalReach
+ && accessKind is (AccessKind.Read or AccessKind.Attach)
+ && HasInteractivePersonalReach(context))
+ {
+ error = string.Empty;
+ return true;
+ }
+
var roots = ResolveAndMergeRoots(access, context, audience, accessKind);
if (roots.Count == 0)
diff --git a/src/Netclaw.Actors/Tools/SetWorkingDirectoryTool.cs b/src/Netclaw.Actors/Tools/SetWorkingDirectoryTool.cs
index b1d6da65b..10574ddb2 100644
--- a/src/Netclaw.Actors/Tools/SetWorkingDirectoryTool.cs
+++ b/src/Netclaw.Actors/Tools/SetWorkingDirectoryTool.cs
@@ -47,7 +47,7 @@ protected override Task ExecuteAsync(Params args, ToolInvocationContext
if (string.IsNullOrEmpty(raw))
return Task.FromResult("Error: path is required.");
- if (!_fileAccessPolicy.TryResolveReadPath(raw, context, out var fullPath, out var accessError))
+ if (!_fileAccessPolicy.TryResolveWorkingDirectory(raw, context, out var fullPath, out var accessError))
return Task.FromResult(accessError);
if (!Directory.Exists(fullPath))
diff --git a/src/Netclaw.Actors/Tools/ToolRegistrationExtensions.cs b/src/Netclaw.Actors/Tools/ToolRegistrationExtensions.cs
index 831efbc2f..4e67dd059 100644
--- a/src/Netclaw.Actors/Tools/ToolRegistrationExtensions.cs
+++ b/src/Netclaw.Actors/Tools/ToolRegistrationExtensions.cs
@@ -40,7 +40,7 @@ public static ToolRegistry WithFirstPartyTools(
registry.Register(new FileListTool(config, paths, pathPolicy));
registry.Register(new FileWriteTool(config, paths, pathPolicy));
registry.Register(new FileEditTool(config, paths, pathPolicy));
- registry.Register(new AttachFileTool(config, paths));
+ registry.Register(new AttachFileTool(config, paths, pathPolicy));
if (webhookRouteStore is not null)
{
registry.Register(new SetWebhookTool(webhookRouteStore));
diff --git a/src/Netclaw.Daemon/Program.cs b/src/Netclaw.Daemon/Program.cs
index 38eb8eb1b..bc2eadca9 100644
--- a/src/Netclaw.Daemon/Program.cs
+++ b/src/Netclaw.Daemon/Program.cs
@@ -581,6 +581,11 @@ static void ConfigureDaemonServices(
paths.SecretsPath,
paths.KeysDirectory,
paths.SqliteDbPath,
+ // SQLite sidecars mirror the shell indicator list — they hold the same
+ // raw page data as the DB and must not be writable through tools either.
+ paths.SqliteDbPath + "-wal",
+ paths.SqliteDbPath + "-shm",
+ paths.SqliteDbPath + "-journal",
paths.PidFilePath,
paths.LockFilePath,
paths.RestartManifestPath,
@@ -603,6 +608,14 @@ static void ConfigureDaemonServices(
paths.WebhooksDirectory,
paths.KeysDirectory,
paths.SqliteDbPath,
+ // SQLite sidecars hold raw page data (webhook secrets, OAuth tokens)
+ // and are reachable via the read-deny union, so they must be denied
+ // exactly like the DB itself. Shell's substring scan already catches
+ // them (command text contains "netclaw.db"); the path-boundary matcher
+ // in ToolPathPolicy does not, hence the explicit entries (#1724).
+ paths.SqliteDbPath + "-wal",
+ paths.SqliteDbPath + "-shm",
+ paths.SqliteDbPath + "-journal",
paths.PidFilePath,
paths.LockFilePath,
paths.RestartManifestPath,
diff --git a/src/Netclaw.Security.Tests/ToolPathPolicyTests.cs b/src/Netclaw.Security.Tests/ToolPathPolicyTests.cs
index fa5b1f4fd..e8598c473 100644
--- a/src/Netclaw.Security.Tests/ToolPathPolicyTests.cs
+++ b/src/Netclaw.Security.Tests/ToolPathPolicyTests.cs
@@ -169,10 +169,19 @@ private static ToolPathPolicy CreateProductionPolicy()
};
var shellIndicators = new[]
{
+ // ConfigDirectory is a directory-scoped shell indicator in production
+ // (src/Netclaw.Daemon/Program.cs), so the whole config dir is denied
+ // for shell references AND (via the IsReadDenied union) for reads.
+ "/home/user/.netclaw/config",
"/home/user/.netclaw/config/secrets.json",
"/home/user/.netclaw/config/webhooks",
"/home/user/.netclaw/keys",
"/home/user/.netclaw/netclaw.db",
+ // SQLite sidecars mirror production (Program.cs) — the path-boundary
+ // matcher would otherwise allow netclaw.db-wal/journal/shm reads.
+ "/home/user/.netclaw/netclaw.db-wal",
+ "/home/user/.netclaw/netclaw.db-shm",
+ "/home/user/.netclaw/netclaw.db-journal",
"/home/user/.netclaw/netclaw.pid",
"/home/user/.netclaw/netclaw.lock",
"/home/user/.netclaw/cache/restart-manifest.json",
@@ -219,9 +228,134 @@ public void IsReadDenied_blocks_sensitive_paths(string path)
Assert.True(policy.IsReadDenied(path));
}
+ // The read deny surface is the union of the read deny list and the shell
+ // indicator list, so read tools cannot reach control-plane lifecycle files
+ // that shell cannot even reference (#1724).
[Theory]
[InlineData("/home/user/.netclaw/config/netclaw.json")]
[InlineData("/home/user/.netclaw/netclaw.db")]
+ [InlineData("/home/user/.netclaw/netclaw.db-wal")]
+ [InlineData("/home/user/.netclaw/netclaw.db-shm")]
+ [InlineData("/home/user/.netclaw/netclaw.db-journal")]
+ [InlineData("/home/user/.netclaw/netclaw.pid")]
+ [InlineData("/home/user/.netclaw/netclaw.lock")]
+ [InlineData("/home/user/.netclaw/cache/restart-manifest.json")]
+ public void IsReadDenied_blocks_control_plane_files(string path)
+ {
+ var policy = CreateProductionPolicy();
+ Assert.True(policy.IsReadDenied(path));
+ }
+
+ public enum SymlinkTraversalShape
+ {
+ SingleSymlinkedDirectory,
+ MultiDepthSymlinkChain,
+ DotDotTraversalAfterResolvedLink,
+ }
+
+ // Regression (#1724): a symlinked INTERMEDIATE directory into a denied
+ // location must not bypass IsReadDenied. Shell catches this via
+ // TryResolveSymlinksInPath; the read side must too, since interactive
+ // Personal reads have IsReadDenied as their sole backstop.
+ [Theory]
+ [InlineData(SymlinkTraversalShape.SingleSymlinkedDirectory)]
+ [InlineData(SymlinkTraversalShape.MultiDepthSymlinkChain)]
+ [InlineData(SymlinkTraversalShape.DotDotTraversalAfterResolvedLink)]
+ public void IsReadDenied_blocks_symlinked_directory_traversal(SymlinkTraversalShape shape)
+ {
+ var scratch = Path.Combine(Path.GetTempPath(), $"netclaw-symlink-{Guid.NewGuid():N}");
+ var deniedDir = Path.Combine(scratch, "denied");
+ Directory.CreateDirectory(deniedDir);
+ File.WriteAllText(Path.Combine(deniedDir, "netclaw.json"), """{"secret":true}""");
+
+ var createdLinks = new List();
+
+ try
+ {
+ string viaLink;
+
+ switch (shape)
+ {
+ case SymlinkTraversalShape.SingleSymlinkedDirectory:
+ {
+ var linkDir = Path.Combine(scratch, "link");
+ Directory.CreateSymbolicLink(linkDir, deniedDir);
+ createdLinks.Add(linkDir);
+
+ // Lexically this path lives in scratch/link, outside any
+ // denied root — only segment-walk symlink resolution
+ // catches it.
+ viaLink = Path.Combine(linkDir, "netclaw.json");
+ break;
+ }
+
+ case SymlinkTraversalShape.MultiDepthSymlinkChain:
+ {
+ // linkA -> linkB -> deniedDir. A resolver that only
+ // follows one hop would stop at linkB; the walk must
+ // reach the final real target.
+ var linkB = Path.Combine(scratch, "linkB");
+ var linkA = Path.Combine(scratch, "linkA");
+ Directory.CreateSymbolicLink(linkB, deniedDir);
+ Directory.CreateSymbolicLink(linkA, linkB);
+ createdLinks.Add(linkB);
+ createdLinks.Add(linkA);
+
+ viaLink = Path.Combine(linkA, "netclaw.json");
+ break;
+ }
+
+ case SymlinkTraversalShape.DotDotTraversalAfterResolvedLink:
+ {
+ var linkDir = Path.Combine(scratch, "link");
+ Directory.CreateSymbolicLink(linkDir, deniedDir);
+ createdLinks.Add(linkDir);
+
+ // "nested" need not exist: Path.GetFullPath collapses the
+ // ".." lexically before any symlink is resolved, leaving
+ // "link/netclaw.json" — the link segment itself survives
+ // the collapse untouched, so resolution still lands
+ // inside deniedDir. Locks in that a decoy ".." placed
+ // after the link cannot be used to dodge the walk.
+ viaLink = Path.Combine(linkDir, "nested", "..", "netclaw.json");
+ break;
+ }
+
+ default:
+ throw new ArgumentOutOfRangeException(nameof(shape), shape, null);
+ }
+
+ // Deny the REAL deniedDir (fixture paths don't exist on disk, and
+ // symlink resolution needs an on-disk target to resolve).
+ var policy = new ToolPathPolicy([deniedDir]);
+
+ Assert.True(policy.IsReadDenied(viaLink));
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return; // Windows without developer mode
+ }
+ finally
+ {
+ foreach (var link in createdLinks)
+ {
+ if (Directory.Exists(link) && new DirectoryInfo(link).LinkTarget is not null)
+ Directory.Delete(link);
+ }
+
+ if (Directory.Exists(scratch))
+ Directory.Delete(scratch, recursive: true);
+ }
+ }
+
+ // The fixture mirrors production (Program.cs): ConfigDirectory is a
+ // directory-scoped shell indicator, so the whole config dir is read-denied
+ // via the IsReadDenied union. Sidecar files (db-wal/db-shm/db-journal) are
+ // also in the fixture, matching the production shell indicator list.
+ [Theory]
+ [InlineData("/home/user/repositories/foo.cs")]
+ [InlineData("/tmp/notes.txt")]
+ [InlineData("/home/user/downloads/report.pdf")]
public void IsReadDenied_allows_non_sensitive_paths(string path)
{
var policy = CreateProductionPolicy();
@@ -229,14 +363,15 @@ public void IsReadDenied_allows_non_sensitive_paths(string path)
}
[Fact]
- public void CommandReferencesDeniedPath_still_allows_ls_of_config_directory()
+ public void CommandReferencesDeniedPath_denies_ls_of_config_directory()
{
- // Regression guard: directory-scoped writeDeny entries must not bleed
- // into the shell substring indicator set, otherwise every shell command
- // whose text contains ".netclaw/config" would be rejected.
+ // Production includes ConfigDirectory in the shell indicator list
+ // (src/Netclaw.Daemon/Program.cs), so `ls ~/.netclaw/config` is denied
+ // by the substring indicator scan. This mirrors production behavior;
+ // the fixture now includes ConfigDirectory to match.
var policy = CreateProductionPolicy();
- Assert.False(policy.CommandReferencesDeniedPath("ls ~/.netclaw/config"));
- Assert.False(policy.CommandReferencesDeniedPath("stat ~/.netclaw/config"));
+ Assert.True(policy.CommandReferencesDeniedPath("ls ~/.netclaw/config"));
+ Assert.True(policy.CommandReferencesDeniedPath("stat ~/.netclaw/config"));
}
[Fact]
diff --git a/src/Netclaw.Security/ToolPathPolicy.cs b/src/Netclaw.Security/ToolPathPolicy.cs
index 05ec9708c..e948cd50e 100644
--- a/src/Netclaw.Security/ToolPathPolicy.cs
+++ b/src/Netclaw.Security/ToolPathPolicy.cs
@@ -1,4 +1,4 @@
-// -----------------------------------------------------------------------
+// -----------------------------------------------------------------------
//
// Copyright (C) 2026 - 2026 Petabridge, LLC
//
@@ -13,10 +13,12 @@ namespace Netclaw.Security;
///
/// Three independent deny surfaces: write (), read
/// (), and shell indicators
-/// (). The shell indicator list must
-/// stay narrow — file-level only — because that path does a raw substring scan
-/// against the command text, so directory-scoped entries would block legitimate
-/// commands whose arguments happen to contain the directory name.
+/// (). Read denies the union of the
+/// read deny list and the shell indicator list, so file tools cannot reach the
+/// control plane that shell cannot reference. The shell indicator list is
+/// scanned as raw substrings of the command text, so directory-scoped entries
+/// (e.g. the config dir) over-block commands whose text merely mentions them —
+/// that is the accepted trade-off for keeping the control plane unreachable.
///
public sealed class ToolPathPolicy
{
@@ -59,13 +61,35 @@ private static HashSet BuildNormalizedSet(IEnumerable paths)
// checks canonicalize the *candidate* path (TryResolveSymlinksInPath /
// TryResolveSymlinkTarget); without the resolved denied form here, a
// candidate resolving to /private/etc/... would slip past a /etc deny.
- if (TryResolveSymlinksInPath(normalized, out var canonical))
+ //
+ // Construction skips a resolution failure (the lexical form above is
+ // still added); the deny CHECKS fail closed on the same failure. A
+ // startup-time resolution throw must not crash the process, and the
+ // lexical entry alone still denies exact and lexical-child matches.
+ if (TryResolveCanonicalForDenySet(normalized, out var canonical))
set.Add(canonical);
}
return set;
}
+ // Construction-only: resolve a denied path's canonical form, but never crash the
+ // policy build if resolution throws. The lexical form is already in the set, and
+ // the runtime deny checks (IsDeniedAgainst / CommandReferencesDeniedPath) fail
+ // CLOSED on a resolution exception — so swallowing here is safe for construction.
+ private static bool TryResolveCanonicalForDenySet(string path, out string canonical)
+ {
+ try
+ {
+ return TryResolveSymlinksInPath(path, out canonical);
+ }
+ catch
+ {
+ canonical = string.Empty;
+ return false;
+ }
+ }
+
private static HashSet BuildCommandIndicators(IEnumerable paths)
{
var materialized = paths.ToList();
@@ -97,11 +121,13 @@ public bool IsDenied(string path)
=> IsDeniedAgainst(path, _writeDeniedPaths);
///
- /// Returns true if the given path is denied for read by policy. Narrower
- /// than : only covers files that leak credentials.
+ /// Returns true if the given path is denied for read by policy. Covers the
+ /// credential-leaking surfaces (secrets, keys, webhooks) plus the shell
+ /// indicator list (config dir, sqlite DB, pid, lock, restart manifest), so
+ /// read tools cannot reach files that shell cannot even reference.
///
public bool IsReadDenied(string path)
- => IsDeniedAgainst(path, _readDeniedPaths);
+ => IsDeniedAgainst(path, _readDeniedPaths) || IsDeniedAgainst(path, _shellDeniedPaths);
private static bool IsDeniedAgainst(string path, HashSet deniedSet)
{
@@ -111,8 +137,30 @@ private static bool IsDeniedAgainst(string path, HashSet deniedSet)
if (PathUtility.TryNormalize(path, null, out var normalized) && IsDeniedNormalized(normalized, deniedSet))
return true;
- return TryResolveSymlinkTarget(path, out var resolvedTarget)
- && IsDeniedNormalized(resolvedTarget, deniedSet);
+ try
+ {
+ // TryResolveSymlinkTarget only resolves the final path element. A path
+ // whose INTERMEDIATE directory is a symlink into a denied location
+ // (e.g. /tmp/x -> ~/.netclaw/config, then /tmp/x/netclaw.json) would
+ // slip past that check. Mirror the shell side (CommandReferencesDeniedPath)
+ // by also walking the path segment by segment — same infrastructure.
+ if (TryResolveSymlinkTarget(path, out var resolvedTarget)
+ && IsDeniedNormalized(resolvedTarget, deniedSet))
+ {
+ return true;
+ }
+
+ return TryResolveSymlinksInPath(path, out var canonical)
+ && IsDeniedNormalized(canonical, deniedSet);
+ }
+ catch
+ {
+ // This method is the SOLE backstop for interactive Personal reads
+ // (IsReadDenied has no other gate above it). An undetermined
+ // resolution must deny, not silently allow — the same defect class
+ // as the double-drive fail-open fixed for #1724. Fail closed.
+ return true;
+ }
}
///
@@ -153,11 +201,23 @@ public bool CommandReferencesDeniedPath(string command, string? workingDirectory
// approved root and waves it through, and the static path check
// here would never see /etc unless we resolve link targets along
// every component of the path.
- if (normalized is not null
- && TryResolveSymlinksInPath(normalized, out var canonical)
- && IsDeniedNormalized(canonical, _shellDeniedPaths))
+ if (normalized is not null)
{
- return true;
+ try
+ {
+ if (TryResolveSymlinksInPath(normalized, out var canonical)
+ && IsDeniedNormalized(canonical, _shellDeniedPaths))
+ {
+ return true;
+ }
+ }
+ catch
+ {
+ // Fail closed: an undetermined resolution means we cannot
+ // rule out this token reaching a denied path via symlink,
+ // so treat the command as referencing one.
+ return true;
+ }
}
var expanded = PathUtility.ExpandHome(token);
@@ -217,97 +277,96 @@ private static bool IsSamePathOrChild(string candidate, string denied)
return boundary == Path.DirectorySeparatorChar || boundary == Path.AltDirectorySeparatorChar;
}
+ // Callers own exception policy here on purpose: BuildNormalizedSet (startup)
+ // skips a failed resolution, while the deny-check call sites (IsDeniedAgainst,
+ // CommandReferencesDeniedPath) fail closed. A blanket catch here would hide
+ // that distinction and force every caller back to the same (wrong) answer.
private static bool TryResolveSymlinksInPath(string path, out string canonical)
{
canonical = string.Empty;
if (string.IsNullOrEmpty(path))
return false;
- try
+ // Walk the path component by component, resolving any directory
+ // or file symlinks encountered. ResolveLinkTarget(returnFinalTarget:
+ // true) follows the chain to a non-link, but only operates on the
+ // entity it's invoked against — it does not see symlinks earlier
+ // in the path. Hence the explicit segment walk.
+ var fullPath = Path.GetFullPath(path);
+ // Seed the builder with the full root (drive + separator on Windows,
+ // "/" on Unix) and split only the REMAINDER after the root. Splitting
+ // the whole path re-emits the drive segment ("C:"), which the root
+ // already provides — appending it again yields "C:\C:\Users\..." so
+ // every Directory.Exists/File.Exists probe below misses and symlink
+ // resolution silently no-ops, failing the deny open. See #1724.
+ var root = Path.GetPathRoot(fullPath) ?? string.Empty;
+ var remainder = fullPath.Length > root.Length ? fullPath[root.Length..] : string.Empty;
+ var segments = remainder.Split(
+ [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
+ StringSplitOptions.RemoveEmptyEntries);
+ var sb = new StringBuilder();
+ sb.Append(root);
+
+ foreach (var segment in segments)
{
- // Walk the path component by component, resolving any directory
- // or file symlinks encountered. ResolveLinkTarget(returnFinalTarget:
- // true) follows the chain to a non-link, but only operates on the
- // entity it's invoked against — it does not see symlinks earlier
- // in the path. Hence the explicit segment walk.
- var fullPath = Path.GetFullPath(path);
- var segments = fullPath.Split(
- [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
- StringSplitOptions.RemoveEmptyEntries);
- var sb = new StringBuilder();
- if (Path.IsPathRooted(fullPath))
+ if (sb.Length > 0 && sb[^1] != Path.DirectorySeparatorChar)
sb.Append(Path.DirectorySeparatorChar);
+ sb.Append(segment);
- foreach (var segment in segments)
+ var partial = sb.ToString();
+ if (Directory.Exists(partial))
{
- if (sb.Length > 0 && sb[^1] != Path.DirectorySeparatorChar)
- sb.Append(Path.DirectorySeparatorChar);
- sb.Append(segment);
-
- var partial = sb.ToString();
- if (Directory.Exists(partial))
+ var target = new DirectoryInfo(partial).ResolveLinkTarget(returnFinalTarget: true);
+ if (target is not null)
{
- var target = new DirectoryInfo(partial).ResolveLinkTarget(returnFinalTarget: true);
- if (target is not null)
- {
- sb.Clear();
- sb.Append(target.FullName);
- }
+ sb.Clear();
+ sb.Append(target.FullName);
}
- else if (File.Exists(partial))
+ }
+ else if (File.Exists(partial))
+ {
+ var target = new FileInfo(partial).ResolveLinkTarget(returnFinalTarget: true);
+ if (target is not null)
{
- var target = new FileInfo(partial).ResolveLinkTarget(returnFinalTarget: true);
- if (target is not null)
- {
- sb.Clear();
- sb.Append(target.FullName);
- }
-
- break;
+ sb.Clear();
+ sb.Append(target.FullName);
}
- }
- canonical = PathUtility.Normalize(sb.ToString());
- return !string.IsNullOrEmpty(canonical) && !string.Equals(canonical, PathUtility.Normalize(fullPath), StringComparison.Ordinal);
- }
- catch
- {
- return false;
+ break;
+ }
}
+
+ canonical = PathUtility.Normalize(sb.ToString());
+ return !string.IsNullOrEmpty(canonical) && !string.Equals(canonical, PathUtility.Normalize(fullPath), StringComparison.Ordinal);
}
+ // Only IsDeniedAgainst calls this; it owns exception policy (fails closed).
+ // See the comment on TryResolveSymlinksInPath for why this does not catch.
private static bool TryResolveSymlinkTarget(string path, out string normalizedTarget)
{
normalizedTarget = string.Empty;
- try
+ if (File.Exists(path))
{
- if (File.Exists(path))
- {
- var target = new FileInfo(path).ResolveLinkTarget(returnFinalTarget: true);
- if (target is null)
- return false;
-
- normalizedTarget = PathUtility.Normalize(target.FullName);
- return true;
- }
-
- if (Directory.Exists(path))
- {
- var target = new DirectoryInfo(path).ResolveLinkTarget(returnFinalTarget: true);
- if (target is null)
- return false;
+ var target = new FileInfo(path).ResolveLinkTarget(returnFinalTarget: true);
+ if (target is null)
+ return false;
- normalizedTarget = PathUtility.Normalize(target.FullName);
- return true;
- }
-
- return false;
+ normalizedTarget = PathUtility.Normalize(target.FullName);
+ return true;
}
- catch
+
+ if (Directory.Exists(path))
{
- return false;
+ var target = new DirectoryInfo(path).ResolveLinkTarget(returnFinalTarget: true);
+ if (target is null)
+ return false;
+
+ normalizedTarget = PathUtility.Normalize(target.FullName);
+ return true;
}
+
+ return false;
}
private static bool LooksLikePath(string token)