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
135 changes: 100 additions & 35 deletions src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ToolApprovalMode>(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,
Expand All @@ -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()
{
Expand Down Expand Up @@ -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<string, ToolApprovalMode>(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<ToolApprovalCheckResult> CheckApprovalAsync(
Expand Down
9 changes: 8 additions & 1 deletion src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -337,11 +337,18 @@ internal async Task<ToolAuthorizationDecision> 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
Expand Down
Loading