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
281 changes: 280 additions & 1 deletion src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Akka.Actor;
using Akka.Hosting;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Netclaw.Actors.Hosting;
using Netclaw.Actors.Tools;
using Netclaw.Configuration;
Expand Down Expand Up @@ -387,6 +388,13 @@ public async Task Shell_execute_is_denied_when_shell_mode_is_off_even_in_persona
ChannelType = "signalr"
});

var decision = await executor.EvaluateAuthorizationAsync(
toolCall,
context,
TestContext.Current.CancellationToken);

Assert.Equal(ToolAuthorizationOutcome.Denied, decision.Outcome);
Assert.Equal("shell_disabled", decision.DenyReason);
var ex = await Assert.ThrowsAsync<ToolAccessDeniedException>(() => executor.ExecuteAsync(toolCall, context, TestContext.Current.CancellationToken));
Assert.Equal("shell_disabled", ex.DenyReason);
}
Expand All @@ -409,6 +417,158 @@ 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()
{
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 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)
});

var decision = await executor.EvaluateAuthorizationAsync(
call,
context,
TestContext.Current.CancellationToken);

Assert.Equal(ToolAuthorizationOutcome.Allowed, decision.Outcome);
Assert.Equal(ToolAllowReason.ApprovalExemptShellCandidates, decision.AllowReason);
Assert.Empty(decision.ApprovalMatches);
}

[Fact]
public async Task Authorization_evaluation_preserves_partial_approval_matches()
{
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 approvedMatch = new ToolApprovalMatch("git status", "session", "this chat");
var approvalService = new FixedApprovalService(
new ToolApprovalCheckResult(["git push"], [approvedMatch]));
var executor = new DispatchingToolExecutor(
registry,
new ToolAccessPolicy(
config,
new EffectivePolicyDefaults(
DeploymentPosture.Personal,
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
UsedStrictFallback: false)),
approvalService);
var call = new FunctionCallContent(
"call-partial-approval",
"shell_execute",
ToolInput.Create("Command", "git status && git push"));
var context = TestToolExecutionContext.CreateBound(
"signalr/thread-partial-approval",
null,
new TestToolExecutionContextOptions
{
Audience = TrustAudience.Personal,
InteractiveApproval = TestToolExecutionContext.InteractiveApproval(true)
});

var decision = await executor.EvaluateAuthorizationAsync(
call,
context,
TestContext.Current.CancellationToken);

Assert.Equal(ToolAuthorizationOutcome.RequiresApproval, decision.Outcome);
Assert.NotNull(decision.ApprovalContext);
Assert.Equal([approvedMatch], decision.ApprovalMatches);
}

[Fact]
public async Task Authorization_evaluation_logs_allow_reason_before_execution()
{
var config = new ToolConfig();
var registry = new ToolRegistry();
var executionCount = 0;
registry.Register(
AIFunctionFactory.Create(() =>
{
executionCount++;
return "ok";
}, "telemetry_probe"),
"test");
var logger = new RecordingLogger<DispatchingToolExecutor>();
var executor = new DispatchingToolExecutor(
registry,
new ToolAccessPolicy(
config,
new EffectivePolicyDefaults(
DeploymentPosture.Personal,
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
UsedStrictFallback: false)),
logger: logger);
var call = new FunctionCallContent(
"call-authorization-telemetry",
"telemetry_probe",
ToolInput.Empty());
var context = TestToolExecutionContext.CreateBound(
"signalr/thread-authorization-telemetry",
null,
new TestToolExecutionContextOptions { Audience = TrustAudience.Personal });

var decision = await executor.EvaluateAuthorizationAsync(
call,
context,
TestContext.Current.CancellationToken);

Assert.Equal(0, executionCount);
Assert.Equal(ToolAuthorizationOutcome.Allowed, decision.Outcome);
Assert.Equal(ToolAllowReason.PolicyAuto, decision.AllowReason);
var log = Assert.Single(logger.Entries);
Assert.Equal(nameof(ToolAuthorizationOutcome.Allowed), log["AuthorizationOutcome"]);
Assert.Equal(nameof(ToolAllowReason.PolicyAuto), log["AuthorizationReason"]);
Assert.Equal(
ToolAllowReason.PolicyAuto.GetDescription(),
log["AuthorizationExplanation"]);
}

[Fact]
public async Task File_read_is_denied_outside_session_directory_in_public_context()
{
Expand Down Expand Up @@ -720,14 +880,27 @@ public async Task One_time_approval_bypasses_policy_for_matching_shell_patterns(
InteractiveApproval = TestToolExecutionContext.InteractiveApproval(true)
});

var initialDecision = await executor.EvaluateAuthorizationAsync(
toolCall,
context,
TestContext.Current.CancellationToken);
Assert.Equal(ToolAuthorizationOutcome.RequiresApproval, initialDecision.Outcome);
Assert.NotNull(initialDecision.ApprovalContext);

var firstAttempt = await Assert.ThrowsAsync<ToolApprovalRequiredException>(() =>
executor.ExecuteAsync(toolCall, context, TestContext.Current.CancellationToken));

context.OneTimeApprovedToolName = toolCall.Name;
context.SetOneTimeApprovedPatterns(firstAttempt.ApprovalContext.Patterns);

var decision = await executor.EvaluateAuthorizationAsync(
toolCall,
context,
TestContext.Current.CancellationToken);
var retryResult = await executor.ExecuteAsync(toolCall, context, TestContext.Current.CancellationToken);

Assert.Equal(ToolAuthorizationOutcome.Allowed, decision.Outcome);
Assert.Equal(ToolAllowReason.OneTimeApproval, decision.AllowReason);
Assert.Contains("bypass", retryResult, StringComparison.OrdinalIgnoreCase);
}

