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)