diff --git a/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs b/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs index 7d94328f8..52eb34896 100644 --- a/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs @@ -417,45 +417,20 @@ public async Task Shell_execute_is_allowed_in_personal_context() Assert.Contains("allowed", result); } - [Fact] - public async Task Approval_exempt_shell_candidates_report_allow_reason() + [Theory] + [InlineData("echo observable")] + [InlineData("printf observable")] + [InlineData(":")] + [InlineData("true")] + [InlineData("false")] + public async Task Approval_exempt_shell_candidates_report_allow_reason(string command) { - var config = new ToolConfig { ShellMode = ShellExecutionMode.HostAllowed }; - config.AudienceProfiles.Personal.ApprovalPolicy = new ToolApprovalConfig - { - ToolOverrides = new Dictionary(StringComparer.Ordinal) - { - ["shell_execute"] = ToolApprovalMode.Approval - } - }; - var registry = new ToolRegistry(); - registry.WithFirstPartyTools( - config, - new NetclawPaths(), - new ToolPathPolicy([]), - new ShellCommandPolicy()); - var executor = new DispatchingToolExecutor( - registry, - new ToolAccessPolicy( - config, - new EffectivePolicyDefaults( - DeploymentPosture.Personal, - TrustAudience.Personal, - ShellExecutionMode.HostAllowed, - UsedStrictFallback: false)), - new UnexpectedApprovalService()); + var executor = CreateApprovalGatedShellExecutor(); var call = new FunctionCallContent( "call-approval-exempt", "shell_execute", - ToolInput.Create("Command", "echo observable")); - var context = TestToolExecutionContext.CreateBound( - "signalr/thread-approval-exempt", - null, - new TestToolExecutionContextOptions - { - Audience = TrustAudience.Personal, - InteractiveApproval = TestToolExecutionContext.InteractiveApproval(true) - }); + ToolInput.Create("Command", command)); + var context = CreateInteractivePersonalContext("signalr/thread-approval-exempt"); var decision = await executor.EvaluateAuthorizationAsync( call, @@ -467,6 +442,58 @@ public async Task Approval_exempt_shell_candidates_report_allow_reason() Assert.Empty(decision.ApprovalMatches); } + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task Shell_approval_without_extracted_candidates_fails_closed(string command) + { + var executor = CreateApprovalGatedShellExecutor(); + var call = new FunctionCallContent( + "call-no-approval-candidates", + "shell_execute", + ToolInput.Create("Command", command)); + var context = CreateInteractivePersonalContext("signalr/thread-no-approval-candidates"); + + var decision = await executor.EvaluateAuthorizationAsync( + call, + context, + TestContext.Current.CancellationToken); + + Assert.Equal(ToolAuthorizationOutcome.RequiresApproval, decision.Outcome); + Assert.NotNull(decision.ApprovalContext); + Assert.Empty(decision.ApprovalContext.Candidates!); + } + + [Fact] + public async Task Shell_parser_rejection_fails_closed_without_execution() + { + if (OperatingSystem.IsWindows()) + return; + + var markerPath = Path.Combine(Path.GetTempPath(), $"netclaw-approval-{Guid.NewGuid():N}"); + var command = $"touch {markerPath} <(true)"; + var arguments = ToolInput.Create("Command", command); + Assert.False(ShellTokenizer.IsMessyCompoundCommand(command)); + Assert.Empty(ShellApprovalMatcher.Instance.ExtractCandidates(new ToolName("shell_execute"), arguments)); + + var executor = CreateApprovalGatedShellExecutor(); + var call = new FunctionCallContent( + "call-parser-rejection", + "shell_execute", + arguments); + var context = CreateInteractivePersonalContext("signalr/thread-parser-rejection"); + + var decision = await executor.EvaluateAuthorizationAsync( + call, + context, + TestContext.Current.CancellationToken); + + Assert.Equal(ToolAuthorizationOutcome.RequiresApproval, decision.Outcome); + Assert.NotNull(decision.ApprovalContext); + Assert.Empty(decision.ApprovalContext.Candidates!); + Assert.False(File.Exists(markerPath)); + } + [Fact] public async Task Authorization_evaluation_preserves_partial_approval_matches() { @@ -1324,6 +1351,44 @@ await approvalService.RecordApprovalAsync( } } + private static DispatchingToolExecutor CreateApprovalGatedShellExecutor() + { + var config = new ToolConfig { ShellMode = ShellExecutionMode.HostAllowed }; + config.AudienceProfiles.Personal.ApprovalPolicy = new ToolApprovalConfig + { + ToolOverrides = new Dictionary(StringComparer.Ordinal) + { + ["shell_execute"] = ToolApprovalMode.Approval + } + }; + var registry = new ToolRegistry(); + registry.WithFirstPartyTools( + config, + new NetclawPaths(), + new ToolPathPolicy([]), + new ShellCommandPolicy()); + return new DispatchingToolExecutor( + registry, + new ToolAccessPolicy( + config, + new EffectivePolicyDefaults( + DeploymentPosture.Personal, + TrustAudience.Personal, + ShellExecutionMode.HostAllowed, + UsedStrictFallback: false)), + new UnexpectedApprovalService()); + } + + private static ToolExecutionContext CreateInteractivePersonalContext(string sessionId) + => TestToolExecutionContext.CreateBound( + sessionId, + null, + new TestToolExecutionContextOptions + { + Audience = TrustAudience.Personal, + InteractiveApproval = TestToolExecutionContext.InteractiveApproval(true) + }); + private sealed class UnexpectedApprovalService : IToolApprovalService { public Task CheckApprovalAsync( diff --git a/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs b/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs index 17af0f5ea..a933fb951 100644 --- a/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs +++ b/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs @@ -337,11 +337,18 @@ internal async Task EvaluateAuthorizationAsync( .Select(verb => new ApprovalCandidate(verb, Directory: null)) .ToList(); - if (candidatesForCheck.Count == 0) + if (approvalContext.Candidates is { Count: > 0 } + && candidatesForCheck.Count == 0) { // Every candidate is side-effect-only — auto-allow. accessDecision = ToolAccessDecision.Allow(ToolAllowReason.ApprovalExemptShellCandidates); } + else if (candidatesForCheck.Count == 0) + { + // A zero-candidate result does not prove that the command is exempt. + // Malformed input or a parser rejection can also produce this result. + accessDecision = ToolAccessDecision.RequiresApproval(approvalContext); + } else { // Use tool.Name (canonical) — not toolCall.Name — so the