Expand Down Expand Up @@ -934,8 +1107,17 @@ public async Task Persistent_approval_hit_records_audit_context_without_promptin
"shell_execute",
ToolInput.Create("Command", "git status"));

await executor.AuthorizeAsync(call, context, TestContext.Current.CancellationToken);
var decision = await executor.EvaluateAuthorizationAsync(
call,
context,
TestContext.Current.CancellationToken);

Assert.Equal(ToolAuthorizationOutcome.Allowed, decision.Outcome);
Assert.Equal(ToolAllowReason.StoredApproval, decision.AllowReason);
var match = Assert.Single(decision.ApprovalMatches);
Assert.Equal("git status", match.Pattern);
Assert.Equal("persistent", match.Source);
Assert.Equal("git status anywhere", match.Scope);
Assert.Equal("PreviouslyApproved", context.AppliedApprovalDecision);
Assert.Equal("git status [persistent: git status anywhere]", context.AppliedApprovalPattern);
}
Expand Down Expand Up @@ -1142,6 +1324,103 @@ await approvalService.RecordApprovalAsync(
}
}

private sealed class UnexpectedApprovalService : IToolApprovalService
{
public Task<ToolApprovalCheckResult> CheckApprovalAsync(
ToolApprovalSessionId? sessionId,
TrustAudience audience,
ToolName toolName,
IReadOnlyList<ApprovalCandidate> candidates,
string? cwd,
CancellationToken ct = default)
=> throw new InvalidOperationException("The approval-exempt path must not query stored approvals.");

public Task<IReadOnlyList<string>> GetUnapprovedPatternsAsync(
ToolApprovalSessionId? sessionId,
TrustAudience audience,
ToolName toolName,
IReadOnlyList<string> patterns,
string? cwd,
CancellationToken ct = default)
=> throw new InvalidOperationException("The approval-exempt path must not query stored approvals.");

public Task RecordApprovalAsync(
ToolApprovalSessionId sessionId,
TrustAudience audience,
ToolName toolName,
IReadOnlyList<string> patterns,
bool persistent,
string? cwd,
CancellationToken ct = default)
=> throw new InvalidOperationException("The approval-exempt path must not record an approval.");
}

private sealed class FixedApprovalService(ToolApprovalCheckResult result) : IToolApprovalService
{
public Task<ToolApprovalCheckResult> CheckApprovalAsync(
ToolApprovalSessionId? sessionId,
TrustAudience audience,
ToolName toolName,
IReadOnlyList<ApprovalCandidate> candidates,
string? cwd,
CancellationToken ct = default)
=> Task.FromResult(result);

public Task<IReadOnlyList<string>> GetUnapprovedPatternsAsync(
ToolApprovalSessionId? sessionId,
TrustAudience audience,
ToolName toolName,
IReadOnlyList<string> patterns,
string? cwd,
CancellationToken ct = default)
=> throw new InvalidOperationException("The test does not use the legacy approval check.");

public Task RecordApprovalAsync(
ToolApprovalSessionId sessionId,
TrustAudience audience,
ToolName toolName,
IReadOnlyList<string> patterns,
bool persistent,
string? cwd,
CancellationToken ct = default)
=> throw new InvalidOperationException("The authorization evaluator must not record an approval.");
}

private sealed class RecordingLogger<T> : ILogger<T>
{
public List<IReadOnlyDictionary<string, object?>> Entries { get; } = [];

public IDisposable BeginScope<TState>(TState state) where TState : notnull
=> EmptyScope.Instance;

public bool IsEnabled(LogLevel logLevel) => true;

public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
if (state is not IEnumerable<KeyValuePair<string, object?>> properties)
return;

Entries.Add(properties.ToDictionary(
property => property.Key,
property => property.Value,
StringComparer.Ordinal));
}

private sealed class EmptyScope : IDisposable
{
public static readonly EmptyScope Instance = new();

public void Dispose()
{
}
}
}

private sealed class StubRequiredActor : IRequiredActor<ToolApprovalActorKey>
{
private readonly IActorRef _actor;
Expand Down
52 changes: 52 additions & 0 deletions src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,58 @@ public void Shell_in_auto_mode_allows_without_approval()

Assert.True(decision.Allowed);
Assert.False(decision.NeedsApproval);
Assert.Equal(ToolAllowReason.PolicyAuto, decision.AllowReason);
}

[Fact]
public void Safe_verb_in_trusted_scope_reports_allow_reason()
{
var projectDirectory = Path.Combine(
Path.GetTempPath(),
$"netclaw-safe-reason-{Guid.NewGuid():N}");
Comment on lines +91 to +93
Directory.CreateDirectory(projectDirectory);
try
{
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 policy = new ToolAccessPolicy(
config,
new EffectivePolicyDefaults(
DeploymentPosture.Personal,
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
UsedStrictFallback: false),
safeVerbs: SafeVerbList.FromVerbs(["git status"]));
var context = TestToolExecutionContext.CreateBound(
"signalr/thread-safe-reason",
null,
new TestToolExecutionContextOptions
{
Audience = TrustAudience.Personal,
ProjectDirectory = projectDirectory,
InteractiveApproval = TestToolExecutionContext.InteractiveApproval(true)
});

var decision = policy.AuthorizeInvocation(
ShellTool(),
context,
ToolInput.Create(
"Command", "git status",
"WorkingDirectory", projectDirectory));

Assert.True(decision.Allowed);
Assert.Equal(ToolAllowReason.SafeVerbInTrustedScope, decision.AllowReason);
}
finally
{
Directory.Delete(projectDirectory, recursive: true);
}
}

[Fact]
Expand Down
Loading
Loading