From 63764748680968f2672d7658ee292c90b60f6e76 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 4 Aug 2026 04:04:46 +0000 Subject: [PATCH 1/2] Fix Bash approval analysis gaps --- Directory.Packages.props | 2 +- .../Tools/ShellApprovalCaseCatalog.cs | 56 +++--- ...roval_cases_match_review_table.verified.md | 32 ++-- .../ShellApprovalMatcherTests.cs | 14 +- .../ShellCommandAnalysisTests.cs | 47 +++++ .../ShellCommandPolicyTests.cs | 17 ++ .../ShellSyntaxTreeIntegrationTests.cs | 16 +- src/Netclaw.Security/IToolApprovalMatcher.cs | 91 ++++------ src/Netclaw.Security/Netclaw.Security.csproj | 4 + .../ShellApprovalSemantics.cs | 10 +- src/Netclaw.Security/ShellCommandAnalysis.cs | 167 ++++++++++++++++++ src/Netclaw.Security/ShellCommandPolicy.cs | 49 ++++- 12 files changed, 393 insertions(+), 112 deletions(-) create mode 100644 src/Netclaw.Security.Tests/ShellCommandAnalysisTests.cs create mode 100644 src/Netclaw.Security/ShellCommandAnalysis.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 810d06828..9aa565b9a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -75,7 +75,7 @@ - + diff --git a/src/Netclaw.Actors.Tests/Tools/ShellApprovalCaseCatalog.cs b/src/Netclaw.Actors.Tests/Tools/ShellApprovalCaseCatalog.cs index 5757159f4..198545d45 100644 --- a/src/Netclaw.Actors.Tests/Tools/ShellApprovalCaseCatalog.cs +++ b/src/Netclaw.Actors.Tests/Tools/ShellApprovalCaseCatalog.cs @@ -309,65 +309,65 @@ public static class ShellApprovalCases Approvals.None, ExpectedApproval.Require(["git add", "git commit", "git push origin dev"])), Case( - "hard-deny-pipeline-tail-currently-prompts", + "hard-deny-pipeline-tail-blocks", Bash("echo safe | netclaw daemon stop"), Approvals.None, - ExpectedApproval.Require(["echo", "netclaw daemon stop"])), + ExpectedApproval.Deny("hard_deny_self_destructive")), Case( "hard-deny-nested-shell-blocks", Bash("bash -lc \"netclaw daemon stop\""), Approvals.None, ExpectedApproval.Deny("hard_deny_self_destructive")), Case( - "nested-shell-currently-prompts-for-wrapper", + "nested-shell-prompts-for-inner-command", Bash("bash -lc \"git push\""), Approvals.None, - ExpectedApproval.Require(["bash"])), + ExpectedApproval.Require(["git push"])), Case( - "nested-shell-inner-grant-currently-does-not-match", + "nested-shell-inner-grant-allows", Bash("bash -lc \"git push\""), Approvals.PersistentAnywhere("git push"), - ExpectedApproval.Require(["bash"])), + ExpectedApproval.Allow(ToolAllowReason.StoredApproval, 1, "persistent:git push")), Case( - "nested-shell-wrapper-grant-currently-allows", + "nested-shell-wrapper-grant-does-not-cover-inner-command", Bash("bash -lc \"git push\""), Approvals.PersistentAnywhere("bash"), - ExpectedApproval.Allow(ToolAllowReason.StoredApproval, 1, "persistent:bash")), + ExpectedApproval.Require(["git push"])), Case( "env-nested-shell-prompts", Bash("env bash -lc \"git push\""), Approvals.None, - ExpectedApproval.Require(["env bash"])), + ExpectedApproval.Require(["git push"])), Case( "timeout-nested-shell-prompts", Bash("timeout 5 bash -lc \"git push\""), Approvals.None, - ExpectedApproval.Require(["timeout"])), + ExpectedApproval.Require(["timeout", "git push"])), Case( "subshell-prompts", Bash("(git status && git push)"), Approvals.None, ExpectedApproval.Require(["git status", "git push"])), Case( - "command-substitution-currently-auto-allows", + "command-substitution-fails-closed", Bash("echo $(git push)"), Approvals.None, - ExpectedApproval.Allow(ToolAllowReason.ApprovalExemptShellCandidates)), + ExpectedApproval.Require([], isMessy: true, approvalChecks: 0)), Case( - "dynamic-path-currently-auto-allows", + "dynamic-path-fails-closed", Bash("cat \"$FILE\""), Approvals.None, - ExpectedApproval.Allow(ToolAllowReason.SafeVerbInTrustedScope)), + ExpectedApproval.Require([], isMessy: true, approvalChecks: 0)), Case( - "dynamic-redirect-currently-prompts", + "dynamic-redirect-fails-closed", Bash("git status > \"$OUTPUT\""), Approvals.None, - ExpectedApproval.Require(["git status"])), + ExpectedApproval.Require([], isMessy: true, approvalChecks: 0)), Case( - "background-list-currently-auto-allows", + "background-list-prompts-for-mutating-tail", Bash("git status & git push"), Approvals.None, - ExpectedApproval.Allow(ToolAllowReason.SafeVerbInTrustedScope)), + ExpectedApproval.Require([], isMessy: true, approvalChecks: 0)), Case( "unbalanced-quote-fails-closed", Bash("git push \"unterminated"), @@ -428,7 +428,7 @@ public static class ShellApprovalCases "heredoc-prompts", Bash("cat <<'EOF'\nhello\nEOF"), Approvals.None, - ExpectedApproval.Require([], approvalChecks: 0)), + ExpectedApproval.Require([], isMessy: true, approvalChecks: 0)), Case( "echo-allows-without-grant", @@ -446,30 +446,30 @@ public static class ShellApprovalCases Approvals.None, ExpectedApproval.Require(["echo"])), Case( - "echo-done-fails-closed", + "echo-control-word-argument-allows", Bash("echo done"), Approvals.None, - ExpectedApproval.Require(["echo"], isMessy: true, approvalChecks: 0)), + ExpectedApproval.Allow(ToolAllowReason.ApprovalExemptShellCandidates)), Case( "control-flow-fails-closed", Bash("for f in *.txt; do cat \"$f\"; done"), Approvals.PersistentAnywhere("cat"), ExpectedApproval.Require([], isMessy: true, approvalChecks: 0)), Case( - "process-substitution-currently-prompts-without-complex-flag", + "process-substitution-fails-closed", Bash("cat <(git push)"), Approvals.PersistentAnywhere("cat", "git push"), - ExpectedApproval.Require([], approvalChecks: 0)), + ExpectedApproval.Require([], isMessy: true, approvalChecks: 0)), Case( - "arithmetic-expansion-currently-prompts-without-complex-flag", + "arithmetic-expansion-fails-closed", Bash("echo $((1 + 2))"), Approvals.None, - ExpectedApproval.Require([], approvalChecks: 0)), + ExpectedApproval.Require([], isMessy: true, approvalChecks: 0)), Case( - "function-definition-currently-prompts-without-complex-flag", + "function-definition-fails-closed", Bash("deploy() { git push; }; deploy"), Approvals.PersistentAnywhere("git push"), - ExpectedApproval.Require([], approvalChecks: 0)), + ExpectedApproval.Require([], isMessy: true, approvalChecks: 0)), Case( "inline-python-prompts-for-interpreter", Bash("python3 -c \"print('hello')\""), @@ -494,7 +494,7 @@ public static class ShellApprovalCases "inline-python-heredoc-fails-closed", Bash("python3 <<'PY'\nprint('hello')\nPY"), Approvals.PersistentAnywhere("python3"), - ExpectedApproval.Require([], approvalChecks: 0)), + ExpectedApproval.Require([], isMessy: true, approvalChecks: 0)), Case( "empty-command-fails-closed", Bash(string.Empty), diff --git a/src/Netclaw.Actors.Tests/Tools/ShellApprovalDispositionMatrixTests.Shell_approval_cases_match_review_table.verified.md b/src/Netclaw.Actors.Tests/Tools/ShellApprovalDispositionMatrixTests.Shell_approval_cases_match_review_table.verified.md index a629768ff..22e085c84 100644 --- a/src/Netclaw.Actors.Tests/Tools/ShellApprovalDispositionMatrixTests.Shell_approval_cases_match_review_table.verified.md +++ b/src/Netclaw.Actors.Tests/Tools/ShellApprovalDispositionMatrixTests.Shell_approval_cases_match_review_table.verified.md @@ -27,18 +27,18 @@ | newline-sequence-prompts | Personal | Project | Interactive | git status\ngit push | none | RequiresApproval | approval required | git status, git push | No | | or-chain-prompts | Personal | Project | Interactive | git status \|\| git push | none | RequiresApproval | approval required | git status, git push | No | | three-step-release-prompts | Personal | Project | Interactive | git add . && git commit -m fix && git push origin dev | none | RequiresApproval | approval required | git add, git commit, git push origin dev | No | -| hard-deny-pipeline-tail-currently-prompts | Personal | Project | Interactive | echo safe \| netclaw daemon stop | none | RequiresApproval | approval required | echo, netclaw daemon stop | No | +| hard-deny-pipeline-tail-blocks | Personal | Project | Interactive | echo safe \| netclaw daemon stop | none | Denied | hard_deny_self_destructive | none | Not applicable | | hard-deny-nested-shell-blocks | Personal | Project | Interactive | bash -lc "netclaw daemon stop" | none | Denied | hard_deny_self_destructive | none | Not applicable | -| nested-shell-currently-prompts-for-wrapper | Personal | Project | Interactive | bash -lc "git push" | none | RequiresApproval | approval required | bash | No | -| nested-shell-inner-grant-currently-does-not-match | Personal | Project | Interactive | bash -lc "git push" | persistent[anywhere]:git push | RequiresApproval | approval required | bash | No | -| nested-shell-wrapper-grant-currently-allows | Personal | Project | Interactive | bash -lc "git push" | persistent[anywhere]:bash | Allowed | StoredApproval | none | Not applicable | -| env-nested-shell-prompts | Personal | Project | Interactive | env bash -lc "git push" | none | RequiresApproval | approval required | env bash | No | -| timeout-nested-shell-prompts | Personal | Project | Interactive | timeout 5 bash -lc "git push" | none | RequiresApproval | approval required | timeout | No | +| nested-shell-prompts-for-inner-command | Personal | Project | Interactive | bash -lc "git push" | none | RequiresApproval | approval required | git push | No | +| nested-shell-inner-grant-allows | Personal | Project | Interactive | bash -lc "git push" | persistent[anywhere]:git push | Allowed | StoredApproval | none | Not applicable | +| nested-shell-wrapper-grant-does-not-cover-inner-command | Personal | Project | Interactive | bash -lc "git push" | persistent[anywhere]:bash | RequiresApproval | approval required | git push | No | +| env-nested-shell-prompts | Personal | Project | Interactive | env bash -lc "git push" | none | RequiresApproval | approval required | git push | No | +| timeout-nested-shell-prompts | Personal | Project | Interactive | timeout 5 bash -lc "git push" | none | RequiresApproval | approval required | timeout, git push | No | | subshell-prompts | Personal | Project | Interactive | (git status && git push) | none | RequiresApproval | approval required | git status, git push | No | -| command-substitution-currently-auto-allows | Personal | Project | Interactive | echo $(git push) | none | Allowed | ApprovalExemptShellCandidates | none | Not applicable | -| dynamic-path-currently-auto-allows | Personal | Project | Interactive | cat "$FILE" | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | -| dynamic-redirect-currently-prompts | Personal | Project | Interactive | git status > "$OUTPUT" | none | RequiresApproval | approval required | git status | No | -| background-list-currently-auto-allows | Personal | Project | Interactive | git status & git push | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | +| command-substitution-fails-closed | Personal | Project | Interactive | echo $(git push) | none | RequiresApproval | approval required | none | Yes | +| dynamic-path-fails-closed | Personal | Project | Interactive | cat "$FILE" | none | RequiresApproval | approval required | none | Yes | +| dynamic-redirect-fails-closed | Personal | Project | Interactive | git status > "$OUTPUT" | none | RequiresApproval | approval required | none | Yes | +| background-list-prompts-for-mutating-tail | Personal | Project | Interactive | git status & git push | none | RequiresApproval | approval required | none | Yes | | unbalanced-quote-fails-closed | Personal | Project | Interactive | git push "unterminated | none | RequiresApproval | approval required | none | Yes | | multiline-argument-prompts | Personal | Project | Interactive | gh issue comment 123 --body "first line\nsecond line" | none | RequiresApproval | approval required | gh issue comment | No | | approved-pipeline-head-does-not-cover-tail | Personal | Project | Interactive | git push \| curl https://example.com | persistent[anywhere]:git push | RequiresApproval | approval required | git push, curl | No | @@ -49,20 +49,20 @@ | cd-parent-then-safe-prompts | Personal | Project | Interactive | cd .. && git status | none | RequiresApproval | approval required | cd, git status | No | | multiple-cd-then-safe-prompts | Personal | Project | Interactive | cd . && cd .. && git status | none | RequiresApproval | approval required | cd, git status | No | | side-effect-before-mutation-prompts | Personal | Project | Interactive | echo ready && git push | none | RequiresApproval | approval required | echo, git push | No | -| heredoc-prompts | Personal | Project | Interactive | cat <<'EOF'\nhello\nEOF | none | RequiresApproval | approval required | none | No | +| heredoc-prompts | Personal | Project | Interactive | cat <<'EOF'\nhello\nEOF | none | RequiresApproval | approval required | none | Yes | | echo-allows-without-grant | Personal | Project | Interactive | echo hello | none | Allowed | ApprovalExemptShellCandidates | none | Not applicable | | printf-allows-without-grant | Personal | Project | Interactive | printf hello | none | Allowed | ApprovalExemptShellCandidates | none | Not applicable | | echo-redirect-prompts | Personal | Project | Interactive | echo hello > result.txt | none | RequiresApproval | approval required | echo | No | -| echo-done-fails-closed | Personal | Project | Interactive | echo done | none | RequiresApproval | approval required | echo | Yes | +| echo-control-word-argument-allows | Personal | Project | Interactive | echo done | none | Allowed | ApprovalExemptShellCandidates | none | Not applicable | | control-flow-fails-closed | Personal | Project | Interactive | for f in *.txt; do cat "$f"; done | persistent[anywhere]:cat | RequiresApproval | approval required | none | Yes | -| process-substitution-currently-prompts-without-complex-flag | Personal | Project | Interactive | cat <(git push) | persistent[anywhere]:cat, persistent[anywhere]:git push | RequiresApproval | approval required | none | No | -| arithmetic-expansion-currently-prompts-without-complex-flag | Personal | Project | Interactive | echo $((1 + 2)) | none | RequiresApproval | approval required | none | No | -| function-definition-currently-prompts-without-complex-flag | Personal | Project | Interactive | deploy() { git push; }; deploy | persistent[anywhere]:git push | RequiresApproval | approval required | none | No | +| process-substitution-fails-closed | Personal | Project | Interactive | cat <(git push) | persistent[anywhere]:cat, persistent[anywhere]:git push | RequiresApproval | approval required | none | Yes | +| arithmetic-expansion-fails-closed | Personal | Project | Interactive | echo $((1 + 2)) | none | RequiresApproval | approval required | none | Yes | +| function-definition-fails-closed | Personal | Project | Interactive | deploy() { git push; }; deploy | persistent[anywhere]:git push | RequiresApproval | approval required | none | Yes | | inline-python-prompts-for-interpreter | Personal | Project | Interactive | python3 -c "print('hello')" | none | RequiresApproval | approval required | python3 | No | | inline-python-interpreter-grant-currently-allows | Personal | Project | Interactive | python3 -c "print('hello')" | persistent[anywhere]:python3 | Allowed | StoredApproval | none | Not applicable | | eval-prompts-for-interpreter | Personal | Project | Interactive | eval "$CODE" | none | RequiresApproval | approval required | eval | No | | eval-grant-currently-allows-dynamic-payload | Personal | Project | Interactive | eval "$CODE" | persistent[anywhere]:eval | Allowed | StoredApproval | none | Not applicable | -| inline-python-heredoc-fails-closed | Personal | Project | Interactive | python3 <<'PY'\nprint('hello')\nPY | persistent[anywhere]:python3 | RequiresApproval | approval required | none | No | +| inline-python-heredoc-fails-closed | Personal | Project | Interactive | python3 <<'PY'\nprint('hello')\nPY | persistent[anywhere]:python3 | RequiresApproval | approval required | none | Yes | | empty-command-fails-closed | Personal | Project | Interactive | | none | RequiresApproval | approval required | none | No | | whitespace-command-fails-closed | Personal | Project | Interactive | | none | RequiresApproval | approval required | none | No | | session-grant-allows | Personal | Project | Interactive | git push | session[this-chat]:git push | Allowed | StoredApproval | none | Not applicable | diff --git a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs index e5cea786c..758c3a52a 100644 --- a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs +++ b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs @@ -340,17 +340,13 @@ public void FormatForDisplay_heredoc_falls_back_to_flattened_raw_command() } [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only — matcher routes through BashParser on POSIX")] - public void ExtractPatterns_redirect_target_with_line_break_terminates_pattern() + public void ExtractPatterns_dynamic_redirect_target_is_not_persistable() { - // A quoted redirect target carrying an embedded newline must not - // reach the stored pattern — quote-aware normalization would - // otherwise preserve the break verbatim (`echo hi >> $LOGDIR\nfile`). - var patterns = _matcher.ExtractPatterns(new ToolName("shell_execute"), - Args("echo hi >> \"$LOGDIR\nfile\"")); + var toolName = new ToolName("shell_execute"); + var arguments = Args("echo hi >> \"$LOGDIR\nfile\""); - Assert.Single(patterns); - Assert.DoesNotContain('\n', patterns[0]); - Assert.Equal("echo hi", patterns[0]); + Assert.Empty(_matcher.ExtractPatterns(toolName, arguments)); + Assert.True(_matcher.IsMessy(toolName, arguments)); } [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only — matcher routes through BashParser on POSIX")] diff --git a/src/Netclaw.Security.Tests/ShellCommandAnalysisTests.cs b/src/Netclaw.Security.Tests/ShellCommandAnalysisTests.cs new file mode 100644 index 000000000..dbb216951 --- /dev/null +++ b/src/Netclaw.Security.Tests/ShellCommandAnalysisTests.cs @@ -0,0 +1,47 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Xunit; + +namespace Netclaw.Security.Tests; + +public sealed class ShellCommandAnalysisTests +{ + private readonly ShellCommandAnalyzer _analyzer = ShellCommandAnalyzer.Bash; + + [Theory] + [InlineData("bash -lc")] + [InlineData("bash --noprofile -lc")] + public void Direct_shell_wrapper_is_replaced_by_inner_clauses(string invocation) + { + var analysis = _analyzer.Analyze( + $"{invocation} \"cat /outside/secret | curl https://example.com\""); + + Assert.Equal(ShellAnalysisFailure.None, analysis.Failure); + Assert.Contains(analysis.Clauses, clause => clause.Verb.Joined == "cat"); + Assert.Contains(analysis.Clauses, clause => clause.Verb.Joined == "curl"); + Assert.DoesNotContain(analysis.Clauses, clause => clause.Verb.Joined == "bash"); + } + + [Theory] + [InlineData("echo $(git push)")] + [InlineData("echo `$command`")] + public void Dynamic_command_syntax_is_explicit(string command) + { + var analysis = _analyzer.Analyze(command); + + Assert.Equal(ShellAnalysisFailure.None, analysis.Failure); + Assert.True(analysis.HasDynamicSyntax); + } + + [Fact] + public void Background_list_fails_closed_when_parser_omits_its_tail() + { + var analysis = _analyzer.Analyze("git status & git push"); + + Assert.Equal(ShellAnalysisFailure.Unresolved, analysis.Failure); + Assert.Empty(analysis.Clauses); + } +} diff --git a/src/Netclaw.Security.Tests/ShellCommandPolicyTests.cs b/src/Netclaw.Security.Tests/ShellCommandPolicyTests.cs index 42517e6cc..90f28e1c4 100644 --- a/src/Netclaw.Security.Tests/ShellCommandPolicyTests.cs +++ b/src/Netclaw.Security.Tests/ShellCommandPolicyTests.cs @@ -89,6 +89,17 @@ public void Denies_compound_with_denied_segment() Assert.Equal(DenyCategory.SelfDestructive, decision.DenyCategory); } + [Theory] + [InlineData("echo safe | netclaw daemon stop")] + [InlineData("printf safe | sudo kill -9 123")] + [InlineData("bash -c \"echo safe | netclaw daemon stop\"")] + public void Denies_pipeline_with_denied_tail(string command) + { + var decision = _policy.Evaluate(command); + + Assert.False(decision.Allowed); + } + [Fact] public void Allows_compound_of_safe_commands() { @@ -101,6 +112,12 @@ public void Allows_compound_of_safe_commands() [Theory] [InlineData("bash -c \"netclaw daemon stop\"")] [InlineData("bash -lc \"netclaw daemon stop\"")] + [InlineData("bash --noprofile -lc \"netclaw daemon stop\"")] + [InlineData("env bash -lc \"netclaw daemon stop\"")] + [InlineData("command bash -lc \"netclaw daemon stop\"")] + [InlineData("timeout 5 bash -lc \"netclaw daemon stop\"")] + [InlineData("nice -n 5 bash -lc \"netclaw daemon stop\"")] + [InlineData("bash -c \"echo safe\" && bash -lc \"netclaw daemon stop\"")] public void Denies_bash_wrapping_denied_command(string command) { var decision = _policy.Evaluate(command); diff --git a/src/Netclaw.Security.Tests/ShellSyntaxTreeIntegrationTests.cs b/src/Netclaw.Security.Tests/ShellSyntaxTreeIntegrationTests.cs index a6ec7e6ad..aa50faa77 100644 --- a/src/Netclaw.Security.Tests/ShellSyntaxTreeIntegrationTests.cs +++ b/src/Netclaw.Security.Tests/ShellSyntaxTreeIntegrationTests.cs @@ -181,6 +181,20 @@ public void Dynamic_token_marked_for_skip() Assert.Null(argWithDynamic.Resolved); } + [Fact] + public void Command_string_wrapper_marks_inner_clause() + { + var parser = new BashParser(); + + var result = parser.Parse("bash -c \"git status\""); + + Assert.False(result.IsUnparseable); + var clause = Assert.Single(result.Clauses); + Assert.True(clause.IsCommandStringWrapped); + Assert.Equal("git status", clause.Verb.Joined); + Assert.Null(clause.Verb.CanonicalVerb); + } + [Fact] public void Leading_line_comment_is_stripped_from_clause_extraction() { @@ -272,7 +286,7 @@ public void Control_flow_keyword_opening_newline_clause_is_unparseable() { // A control-flow keyword that opens a newline-separated clause makes // the whole parse unparseable (control flow is unsupported in v0.1). - // ExtractCandidatesViaBashParser maps that to an empty candidate + // The Bash analysis maps that to an empty candidate // list, so the approval gate fails closed to a Once/Deny prompt. var parser = new BashParser(); diff --git a/src/Netclaw.Security/IToolApprovalMatcher.cs b/src/Netclaw.Security/IToolApprovalMatcher.cs index 35afa0163..74632ca6d 100644 --- a/src/Netclaw.Security/IToolApprovalMatcher.cs +++ b/src/Netclaw.Security/IToolApprovalMatcher.cs @@ -110,10 +110,7 @@ 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(); + private static readonly ShellCommandAnalyzer Analyzer = ShellCommandAnalyzer.Bash; public string GetApprovalModeKey(ToolName toolName, IDictionary? arguments) => toolName.Value; @@ -137,7 +134,7 @@ public IReadOnlyList ExtractPatterns(ToolName toolName, IDictionary ExtractCandidates(ToolName toolName, IDi // when the clause itself has no anchored path arg. Windows keeps // the legacy ShellTokenizer path — ShellSyntaxTree is bash-only. if (!OperatingSystem.IsWindows()) - return ExtractCandidatesViaBashParser(command, GetWorkingDirectory(arguments)); + return ExtractCandidatesViaBashAnalysis(command, GetWorkingDirectory(arguments)); var seen = new HashSet<(string, string?)>(); var candidates = new List(); @@ -196,38 +193,25 @@ public IReadOnlyList ExtractCandidates(ToolName toolName, IDi } /// - /// Parses , returning null when the parser - /// rejects it (unparseable, no clauses) or throws — a parser exception - /// must never take down the approval flow. Callers treat null as - /// "cannot decompose" and apply their own fail-safe: an empty - /// pattern/candidate list (messy semantics — Once/Deny prompt only) or - /// the flattened raw command for display. + /// Returns null when the parser cannot resolve the complete command. + /// Callers then offer only one-time approval or show the raw command. /// - private static ShellSyntaxTree.ParsedCommand? TryParseCommand( + private static ShellCommandAnalysis? TryAnalyzeCommand( string command, string? workingDirectory = null) { - try - { - var parser = string.IsNullOrWhiteSpace(workingDirectory) - ? Parser - : new ShellSyntaxTree.BashParser( - new ShellSyntaxTree.BashParserOptions { WorkingDirectory = workingDirectory }); - var result = parser.Parse(command); - return result.IsUnparseable || result.Clauses.Count == 0 ? null : result; - } - catch - { - return null; - } + var result = Analyzer.Analyze(command, workingDirectory); + return result.Failure == ShellAnalysisFailure.None && result.Clauses.Count > 0 + ? result + : null; } - private static IReadOnlyList ExtractCandidatesViaBashParser( + private static IReadOnlyList ExtractCandidatesViaBashAnalysis( string command, string? workingDirectory) { - var result = TryParseCommand(command, workingDirectory); - if (result is null) + var result = TryAnalyzeCommand(command, workingDirectory); + if (result is null || result.HasDynamicSyntax) return []; // The prompt groups a pipe as one approval unit. Authorization still @@ -247,8 +231,12 @@ private static IReadOnlyList ExtractCandidatesViaBashParser( // `git tag 0.4.2` matches. Mirrors the value-termination in // ReconstructClauseText so the gate candidate and the persisted // pattern normalize identically. - var verb = ShellTokenizer.ApplyVerbShortCircuit( - string.Join(" ", TrimTrailingValueTokens(clause.Verb.Tokens))); + if (clause.Verb.IsDynamic) + continue; + + var parsedVerb = clause.Verb.CanonicalVerb + ?? string.Join(" ", TrimTrailingValueTokens(clause.Verb.Tokens)); + var verb = ShellTokenizer.ApplyVerbShortCircuit(parsedVerb); if (string.IsNullOrEmpty(verb)) continue; @@ -352,18 +340,14 @@ private static IReadOnlyList ExtractCandidatesViaBashParser( /// commands — mirroring the legacy /// empty-result contract so the prompt builder offers only Once/Deny. /// - private static IReadOnlyList ExtractApprovalUnitsViaBashParser( + private static IReadOnlyList ExtractApprovalUnitsViaBashAnalysis( string command, string? workingDirectory) { - // 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 []; - - var result = TryParseCommand(command, workingDirectory); - if (result is null) + // The parser is the sole structural authority. Dynamic or unresolved + // syntax cannot produce a persistent approval unit. + var result = TryAnalyzeCommand(command, workingDirectory); + if (result is null || result.HasDynamicSyntax) return []; try @@ -560,17 +544,8 @@ public bool IsApproved( if (string.IsNullOrWhiteSpace(command)) return false; - // Messy commands cannot be auto-approved: the matcher cannot extract a - // candidate verb-chain to evaluate against the persisted store, so - // every messy invocation must round-trip through the user. The prompt - // builder offers only Once/Deny in this case (see IsMessy). - if (ShellTokenizer.IsMessyCompoundCommand(command)) - return false; - - // Fail-closed on a parser miss: BashParser swallows exceptions and - // unparseable results return an empty candidate list. Treating that - // as "approved" would silently auto-allow any command our parser - // regresses on. Force the gate instead. + // Empty candidates include parser failures and dynamic syntax. + // Both cases must return to the approval gate. var candidates = ExtractCandidates(toolName, arguments); if (candidates.Count == 0) return false; @@ -593,7 +568,17 @@ public bool IsApproved( } public bool IsMessy(ToolName toolName, IDictionary? arguments) - => ShellTokenizer.IsMessyCompoundCommand(GetCommand(arguments)); + { + var command = GetCommand(arguments); + if (string.IsNullOrWhiteSpace(command)) + return false; + + if (OperatingSystem.IsWindows()) + return ShellTokenizer.IsMessyCompoundCommand(command); + + var analysis = TryAnalyzeCommand(command, GetWorkingDirectory(arguments)); + return analysis is null || analysis.HasDynamicSyntax; + } public string FormatForDisplay(ToolName toolName, IDictionary? arguments) { @@ -636,7 +621,7 @@ public string FormatForDisplay(ToolName toolName, IDictionary? /// private static string BuildSanitizedDisplayViaParser(string command) { - var result = TryParseCommand(command); + var result = TryAnalyzeCommand(command); if (result is null) return command; diff --git a/src/Netclaw.Security/Netclaw.Security.csproj b/src/Netclaw.Security/Netclaw.Security.csproj index a598f1f6d..38dbb0a69 100644 --- a/src/Netclaw.Security/Netclaw.Security.csproj +++ b/src/Netclaw.Security/Netclaw.Security.csproj @@ -6,6 +6,10 @@ enable + + + + diff --git a/src/Netclaw.Security/ShellApprovalSemantics.cs b/src/Netclaw.Security/ShellApprovalSemantics.cs index 28cd1e1c8..f548d348e 100644 --- a/src/Netclaw.Security/ShellApprovalSemantics.cs +++ b/src/Netclaw.Security/ShellApprovalSemantics.cs @@ -350,8 +350,14 @@ public override IReadOnlyList ExtractInnerCommands(string command) if (!IsPosixShellInvoker(verb)) continue; - if (i + 1 < tokens.Count && IsShellCommandFlag(tokens[i + 1]) && i + 2 < tokens.Count) - results.Add(tokens[i + 2]); + for (var j = i + 1; j < tokens.Count - 1; j++) + { + if (!IsShellCommandFlag(tokens[j])) + continue; + + results.Add(tokens[j + 1]); + break; + } } return results; diff --git a/src/Netclaw.Security/ShellCommandAnalysis.cs b/src/Netclaw.Security/ShellCommandAnalysis.cs new file mode 100644 index 000000000..bb6c6c4f7 --- /dev/null +++ b/src/Netclaw.Security/ShellCommandAnalysis.cs @@ -0,0 +1,167 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using ShellSyntaxTree; + +namespace Netclaw.Security; + +/// +/// Parses Bash commands and expands nested shell command strings. +/// Approval and hard-deny policies share this analysis. +/// +internal sealed class ShellCommandAnalyzer +{ + private const int MaxWrapperDepth = 8; + + public static readonly ShellCommandAnalyzer Bash = new(); + + private ShellCommandAnalyzer() + { + } + + public ShellCommandAnalysis Analyze(string command, string? workingDirectory = null) + { + var clauses = new List(); + var failure = Analyze(command, workingDirectory, depth: 0, clauses); + return new ShellCommandAnalysis(clauses, failure); + } + + private static ShellAnalysisFailure Analyze( + string command, + string? workingDirectory, + int depth, + List clauses) + { + if (depth > MaxWrapperDepth) + return ShellAnalysisFailure.Unresolved; + + // ShellSyntaxTree 0.2 does not expose a background-list tail. + // Treat that parser boundary as unresolved so the tail cannot inherit + // the first command's safe-verb or stored-approval result. + if (ContainsBackgroundListOperator(command)) + return ShellAnalysisFailure.Unresolved; + + ParsedCommand parsed; + try + { + var parser = string.IsNullOrWhiteSpace(workingDirectory) + ? new BashParser() + : new BashParser(new BashParserOptions { WorkingDirectory = workingDirectory }); + parsed = parser.Parse(command); + } + catch + { + return ShellAnalysisFailure.Unresolved; + } + + if (parsed.IsUnparseable || parsed.Clauses.Count == 0) + return ShellAnalysisFailure.Unresolved; + + var innerCommands = PosixShellApprovalSemantics.Instance.ExtractInnerCommands(command); + var hasUnexpandedWrapper = parsed.Clauses.Any(IsUnexpandedWrapperClause); + if (innerCommands.Count == 0 || !hasUnexpandedWrapper) + { + clauses.AddRange(parsed.Clauses); + return ShellAnalysisFailure.None; + } + + // A direct shell wrapper adds no independent authority scope. + // Prefix wrappers such as env and timeout remain visible. + clauses.AddRange(parsed.Clauses.Where(clause => + !IsUnexpandedWrapperClause(clause) || HasShellInvokerInArguments(clause))); + + foreach (var innerCommand in innerCommands) + { + var failure = Analyze(innerCommand, workingDirectory, depth + 1, clauses); + if (failure != ShellAnalysisFailure.None) + return failure; + } + + return ShellAnalysisFailure.None; + } + + private static bool IsUnexpandedWrapperClause(Clause clause) + { + if (clause.Verb.Tokens.Count == 0 || clause.Args.Count == 0) + return false; + + if (!clause.Verb.Tokens.Any(IsShellInvokerToken) && !HasShellInvokerInArguments(clause)) + return false; + + return clause.Args.Any(static arg => + arg.Raw.Length > 1 + && arg.Raw[0] == '-' + && !arg.Raw.StartsWith("--", StringComparison.Ordinal) + && arg.Raw.AsSpan(1).IndexOf('c') >= 0); + } + + private static bool HasShellInvokerInArguments(Clause clause) + => clause.Args.Any(static arg => + arg.Kind != ArgKind.DynamicSkip && IsShellInvokerToken(arg.Raw)); + + private static bool IsShellInvokerToken(string token) + => PosixShellApprovalSemantics.IsPosixShellInvoker( + ShellTokenizer.TrimShellPunctuation(token)); + + private static bool ContainsBackgroundListOperator(string command) + { + char? quote = null; + var escaped = false; + + for (var i = 0; i < command.Length; i++) + { + var ch = command[i]; + if (escaped) + { + escaped = false; + continue; + } + + if (ch == '\\' && quote != '\'') + { + escaped = true; + continue; + } + + if (ch is '\'' or '"') + { + if (quote is null) + quote = ch; + else if (quote == ch) + quote = null; + + continue; + } + + if (quote is not null || ch != '&') + continue; + + var previous = i > 0 ? command[i - 1] : '\0'; + var next = i + 1 < command.Length ? command[i + 1] : '\0'; + if (previous is '&' or '>' || next is '&' or '>') + continue; + + return true; + } + + return false; + } +} + +internal enum ShellAnalysisFailure +{ + None, + Unresolved +} + +internal sealed record ShellCommandAnalysis( + IReadOnlyList Clauses, + ShellAnalysisFailure Failure) +{ + public bool HasDynamicSyntax => Clauses.Any(static clause => + clause.Verb.IsDynamic + || clause.Args.Any(static arg => arg.Kind == ArgKind.DynamicSkip) + || clause.Redirects.Any(static redirect => redirect.IsDynamicSkip)); +} diff --git a/src/Netclaw.Security/ShellCommandPolicy.cs b/src/Netclaw.Security/ShellCommandPolicy.cs index c5fb97018..a926d2f3a 100644 --- a/src/Netclaw.Security/ShellCommandPolicy.cs +++ b/src/Netclaw.Security/ShellCommandPolicy.cs @@ -23,6 +23,7 @@ public sealed record ShellCommandDecision(bool Allowed, string? DenyReason = nul /// public sealed class ShellCommandPolicy { + private static readonly ShellCommandAnalyzer Analyzer = ShellCommandAnalyzer.Bash; private readonly IReadOnlyList _denyPatterns; private readonly IReadOnlyList _rawStringPatterns; @@ -122,8 +123,28 @@ public ShellCommandDecision Evaluate(string command) if (!rawDecision.Allowed) return rawDecision; - var segments = ShellTokenizer.GetAllCommandSegments(command); - foreach (var segment in segments) + if (OperatingSystem.IsWindows()) + return EvaluateLegacySegments(command); + + var analysis = Analyzer.Analyze(command); + if (analysis.Failure == ShellAnalysisFailure.Unresolved || analysis.Clauses.Count == 0) + return EvaluateLegacySegments(command); + + foreach (var clause in analysis.Clauses) + { + var decision = EvaluateClause(clause); + if (!decision.Allowed) + return decision; + } + + return ShellCommandDecision.Allow(); + } + + private ShellCommandDecision EvaluateLegacySegments(string command) + { + // The approval matcher does not persist unresolved syntax. Keep the + // legacy scan here so known deny forms still fail at this boundary. + foreach (var segment in ShellTokenizer.GetAllCommandSegments(command)) { var decision = EvaluateSegment(segment); if (!decision.Allowed) @@ -159,6 +180,30 @@ private ShellCommandDecision EvaluateSegment(string segment) return ShellCommandDecision.Allow(); } + private ShellCommandDecision EvaluateClause(ShellSyntaxTree.Clause clause) + { + var tokens = new List( + clause.Verb.Tokens.Count + clause.Args.Count + clause.Redirects.Count); + tokens.AddRange(clause.Verb.Tokens); + tokens.AddRange(clause.Args + .Where(static arg => !arg.IsCwdAttribution) + .Select(static arg => arg.Raw)); + tokens.AddRange(clause.Redirects + .Where(static redirect => !string.IsNullOrEmpty(redirect.Target)) + .Select(static redirect => redirect.Target)); + + if (tokens.Count == 0) + return ShellCommandDecision.Allow(); + + foreach (var pattern in _denyPatterns) + { + if (pattern.Matches(tokens)) + return ShellCommandDecision.Deny(pattern.Reason, pattern.Category); + } + + return ShellCommandDecision.Allow(); + } + private static DenyPattern? ParseDenyPattern(string raw) { var tokens = ShellTokenizer.Tokenize(raw).ToList(); From 31d58b5ac08694130d3dd13219d26f734bee0585 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 4 Aug 2026 04:44:16 +0000 Subject: [PATCH 2/2] Test Bash policy on every host --- .../ShellCommandPolicyTests.cs | 4 ++-- src/Netclaw.Security/ShellCommandPolicy.cs | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/Netclaw.Security.Tests/ShellCommandPolicyTests.cs b/src/Netclaw.Security.Tests/ShellCommandPolicyTests.cs index 90f28e1c4..86e4a2710 100644 --- a/src/Netclaw.Security.Tests/ShellCommandPolicyTests.cs +++ b/src/Netclaw.Security.Tests/ShellCommandPolicyTests.cs @@ -95,7 +95,7 @@ public void Denies_compound_with_denied_segment() [InlineData("bash -c \"echo safe | netclaw daemon stop\"")] public void Denies_pipeline_with_denied_tail(string command) { - var decision = _policy.Evaluate(command); + var decision = _policy.EvaluateBash(command); Assert.False(decision.Allowed); } @@ -120,7 +120,7 @@ public void Allows_compound_of_safe_commands() [InlineData("bash -c \"echo safe\" && bash -lc \"netclaw daemon stop\"")] public void Denies_bash_wrapping_denied_command(string command) { - var decision = _policy.Evaluate(command); + var decision = _policy.EvaluateBash(command); Assert.False(decision.Allowed); Assert.Equal(DenyCategory.SelfDestructive, decision.DenyCategory); } diff --git a/src/Netclaw.Security/ShellCommandPolicy.cs b/src/Netclaw.Security/ShellCommandPolicy.cs index a926d2f3a..0d3809e81 100644 --- a/src/Netclaw.Security/ShellCommandPolicy.cs +++ b/src/Netclaw.Security/ShellCommandPolicy.cs @@ -126,6 +126,24 @@ public ShellCommandDecision Evaluate(string command) if (OperatingSystem.IsWindows()) return EvaluateLegacySegments(command); + return EvaluateBashAnalysis(command); + } + + /// + /// Evaluates a Bash command with the same structural path that production + /// uses on Linux and macOS. + /// + internal ShellCommandDecision EvaluateBash(string command) + { + if (string.IsNullOrWhiteSpace(command)) + return ShellCommandDecision.Allow(); + + var rawDecision = EvaluateRawString(command); + return rawDecision.Allowed ? EvaluateBashAnalysis(command) : rawDecision; + } + + private ShellCommandDecision EvaluateBashAnalysis(string command) + { var analysis = Analyzer.Analyze(command); if (analysis.Failure == ShellAnalysisFailure.Unresolved || analysis.Clauses.Count == 0) return EvaluateLegacySegments(command);