Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions IMPLEMENTATION_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@ Done when:
explicit Bash redirect facts for the existing grammar.
- [x] Unknown occurrences, cwd facts, wrappers, and redirects stay prompt-only.
Static descriptor redirects no longer appear dynamic.
- [x] The resolved POSIX `/dev/null` device does not create an approval
directory after host symlink checks. Other device paths and dynamic redirect
targets stay strict.
- [x] Netclaw consumes ShellSyntaxTree `0.3.0-alpha.1` and promotes Bash
command-resolution mutation and reserved execution forms into the strict
181-case review matrix.
Expand Down
5 changes: 5 additions & 0 deletions src/Netclaw.Actors.Tests/Tools/ShellApprovalCaseCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,11 @@ public static class ShellApprovalCases
Bash($"git status > {TemporaryFile("netclaw-approval-matrix.txt")}"),
Approvals.None,
ExpectedApproval.Require(["git status"])),
Case(
"safe-verb-null-device-redirect-allows",
Bash("ls -la 2>/dev/null"),
Approvals.None,
ExpectedApproval.Allow(ToolAllowReason.SafeVerbInTrustedScope)),
Case(
"mutating-verb-project-prompts",
Bash("git push"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
| safe-verb-traversal-external-path-prompts | Bash | Personal | Project | Interactive | cat safe/../../../../../../etc/netclaw.secret | none | RequiresApproval | approval required | cat | No |
| safe-verb-bash-provider-looking-relative-path-allows | Bash | Personal | Project | Interactive | cat filesystem::/etc/netclaw.secret | none | Allowed | SafeVerbInTrustedScope | none | Not applicable |
| safe-verb-external-redirect-prompts | Bash | Personal | Project | Interactive | git status > /netclaw-approval-external/netclaw-approval-matrix.txt | none | RequiresApproval | approval required | git status | No |
| safe-verb-null-device-redirect-allows | Bash | Personal | Project | Interactive | ls -la 2>/dev/null | none | Allowed | SafeVerbInTrustedScope | none | Not applicable |
| mutating-verb-project-prompts | Bash | Personal | Project | Interactive | git push | none | RequiresApproval | approval required | git push | No |
| all-safe-compound-allows | Bash | Personal | Project | Interactive | git status && git log | none | Allowed | SafeVerbInTrustedScope | none | Not applicable |
| four-safe-mixed-operator-clauses-allow | Bash | Personal | Project | Interactive | git status && git log \| head -20; pwd | none | Allowed | SafeVerbInTrustedScope | none | Not applicable |
Expand Down
76 changes: 76 additions & 0 deletions src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,22 @@ public void Power_shell_redirect_uses_the_environment_path_style()
Assert.Equal("C:/work", candidate.Directory);
}

[Fact]
public void Power_shell_redirect_does_not_use_the_posix_null_device_exception()
{
var matcher = new ShellApprovalMatcher(
ShellExecutionEnvironment.CreatePowerShell(
@"C:\Program Files\PowerShell\7\pwsh.exe",
PwshDialect.PowerShell7));

var analysis = matcher.AnalyzeInvocation(
new ToolName("shell_execute"),
Args("Get-Content .\\input.txt > /dev/null", @"C:\work"));

Assert.True(analysis.IsMessy);
Assert.Empty(analysis.Candidates);
}

[Fact]
public void Dialect_change_reparses_before_candidates_can_match_a_grant()
{
Expand Down Expand Up @@ -1547,6 +1563,66 @@ public void ExtractCandidates_uses_redirect_target_and_invocation_working_direct
candidate.Verb == "echo" && candidate.Directory == workingDirectory);
}

[SlopwatchSuppress("SW001", "This theory verifies POSIX null device behavior, which does not apply to the Windows shell parser.")]
[Theory(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")]
[InlineData("ls -la 2>/dev/null", "ls")]
[InlineData("ls -la 2>/dev/./null", "ls")]
[InlineData("cat </dev/null", "cat")]
public void ExtractCandidates_ignores_resolved_posix_null_device_redirect(
string command,
string expectedVerb)
{
var candidates = _matcher.ExtractCandidates(
new ToolName("shell_execute"),
Args(command));

var candidate = Assert.Single(candidates);
Assert.Equal(expectedVerb, candidate.Verb);
Assert.Null(candidate.Directory);
}

[Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")]
public void ExtractCandidates_uses_invocation_directory_after_null_device_redirect()
{
const string workingDirectory = "/home/user/repos/demo";
var candidates = _matcher.ExtractCandidates(
new ToolName("shell_execute"),
Args("tmux ls 2>/dev/null", workingDirectory));

var candidate = Assert.Single(candidates);
Assert.Equal("tmux ls", candidate.Verb);
Assert.Equal(workingDirectory, candidate.Directory);
}

[Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")]
public void ExtractCandidates_keeps_other_redirect_scope_after_null_device_redirect()
{
var candidates = _matcher.ExtractCandidates(
new ToolName("shell_execute"),
Args(
"tmux ls 2>/dev/null >/netclaw-approval-external/netclaw-output.txt",
"/work"));

var candidate = Assert.Single(candidates);
Assert.Equal("tmux ls", candidate.Verb);
Assert.Equal("/netclaw-approval-external", candidate.Directory);
}

[SlopwatchSuppress("SW001", "This theory verifies POSIX null device lookalikes, which do not apply to the Windows shell parser.")]
[Theory(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")]
[InlineData("ls -la 2>/dev/nul")]
[InlineData("ls -la 2>/dev/null.backup")]
public void ExtractCandidates_does_not_generalize_posix_null_device_exception(string command)
{
var candidates = _matcher.ExtractCandidates(
new ToolName("shell_execute"),
Args(command));

var candidate = Assert.Single(candidates);
Assert.Equal("ls", candidate.Verb);
Assert.Equal("/dev", candidate.Directory);
}

[SlopwatchSuppress("SW001", "This test verifies POSIX symlink redirect behavior, which does not apply to the Windows shell parser.")]
[Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")]
public void ExtractCandidates_rejects_redirect_through_symlink_directory()
Expand Down
16 changes: 13 additions & 3 deletions src/Netclaw.Security/IToolApprovalMatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ public sealed class ShellApprovalMatcher : IToolApprovalMatcher
{
public static readonly ShellApprovalMatcher Instance = new();

private const string PosixNullDevicePath = "/dev/null";

private readonly ShellCommandAnalyzer _analyzer;

public ShellApprovalMatcher()
Expand Down Expand Up @@ -572,11 +574,19 @@ or UnauthorizedAccessException
if (string.IsNullOrWhiteSpace(target))
return null;

var directory = GetRedirectDirectory(target, pathStyle);
if (directory is null)
if (UsesHostPathStyle(pathStyle) && HasUnsafeHostPath(target))
return null;

if (UsesHostPathStyle(pathStyle) && HasUnsafeHostPath(target))
// The resolved POSIX null device creates no reusable filesystem
// authority. Other device paths stay strict.
if (pathStyle == ShellPathStyle.Posix
&& string.Equals(target, PosixNullDevicePath, StringComparison.Ordinal))
{
continue;
}

var directory = GetRedirectDirectory(target, pathStyle);
if (directory is null)
return null;

directories.Add(directory);
Expand Down
Loading