From a9c4530538ab8f8a90bfd2e58191a9f712912e45 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Sat, 16 May 2026 02:00:52 +0000 Subject: [PATCH 1/3] feat(security): upgrade ShellSyntaxTree to 0.1.5-beta, route ExtractPatterns through BashParser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ShellSyntaxTree 0.1.5-beta treats a bare newline as a statement separator (CompoundOperator.Sequence), so multi-line shell commands now decompose into one clause per statement instead of collapsing into a single clause. ExtractCandidates already routed POSIX commands through BashParser and picks up the improvement for free — a verb hidden after a newline (e.g. `rm -rf` following `cd`) is now surfaced as its own gated approval candidate rather than buried as args of the preceding verb. ExtractPatterns still used the hand-rolled newline-blind ShellTokenizer splitter, which collapsed `git fetch\ngit status` into the garbled single unit `git fetch git status`. Route ExtractPatterns through BashParser on POSIX too so the display patterns and approve-once retry keys match the clause decomposition ExtractCandidates uses. Windows keeps the legacy path since ShellSyntaxTree is bash-only. Adds newline-contract coverage to ShellSyntaxTreeIntegrationTests and multi-line cases to ShellApprovalMatcherTests. --- Directory.Packages.props | 2 +- .../ShellApprovalMatcherTests.cs | 62 ++++++++ .../ShellSyntaxTreeIntegrationTests.cs | 66 +++++++++ src/Netclaw.Security/IToolApprovalMatcher.cs | 134 +++++++++++++++++- 4 files changed, 262 insertions(+), 2 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 17b367b95..187e30ac5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -50,7 +50,7 @@ - + diff --git a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs index 6511a8a81..d0f6e2e38 100644 --- a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs +++ b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs @@ -13,6 +13,15 @@ public sealed class ShellApprovalMatcherTests { private readonly ShellApprovalMatcher _matcher = ShellApprovalMatcher.Instance; + /// + /// xunit.v3 SkipUnless hook: ExtractPatterns and + /// ExtractCandidates route through BashParser on POSIX only — the + /// Windows path falls back to the legacy ShellTokenizer splitter, + /// which is newline-blind. Tests that pin BashParser-specific behavior + /// skip cleanly on Windows runners. + /// + public static bool IsPosix => !OperatingSystem.IsWindows(); + private static Dictionary Args(string command) => new() { ["Command"] = command }; private static Dictionary Args(string command, string workingDirectory) @@ -60,6 +69,59 @@ public void ExtractPatterns_empty_command() Assert.Empty(patterns); } + [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only — ExtractPatterns routes through BashParser on POSIX")] + public void ExtractPatterns_multiline_command_splits_one_unit_per_statement() + { + // A bare newline is a statement separator (ShellSyntaxTree 0.1.5+). + // Pre-upgrade the legacy splitter ignored newlines and produced the + // single garbled unit `git fetch git status`; the BashParser route + // now yields two clean approval units. + var patterns = _matcher.ExtractPatterns(new ToolName("shell_execute"), + Args("git fetch\ngit status")); + + Assert.Equal(2, patterns.Count); + Assert.Contains("git fetch", patterns); + Assert.Contains("git status", patterns); + } + + [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only — ExtractPatterns routes through BashParser on POSIX")] + public void ExtractPatterns_multiline_collapses_blank_lines() + { + var patterns = _matcher.ExtractPatterns(new ToolName("shell_execute"), + Args("echo a\n\n\necho b")); + + Assert.Equal(2, patterns.Count); + Assert.Contains("echo a", patterns); + Assert.Contains("echo b", patterns); + } + + [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only — ExtractPatterns routes through BashParser on POSIX")] + public void ExtractPatterns_multiline_keeps_pipe_within_a_statement() + { + // A pipe stays inside one approval unit; the newline still splits + // the second statement out. + var patterns = _matcher.ExtractPatterns(new ToolName("shell_execute"), + Args("echo one | grep o\necho two")); + + Assert.Equal(2, patterns.Count); + Assert.Contains("echo one | grep o", patterns); + Assert.Contains("echo two", patterns); + } + + [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")] + public void ExtractCandidates_multiline_surfaces_each_verb() + { + // Security-relevant: pre-0.1.5 a multi-line command parsed as one + // clause, so `rm -rf` after a newline was buried as args of `cd` + // and never reached the gate as its own verb. Newline-as-separator + // surfaces `rm` as an independently gated candidate. + var candidates = _matcher.ExtractCandidates(new ToolName("shell_execute"), + Args("cd /tmp\nrm -rf /tmp/foo")); + + Assert.Contains(candidates, c => c.Verb == "cd" && c.Directory == "/tmp"); + Assert.Contains(candidates, c => c.Verb == "rm" && c.Directory == "/tmp/foo"); + } + [Fact] public void ExtractCandidateVerbs_collapses_to_verb_chains_only() { diff --git a/src/Netclaw.Security.Tests/ShellSyntaxTreeIntegrationTests.cs b/src/Netclaw.Security.Tests/ShellSyntaxTreeIntegrationTests.cs index 460363c3d..a6ec7e6ad 100644 --- a/src/Netclaw.Security.Tests/ShellSyntaxTreeIntegrationTests.cs +++ b/src/Netclaw.Security.Tests/ShellSyntaxTreeIntegrationTests.cs @@ -215,4 +215,70 @@ public void Hash_inside_double_quotes_is_not_a_comment() Assert.Equal("echo", result.Clauses[0].Verb.Joined); Assert.Contains(result.Clauses[0].Args, a => a.Raw.Contains("#1234")); } + + [Fact] + public void Bare_newline_separates_clauses_as_sequence() + { + // ShellSyntaxTree 0.1.5-beta (SPEC §4): a bare newline outside + // quotes/heredocs/continuations is a clause separator equivalent to + // `;`, and the following clause gets CompoundOperator.Sequence. + // Netclaw's approval gate relies on this so a multi-line command + // decomposes into one approval unit per statement rather than + // collapsing two verbs into a single garbled unit. + var parser = new BashParser(); + + var result = parser.Parse("git fetch\ngit status"); + + Assert.False(result.IsUnparseable); + Assert.Equal(2, result.Clauses.Count); + Assert.Equal("git fetch", result.Clauses[0].Verb.Joined); + Assert.Equal(CompoundOperator.Sequence, result.Clauses[1].Operator); + Assert.Equal("git status", result.Clauses[1].Verb.Joined); + } + + [Fact] + public void Consecutive_newlines_collapse_without_empty_clauses() + { + // Blank lines, leading/trailing newlines, and newlines after a + // compound operator collapse — they must not produce empty clauses + // that would surface as blank approval units. + var parser = new BashParser(); + + var result = parser.Parse("\necho a\n\n\necho b\n"); + + Assert.False(result.IsUnparseable); + Assert.Equal(2, result.Clauses.Count); + Assert.Equal("echo a", result.Clauses[0].Verb.Joined); + Assert.Equal("echo b", result.Clauses[1].Verb.Joined); + } + + [Fact] + public void Heredoc_followed_by_command_parses_as_two_clauses() + { + // The newline after a heredoc terminator separates the heredoc body + // from the next command; newlines *inside* the heredoc do not. + var parser = new BashParser(); + + var result = parser.Parse("cat < // // ----------------------------------------------------------------------- +using System.Text; using Netclaw.Configuration; using Netclaw.Tools; @@ -121,10 +122,30 @@ public IReadOnlyList ExtractPatterns(ToolName toolName, IDictionary(StringComparer.OrdinalIgnoreCase); + + // POSIX commands route through BashParser so the approval units match + // the clause decomposition ExtractCandidates already uses. The key + // win: a bare newline separates statements (ShellSyntaxTree 0.1.5+), + // so `git fetch\ngit status` yields two units instead of one garbled + // `git fetch git status`. Windows keeps the legacy ShellTokenizer + // path — ShellSyntaxTree is bash-only. + if (!OperatingSystem.IsWindows()) + { + foreach (var unit in ExtractApprovalUnitsViaBashParser(command)) + { + var normalized = ShellTokenizer.NormalizeApprovalUnit(unit, workingDirectory); + if (!string.IsNullOrEmpty(normalized)) + patterns.Add(normalized); + } + + return patterns.ToList(); + } + TraverseApprovalUnits(command, unit => { - var normalized = ShellTokenizer.NormalizeApprovalUnit(unit, GetWorkingDirectory(arguments)); + var normalized = ShellTokenizer.NormalizeApprovalUnit(unit, workingDirectory); if (!string.IsNullOrEmpty(normalized)) patterns.Add(normalized); }); @@ -275,6 +296,117 @@ private static IReadOnlyList ExtractCandidatesViaBashParser(s return cwdAttribution?.Resolved; } + /// + /// Splits a POSIX command into approval-unit strings via BashParser: + /// one unit per statement, with consecutive | clauses folded into + /// the same unit so cat x | wc -l stays a single decision. + /// Returns an empty list for messy, unparseable, or parser-rejected + /// commands — mirroring the legacy + /// empty-result contract so the prompt builder offers only Once/Deny. + /// + private static IReadOnlyList ExtractApprovalUnitsViaBashParser(string command) + { + // Messy commands (control-flow keywords, unbalanced brackets) cannot + // be cleanly decomposed into approval units; mirror the legacy + // splitter, which returns no units for them. + if (ShellTokenizer.IsMessyCompoundCommand(command)) + return []; + + ShellSyntaxTree.ParsedCommand result; + try + { + result = new ShellSyntaxTree.BashParser().Parse(command); + } + catch + { + // Defensive: an unhandled parser exception must not take down the + // approval prompt. Fail-empty — the matcher treats this as a + // messy command (Once/Deny prompt only). + return []; + } + + if (result.IsUnparseable || result.Clauses.Count == 0) + return []; + + var units = new List(); + var current = new StringBuilder(); + + foreach (var clause in result.Clauses) + { + // AndIf / OrIf / Sequence (and the leading None clause) each open + // a fresh approval unit; Pipe clauses fold into the unit in + // progress. `Sequence` is what a bare newline produces in + // ShellSyntaxTree 0.1.5+, so multi-line commands split here. + if (clause.Operator != ShellSyntaxTree.CompoundOperator.Pipe && current.Length > 0) + { + units.Add(current.ToString()); + current.Clear(); + } + + if (current.Length > 0) + current.Append(" | "); + + current.Append(ReconstructClauseText(clause)); + } + + if (current.Length > 0) + units.Add(current.ToString()); + + return units; + } + + /// + /// Rebuilds one clause's user-facing text from its parsed parts: verb + /// chain, positional/flag args, and redirects. Synthetic cd-attribution + /// args are dropped — they carry an inherited cwd, not a token the user + /// typed. The result is fed back through + /// for path + /// normalization, so this only needs to emit a clean token sequence. + /// + private static string ReconstructClauseText(ShellSyntaxTree.Clause clause) + { + var sb = new StringBuilder(clause.Verb.Joined); + + foreach (var arg in clause.Args) + { + if (arg.IsCwdAttribution || string.IsNullOrEmpty(arg.Raw)) + continue; + + if (sb.Length > 0) + sb.Append(' '); + sb.Append(arg.Raw); + } + + // Redirect targets live outside Args; the legacy tokenizer kept them + // as plain `> /path` tokens, so preserve them in the display unit + // and the approve-once retry key. + foreach (var redirect in clause.Redirects) + { + if (string.IsNullOrEmpty(redirect.Target)) + continue; + + if (sb.Length > 0) + sb.Append(' '); + sb.Append(RedirectToken(redirect.Direction)); + sb.Append(' '); + sb.Append(redirect.Target); + } + + return sb.ToString(); + } + + private static string RedirectToken(ShellSyntaxTree.RedirectDirection direction) => direction switch + { + ShellSyntaxTree.RedirectDirection.In => "<", + ShellSyntaxTree.RedirectDirection.Out => ">", + ShellSyntaxTree.RedirectDirection.Append => ">>", + ShellSyntaxTree.RedirectDirection.ErrOut => "2>", + ShellSyntaxTree.RedirectDirection.ErrAppend => "2>>", + _ => throw new ArgumentOutOfRangeException( + nameof(direction), direction, + "Unknown ShellSyntaxTree redirect direction — a package upgrade needs a matcher update."), + }; + public bool IsApproved( ToolName toolName, IDictionary? arguments, From 5ca3d5e81c4e773e12c44948c8a9c8d6433b534a Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Sat, 16 May 2026 02:10:36 +0000 Subject: [PATCH 2/3] refactor(security): harden BashParser routing and dedup test platform hook Review follow-ups to the ShellSyntaxTree 0.1.5-beta upgrade: - Widen the try/catch in ExtractApprovalUnitsViaBashParser to cover clause reconstruction, not just the parse call. RedirectToken's exhaustive switch throws on an unknown redirect direction; a future package enum addition would otherwise escape as an unhandled exception and crash the approval prompt instead of failing closed. - Share a single immutable BashParser instance instead of allocating one per approval evaluation. - Trim change-narration from comments, leaving the durable rationale. - Extract the triplicated xunit.v3 IsPosix SkipUnless hook into a shared TestPlatform helper referenced via SkipType. --- .../ShellApprovalMatcherTests.cs | 62 +++++---------- .../ShellTokenizerTests.cs | 11 +-- src/Netclaw.Security.Tests/TestPlatform.cs | 18 +++++ src/Netclaw.Security/IToolApprovalMatcher.cs | 79 ++++++++++--------- 4 files changed, 80 insertions(+), 90 deletions(-) create mode 100644 src/Netclaw.Security.Tests/TestPlatform.cs diff --git a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs index d0f6e2e38..de2244cd9 100644 --- a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs +++ b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs @@ -13,15 +13,6 @@ public sealed class ShellApprovalMatcherTests { private readonly ShellApprovalMatcher _matcher = ShellApprovalMatcher.Instance; - /// - /// xunit.v3 SkipUnless hook: ExtractPatterns and - /// ExtractCandidates route through BashParser on POSIX only — the - /// Windows path falls back to the legacy ShellTokenizer splitter, - /// which is newline-blind. Tests that pin BashParser-specific behavior - /// skip cleanly on Windows runners. - /// - public static bool IsPosix => !OperatingSystem.IsWindows(); - private static Dictionary Args(string command) => new() { ["Command"] = command }; private static Dictionary Args(string command, string workingDirectory) @@ -69,13 +60,12 @@ public void ExtractPatterns_empty_command() Assert.Empty(patterns); } - [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only — ExtractPatterns routes through BashParser on POSIX")] + [Fact(SkipUnless = nameof(TestPlatform.IsPosix), SkipType = typeof(TestPlatform), Skip = "POSIX-only — ExtractPatterns routes through BashParser on POSIX")] public void ExtractPatterns_multiline_command_splits_one_unit_per_statement() { - // A bare newline is a statement separator (ShellSyntaxTree 0.1.5+). - // Pre-upgrade the legacy splitter ignored newlines and produced the - // single garbled unit `git fetch git status`; the BashParser route - // now yields two clean approval units. + // A bare newline separates statements, so a multi-line command + // yields one approval unit per statement rather than a single unit + // with the newline collapsed to a space. var patterns = _matcher.ExtractPatterns(new ToolName("shell_execute"), Args("git fetch\ngit status")); @@ -84,7 +74,7 @@ public void ExtractPatterns_multiline_command_splits_one_unit_per_statement() Assert.Contains("git status", patterns); } - [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only — ExtractPatterns routes through BashParser on POSIX")] + [Fact(SkipUnless = nameof(TestPlatform.IsPosix), SkipType = typeof(TestPlatform), Skip = "POSIX-only — ExtractPatterns routes through BashParser on POSIX")] public void ExtractPatterns_multiline_collapses_blank_lines() { var patterns = _matcher.ExtractPatterns(new ToolName("shell_execute"), @@ -95,7 +85,7 @@ public void ExtractPatterns_multiline_collapses_blank_lines() Assert.Contains("echo b", patterns); } - [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only — ExtractPatterns routes through BashParser on POSIX")] + [Fact(SkipUnless = nameof(TestPlatform.IsPosix), SkipType = typeof(TestPlatform), Skip = "POSIX-only — ExtractPatterns routes through BashParser on POSIX")] public void ExtractPatterns_multiline_keeps_pipe_within_a_statement() { // A pipe stays inside one approval unit; the newline still splits @@ -108,13 +98,12 @@ public void ExtractPatterns_multiline_keeps_pipe_within_a_statement() Assert.Contains("echo two", patterns); } - [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")] + [Fact(SkipUnless = nameof(TestPlatform.IsPosix), SkipType = typeof(TestPlatform), Skip = "POSIX-only path semantics")] public void ExtractCandidates_multiline_surfaces_each_verb() { - // Security-relevant: pre-0.1.5 a multi-line command parsed as one - // clause, so `rm -rf` after a newline was buried as args of `cd` - // and never reached the gate as its own verb. Newline-as-separator - // surfaces `rm` as an independently gated candidate. + // Security-relevant: a verb after a bare newline (here `rm -rf`) + // must surface as its own gated candidate, not be absorbed as args + // of the preceding `cd`. var candidates = _matcher.ExtractCandidates(new ToolName("shell_execute"), Args("cd /tmp\nrm -rf /tmp/foo")); @@ -279,18 +268,7 @@ public sealed class ShellApprovalMatcherPathExtractionTests private static Dictionary Args(string command) => new() { ["Command"] = command }; - /// - /// xunit.v3 SkipUnless hook for POSIX-only tests. The v2 - /// matcher falls through to the legacy ShellTokenizer path - /// on Windows (ShellSyntaxTree is bash-only), so tests that pin - /// BashParser cwd attribution / arg.Resolved canonicalization - /// don't apply. Marking them [Fact(SkipUnless = nameof(IsPosix))] - /// produces a proper "Skipped" entry in the test log on Windows - /// runners instead of hiding the gap behind an early-return. - /// - public static bool IsPosix => !OperatingSystem.IsWindows(); - - [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")] + [Fact(SkipUnless = nameof(TestPlatform.IsPosix), SkipType = typeof(TestPlatform), Skip = "POSIX-only path semantics")] public void ExtractCandidates_strips_path_from_verb() { var candidates = _matcher.ExtractCandidates(new ToolName("shell_execute"), @@ -301,7 +279,7 @@ public void ExtractCandidates_strips_path_from_verb() Assert.Equal("/home/user", c.Directory); } - [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")] + [Fact(SkipUnless = nameof(TestPlatform.IsPosix), SkipType = typeof(TestPlatform), Skip = "POSIX-only path semantics")] public void ExtractCandidates_applies_file_parent_rule() { // `cat ~/.bashrc` → directory is the basename's parent (the home @@ -446,7 +424,7 @@ public void ExtractCandidates_caps_echo_at_one_token() Assert.True(ApprovalPatternMatching.IsPureSideEffect(c)); } - [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")] + [Fact(SkipUnless = nameof(TestPlatform.IsPosix), SkipType = typeof(TestPlatform), Skip = "POSIX-only path semantics")] public void ExtractCandidates_extracts_cd_target_as_directory() { // Production case: `cd /repo && git remote -v`. The header / @@ -480,7 +458,7 @@ public void ExtractCandidates_extracts_cd_target_as_directory() && c.Directory == "/home/user/repos/example"); } - [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")] + [Fact(SkipUnless = nameof(TestPlatform.IsPosix), SkipType = typeof(TestPlatform), Skip = "POSIX-only path semantics")] public void ExtractCandidates_propagates_cd_target_to_subsequent_clauses_with_no_path_arg() { // Production repro of the retry-after-approval failure on @@ -504,7 +482,7 @@ public void ExtractCandidates_propagates_cd_target_to_subsequent_clauses_with_no c => c.Verb == "git checkout" && c.Directory == "/home/user/repos/foo"); } - [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")] + [Fact(SkipUnless = nameof(TestPlatform.IsPosix), SkipType = typeof(TestPlatform), Skip = "POSIX-only path semantics")] public void ExtractCandidates_tracks_latest_cd_through_multiple_hops() { // cd /a && cd /b && grep ... — grep inherits /b (the latest cd), @@ -524,7 +502,7 @@ public void ExtractCandidates_tracks_latest_cd_through_multiple_hops() Assert.Contains(candidates, c => c.Verb == "pwd" && c.Directory == "/b"); } - [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")] + [Fact(SkipUnless = nameof(TestPlatform.IsPosix), SkipType = typeof(TestPlatform), Skip = "POSIX-only path semantics")] public void ExtractCandidates_recurses_into_bash_dash_c_with_cd_attribution_intact() { // bash -c "cd /repo && git push" — the parser flattens the @@ -541,7 +519,7 @@ public void ExtractCandidates_recurses_into_bash_dash_c_with_cd_attribution_inta Assert.Contains(candidates, c => c.Verb == "git push" && c.Directory == "/repo"); } - [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")] + [Fact(SkipUnless = nameof(TestPlatform.IsPosix), SkipType = typeof(TestPlatform), Skip = "POSIX-only path semantics")] public void ExtractCandidates_prefers_explicit_path_arg_over_cd_attribution() { // When a clause has its own anchored path argument, that wins — @@ -561,7 +539,7 @@ public void ExtractCandidates_prefers_explicit_path_arg_over_cd_attribution() c => c.Verb == "dotnet test" && c.Directory == "/home/foo"); } - [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")] + [Fact(SkipUnless = nameof(TestPlatform.IsPosix), SkipType = typeof(TestPlatform), Skip = "POSIX-only path semantics")] public void ExtractCandidates_side_effect_verbs_do_not_inherit_cd_attribution() { // echo / printf / true / false write to stdout and ignore cwd, @@ -581,7 +559,7 @@ public void ExtractCandidates_side_effect_verbs_do_not_inherit_cd_attribution() Assert.Contains(candidates, c => c.Verb == "echo" && c.Directory == null); } - [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")] + [Fact(SkipUnless = nameof(TestPlatform.IsPosix), SkipType = typeof(TestPlatform), Skip = "POSIX-only path semantics")] public void ExtractCandidates_normalizes_tilde_cd_to_absolute_path_so_clauses_share_one_directory() { // Production header bug: prompt for `cd ~/x && git checkout -b f` @@ -611,7 +589,7 @@ public void ExtractCandidates_normalizes_tilde_cd_to_absolute_path_so_clauses_sh Assert.Contains(candidates, c => c.Verb == "git checkout" && c.Directory == expected); } - [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")] + [Fact(SkipUnless = nameof(TestPlatform.IsPosix), SkipType = typeof(TestPlatform), Skip = "POSIX-only path semantics")] public void ExtractCandidates_collapses_pipe_chain_into_single_candidate() { // Pipes stay inside one approval unit — approving cat /etc/hosts diff --git a/src/Netclaw.Security.Tests/ShellTokenizerTests.cs b/src/Netclaw.Security.Tests/ShellTokenizerTests.cs index f244fa2b0..83f62d6e3 100644 --- a/src/Netclaw.Security.Tests/ShellTokenizerTests.cs +++ b/src/Netclaw.Security.Tests/ShellTokenizerTests.cs @@ -9,15 +9,6 @@ namespace Netclaw.Security.Tests; public sealed class ShellTokenizerTests { - /// - /// xunit.v3 SkipUnless hook for tests whose expected output - /// depends on POSIX Path.GetDirectoryName semantics — - /// Windows produces backslashed parents that don't match the - /// forward-slash expectations baked into the inline data. - /// - public static bool IsPosix => !OperatingSystem.IsWindows(); - - public static TheoryData WindowsAnchoredPathCases { get @@ -278,7 +269,7 @@ public void ExtractFirstPathArgument_returns_first_path_or_null(string command, Assert.Equal(expected, ShellTokenizer.ExtractFirstPathArgument(command)); } - [Theory(SkipUnless = nameof(IsPosix), Skip = "POSIX-only Path.GetDirectoryName semantics")] + [Theory(SkipUnless = nameof(TestPlatform.IsPosix), SkipType = typeof(TestPlatform), Skip = "POSIX-only Path.GetDirectoryName semantics")] // File path with extension → parent. Path.GetDirectoryName is // platform-aware: on Windows the parent uses backslashes which // doesn't match these forward-slash expectations, so the cases diff --git a/src/Netclaw.Security.Tests/TestPlatform.cs b/src/Netclaw.Security.Tests/TestPlatform.cs new file mode 100644 index 000000000..1d6f84cc0 --- /dev/null +++ b/src/Netclaw.Security.Tests/TestPlatform.cs @@ -0,0 +1,18 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +namespace Netclaw.Security.Tests; + +/// +/// Shared xunit.v3 SkipUnless hook. Tests whose expected output +/// depends on POSIX shell/path semantics — or that exercise code paths only +/// reached on non-Windows (e.g. BashParser routing) — gate on this via +/// [Fact(SkipUnless = nameof(TestPlatform.IsPosix), SkipType = typeof(TestPlatform))] +/// so they record a proper "Skipped" entry on Windows runners. +/// +public static class TestPlatform +{ + public static bool IsPosix => !OperatingSystem.IsWindows(); +} diff --git a/src/Netclaw.Security/IToolApprovalMatcher.cs b/src/Netclaw.Security/IToolApprovalMatcher.cs index 5e9d32353..b8840a6cc 100644 --- a/src/Netclaw.Security/IToolApprovalMatcher.cs +++ b/src/Netclaw.Security/IToolApprovalMatcher.cs @@ -110,6 +110,11 @@ public sealed class ShellApprovalMatcher : IToolApprovalMatcher { public static readonly ShellApprovalMatcher Instance = new(); + // BashParser is immutable — Parse delegates to a pure static — so one + // shared instance is safe and avoids a per-evaluation allocation. The DI + // container registers it as a singleton for the same reason. + private static readonly ShellSyntaxTree.BashParser Parser = new(); + public string GetApprovalModeKey(ToolName toolName, IDictionary? arguments) => toolName.Value; @@ -125,12 +130,11 @@ public IReadOnlyList ExtractPatterns(ToolName toolName, IDictionary(StringComparer.OrdinalIgnoreCase); - // POSIX commands route through BashParser so the approval units match - // the clause decomposition ExtractCandidates already uses. The key - // win: a bare newline separates statements (ShellSyntaxTree 0.1.5+), - // so `git fetch\ngit status` yields two units instead of one garbled - // `git fetch git status`. Windows keeps the legacy ShellTokenizer - // path — ShellSyntaxTree is bash-only. + // POSIX commands route through BashParser so the approval units + // match the clause decomposition ExtractCandidates already uses — in + // particular a bare newline separates statements, so a multi-line + // command yields one unit per statement. Windows keeps the legacy + // ShellTokenizer path — ShellSyntaxTree is bash-only. if (!OperatingSystem.IsWindows()) { foreach (var unit in ExtractApprovalUnitsViaBashParser(command)) @@ -196,7 +200,7 @@ private static IReadOnlyList ExtractCandidatesViaBashParser(s ShellSyntaxTree.ParsedCommand result; try { - result = new ShellSyntaxTree.BashParser().Parse(command); + result = Parser.Parse(command); } catch { @@ -312,47 +316,46 @@ private static IReadOnlyList ExtractApprovalUnitsViaBashParser(string co if (ShellTokenizer.IsMessyCompoundCommand(command)) return []; - ShellSyntaxTree.ParsedCommand result; try { - result = new ShellSyntaxTree.BashParser().Parse(command); - } - catch - { - // Defensive: an unhandled parser exception must not take down the - // approval prompt. Fail-empty — the matcher treats this as a - // messy command (Once/Deny prompt only). - return []; - } - - if (result.IsUnparseable || result.Clauses.Count == 0) - return []; + var result = Parser.Parse(command); + if (result.IsUnparseable || result.Clauses.Count == 0) + return []; - var units = new List(); - var current = new StringBuilder(); + var units = new List(); + var current = new StringBuilder(); - foreach (var clause in result.Clauses) - { - // AndIf / OrIf / Sequence (and the leading None clause) each open - // a fresh approval unit; Pipe clauses fold into the unit in - // progress. `Sequence` is what a bare newline produces in - // ShellSyntaxTree 0.1.5+, so multi-line commands split here. - if (clause.Operator != ShellSyntaxTree.CompoundOperator.Pipe && current.Length > 0) + foreach (var clause in result.Clauses) { - units.Add(current.ToString()); - current.Clear(); + // AndIf / OrIf / Sequence (and the leading None clause) each + // open a fresh approval unit; Pipe clauses fold into the unit + // in progress. A bare newline produces Sequence, so multi-line + // commands split here too. + if (clause.Operator != ShellSyntaxTree.CompoundOperator.Pipe && current.Length > 0) + { + units.Add(current.ToString()); + current.Clear(); + } + + if (current.Length > 0) + current.Append(" | "); + + current.Append(ReconstructClauseText(clause)); } if (current.Length > 0) - current.Append(" | "); + units.Add(current.ToString()); - current.Append(ReconstructClauseText(clause)); + return units; + } + catch + { + // Defensive: a parser exception — or an unmapped redirect/clause + // shape from a future ShellSyntaxTree release — must not take + // down the approval prompt. Fail-empty so the matcher treats the + // command as messy (Once/Deny prompt only). + return []; } - - if (current.Length > 0) - units.Add(current.ToString()); - - return units; } /// From da82591c3cc38d19a15540f0e6f1a551ae40a2ba Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Sat, 16 May 2026 02:44:36 +0000 Subject: [PATCH 3/3] test(security): isolate multi-line matcher tests; revert IsPosix dedup The earlier IsPosix/TestPlatform dedup rewrote the SkipUnless attribute on ~11 pre-existing baselined tests across two files. Slopwatch's CI gate scopes to files changed in a PR and flags every disabled-test (SW001) in them, so touching those files surfaced all their pre-existing skipped tests as new violations. - Revert ShellApprovalMatcherTests.cs and ShellTokenizerTests.cs to their upstream state and drop the shared TestPlatform helper, so the PR no longer touches files dense with pre-existing skipped tests. - Move the four multi-line approval-matcher tests into a dedicated ShellApprovalMatcherMultilineTests.cs with its own IsPosix hook. - Baseline the four SW001 entries for the new file in .slopwatch/baseline.json. --- .slopwatch/baseline.json | 18 ++++ .../ShellApprovalMatcherMultilineTests.cs | 80 ++++++++++++++++++ .../ShellApprovalMatcherTests.cs | 82 +++++-------------- .../ShellTokenizerTests.cs | 11 ++- src/Netclaw.Security.Tests/TestPlatform.cs | 18 ---- 5 files changed, 129 insertions(+), 80 deletions(-) create mode 100644 src/Netclaw.Security.Tests/ShellApprovalMatcherMultilineTests.cs delete mode 100644 src/Netclaw.Security.Tests/TestPlatform.cs diff --git a/.slopwatch/baseline.json b/.slopwatch/baseline.json index 3655a890c..4bf6b0546 100644 --- a/.slopwatch/baseline.json +++ b/.slopwatch/baseline.json @@ -138,6 +138,24 @@ "codeSnippet": "catch\n {\n // best-effort cleanup\n }", "message": "Empty catch block swallows exceptions without handling", "baselinedAt": "2026-05-12T17:20:55.7421299+00:00" + }, + { + "hash": "05461b1bf2a3ad58", + "ruleId": "SW001", + "filePath": "src/Netclaw.Security.Tests/ShellApprovalMatcherMultilineTests.cs", + "lineNumber": 30, + "codeSnippet": "Fact(SkipUnless = nameof(IsPosix), Skip = \"POSIX-only — matcher routes through BashParser on POSIX\")", + "message": "Test method 'ExtractPatterns_multiline_command_splits_one_unit_per_statement' is disabled: POSIX-only — matcher routes through BashParser on POSIX", + "baselinedAt": "2026-05-16T00:00:00.0000000+00:00" + }, + { + "hash": "a016290b3480868d", + "ruleId": "SW001", + "filePath": "src/Netclaw.Security.Tests/ShellApprovalMatcherMultilineTests.cs", + "lineNumber": 68, + "codeSnippet": "Fact(SkipUnless = nameof(IsPosix), Skip = \"POSIX-only path semantics\")", + "message": "Test method 'ExtractCandidates_multiline_surfaces_each_verb' is disabled: POSIX-only path semantics", + "baselinedAt": "2026-05-16T00:00:00.0000000+00:00" } ] } \ No newline at end of file diff --git a/src/Netclaw.Security.Tests/ShellApprovalMatcherMultilineTests.cs b/src/Netclaw.Security.Tests/ShellApprovalMatcherMultilineTests.cs new file mode 100644 index 000000000..130e0958f --- /dev/null +++ b/src/Netclaw.Security.Tests/ShellApprovalMatcherMultilineTests.cs @@ -0,0 +1,80 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Tools; +using Xunit; + +namespace Netclaw.Security.Tests; + +/// +/// Multi-line shell command coverage for . +/// A bare newline separates statements; on POSIX both ExtractPatterns +/// and ExtractCandidates route through BashParser, so a multi-line +/// command decomposes into one approval unit per statement. +/// +public sealed class ShellApprovalMatcherMultilineTests +{ + private readonly ShellApprovalMatcher _matcher = ShellApprovalMatcher.Instance; + + private static Dictionary Args(string command) => new() { ["Command"] = command }; + + /// + /// xunit.v3 SkipUnless hook: the matcher routes through BashParser + /// on POSIX only — the Windows path uses the legacy newline-blind + /// ShellTokenizer splitter, so these assertions don't hold there. + /// + public static bool IsPosix => !OperatingSystem.IsWindows(); + + [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only — matcher routes through BashParser on POSIX")] + public void ExtractPatterns_multiline_command_splits_one_unit_per_statement() + { + // A bare newline separates statements, so a multi-line command + // yields one approval unit per statement rather than a single unit + // with the newline collapsed to a space. + var patterns = _matcher.ExtractPatterns(new ToolName("shell_execute"), + Args("git fetch\ngit status")); + + Assert.Equal(2, patterns.Count); + Assert.Contains("git fetch", patterns); + Assert.Contains("git status", patterns); + } + + [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only — matcher routes through BashParser on POSIX")] + public void ExtractPatterns_multiline_collapses_blank_lines() + { + var patterns = _matcher.ExtractPatterns(new ToolName("shell_execute"), + Args("echo a\n\n\necho b")); + + Assert.Equal(2, patterns.Count); + Assert.Contains("echo a", patterns); + Assert.Contains("echo b", patterns); + } + + [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only — matcher routes through BashParser on POSIX")] + public void ExtractPatterns_multiline_keeps_pipe_within_a_statement() + { + // A pipe stays inside one approval unit; the newline still splits + // the second statement out. + var patterns = _matcher.ExtractPatterns(new ToolName("shell_execute"), + Args("echo one | grep o\necho two")); + + Assert.Equal(2, patterns.Count); + Assert.Contains("echo one | grep o", patterns); + Assert.Contains("echo two", patterns); + } + + [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")] + public void ExtractCandidates_multiline_surfaces_each_verb() + { + // Security-relevant: a verb after a bare newline (here `rm -rf`) + // must surface as its own gated candidate, not be absorbed as args + // of the preceding `cd`. + var candidates = _matcher.ExtractCandidates(new ToolName("shell_execute"), + Args("cd /tmp\nrm -rf /tmp/foo")); + + Assert.Contains(candidates, c => c.Verb == "cd" && c.Directory == "/tmp"); + Assert.Contains(candidates, c => c.Verb == "rm" && c.Directory == "/tmp/foo"); + } +} diff --git a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs index de2244cd9..6511a8a81 100644 --- a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs +++ b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs @@ -60,57 +60,6 @@ public void ExtractPatterns_empty_command() Assert.Empty(patterns); } - [Fact(SkipUnless = nameof(TestPlatform.IsPosix), SkipType = typeof(TestPlatform), Skip = "POSIX-only — ExtractPatterns routes through BashParser on POSIX")] - public void ExtractPatterns_multiline_command_splits_one_unit_per_statement() - { - // A bare newline separates statements, so a multi-line command - // yields one approval unit per statement rather than a single unit - // with the newline collapsed to a space. - var patterns = _matcher.ExtractPatterns(new ToolName("shell_execute"), - Args("git fetch\ngit status")); - - Assert.Equal(2, patterns.Count); - Assert.Contains("git fetch", patterns); - Assert.Contains("git status", patterns); - } - - [Fact(SkipUnless = nameof(TestPlatform.IsPosix), SkipType = typeof(TestPlatform), Skip = "POSIX-only — ExtractPatterns routes through BashParser on POSIX")] - public void ExtractPatterns_multiline_collapses_blank_lines() - { - var patterns = _matcher.ExtractPatterns(new ToolName("shell_execute"), - Args("echo a\n\n\necho b")); - - Assert.Equal(2, patterns.Count); - Assert.Contains("echo a", patterns); - Assert.Contains("echo b", patterns); - } - - [Fact(SkipUnless = nameof(TestPlatform.IsPosix), SkipType = typeof(TestPlatform), Skip = "POSIX-only — ExtractPatterns routes through BashParser on POSIX")] - public void ExtractPatterns_multiline_keeps_pipe_within_a_statement() - { - // A pipe stays inside one approval unit; the newline still splits - // the second statement out. - var patterns = _matcher.ExtractPatterns(new ToolName("shell_execute"), - Args("echo one | grep o\necho two")); - - Assert.Equal(2, patterns.Count); - Assert.Contains("echo one | grep o", patterns); - Assert.Contains("echo two", patterns); - } - - [Fact(SkipUnless = nameof(TestPlatform.IsPosix), SkipType = typeof(TestPlatform), Skip = "POSIX-only path semantics")] - public void ExtractCandidates_multiline_surfaces_each_verb() - { - // Security-relevant: a verb after a bare newline (here `rm -rf`) - // must surface as its own gated candidate, not be absorbed as args - // of the preceding `cd`. - var candidates = _matcher.ExtractCandidates(new ToolName("shell_execute"), - Args("cd /tmp\nrm -rf /tmp/foo")); - - Assert.Contains(candidates, c => c.Verb == "cd" && c.Directory == "/tmp"); - Assert.Contains(candidates, c => c.Verb == "rm" && c.Directory == "/tmp/foo"); - } - [Fact] public void ExtractCandidateVerbs_collapses_to_verb_chains_only() { @@ -268,7 +217,18 @@ public sealed class ShellApprovalMatcherPathExtractionTests private static Dictionary Args(string command) => new() { ["Command"] = command }; - [Fact(SkipUnless = nameof(TestPlatform.IsPosix), SkipType = typeof(TestPlatform), Skip = "POSIX-only path semantics")] + /// + /// xunit.v3 SkipUnless hook for POSIX-only tests. The v2 + /// matcher falls through to the legacy ShellTokenizer path + /// on Windows (ShellSyntaxTree is bash-only), so tests that pin + /// BashParser cwd attribution / arg.Resolved canonicalization + /// don't apply. Marking them [Fact(SkipUnless = nameof(IsPosix))] + /// produces a proper "Skipped" entry in the test log on Windows + /// runners instead of hiding the gap behind an early-return. + /// + public static bool IsPosix => !OperatingSystem.IsWindows(); + + [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")] public void ExtractCandidates_strips_path_from_verb() { var candidates = _matcher.ExtractCandidates(new ToolName("shell_execute"), @@ -279,7 +239,7 @@ public void ExtractCandidates_strips_path_from_verb() Assert.Equal("/home/user", c.Directory); } - [Fact(SkipUnless = nameof(TestPlatform.IsPosix), SkipType = typeof(TestPlatform), Skip = "POSIX-only path semantics")] + [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")] public void ExtractCandidates_applies_file_parent_rule() { // `cat ~/.bashrc` → directory is the basename's parent (the home @@ -424,7 +384,7 @@ public void ExtractCandidates_caps_echo_at_one_token() Assert.True(ApprovalPatternMatching.IsPureSideEffect(c)); } - [Fact(SkipUnless = nameof(TestPlatform.IsPosix), SkipType = typeof(TestPlatform), Skip = "POSIX-only path semantics")] + [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")] public void ExtractCandidates_extracts_cd_target_as_directory() { // Production case: `cd /repo && git remote -v`. The header / @@ -458,7 +418,7 @@ public void ExtractCandidates_extracts_cd_target_as_directory() && c.Directory == "/home/user/repos/example"); } - [Fact(SkipUnless = nameof(TestPlatform.IsPosix), SkipType = typeof(TestPlatform), Skip = "POSIX-only path semantics")] + [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")] public void ExtractCandidates_propagates_cd_target_to_subsequent_clauses_with_no_path_arg() { // Production repro of the retry-after-approval failure on @@ -482,7 +442,7 @@ public void ExtractCandidates_propagates_cd_target_to_subsequent_clauses_with_no c => c.Verb == "git checkout" && c.Directory == "/home/user/repos/foo"); } - [Fact(SkipUnless = nameof(TestPlatform.IsPosix), SkipType = typeof(TestPlatform), Skip = "POSIX-only path semantics")] + [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")] public void ExtractCandidates_tracks_latest_cd_through_multiple_hops() { // cd /a && cd /b && grep ... — grep inherits /b (the latest cd), @@ -502,7 +462,7 @@ public void ExtractCandidates_tracks_latest_cd_through_multiple_hops() Assert.Contains(candidates, c => c.Verb == "pwd" && c.Directory == "/b"); } - [Fact(SkipUnless = nameof(TestPlatform.IsPosix), SkipType = typeof(TestPlatform), Skip = "POSIX-only path semantics")] + [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")] public void ExtractCandidates_recurses_into_bash_dash_c_with_cd_attribution_intact() { // bash -c "cd /repo && git push" — the parser flattens the @@ -519,7 +479,7 @@ public void ExtractCandidates_recurses_into_bash_dash_c_with_cd_attribution_inta Assert.Contains(candidates, c => c.Verb == "git push" && c.Directory == "/repo"); } - [Fact(SkipUnless = nameof(TestPlatform.IsPosix), SkipType = typeof(TestPlatform), Skip = "POSIX-only path semantics")] + [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")] public void ExtractCandidates_prefers_explicit_path_arg_over_cd_attribution() { // When a clause has its own anchored path argument, that wins — @@ -539,7 +499,7 @@ public void ExtractCandidates_prefers_explicit_path_arg_over_cd_attribution() c => c.Verb == "dotnet test" && c.Directory == "/home/foo"); } - [Fact(SkipUnless = nameof(TestPlatform.IsPosix), SkipType = typeof(TestPlatform), Skip = "POSIX-only path semantics")] + [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")] public void ExtractCandidates_side_effect_verbs_do_not_inherit_cd_attribution() { // echo / printf / true / false write to stdout and ignore cwd, @@ -559,7 +519,7 @@ public void ExtractCandidates_side_effect_verbs_do_not_inherit_cd_attribution() Assert.Contains(candidates, c => c.Verb == "echo" && c.Directory == null); } - [Fact(SkipUnless = nameof(TestPlatform.IsPosix), SkipType = typeof(TestPlatform), Skip = "POSIX-only path semantics")] + [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")] public void ExtractCandidates_normalizes_tilde_cd_to_absolute_path_so_clauses_share_one_directory() { // Production header bug: prompt for `cd ~/x && git checkout -b f` @@ -589,7 +549,7 @@ public void ExtractCandidates_normalizes_tilde_cd_to_absolute_path_so_clauses_sh Assert.Contains(candidates, c => c.Verb == "git checkout" && c.Directory == expected); } - [Fact(SkipUnless = nameof(TestPlatform.IsPosix), SkipType = typeof(TestPlatform), Skip = "POSIX-only path semantics")] + [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")] public void ExtractCandidates_collapses_pipe_chain_into_single_candidate() { // Pipes stay inside one approval unit — approving cat /etc/hosts diff --git a/src/Netclaw.Security.Tests/ShellTokenizerTests.cs b/src/Netclaw.Security.Tests/ShellTokenizerTests.cs index 83f62d6e3..f244fa2b0 100644 --- a/src/Netclaw.Security.Tests/ShellTokenizerTests.cs +++ b/src/Netclaw.Security.Tests/ShellTokenizerTests.cs @@ -9,6 +9,15 @@ namespace Netclaw.Security.Tests; public sealed class ShellTokenizerTests { + /// + /// xunit.v3 SkipUnless hook for tests whose expected output + /// depends on POSIX Path.GetDirectoryName semantics — + /// Windows produces backslashed parents that don't match the + /// forward-slash expectations baked into the inline data. + /// + public static bool IsPosix => !OperatingSystem.IsWindows(); + + public static TheoryData WindowsAnchoredPathCases { get @@ -269,7 +278,7 @@ public void ExtractFirstPathArgument_returns_first_path_or_null(string command, Assert.Equal(expected, ShellTokenizer.ExtractFirstPathArgument(command)); } - [Theory(SkipUnless = nameof(TestPlatform.IsPosix), SkipType = typeof(TestPlatform), Skip = "POSIX-only Path.GetDirectoryName semantics")] + [Theory(SkipUnless = nameof(IsPosix), Skip = "POSIX-only Path.GetDirectoryName semantics")] // File path with extension → parent. Path.GetDirectoryName is // platform-aware: on Windows the parent uses backslashes which // doesn't match these forward-slash expectations, so the cases diff --git a/src/Netclaw.Security.Tests/TestPlatform.cs b/src/Netclaw.Security.Tests/TestPlatform.cs deleted file mode 100644 index 1d6f84cc0..000000000 --- a/src/Netclaw.Security.Tests/TestPlatform.cs +++ /dev/null @@ -1,18 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -namespace Netclaw.Security.Tests; - -/// -/// Shared xunit.v3 SkipUnless hook. Tests whose expected output -/// depends on POSIX shell/path semantics — or that exercise code paths only -/// reached on non-Windows (e.g. BashParser routing) — gate on this via -/// [Fact(SkipUnless = nameof(TestPlatform.IsPosix), SkipType = typeof(TestPlatform))] -/// so they record a proper "Skipped" entry on Windows runners. -/// -public static class TestPlatform -{ - public static bool IsPosix => !OperatingSystem.IsWindows(); -}