From 23aa099c377461c702eee3070131797893cc1279 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Sat, 8 Aug 2026 18:16:45 +0000 Subject: [PATCH] Handle macOS redirect path aliases --- .../ShellApprovalMatcherTests.cs | 128 ++++++++++++ src/Netclaw.Security/IToolApprovalMatcher.cs | 193 +++++++++++++++++- 2 files changed, 313 insertions(+), 8 deletions(-) diff --git a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs index 0f0e8df96..444cef135 100644 --- a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs +++ b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs @@ -645,6 +645,7 @@ public sealed class ShellApprovalMatcherPathExtractionTests /// runners instead of hiding the gap behind an early-return. /// public static bool IsPosix => !OperatingSystem.IsWindows(); + public static bool IsMacOs => OperatingSystem.IsMacOS(); [SlopwatchSuppress("SW001", "This theory verifies Bash parser path scopes, which do not apply to the Windows shell parser.")] [Theory(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")] @@ -1185,6 +1186,133 @@ public void Redirect_to_symlink_target_fails_closed() } } + [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")] + public void Redirect_through_symlink_below_working_directory_fails_closed() + { + var root = Path.Combine(Path.GetTempPath(), $"netclaw-redirect-escape-{Guid.NewGuid():N}"); + var projectDirectory = Path.Combine(root, "project"); + var externalDirectory = Path.Combine(root, "external"); + var linkDirectory = Path.Combine(projectDirectory, "link"); + Directory.CreateDirectory(projectDirectory); + Directory.CreateDirectory(externalDirectory); + Directory.CreateSymbolicLink(linkDirectory, externalDirectory); + + try + { + var arguments = Args("git status > link/result.log", projectDirectory); + + Assert.Empty(_matcher.ExtractCandidates( + new ToolName("shell_execute"), + arguments)); + Assert.True(_matcher.IsMessy( + new ToolName("shell_execute"), + arguments)); + } + finally + { + Directory.Delete(linkDirectory); + Directory.Delete(root, recursive: true); + } + } + + [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")] + public void Redirect_through_symlink_before_parent_segment_fails_closed() + { + var root = Path.Combine(Path.GetTempPath(), $"netclaw-redirect-parent-{Guid.NewGuid():N}"); + var projectDirectory = Path.Combine(root, "project"); + var externalDirectory = Path.Combine(root, "external", "nested"); + var linkDirectory = Path.Combine(projectDirectory, "link"); + Directory.CreateDirectory(projectDirectory); + Directory.CreateDirectory(externalDirectory); + Directory.CreateSymbolicLink(linkDirectory, externalDirectory); + + try + { + var arguments = Args("git status > link/../result.log", projectDirectory); + + Assert.Empty(_matcher.ExtractCandidates( + new ToolName("shell_execute"), + arguments)); + Assert.True(_matcher.IsMessy( + new ToolName("shell_execute"), + arguments)); + } + finally + { + Directory.Delete(linkDirectory); + Directory.Delete(root, recursive: true); + } + } + + [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")] + public void Redirect_from_symlink_working_directory_fails_closed() + { + var root = Path.Combine(Path.GetTempPath(), $"netclaw-redirect-cwd-{Guid.NewGuid():N}"); + var realProjectDirectory = Path.Combine(root, "real-project"); + var linkProjectDirectory = Path.Combine(root, "link-project"); + Directory.CreateDirectory(realProjectDirectory); + Directory.CreateSymbolicLink(linkProjectDirectory, realProjectDirectory); + + try + { + var arguments = Args("git status > result.log", linkProjectDirectory); + + Assert.Empty(_matcher.ExtractCandidates( + new ToolName("shell_execute"), + arguments)); + Assert.True(_matcher.IsMessy( + new ToolName("shell_execute"), + arguments)); + } + finally + { + Directory.Delete(linkProjectDirectory); + Directory.Delete(root, recursive: true); + } + } + + [SlopwatchSuppress("SW001", "This test verifies macOS system root aliases and runs on the macOS CI lane.")] + [Fact(SkipUnless = nameof(IsMacOs), Skip = "macOS root-alias semantics")] + public void Redirect_to_macos_root_alias_itself_fails_closed() + { + var arguments = Args("git status > /var", Path.GetTempPath()); + + Assert.Empty(_matcher.ExtractCandidates( + new ToolName("shell_execute"), + arguments)); + Assert.True(_matcher.IsMessy( + new ToolName("shell_execute"), + arguments)); + } + + [SlopwatchSuppress("SW001", "These tests verify macOS system root aliases and run on the macOS CI lane.")] + [Theory(SkipUnless = nameof(IsMacOs), Skip = "macOS root-alias semantics")] + [InlineData("git status > result.log", false)] + [InlineData("git status > ../result.log", true)] + public void Relative_redirect_from_macos_root_alias_handles_parent_traversal( + string command, + bool expectedMessy) + { + var arguments = Args(command, "/tmp"); + + Assert.Equal(expectedMessy, _matcher.IsMessy( + new ToolName("shell_execute"), + arguments)); + if (expectedMessy) + { + Assert.Empty(_matcher.ExtractCandidates( + new ToolName("shell_execute"), + arguments)); + } + else + { + var candidate = Assert.Single(_matcher.ExtractCandidates( + new ToolName("shell_execute"), + arguments)); + Assert.Equal("/tmp", candidate.Directory); + } + } + [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")] public void ExtractCandidates_prefers_explicit_path_arg_over_cd_attribution() { diff --git a/src/Netclaw.Security/IToolApprovalMatcher.cs b/src/Netclaw.Security/IToolApprovalMatcher.cs index f14e3192b..590059a18 100644 --- a/src/Netclaw.Security/IToolApprovalMatcher.cs +++ b/src/Netclaw.Security/IToolApprovalMatcher.cs @@ -111,6 +111,7 @@ public sealed class ShellApprovalMatcher : IToolApprovalMatcher public static readonly ShellApprovalMatcher Instance = new(); private static readonly ShellCommandAnalyzer Analyzer = ShellCommandAnalyzer.Bash; + private static readonly string[] MacOsSystemRootAliases = ["/etc", "/tmp", "/var"]; public string GetApprovalModeKey(ToolName toolName, IDictionary? arguments) => toolName.Value; @@ -300,7 +301,10 @@ private static IReadOnlyList ExtractCandidatesViaBashAnalysis foreach (var redirect in occurrence.Redirects) { - var redirectDirectories = ResolveRedirectDirectories(redirect); + var redirectDirectories = ResolveRedirectDirectories( + redirect, + clause, + clauseWorkingDirectory); if (redirectDirectories is null) return null; @@ -464,7 +468,9 @@ or NotSupportedException : null; private static IReadOnlyList? ResolveRedirectDirectories( - ShellSyntaxTree.RedirectAnalysis redirect) + ShellSyntaxTree.RedirectAnalysis redirect, + ShellSyntaxTree.Clause clause, + string? workingDirectory) { if (!redirect.IsPathRelevant) return []; @@ -472,6 +478,14 @@ or NotSupportedException if (!redirect.IsComplete) return null; + if (ContainsSymlinkBeforeLexicalNormalization( + clause, + redirect.RedirectIndex, + workingDirectory)) + { + return null; + } + if (redirect.Target.Kind == ShellSyntaxTree.ShellValueDomainKind.Pattern) { var coveringDirectory = redirect.Target.CoveringDirectory; @@ -497,9 +511,11 @@ or NotSupportedException try { var normalizedTarget = PathUtility.Normalize(target); - var pathRoot = Path.GetPathRoot(normalizedTarget); - if (string.IsNullOrWhiteSpace(pathRoot) - || PathUtility.ContainsSymlinkSegment(pathRoot, normalizedTarget)) + var symlinkBoundary = ResolveSymlinkBoundary( + normalizedTarget, + allowMacOsAliasEquality: false); + if (symlinkBoundary is null + || PathUtility.ContainsSymlinkSegment(symlinkBoundary, normalizedTarget)) { return null; } @@ -520,6 +536,169 @@ or UnauthorizedAccessException return directories; } + private static bool ContainsSymlinkBeforeLexicalNormalization( + ShellSyntaxTree.Clause clause, + int redirectIndex, + string? workingDirectory) + { + if (redirectIndex < 0) + return true; + + ShellSyntaxTree.ClauseElement? redirectElement = null; + var currentRedirectIndex = 0; + foreach (var element in clause.Elements) + { + if (element.Role != ShellSyntaxTree.ClauseElementRole.Redirect) + continue; + + if (currentRedirectIndex == redirectIndex) + { + redirectElement = element; + break; + } + + currentRedirectIndex++; + } + + if (redirectElement is null + || !redirectElement.IsPath + || redirectElement.Kind == ShellSyntaxTree.ArgKind.DynamicSkip + || string.IsNullOrWhiteSpace(redirectElement.Value)) + { + return true; + } + + try + { + var authoredPath = redirectElement.Value; + var rooted = Path.IsPathRooted(authoredPath); + if (!rooted && string.IsNullOrWhiteSpace(workingDirectory)) + return true; + + if (rooted && authoredPath.StartsWith("//", StringComparison.Ordinal)) + return true; + + var root = rooted ? Path.GetPathRoot(authoredPath) : null; + if (rooted && string.IsNullOrWhiteSpace(root)) + return true; + + var remainder = rooted + ? authoredPath[root!.Length..] + : authoredPath; + var segments = remainder.Split( + Path.DirectorySeparatorChar, + StringSplitOptions.RemoveEmptyEntries); + var currentPath = root!; + if (!rooted) + { + currentPath = PathUtility.Normalize(workingDirectory!); + var workingDirectoryBoundary = ResolveSymlinkBoundary( + currentPath, + allowMacOsAliasEquality: true); + if (workingDirectoryBoundary is null + || PathUtility.ContainsSymlinkSegment( + workingDirectoryBoundary, + currentPath)) + { + return true; + } + + if ((authoredPath == "~" + || authoredPath.StartsWith("~/", StringComparison.Ordinal)) + && segments.Contains("..", StringComparer.Ordinal)) + { + return true; + } + + if (OperatingSystem.IsMacOS() + && segments.Contains("..", StringComparer.Ordinal) + && MacOsSystemRootAliases.Any(alias => + PathUtility.IsNormalizedWithinRoot(currentPath, alias))) + { + return true; + } + } + var firstSegment = 0; + + // A parent segment after a symlink has different semantics from + // lexical Path.GetFullPath normalization. Only skip macOS's stable + // root alias when no later parent traversal can observe its target. + if (rooted + && OperatingSystem.IsMacOS() + && segments.Length > 1 + && !segments.Contains("..", StringComparer.Ordinal)) + { + foreach (var alias in MacOsSystemRootAliases) + { + if (segments[0].AsSpan().Equals( + alias.AsSpan(1), + StringComparison.Ordinal)) + { + currentPath = alias; + firstSegment = 1; + break; + } + } + } + + for (var index = firstSegment; index < segments.Length; index++) + { + var segment = segments[index]; + if (segment == ".") + continue; + + if (segment == "..") + { + currentPath = Path.GetDirectoryName(currentPath) ?? currentPath; + continue; + } + + currentPath = Path.Combine(currentPath, segment); + if (!File.Exists(currentPath) && !Directory.Exists(currentPath)) + continue; + + if ((File.GetAttributes(currentPath) & FileAttributes.ReparsePoint) != 0) + return true; + } + + return false; + } + catch (Exception ex) when (ex is ArgumentException + or IOException + or NotSupportedException + or PathTooLongException + or UnauthorizedAccessException + or System.Security.SecurityException) + { + return true; + } + } + + private static string? ResolveSymlinkBoundary( + string normalizedPath, + bool allowMacOsAliasEquality) + { + var pathRoot = Path.GetPathRoot(normalizedPath); + if (string.IsNullOrWhiteSpace(pathRoot)) + return null; + + if (OperatingSystem.IsMacOS()) + { + // macOS publishes these stable root aliases as symlinks into + // /private. They are outside application-controlled scope and + // appear in ordinary temp and system paths. Start below the alias + // so later, writable symlink segments still fail closed. + foreach (var alias in MacOsSystemRootAliases) + { + if ((allowMacOsAliasEquality || normalizedPath.Length > alias.Length) + && PathUtility.IsNormalizedWithinRoot(normalizedPath, alias)) + return alias; + } + } + + return pathRoot; + } + /// /// Splits a POSIX command into approval-unit strings via BashParser: /// one unit per statement, with consecutive | clauses folded into @@ -787,9 +966,7 @@ public bool IsMessy(ToolName toolName, IDictionary? arguments) return true; } - return analysis.Commands - .SelectMany(static command => command.Redirects) - .Any(static redirect => ResolveRedirectDirectories(redirect) is null); + return false; } private static bool IsSideEffectCommand(ShellSyntaxTree.CommandOccurrence occurrence)